diff --git a/.idea/modules.xml b/.idea/modules.xml
index a811ba430e6b..a210de784b12 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -1414,6 +1414,8 @@
+
+
diff --git a/BUILD.bazel b/BUILD.bazel
index 276e881ffe46..aff012b6ffe6 100644
--- a/BUILD.bazel
+++ b/BUILD.bazel
@@ -558,6 +558,7 @@ jvm_library(
"//plugins/maven/maven40-server-impl:server-m40",
"//plugins/mcp-server:mcpserver",
"//plugins/textmate/tests:tests_test_lib",
+ "//python/python-process-output:processOutput",
]
)
### auto-generated section `build intellij.idea.community.main` end
diff --git a/build/bazel-generated-file-list.txt b/build/bazel-generated-file-list.txt
index 590ac37c3906..bbec0e72b94a 100644
--- a/build/bazel-generated-file-list.txt
+++ b/build/bazel-generated-file-list.txt
@@ -1455,6 +1455,8 @@ python/python-markdown
python/python-parser
python/python-poetry/backend
python/python-poetry/common
+python/python-process-output
+python/python-process-output/impl
python/python-psi-api
python/python-psi-impl
python/python-pyproject
diff --git a/intellij.idea.community.main.iml b/intellij.idea.community.main.iml
index c767ff1ca9b8..89e3fe50cef0 100644
--- a/intellij.idea.community.main.iml
+++ b/intellij.idea.community.main.iml
@@ -255,5 +255,6 @@
+
\ No newline at end of file
diff --git a/python/BUILD.bazel b/python/BUILD.bazel
index f3bd85b676be..1487c50a7ba6 100644
--- a/python/BUILD.bazel
+++ b/python/BUILD.bazel
@@ -356,6 +356,7 @@ jvm_library(
"//python/python-poetry/common",
"//python/python-uv/common",
"//python/common",
+ "//python/python-process-output:processOutput",
],
exports = [
"//python/openapi:community",
diff --git a/python/aliasProvider/resources/intellij.python.community.aliasProvider.xml b/python/aliasProvider/resources/intellij.python.community.aliasProvider.xml
index 22360aaa0e9f..a0056a189ef7 100644
--- a/python/aliasProvider/resources/intellij.python.community.aliasProvider.xml
+++ b/python/aliasProvider/resources/intellij.python.community.aliasProvider.xml
@@ -25,6 +25,7 @@
+
diff --git a/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/conda/PyEnvironmentYmlSdkConfiguration.kt b/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/conda/PyEnvironmentYmlSdkConfiguration.kt
index 0b62c6dfa39c..8c3cf21ed029 100644
--- a/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/conda/PyEnvironmentYmlSdkConfiguration.kt
+++ b/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/conda/PyEnvironmentYmlSdkConfiguration.kt
@@ -22,6 +22,7 @@ import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.jetbrains.python.PyBundle
import com.jetbrains.python.configuration.PyConfigurableInterpreterList
import com.jetbrains.python.errorProcessing.PyResult
+import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.getOrNull
import com.jetbrains.python.onSuccess
import com.jetbrains.python.packaging.conda.environmentYml.CondaEnvironmentYmlSdkUtils
diff --git a/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/miscProject/PyMiscService.kt b/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/miscProject/PyMiscService.kt
index dcdb653d24a1..3816b7b2bd83 100644
--- a/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/miscProject/PyMiscService.kt
+++ b/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/miscProject/PyMiscService.kt
@@ -9,6 +9,7 @@ import com.intellij.openapi.ui.MessageDialogBuilder
import com.intellij.pycharm.community.ide.impl.PyCharmCommunityCustomizationBundle
import com.intellij.pycharm.community.ide.impl.miscProject.impl.MiscProjectUsageCollector
import com.jetbrains.python.Result
+import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.util.ShowingMessageErrorSync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -41,7 +42,7 @@ class PyMiscService(private val scope: CoroutineScope) {
}
is Result.Failure -> {
withContext(Dispatchers.EDT) {
- ShowingMessageErrorSync.emit(projectCreationResult.error)
+ ShowingMessageErrorSync.emit(projectCreationResult.error, project)
}
}
}
diff --git a/python/intellij.python.community.impl.iml b/python/intellij.python.community.impl.iml
index 7404f25e9c9b..6ea21e6cbb23 100644
--- a/python/intellij.python.community.impl.iml
+++ b/python/intellij.python.community.impl.iml
@@ -193,5 +193,6 @@
+
\ No newline at end of file
diff --git a/python/openapi/src/com/jetbrains/python/errorProcessing/ErrorSink.kt b/python/openapi/src/com/jetbrains/python/errorProcessing/ErrorSink.kt
index 6549e3c86119..11bb24c169cb 100644
--- a/python/openapi/src/com/jetbrains/python/errorProcessing/ErrorSink.kt
+++ b/python/openapi/src/com/jetbrains/python/errorProcessing/ErrorSink.kt
@@ -1,6 +1,7 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.errorProcessing
+import com.intellij.openapi.project.Project
import kotlinx.coroutines.flow.FlowCollector
/**
@@ -14,4 +15,13 @@ import kotlinx.coroutines.flow.FlowCollector
*
* See [PyError]
*/
-typealias ErrorSink = FlowCollector
\ No newline at end of file
+typealias ErrorSink = FlowCollector
+
+data class PyErrorDetail(
+ val error: PyError,
+ val project: Project? = null,
+)
+
+suspend fun ErrorSink.emit(error: PyError, project: Project? = null) {
+ emit(PyErrorDetail(error, project))
+}
\ No newline at end of file
diff --git a/python/openapi/src/com/jetbrains/python/errorProcessing/ExecError.kt b/python/openapi/src/com/jetbrains/python/errorProcessing/ExecError.kt
index d16e3bc1c90c..684198e75707 100644
--- a/python/openapi/src/com/jetbrains/python/errorProcessing/ExecError.kt
+++ b/python/openapi/src/com/jetbrains/python/errorProcessing/ExecError.kt
@@ -14,6 +14,8 @@ import com.jetbrains.python.PyCommunityBundle
import org.jetbrains.annotations.Nls
import kotlin.io.path.Path
+private val separatorRegex = Regex("[/\\\\]+")
+
/**
* Exe might sit on eel (new one) or on target (legacy)
*/
@@ -29,6 +31,12 @@ sealed interface Exe {
}
}
+ fun pathParts(): List =
+ when (this) {
+ is OnEel -> eelPath.parts
+ is OnTarget -> path.split(separatorRegex)
+ }
+
data class OnEel(val eelPath: EelPath) : Exe {
override fun toString(): String = eelPath.toString()
}
@@ -55,6 +63,11 @@ class ExecErrorImpl(
* optional message to be displayed to the user: Why did we run this process. I.e "running pip to install package".
*/
val additionalMessageToUser: @NlsContexts.DialogTitle String? = null,
+
+ /**
+ * Optional association with a [com.intellij.python.community.execService.impl.LoggedProcess] by its id.
+ */
+ val loggedProcessId: Int? = null,
) : PyError(getExecErrorMessage(exe.toString(), args, additionalMessageToUser, errorReason)) {
val asCommand: String get() = (arrayOf(exe.toString()) + args).joinToString(" ")
}
diff --git a/python/pluginCore/plugin-content.yaml b/python/pluginCore/plugin-content.yaml
index 8297bc6a2911..3cf20db4dcbb 100644
--- a/python/pluginCore/plugin-content.yaml
+++ b/python/pluginCore/plugin-content.yaml
@@ -141,6 +141,9 @@
- name: lib/modules/intellij.python.parser.jar
contentModules:
- name: intellij.python.parser
+- name: lib/modules/intellij.python.processOutput.jar
+ contentModules:
+ - name: intellij.python.processOutput
- name: lib/modules/intellij.python.psi.impl.jar
contentModules:
- name: intellij.python.psi.impl
@@ -190,4 +193,5 @@
modules:
- name: intellij.python.community.plugin
contentModules:
+ - name: intellij.python.processOutput.impl
- name: intellij.commandInterface
\ No newline at end of file
diff --git a/python/pluginCore/resources/META-INF/plugin.xml b/python/pluginCore/resources/META-INF/plugin.xml
index ef26865cb51f..32fbea020086 100644
--- a/python/pluginCore/resources/META-INF/plugin.xml
+++ b/python/pluginCore/resources/META-INF/plugin.xml
@@ -57,6 +57,8 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
+
+
diff --git a/python/pluginResources/intellij.python.community.impl.xml b/python/pluginResources/intellij.python.community.impl.xml
index b843ff1c972d..f8024a34b6a3 100644
--- a/python/pluginResources/intellij.python.community.impl.xml
+++ b/python/pluginResources/intellij.python.community.impl.xml
@@ -23,6 +23,7 @@
+
messages.PyBundle
@@ -88,7 +89,6 @@
-
diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties
index b17374879bb8..11a261018cae 100644
--- a/python/pluginResources/messages/PyBundle.properties
+++ b/python/pluginResources/messages/PyBundle.properties
@@ -1754,12 +1754,15 @@ tracecontext.detecting.hatch.environments=Detecting Hatch Environments
tracecontext.detecting.executable=Detecting {0} Executable
tracecontext.detecting.venv=Detecting venv folder
tracecontext.generating.git=Generating git
-tracecontext.packaging.tool.window=Packaging tool window
+tracecontext.packaging.tool.window=Packaging Tool Window
tracecontext.packages.sdk.controller=Packages SDK Controller
tracecontext.add.local.python.sdk.dialog=Add Local Python SDK Dialog
tracecontext.new.project.wizard=New Project Wizard
tracecontext.loading.interpreter.list=Loading Interpreter List
tracecontext.detecting.conda.executable.and.environments=Detecting Conda Executable and environments
+tracecontext.packaging.tool.window.delete=Deleting Python Package
+tracecontext.packaging.tool.window.install=Installing Python Package
+
evolution.uv.executable.is.not.found=uv executable is not found
evolution.pyproject.toml.file.is.required.for.poetry=pyproject.toml file is required for Poetry
modal.progress.title.path.validation=Path validation
diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/impl/ExecServiceImpl.kt b/python/python-exec-service/src/com/intellij/python/community/execService/impl/ExecServiceImpl.kt
index ef1fc43d516f..24d9e130c50e 100644
--- a/python/python-exec-service/src/com/intellij/python/community/execService/impl/ExecServiceImpl.kt
+++ b/python/python-exec-service/src/com/intellij/python/community/execService/impl/ExecServiceImpl.kt
@@ -73,7 +73,8 @@ internal object ExecServiceImpl : ExecService {
}
return@withTimeout processLauncher.createExecError(
messageToUser = additionalMessage,
- errorReason = ExecErrorReason.UnexpectedProcessTermination(output)
+ errorReason = ExecErrorReason.UnexpectedProcessTermination(output),
+ loggedProcessId = process.loggedProcess.id,
)
}
Result.success(successResult)
@@ -83,7 +84,8 @@ internal object ExecServiceImpl : ExecService {
processLauncher.killAndJoin()
processLauncher.createExecError(
messageToUser = PyExecBundle.message("py.exec.timeout.error", description, options.timeout),
- errorReason = ExecErrorReason.Timeout
+ errorReason = ExecErrorReason.Timeout,
+ loggedProcessId = process.loggedProcess.id,
)
}
return@coroutineScope result
@@ -91,12 +93,17 @@ internal object ExecServiceImpl : ExecService {
}
}
-private fun ProcessLauncher.createExecError(messageToUser: @Nls String, errorReason: T): Result.Failure> =
+private fun ProcessLauncher.createExecError(
+ messageToUser: @Nls String,
+ errorReason: T,
+ loggedProcessId: Int? = null,
+): Result.Failure> =
ExecErrorImpl(
exe = exeForError,
args = args.toTypedArray(),
additionalMessageToUser = messageToUser,
- errorReason = errorReason
+ errorReason = errorReason,
+ loggedProcessId = loggedProcessId,
).logAndFail()
diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/impl/logging.kt b/python/python-exec-service/src/com/intellij/python/community/execService/impl/logging.kt
new file mode 100644
index 000000000000..bafb63724614
--- /dev/null
+++ b/python/python-exec-service/src/com/intellij/python/community/execService/impl/logging.kt
@@ -0,0 +1,250 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.python.community.execService.impl
+
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.components.service
+import com.intellij.util.io.readLineAsync
+import com.jetbrains.python.TraceContext
+import com.jetbrains.python.errorProcessing.Exe
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.jetbrains.annotations.ApiStatus
+import org.jetbrains.annotations.Nls
+import java.io.BufferedReader
+import java.io.IOException
+import java.io.InputStream
+import java.io.InputStreamReader
+import java.io.OutputStream
+import java.io.PipedInputStream
+import java.io.PipedOutputStream
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+internal object LoggingLimits {
+ const val MAX_LINE_SIZE = 16_384
+ const val MAX_LINES = 1024
+}
+
+@ApiStatus.Internal
+data class LoggedProcess(
+ val traceContext: TraceContext?,
+ val pid: Long?,
+ val startedAt: Instant,
+ val cwd: String?,
+ val exe: LoggedProcessExe,
+ val args: List,
+ val env: Map,
+ val lines: SharedFlow,
+ val exitInfo: MutableStateFlow,
+) {
+ val id: Int = nextId.getAndAdd(1)
+
+ val commandString: String
+ get() = commandFromSegments(listOf(exe.path) + args)
+
+ /**
+ * Command string with the full path of the exe trimmed only to the latest segments. E.g., `/usr/bin/uv` -> `uv`.
+ */
+ val shortenedCommandString: String
+ get() = commandFromSegments(listOf(exe.parts.last()) + args)
+
+ companion object {
+ private val nextId: AtomicInteger = AtomicInteger(0)
+
+ private fun commandFromSegments(segments: List) =
+ segments.joinToString(" ")
+ }
+}
+
+@ApiStatus.Internal
+data class LoggedProcessExe(
+ val path: String,
+ val parts: List,
+)
+
+@ApiStatus.Internal
+data class LoggedProcessExitInfo(
+ val exitedAt: Instant,
+ val exitValue: Int,
+ val additionalMessageToUser: @Nls String? = null,
+)
+
+@ApiStatus.Internal
+data class LoggedProcessLine(
+ val text: String,
+ val kind: Kind,
+) {
+ enum class Kind {
+ OUT,
+ ERR
+ }
+}
+
+@ApiStatus.Internal
+@Service
+class ExecLoggerService(val scope: CoroutineScope) {
+ internal val processesInternal = MutableSharedFlow()
+ val processes: Flow = processesInternal.asSharedFlow()
+}
+
+@ApiStatus.Internal
+class LoggingProcess(
+ private val backingProcess: Process,
+ traceContext: TraceContext?,
+ startedAt: Instant,
+ cwd: String?,
+ exe: Exe,
+ args: List,
+ env: Map,
+) : Process() {
+ val loggedProcess: LoggedProcess
+
+ private val stdoutStream = LoggingInputStream(backingProcess.inputStream)
+ private val stderrStream = LoggingInputStream(backingProcess.errorStream)
+
+ init {
+ val service = ApplicationManager.getApplication().service()
+ val linesFlow = MutableSharedFlow(replay = LoggingLimits.MAX_LINES)
+ val exitInfoFlow = MutableStateFlow(null)
+
+ loggedProcess =
+ LoggedProcess(
+ traceContext,
+ try {
+ backingProcess.pid()
+ }
+ catch (_: UnsupportedOperationException) {
+ null
+ },
+ startedAt,
+ cwd,
+ LoggedProcessExe(
+ path = exe.toString(),
+ parts = exe.pathParts(),
+ ),
+ args,
+ env,
+ linesFlow,
+ exitInfoFlow,
+ )
+
+ val outCollector = service.scope.launch {
+ collectOutputLines(stdoutStream.inputStream, linesFlow, LoggedProcessLine.Kind.OUT)
+ }
+
+ val errCollector = service.scope.launch {
+ collectOutputLines(stderrStream.inputStream, linesFlow, LoggedProcessLine.Kind.ERR)
+ }
+
+ service.scope.launch {
+ service.processesInternal.emit(loggedProcess)
+ withContext(Dispatchers.IO) {
+ waitFor()
+ }
+ exitInfoFlow.value = LoggedProcessExitInfo(
+ exitedAt = Clock.System.now(),
+ exitValue = exitValue(),
+ )
+
+ outCollector.cancel()
+ errCollector.cancel()
+ }
+ }
+
+ override fun getOutputStream(): OutputStream =
+ backingProcess.outputStream
+
+ override fun getInputStream(): InputStream =
+ stdoutStream
+
+ override fun getErrorStream(): InputStream =
+ stderrStream
+
+ override fun waitFor(): Int {
+ return backingProcess.waitFor()
+ }
+
+ override fun waitFor(timeout: Long, unit: TimeUnit): Boolean {
+ return backingProcess.waitFor(timeout, unit)
+ }
+
+ override fun exitValue(): Int =
+ backingProcess.exitValue()
+
+ override fun destroy(): Unit =
+ backingProcess.destroy()
+
+ override fun destroyForcibly(): Process? =
+ backingProcess.destroyForcibly()
+
+ override fun toHandle(): ProcessHandle? =
+ backingProcess.toHandle()
+
+ override fun supportsNormalTermination(): Boolean =
+ backingProcess.supportsNormalTermination()
+}
+
+private class LoggingInputStream(
+ private val backingInputStream: InputStream,
+) : InputStream() {
+ private val outputStream = PipedOutputStream()
+ val inputStream: InputStream = PipedInputStream(outputStream)
+
+ override fun read(): Int {
+ val byte = try {
+ backingInputStream.read()
+ }
+ catch (e: IOException) {
+ outputStream.close()
+
+ // ugly hack; but the Process' `.destroy` methods abruptly close
+ // the stream, making all pending readers throw an exception.
+ // we can handle this case as legal here
+ if (e.message == "Stream closed") {
+ return -1
+ }
+
+ throw e
+ }
+
+ try {
+ if (byte == -1) {
+ outputStream.close()
+ }
+ else {
+ outputStream.write(byte)
+ }
+ }
+ catch (_: IOException) {
+ // pipe might be closed, simply ignore it in this case
+ }
+
+ return byte
+ }
+}
+
+private suspend fun collectOutputLines(
+ inputStream: InputStream,
+ linesFlow: MutableSharedFlow,
+ kind: LoggedProcessLine.Kind,
+) {
+ val reader = BufferedReader(InputStreamReader(inputStream))
+ var line: String? = null
+
+ while (reader.readLineAsync()?.also { line = it } != null) {
+ linesFlow.emit(LoggedProcessLine(
+ text = line!!.substring(0, line.length.coerceAtMost(LoggingLimits.MAX_LINE_SIZE)),
+ kind = kind,
+ ))
+ }
+}
diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/ProcessLauncher.kt b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/ProcessLauncher.kt
index 898208b3fc6d..ac086210fd58 100644
--- a/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/ProcessLauncher.kt
+++ b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/ProcessLauncher.kt
@@ -5,10 +5,13 @@ import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.platform.eel.provider.utils.ProcessFunctions
import com.intellij.python.community.execService.Args
import com.intellij.python.community.execService.TtySize
+import com.intellij.python.community.execService.impl.LoggingProcess
import com.jetbrains.python.Result
+import com.jetbrains.python.TraceContext
import com.jetbrains.python.errorProcessing.Exe
import com.jetbrains.python.errorProcessing.ExecErrorReason
import kotlinx.coroutines.CoroutineScope
+import kotlin.time.Clock
private val logger = fileLogger()
@@ -17,7 +20,20 @@ internal class ProcessLauncher(
val args: List,
private val processCommands: ProcessCommands,
) {
- suspend fun start(): Result = processCommands.start()
+ suspend fun start(): Result =
+ processCommands.start()
+ .mapSuccess {
+ LoggingProcess(
+ it,
+ processCommands.scopeToBind.coroutineContext[TraceContext.Key],
+ Clock.System.now(),
+ processCommands.cwd,
+ exeForError,
+ args,
+ processCommands.env,
+ )
+ }
+
suspend fun killAndJoin() {
processCommands.processFunctions.killAndJoin(logger, exeForError.toString())
}
@@ -26,11 +42,14 @@ internal class ProcessLauncher(
internal interface ProcessCommands {
suspend fun start(): Result
val processFunctions: ProcessFunctions
+ val scopeToBind: CoroutineScope
+ val env: Map
+ val cwd: String?
}
internal data class LaunchRequest(
val scopeToBind: CoroutineScope,
val args: Args,
val env: Map,
- val usePty: TtySize?
-)
+ val usePty: TtySize?,
+)
\ No newline at end of file
diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/eel.kt b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/eel.kt
index 6b2aa9ce3076..d2651db2e369 100644
--- a/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/eel.kt
+++ b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/eel.kt
@@ -15,6 +15,8 @@ import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.Exe
import com.jetbrains.python.errorProcessing.ExecErrorReason
import kotlinx.coroutines.CoroutineScope
+import java.nio.file.Path
+import kotlin.io.path.pathString
internal suspend fun createProcessLauncherOnEel(binOnEel: BinOnEel, launchRequest: LaunchRequest): ProcessLauncher {
val exePath: EelPath = with(binOnEel) {
@@ -35,15 +37,18 @@ internal suspend fun createProcessLauncherOnEel(binOnEel: BinOnEel, launchReques
}
private class EelProcessCommands(
- private val scopeToBind: CoroutineScope,
+ override val scopeToBind: CoroutineScope,
private val binOnEel: BinOnEel,
private val path: EelPath,
private val args: List,
- private val env: Map,
+ override val env: Map,
private val tty: TtySize?,
) : ProcessCommands {
private var eelProcess: EelProcess? = null
+ override val cwd: String?
+ get() = binOnEel.workDir?.toRealPath()?.pathString
+
override val processFunctions: ProcessFunctions = ProcessFunctions(
waitForExit = { eelProcess?.exitCode?.await() },
killProcess = { eelProcess?.kill() }
diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/targets.kt b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/targets.kt
index 8fdbc1046316..791834635cad 100644
--- a/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/targets.kt
+++ b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/targets.kt
@@ -10,6 +10,7 @@ import com.intellij.execution.target.local.LocalTargetEnvironmentRequest
import com.intellij.execution.target.local.LocalTargetPtyOptions
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.project.ProjectManager
+import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.platform.eel.provider.utils.ProcessFunctions
import com.intellij.platform.eel.provider.utils.bindProcessToScopeImpl
import com.intellij.python.community.execService.BinOnTarget
@@ -24,6 +25,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import com.intellij.remoteServer.util.ServerRuntimeException
+import java.nio.file.Path
import kotlin.io.path.pathString
import kotlin.time.Duration.Companion.milliseconds
@@ -83,11 +85,16 @@ internal suspend fun createProcessLauncherOnTarget(binOnTarget: BinOnTarget, lau
}
private class TargetProcessCommands(
- private val scopeToBind: CoroutineScope,
+ override val scopeToBind: CoroutineScope,
private val exePath: FullPathOnTarget,
private val targetEnv: TargetEnvironment,
private val cmdLine: TargetedCommandLine,
) : ProcessCommands {
+ override val env: Map
+ get() = cmdLine.environmentVariables
+
+ override val cwd: String?
+ get() = cmdLine.workingDirectory
private var process: Process? = null
@@ -115,4 +122,4 @@ private class TargetProcessCommands(
}
}
-private fun ExecutionException.asCantStart(): Result.Failure = Result.failure(ExecErrorReason.CantStart(null, localizedMessage))
\ No newline at end of file
+private fun ExecutionException.asCantStart(): Result.Failure = Result.failure(ExecErrorReason.CantStart(null, localizedMessage))
diff --git a/python/python-exec-service/tests/com/intellij/python/junit5Tests/unit/LoggingTest.kt b/python/python-exec-service/tests/com/intellij/python/junit5Tests/unit/LoggingTest.kt
new file mode 100644
index 000000000000..7cffb2e6ff3f
--- /dev/null
+++ b/python/python-exec-service/tests/com/intellij/python/junit5Tests/unit/LoggingTest.kt
@@ -0,0 +1,261 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.python.junit5Tests.unit
+
+import com.intellij.python.community.execService.impl.LoggedProcess
+import com.intellij.python.community.execService.impl.LoggedProcessExe
+import com.intellij.python.community.execService.impl.LoggedProcessLine
+import com.intellij.python.community.execService.impl.LoggingLimits
+import com.intellij.python.community.execService.impl.LoggingProcess
+import com.intellij.testFramework.common.timeoutRunBlocking
+import com.intellij.testFramework.common.waitUntil
+import com.intellij.testFramework.junit5.TestApplication
+import com.jetbrains.python.TraceContext
+import com.jetbrains.python.errorProcessing.Exe
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.InputStream
+import java.io.OutputStream
+import java.util.concurrent.CompletableFuture
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+private class LoggingTest {
+ @Nested
+ inner class LoggedProcessTest {
+ @Test
+ fun `loggedProcess id should increment with each instantiation`() {
+ val process1 = process("process1")
+ val process2 = process("process2")
+ val process3 = process("process2")
+
+ assert(process1.id + 1 == process2.id)
+ assert(process2.id + 1 == process3.id)
+ }
+
+ @Test
+ fun `commandString is constructed as expected`() {
+ val process1 = process("/usr/bin/uv", "install", "requests")
+
+ assertEquals("/usr/bin/uv install requests", process1.commandString)
+ }
+
+ @Test
+ fun `shortenedCommandString is constructed as expected from multiple segments`() {
+ val process1 = process("/usr/bin/uv", "install", "requests")
+
+ assertEquals("uv install requests", process1.shortenedCommandString)
+ }
+
+ @Test
+ fun `shortenedCommandString is constructed as expected from single segment`() {
+ val process1 = process("uv", "install", "requests")
+
+ assertEquals("uv install requests", process1.shortenedCommandString)
+ }
+ }
+
+ @TestApplication
+ @Nested
+ inner class LoggingProcessTest {
+ @Test
+ fun `logged process gets created correctly`() = timeoutRunBlocking {
+ val traceContext = TraceContext("some trace")
+ val loggingProcess = fakeLoggingProcess(
+ stdout = "stdout text",
+ stderr = "stderr text",
+ exitValue = 10,
+ pid = 100,
+ traceContext = traceContext,
+ startedAt = Instant.fromEpochSeconds(100),
+ cwd = "/some/cwd",
+ pathToExe = "/usr/bin/exe",
+ args = listOf("foo", "bar"),
+ env = mapOf("foo" to "bar")
+ )
+
+ val loggedProcess = loggingProcess.loggedProcess
+ val stdout = loggingProcess.inputStream.readAllBytes().toString(charset = Charsets.UTF_8)
+ val stderr = loggingProcess.errorStream.readAllBytes().toString(charset = Charsets.UTF_8)
+
+ assert(traceContext == loggedProcess.traceContext)
+ assert(100L == loggedProcess.pid)
+ assert(Instant.fromEpochSeconds(100) == loggedProcess.startedAt)
+ assert("/some/cwd" == loggedProcess.cwd)
+ assert(LoggedProcessExe(path = "/usr/bin/exe", listOf("usr", "bin", "exe")) == loggedProcess.exe)
+ assert(listOf("foo", "bar") == loggedProcess.args)
+ assert(mapOf("foo" to "bar") == loggedProcess.env)
+ assert(stdout == "stdout text")
+ assert(stderr == "stderr text")
+
+ loggingProcess.destroy()
+ }
+
+ @Test
+ fun `lines get properly collected from out and err`() = timeoutRunBlocking {
+ val loggingProcess = fakeLoggingProcess(
+ "outline1\noutline2\noutline3",
+ "errline1\nerrline2\nerrline3"
+ )
+ val loggedProcess = loggingProcess.loggedProcess
+
+ assert(loggedProcess.lines.replayCache.isEmpty())
+
+ loggingProcess.inputStream.readAllBytes()
+ waitUntil { loggedProcess.lines.replayCache.size == 3 }
+
+ (1..3).forEach {
+ assert(loggedProcess.lines.replayCache[it - 1].text == "outline$it")
+ assert(loggedProcess.lines.replayCache[it - 1].kind == LoggedProcessLine.Kind.OUT)
+ }
+
+ loggingProcess.errorStream.readAllBytes()
+ waitUntil { loggedProcess.lines.replayCache.size == 6 }
+
+ (4..6).forEach {
+ assert(loggedProcess.lines.replayCache[it - 1].text == "errline${it - 3}")
+ assert(loggedProcess.lines.replayCache[it - 1].kind == LoggedProcessLine.Kind.ERR)
+ }
+
+ loggingProcess.destroy()
+ }
+
+ @Test
+ fun `exit info gets properly populated`() = timeoutRunBlocking {
+ val now = Clock.System.now()
+ val loggingProcess = fakeLoggingProcess(
+ exitValue = 30
+ )
+ val loggedProcess = loggingProcess.loggedProcess
+
+ loggingProcess.destroy()
+
+ waitUntil { loggedProcess.exitInfo.value != null }
+
+ assert(loggedProcess.exitInfo.value!!.exitValue == 30)
+ assert(loggedProcess.exitInfo.value!!.exitedAt >= now)
+ }
+
+ @Test
+ fun `old lines are evicted when the line limit is reached`() = timeoutRunBlocking {
+ val loggingProcess = fakeLoggingProcess(
+ stdout = buildString {
+ repeat(LoggingLimits.MAX_LINES + 2) {
+ appendLine("line$it")
+ }
+ },
+ stderr = ""
+ )
+ val loggedProcess = loggingProcess.loggedProcess
+
+ loggingProcess.inputStream.readAllBytes()
+ loggingProcess.errorStream.readAllBytes()
+
+ waitUntil { loggedProcess.lines.replayCache.last().text == "line${LoggingLimits.MAX_LINES + 1}" }
+
+ assert(loggedProcess.lines.replayCache.size == LoggingLimits.MAX_LINES)
+ assert(loggedProcess.lines.replayCache[0].text == "line2")
+
+ loggingProcess.destroy()
+ }
+
+ @Test
+ fun `line text is truncated when its size goes over the limit`() = timeoutRunBlocking {
+ val longLine = buildString {
+ repeat(LoggingLimits.MAX_LINE_SIZE) {
+ append('a')
+ }
+ }
+
+ val loggingProcess = fakeLoggingProcess(
+ stdout = "${longLine}bbb",
+ stderr = "",
+ )
+ val loggedProcess = loggingProcess.loggedProcess
+
+ loggingProcess.inputStream.readAllBytes()
+ loggingProcess.errorStream.readAllBytes()
+
+ waitUntil { loggedProcess.lines.replayCache.size == 1 }
+
+ assert(loggedProcess.lines.replayCache[0].text == longLine)
+
+ loggingProcess.destroy()
+ }
+ }
+
+ companion object {
+ fun process(vararg command: String) =
+ LoggedProcess(
+ traceContext = null,
+ pid = 123,
+ startedAt = Clock.System.now(),
+ cwd = null,
+ exe = LoggedProcessExe(
+ path = command.first(),
+ parts = command.first().split(Regex("[/\\\\]+"))
+ ),
+ args = command.drop(1),
+ env = mapOf(),
+ lines = MutableSharedFlow(),
+ exitInfo = MutableStateFlow(null),
+ )
+
+ fun fakeLoggingProcess(
+ stdout: String = "stdout",
+ stderr: String = "stderr",
+ exitValue: Int = 0,
+ pid: Long = 0,
+ traceContext: TraceContext? = null,
+ startedAt: Instant = Instant.fromEpochSeconds(0),
+ cwd: String? = "/some/cwd",
+ pathToExe: String = "/usr/bin/exe",
+ args: List = listOf("foo", "bar"),
+ env: Map = mapOf("foo" to "bar"),
+ ) =
+ LoggingProcess(
+ object : Process() {
+ val stdoutStream = ByteArrayInputStream(stdout.toByteArray())
+ val stderrStream = ByteArrayInputStream(stderr.toByteArray())
+ val stdinStream = ByteArrayOutputStream()
+ val destroyFuture = CompletableFuture()
+
+ override fun getOutputStream(): OutputStream =
+ stdinStream
+
+ override fun getInputStream(): InputStream =
+ stdoutStream
+
+ override fun getErrorStream(): InputStream =
+ stderrStream
+
+ override fun waitFor(): Int {
+ destroyFuture.get()
+ return exitValue
+ }
+
+ override fun exitValue(): Int {
+ return exitValue
+ }
+
+ override fun destroy() {
+ destroyFuture.complete(10)
+ }
+
+ override fun pid(): Long =
+ pid
+ },
+ traceContext,
+ startedAt,
+ cwd,
+ Exe.fromString(pathToExe),
+ args,
+ env
+ )
+ }
+}
+
diff --git a/python/python-features-trainer/src/com/intellij/python/featuresTrainer/ift/PythonLangSupport.kt b/python/python-features-trainer/src/com/intellij/python/featuresTrainer/ift/PythonLangSupport.kt
index 1e09c0e9017f..75b2df4611c5 100644
--- a/python/python-features-trainer/src/com/intellij/python/featuresTrainer/ift/PythonLangSupport.kt
+++ b/python/python-features-trainer/src/com/intellij/python/featuresTrainer/ift/PythonLangSupport.kt
@@ -23,6 +23,7 @@ import com.jetbrains.python.sdk.impl.PySdkBundle
import com.jetbrains.python.Result
import com.jetbrains.python.configuration.PyConfigurableInterpreterList
import com.jetbrains.python.errorProcessing.ErrorSink
+import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.inspections.PyInterpreterInspection
import com.jetbrains.python.projectCreation.createVenvAndSdk
import com.jetbrains.python.sdk.PySdkToInstall
@@ -90,7 +91,7 @@ internal class PythonLangSupport(private val errorSink: ErrorSink = ShowingMessa
override fun getSdkForProject(project: Project, selectedSdk: Sdk?): Sdk = runWithModalProgressBlocking(project, "...") {
when (val r = createVenvAndSdk(project)) {
is Result.Failure -> {
- errorSink.emit(r.error)
+ errorSink.emit(r.error, project)
null
}
is Result.Success -> r.result
diff --git a/python/python-features-trainer/testSrc/com/intellij/python/junit5Tests/env/PythonLangSupportTest.kt b/python/python-features-trainer/testSrc/com/intellij/python/junit5Tests/env/PythonLangSupportTest.kt
index ecfdbc032dce..c6790d3c3350 100644
--- a/python/python-features-trainer/testSrc/com/intellij/python/junit5Tests/env/PythonLangSupportTest.kt
+++ b/python/python-features-trainer/testSrc/com/intellij/python/junit5Tests/env/PythonLangSupportTest.kt
@@ -16,11 +16,13 @@ import com.intellij.python.junit5Tests.framework.winLockedFile.deleteCheckLockin
import com.intellij.testFramework.common.timeoutRunBlocking
import com.jetbrains.python.PythonBinary
import com.jetbrains.python.errorProcessing.ErrorSink
+import com.jetbrains.python.errorProcessing.PyError
import com.jetbrains.python.getOrThrow
import com.jetbrains.python.sdk.pythonSdk
import com.jetbrains.python.venvReader.VirtualEnvReader.Companion.DEFAULT_VIRTUALENV_DIRNAME
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.withContext
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Assertions
@@ -62,7 +64,7 @@ class PythonLangSupportTest {
assert(learningProjectsPath.startsWith(temporarySystemPath)) { "$learningProjectsPath must reside in $temporarySystemPath" }
val sut = PythonLangSupport(ErrorSink {
- Assertions.fail(it.message)
+ Assertions.fail(it.error.message)
})
if (venvAlreadyExists) {
diff --git a/python/python-process-output/BUILD.bazel b/python/python-process-output/BUILD.bazel
new file mode 100644
index 000000000000..e209e0d26692
--- /dev/null
+++ b/python/python-process-output/BUILD.bazel
@@ -0,0 +1,22 @@
+### auto-generated section `build intellij.python.processOutput` start
+load("@rules_jvm//:jvm.bzl", "jvm_library", "resourcegroup")
+
+resourcegroup(
+ name = "processOutput_resources",
+ srcs = glob(["resources/**/*"]),
+ strip_prefix = "resources"
+)
+
+jvm_library(
+ name = "processOutput",
+ module_name = "intellij.python.processOutput",
+ visibility = ["//visibility:public"],
+ srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True),
+ resources = [":processOutput_resources"],
+ deps = [
+ "@lib//:kotlin-stdlib",
+ "//platform/platform-api:ide",
+ "//platform/core-api:core",
+ ]
+)
+### auto-generated section `build intellij.python.processOutput` end
\ No newline at end of file
diff --git a/python/python-process-output/impl/.editorconfig b/python/python-process-output/impl/.editorconfig
new file mode 100644
index 000000000000..d8ba55ebe96e
--- /dev/null
+++ b/python/python-process-output/impl/.editorconfig
@@ -0,0 +1,91 @@
+# This .editorconfig section approximates ktfmt's formatting rules. They are applied here to align
+# with the code style from the Compose AOSP repository.
+# See https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:buildSrc/private/src/main/kotlin/androidx/build/Ktfmt.kt;l=169;drc=8cf117b703d0ce3b9d31263451176d40a23ce828
+
+[{*.kt,*.kts}]
+indent_style = space
+insert_final_newline = true
+max_line_length = 100
+ij_visual_guides = 100
+indent_size = 4
+ij_continuation_indent_size = 4
+ij_java_names_count_to_use_import_on_demand = 9999
+ij_kotlin_align_in_columns_case_branch = false
+ij_kotlin_align_multiline_binary_operation = false
+ij_kotlin_align_multiline_extends_list = false
+ij_kotlin_align_multiline_method_parentheses = false
+ij_kotlin_align_multiline_parameters = true
+ij_kotlin_align_multiline_parameters_in_calls = false
+ij_kotlin_allow_trailing_comma = true
+ij_kotlin_allow_trailing_comma_on_call_site = true
+ij_kotlin_assignment_wrap = normal
+ij_kotlin_blank_lines_after_class_header = 0
+ij_kotlin_blank_lines_around_block_when_branches = 0
+ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1
+ij_kotlin_block_comment_at_first_column = true
+ij_kotlin_call_parameters_new_line_after_left_paren = true
+ij_kotlin_call_parameters_right_paren_on_new_line = false
+ij_kotlin_call_parameters_wrap = on_every_item
+ij_kotlin_catch_on_new_line = false
+ij_kotlin_class_annotation_wrap = split_into_lines
+ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
+ij_kotlin_continuation_indent_for_chained_calls = true
+ij_kotlin_continuation_indent_for_expression_bodies = true
+ij_kotlin_continuation_indent_in_argument_lists = true
+ij_kotlin_continuation_indent_in_elvis = false
+ij_kotlin_continuation_indent_in_if_conditions = false
+ij_kotlin_continuation_indent_in_parameter_lists = false
+ij_kotlin_continuation_indent_in_supertype_lists = false
+ij_kotlin_else_on_new_line = false
+ij_kotlin_enum_constants_wrap = off
+ij_kotlin_extends_list_wrap = normal
+ij_kotlin_field_annotation_wrap = off
+ij_kotlin_finally_on_new_line = false
+ij_kotlin_if_rparen_on_new_line = false
+ij_kotlin_import_nested_classes = false
+ij_kotlin_imports_layout = *
+ij_kotlin_insert_whitespaces_in_simple_one_line_method = true
+ij_kotlin_keep_blank_lines_before_right_brace = 0
+ij_kotlin_keep_blank_lines_in_code = 1
+ij_kotlin_keep_blank_lines_in_declarations = 1
+ij_kotlin_keep_first_column_comment = true
+ij_kotlin_keep_indents_on_empty_lines = false
+ij_kotlin_keep_line_breaks = true
+ij_kotlin_lbrace_on_next_line = false
+ij_kotlin_line_comment_add_space = false
+ij_kotlin_line_comment_at_first_column = true
+ij_kotlin_method_annotation_wrap = split_into_lines
+ij_kotlin_method_call_chain_wrap = normal
+ij_kotlin_method_parameters_new_line_after_left_paren = true
+ij_kotlin_method_parameters_right_paren_on_new_line = true
+ij_kotlin_method_parameters_wrap = on_every_item
+ij_kotlin_name_count_to_use_star_import = 9999
+ij_kotlin_name_count_to_use_star_import_for_members = 9999
+ij_kotlin_parameter_annotation_wrap = off
+ij_kotlin_space_after_comma = true
+ij_kotlin_space_after_extend_colon = true
+ij_kotlin_space_after_type_colon = true
+ij_kotlin_space_before_catch_parentheses = true
+ij_kotlin_space_before_comma = false
+ij_kotlin_space_before_extend_colon = true
+ij_kotlin_space_before_for_parentheses = true
+ij_kotlin_space_before_if_parentheses = true
+ij_kotlin_space_before_lambda_arrow = true
+ij_kotlin_space_before_type_colon = false
+ij_kotlin_space_before_when_parentheses = true
+ij_kotlin_space_before_while_parentheses = true
+ij_kotlin_spaces_around_additive_operators = true
+ij_kotlin_spaces_around_assignment_operators = true
+ij_kotlin_spaces_around_equality_operators = true
+ij_kotlin_spaces_around_function_type_arrow = true
+ij_kotlin_spaces_around_logical_operators = true
+ij_kotlin_spaces_around_multiplicative_operators = true
+ij_kotlin_spaces_around_range = false
+ij_kotlin_spaces_around_relational_operators = true
+ij_kotlin_spaces_around_unary_operator = false
+ij_kotlin_spaces_around_when_arrow = true
+ij_kotlin_variable_annotation_wrap = off
+ij_kotlin_while_on_new_line = false
+ij_kotlin_wrap_elvis_expressions = 1
+ij_kotlin_wrap_expression_body_functions = 1
+ij_kotlin_wrap_first_method_in_call_chain = false
diff --git a/python/python-process-output/impl/BUILD.bazel b/python/python-process-output/impl/BUILD.bazel
new file mode 100644
index 000000000000..e3f568db48df
--- /dev/null
+++ b/python/python-process-output/impl/BUILD.bazel
@@ -0,0 +1,105 @@
+### auto-generated section `build intellij.python.processOutput.impl` start
+load("//build:compiler-options.bzl", "create_kotlinc_options")
+load("@rules_jvm//:jvm.bzl", "jvm_library", "resourcegroup")
+
+create_kotlinc_options(
+ name = "custom_impl",
+ opt_in = ["kotlin.time.ExperimentalTime"],
+ plugin_options = ["plugin:androidx.compose.compiler.plugins.kotlin:generateFunctionKeyMetaAnnotations=true"]
+)
+
+resourcegroup(
+ name = "impl_resources",
+ srcs = glob(["resources/**/*"]),
+ strip_prefix = "resources"
+)
+
+jvm_library(
+ name = "impl",
+ module_name = "intellij.python.processOutput.impl",
+ visibility = ["//visibility:public"],
+ srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True),
+ resources = [":impl_resources"],
+ kotlinc_opts = ":custom_impl",
+ deps = [
+ "@lib//:kotlin-stdlib",
+ "//platform/platform-api:ide",
+ "//platform/core-api:core",
+ "//libraries/skiko",
+ "//libraries/compose-foundation-desktop",
+ "//platform/jewel/foundation",
+ "//platform/jewel/ui",
+ "//platform/jewel/ide-laf-bridge",
+ "//platform/core-ui",
+ "//python/python-exec-service:community-execService",
+ "//python/openapi:community",
+ "//platform/util:util-ui",
+ "//platform/editor-ui-api:editor-ui",
+ "//platform/projectModel-api:projectModel",
+ "//platform/execution-impl",
+ "@lib//:jediterm-core",
+ "@lib//:kotlin-reflect",
+ "@lib//:kotlinx-collections-immutable",
+ "//platform/statistics",
+ "//python/python-process-output:processOutput",
+ ],
+ plugins = ["@lib//:compose-plugin"]
+)
+
+jvm_library(
+ name = "impl_test_lib",
+ visibility = ["//visibility:public"],
+ srcs = glob(["test/**/*.kt", "test/**/*.java", "test/**/*.form"], allow_empty = True),
+ kotlinc_opts = ":custom_impl",
+ associates = [":impl"],
+ deps = [
+ "@lib//:kotlin-stdlib",
+ "//platform/platform-api:ide",
+ "//platform/core-api:core",
+ "//libraries/skiko",
+ "//libraries/compose-foundation-desktop",
+ "//platform/jewel/foundation",
+ "//platform/jewel/foundation:foundation_test_lib",
+ "//platform/jewel/ui",
+ "//platform/jewel/ui:ui_test_lib",
+ "//platform/jewel/ide-laf-bridge",
+ "//platform/jewel/ide-laf-bridge:ide-laf-bridge_test_lib",
+ "//platform/core-ui",
+ "//python/python-exec-service:community-execService",
+ "//python/python-exec-service:community-execService_test_lib",
+ "//python/openapi:community",
+ "//python/openapi:community_test_lib",
+ "//platform/util:util-ui",
+ "//platform/editor-ui-api:editor-ui",
+ "//libraries/compose-foundation-desktop-junit",
+ "@lib//:kotlin-test",
+ "@lib//:kotlin-test-assertions-core-jvm",
+ "@lib//:kotlin-test-junit",
+ "//platform/jewel/int-ui/int-ui-standalone:jewel-intUi-standalone",
+ "//platform/projectModel-api:projectModel",
+ "//platform/execution-impl",
+ "@lib//:jediterm-core",
+ "@lib//:io-mockk",
+ "@lib//:io-mockk-jvm",
+ "@lib//:kotlin-reflect",
+ "@lib//:kotlinx-collections-immutable",
+ "//platform/testFramework/junit5",
+ "//platform/testFramework/junit5:junit5_test_lib",
+ "@lib//:junit5",
+ "//platform/testFramework/common",
+ "//platform/statistics",
+ "//platform/statistics:statistics_test_lib",
+ "//python/python-process-output:processOutput",
+ ],
+ plugins = ["@lib//:compose-plugin"]
+)
+### auto-generated section `build intellij.python.processOutput.impl` end
+
+### auto-generated section `test intellij.python.processOutput.impl` start
+load("@community//build:tests-options.bzl", "jps_test")
+
+jps_test(
+ name = "impl_test",
+ runtime_deps = [":impl_test_lib"]
+)
+### auto-generated section `test intellij.python.processOutput.impl` end
\ No newline at end of file
diff --git a/python/python-process-output/impl/intellij.python.processOutput.impl.iml b/python/python-process-output/impl/intellij.python.processOutput.impl.iml
new file mode 100644
index 000000000000..d828dae5dce0
--- /dev/null
+++ b/python/python-process-output/impl/intellij.python.processOutput.impl.iml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-compose-compiler-plugin/2.2.20/kotlin-compose-compiler-plugin-2.2.20.jar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/python/python-process-output/impl/resources/icons/commandQueue.svg b/python/python-process-output/impl/resources/icons/commandQueue.svg
new file mode 100644
index 000000000000..519e8a87b4b1
--- /dev/null
+++ b/python/python-process-output/impl/resources/icons/commandQueue.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/python/python-process-output/impl/resources/icons/commandQueue_dark.svg b/python/python-process-output/impl/resources/icons/commandQueue_dark.svg
new file mode 100644
index 000000000000..263388e4a3c9
--- /dev/null
+++ b/python/python-process-output/impl/resources/icons/commandQueue_dark.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/python/python-process-output/impl/resources/icons/process.svg b/python/python-process-output/impl/resources/icons/process.svg
new file mode 100644
index 000000000000..4246a0e76c03
--- /dev/null
+++ b/python/python-process-output/impl/resources/icons/process.svg
@@ -0,0 +1,3 @@
+
diff --git a/python/python-process-output/impl/resources/icons/processBack.svg b/python/python-process-output/impl/resources/icons/processBack.svg
new file mode 100644
index 000000000000..378432c6a9c6
--- /dev/null
+++ b/python/python-process-output/impl/resources/icons/processBack.svg
@@ -0,0 +1,3 @@
+
diff --git a/python/python-process-output/impl/resources/icons/processBackError.svg b/python/python-process-output/impl/resources/icons/processBackError.svg
new file mode 100644
index 000000000000..ac51234f2e45
--- /dev/null
+++ b/python/python-process-output/impl/resources/icons/processBackError.svg
@@ -0,0 +1,7 @@
+
diff --git a/python/python-process-output/impl/resources/icons/processBackError_dark.svg b/python/python-process-output/impl/resources/icons/processBackError_dark.svg
new file mode 100644
index 000000000000..085277b051b7
--- /dev/null
+++ b/python/python-process-output/impl/resources/icons/processBackError_dark.svg
@@ -0,0 +1,7 @@
+
diff --git a/python/python-process-output/impl/resources/icons/processBack_dark.svg b/python/python-process-output/impl/resources/icons/processBack_dark.svg
new file mode 100644
index 000000000000..22e36f9a509a
--- /dev/null
+++ b/python/python-process-output/impl/resources/icons/processBack_dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/python/python-process-output/impl/resources/icons/processError.svg b/python/python-process-output/impl/resources/icons/processError.svg
new file mode 100644
index 000000000000..6931ed80141c
--- /dev/null
+++ b/python/python-process-output/impl/resources/icons/processError.svg
@@ -0,0 +1,7 @@
+
diff --git a/python/python-process-output/impl/resources/icons/processError_dark.svg b/python/python-process-output/impl/resources/icons/processError_dark.svg
new file mode 100644
index 000000000000..d435f28789ae
--- /dev/null
+++ b/python/python-process-output/impl/resources/icons/processError_dark.svg
@@ -0,0 +1,7 @@
+
diff --git a/python/python-process-output/impl/resources/icons/process_dark.svg b/python/python-process-output/impl/resources/icons/process_dark.svg
new file mode 100644
index 000000000000..9f154bb73b45
--- /dev/null
+++ b/python/python-process-output/impl/resources/icons/process_dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/python/python-process-output/impl/resources/intellij.python.processOutput.impl.xml b/python/python-process-output/impl/resources/intellij.python.processOutput.impl.xml
new file mode 100644
index 000000000000..0692a49dcdbf
--- /dev/null
+++ b/python/python-process-output/impl/resources/intellij.python.processOutput.impl.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/python/python-process-output/impl/resources/messages/ProcessOutputBundle.properties b/python/python-process-output/impl/resources/messages/ProcessOutputBundle.properties
new file mode 100644
index 000000000000..4c84e37b719d
--- /dev/null
+++ b/python/python-process-output/impl/resources/messages/ProcessOutputBundle.properties
@@ -0,0 +1,38 @@
+process.output.title=Python Process Output
+
+process.output.icon.description.processBackError=Background Process Error
+process.output.icon.description.processBack=Background Process
+process.output.icon.description.processError=Process Error
+process.output.icon.description.process=Process
+process.output.icon.description.folder=Folder
+process.output.icon.description.search=Search
+process.output.icon.description.clear=Clear
+process.output.icon.description.dropdown=Dropdown
+process.output.icon.description.chevronRight=Chevron Right
+process.output.icon.description.chevronDown=Chevron Down
+process.output.icon.description.checked=Checked
+
+process.output.viewOptions.tooltip=View Options
+
+process.output.tree.search.placeholder=Search
+process.output.tree.buttons.expandAll=Expand All
+process.output.tree.buttons.collapseAll=Collapse All
+
+process.output.tree.blankMessage=No processes found
+
+process.output.output.buttons.copyOutput=Copy Output
+
+process.output.output.sections.info=Process Info
+process.output.output.sections.info.started=started
+process.output.output.sections.info.command=command
+process.output.output.sections.info.pid=pid
+process.output.output.sections.info.cwd=cwd
+process.output.output.sections.info.env=env
+
+process.output.output.sections.output=Process Output
+
+process.output.output.blankMessage=Select a process to view its output
+
+process.output.filters.tree.time=Show start time
+process.output.filters.tree.backgroundProcesses=Show background processes
+process.output.filters.output.tags=Show tags
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputApiImpl.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputApiImpl.kt
new file mode 100644
index 000000000000..1f9bd019b120
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputApiImpl.kt
@@ -0,0 +1,25 @@
+package com.intellij.python.processOutput.impl
+
+import com.intellij.openapi.components.service
+import com.intellij.openapi.project.Project
+import com.intellij.python.processOutput.ProcessOutputApi
+import org.jetbrains.annotations.Nls
+
+internal class ProcessOutputApiImpl : ProcessOutputApi {
+ override fun specifyAdditionalMessageToUser(
+ project: Project,
+ logId: Int,
+ text: @Nls String,
+ ) {
+ val service = project.service()
+ service.specifyAdditionalMessageToUser(logId, text)
+ }
+
+ override fun tryOpenLogInToolWindow(
+ project: Project,
+ logId: Int,
+ ): Boolean {
+ val service = project.service()
+ return service.tryOpenLogInToolWindow(logId)
+ }
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputBundle.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputBundle.kt
new file mode 100644
index 000000000000..50e2ec7b2e7b
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputBundle.kt
@@ -0,0 +1,18 @@
+package com.intellij.python.processOutput.impl
+
+import com.intellij.DynamicBundle
+import org.jetbrains.annotations.Nls
+import org.jetbrains.annotations.NonNls
+import org.jetbrains.annotations.PropertyKey
+
+internal object ProcessOutputBundle {
+ private const val BUNDLE_FQN: @NonNls String = "messages.ProcessOutputBundle"
+ private val BUNDLE = DynamicBundle(ProcessOutputBundle::class.java, BUNDLE_FQN)
+
+ fun message(
+ key: @PropertyKey(resourceBundle = BUNDLE_FQN) String,
+ vararg params: Any,
+ ): @Nls String {
+ return BUNDLE.getMessage(key, *params)
+ }
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputControllerService.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputControllerService.kt
new file mode 100644
index 000000000000..d71d2e71f34e
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputControllerService.kt
@@ -0,0 +1,510 @@
+package com.intellij.python.processOutput.impl
+
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.clearText
+import androidx.compose.runtime.mutableStateSetOf
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.snapshots.SnapshotStateSet
+import androidx.compose.ui.util.fastMaxOfOrDefault
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.application.EDT
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.components.service
+import com.intellij.openapi.ide.CopyPasteManager
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.wm.ToolWindowManager
+import com.intellij.python.community.execService.impl.ExecLoggerService
+import com.intellij.python.community.execService.impl.LoggedProcess
+import com.intellij.python.community.execService.impl.LoggedProcessLine
+import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
+import com.intellij.python.processOutput.impl.ui.components.FilterItem
+import com.intellij.python.processOutput.impl.ui.toggle
+import com.intellij.util.concurrency.annotations.RequiresEdt
+import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
+import com.jetbrains.python.TraceContext
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+import org.jetbrains.annotations.ApiStatus
+import org.jetbrains.annotations.Nls
+import org.jetbrains.jewel.foundation.lazy.SelectableLazyListState
+import org.jetbrains.jewel.foundation.lazy.tree.Tree
+import org.jetbrains.jewel.foundation.lazy.tree.TreeGeneratorScope
+import org.jetbrains.jewel.foundation.lazy.tree.TreeState
+import org.jetbrains.jewel.foundation.lazy.tree.buildTree
+
+internal object ProcessOutputControllerServiceLimits {
+ const val MAX_PROCESSES = 256
+}
+
+internal interface ProcessOutputController {
+ val selectedProcess: StateFlow
+ val processTreeUiState: TreeUiState
+ val processOutputUiState: OutputUiState
+
+ fun collapseAllContexts()
+ fun expandAllContexts()
+ fun selectProcess(process: LoggedProcess)
+ fun toggleTreeFilter(filter: TreeFilter)
+ fun toggleOutputFilter(filter: OutputFilter)
+ fun toggleProcessInfo()
+ fun toggleProcessOutput()
+ fun specifyAdditionalMessageToUser(logId: Int, message: @Nls String)
+ fun copyOutputToClipboard(loggedProcess: LoggedProcess)
+
+ @RequiresEdt
+ fun tryOpenLogInToolWindow(logId: Int): Boolean
+}
+
+@ApiStatus.Internal
+data class TreeUiState(
+ val filters: Set,
+ val searchState: TextFieldState,
+ val selectableLazyListState: SelectableLazyListState,
+ val treeState: TreeState,
+ val tree: StateFlow>,
+)
+
+@ApiStatus.Internal
+sealed class TreeFilter : FilterItem {
+ object ShowTime : TreeFilter() {
+ override val title: String = message("process.output.filters.tree.time")
+ }
+
+ object ShowBackgroundProcesses : TreeFilter() {
+ override val title: String = message("process.output.filters.tree.backgroundProcesses")
+ }
+}
+
+@ApiStatus.Internal
+sealed interface TreeNode {
+ data class Context(
+ val traceContext: TraceContext,
+ ) : TreeNode
+
+ data class Process(
+ val process: LoggedProcess,
+ ) : TreeNode
+}
+
+@ApiStatus.Internal
+sealed class OutputFilter : FilterItem {
+ object ShowTags : OutputFilter() {
+ override val title: String = message("process.output.filters.output.tags")
+ }
+}
+
+@ApiStatus.Internal
+data class OutputUiState(
+ val filters: Set,
+ val isInfoExpanded: StateFlow,
+ val isOutputExpanded: StateFlow,
+ val lazyListState: LazyListState,
+)
+
+@ApiStatus.Internal
+@Service(Service.Level.PROJECT)
+class ProcessOutputControllerService(
+ private val project: Project,
+ private val coroutineScope: CoroutineScope,
+) : ProcessOutputController {
+ private val loggedProcesses: StateFlow> = run {
+ var processList = listOf()
+ ApplicationManager.getApplication().service()
+ .processes
+ .map {
+ processList = processList + it
+
+ if (processList.size > ProcessOutputControllerServiceLimits.MAX_PROCESSES) {
+ processList = processList.drop(
+ processList.size - ProcessOutputControllerServiceLimits.MAX_PROCESSES,
+ )
+ }
+
+ it.traceContext
+ ?.takeIf { context -> context != NON_INTERACTIVE_ROOT_TRACE_CONTEXT }
+ ?.also { context ->
+ processTreeUiState.treeState.openNodes(context.hierarchy())
+ }
+
+ processList
+ }
+ .stateIn(
+ coroutineScope + Dispatchers.EDT,
+ SharingStarted.Eagerly,
+ emptyList(),
+ )
+ }
+
+ private val processTree = MutableStateFlow(buildTree {})
+ private val processTreeFilters: SnapshotStateSet = mutableStateSetOf(
+ TreeFilter.ShowTime,
+ )
+
+ private val processOutputFilters: SnapshotStateSet = mutableStateSetOf(
+ OutputFilter.ShowTags,
+ )
+ private val processOutputInfoExpanded = MutableStateFlow(false)
+ private val processOutputOutputExpanded = MutableStateFlow(true)
+
+ override val selectedProcess: MutableStateFlow = MutableStateFlow(null)
+ override val processTreeUiState: TreeUiState = run {
+ val selectableLazyListState = SelectableLazyListState(LazyListState())
+ TreeUiState(
+ filters = processTreeFilters,
+ searchState = TextFieldState(),
+ selectableLazyListState = selectableLazyListState,
+ treeState = TreeState(selectableLazyListState),
+ tree = processTree,
+ )
+ }
+ override val processOutputUiState: OutputUiState = OutputUiState(
+ filters = processOutputFilters,
+ isInfoExpanded = processOutputInfoExpanded,
+ isOutputExpanded = processOutputOutputExpanded,
+ lazyListState = LazyListState(),
+ )
+
+ init {
+ collectSearchStats()
+ collectProcessTree()
+ ensureProcessTreeScroll()
+ }
+
+ override fun collapseAllContexts() {
+ processTreeUiState.treeState.openNodes.forEach {
+ processTreeUiState.treeState.toggleNode(it)
+ }
+
+ ProcessOutputUsageCollector.treeCollapseAllClicked()
+ }
+
+ override fun expandAllContexts() {
+ loggedProcesses.value
+ .mapNotNull { it.traceContext }
+ .toSet()
+ .subtract(processTreeUiState.treeState.openNodes)
+ .forEach {
+ processTreeUiState.treeState.toggleNode(it)
+ }
+
+ ProcessOutputUsageCollector.treeExpandAllClicked()
+ }
+
+ override fun selectProcess(process: LoggedProcess) {
+ selectedProcess.value = process
+ ProcessOutputUsageCollector.treeProcessSelected()
+ }
+
+ override fun toggleTreeFilter(filter: TreeFilter) {
+ processTreeFilters.toggle(filter)
+
+ when (filter) {
+ TreeFilter.ShowBackgroundProcesses ->
+ ProcessOutputUsageCollector.treeFilterBackgroundProcessesToggled(
+ processTreeFilters.contains(TreeFilter.ShowBackgroundProcesses),
+ )
+ TreeFilter.ShowTime ->
+ ProcessOutputUsageCollector.treeFilterTimeToggled(
+ processTreeFilters.contains(TreeFilter.ShowTime),
+ )
+ }
+ }
+
+ override fun toggleOutputFilter(filter: OutputFilter) {
+ processOutputFilters.toggle(filter)
+
+ when (filter) {
+ OutputFilter.ShowTags ->
+ ProcessOutputUsageCollector.outputFilterShowTagsToggled(
+ processOutputFilters.contains(OutputFilter.ShowTags),
+ )
+ }
+ }
+
+ override fun toggleProcessInfo() {
+ val expanded = processOutputInfoExpanded.value
+
+ processOutputInfoExpanded.value = !expanded
+
+ ProcessOutputUsageCollector.outputProcessInfoToggled(!expanded)
+ }
+
+ override fun toggleProcessOutput() {
+ val expanded = processOutputOutputExpanded.value
+
+ processOutputOutputExpanded.value = !expanded
+
+ ProcessOutputUsageCollector.outputProcessOutputToggled(!expanded)
+ }
+
+ override fun copyOutputToClipboard(loggedProcess: LoggedProcess) {
+ val showTags = processOutputUiState.filters.contains(OutputFilter.ShowTags)
+
+ val stringToCopy = buildString {
+ loggedProcess.lines.replayCache.forEach { line ->
+ if (showTags) {
+ val tag = when (line.kind) {
+ LoggedProcessLine.Kind.ERR -> Tag.ERROR
+ LoggedProcessLine.Kind.OUT -> Tag.OUTPUT
+ }
+
+ append("[$tag] ".padStart(Tag.maxLength + 3))
+ } else {
+ repeat(Tag.maxLength + 3) {
+ append(' ')
+ }
+ }
+
+ appendLine(line.text)
+ }
+
+ loggedProcess.exitInfo.value?.also {
+ append("[${Tag.EXIT}] ".padStart(Tag.maxLength + 3))
+ append(it.exitValue)
+
+ it.additionalMessageToUser?.also { message ->
+ append(": ")
+ append(message)
+ }
+
+ appendLine()
+ }
+ }
+
+ CopyPasteManager.copyTextToClipboard(stringToCopy)
+
+ ProcessOutputUsageCollector.outputCopyClicked()
+ }
+
+ @RequiresEdt
+ override fun tryOpenLogInToolWindow(logId: Int): Boolean {
+ val match = loggedProcesses.value.find { process -> process.id == logId }
+
+ if (match == null) {
+ return false
+ }
+
+ ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID)?.show()
+
+ coroutineScope.launch(Dispatchers.EDT) {
+ val process = loggedProcesses.value.find { it.id == logId } ?: return@launch
+
+ // select the process
+ selectedProcess.value = process
+
+ // open all the parent nodes of the process
+ process.traceContext?.also {
+ processTreeUiState.treeState.openNodes(it.hierarchy())
+ }
+
+ // select the process in the list state
+ processTreeUiState.treeState.selectedKeys = setOf(process)
+
+ // scroll to the top of the list
+ processTreeUiState.selectableLazyListState.lazyListState.scrollToItem(0)
+
+ // clear search text
+ processTreeUiState.searchState.clearText()
+
+ // expand process output section
+ processOutputOutputExpanded.value = true
+
+ // wait until output has recomposed
+ delay(100.milliseconds)
+
+ // scroll output all the way to the bottom
+ val index = processOutputUiState.lazyListState.layoutInfo.totalItemsCount
+ processOutputUiState.lazyListState.scrollToItem(index.coerceAtLeast(0))
+ }
+
+ ProcessOutputUsageCollector.toolwindowOpenedDueToError()
+
+ return true
+ }
+
+ override fun specifyAdditionalMessageToUser(logId: Int, @Nls message: String) {
+ val trimmed = message.trim()
+
+ if (trimmed.isEmpty()) {
+ return
+ }
+
+ loggedProcesses.value.find { it.id == logId }?.exitInfo?.also { exitInfo ->
+ exitInfo.value = exitInfo.value?.copy(additionalMessageToUser = message)
+ }
+ }
+
+ private fun collectSearchStats() {
+ coroutineScope.launch {
+ snapshotFlow { processTreeUiState.searchState.text }
+ .collect {
+ ProcessOutputUsageCollector.treeSearchEdited()
+ }
+ }
+ }
+
+ private fun collectProcessTree() {
+ val backgroundErrorProcesses = MutableStateFlow>(setOf())
+
+ coroutineScope.launch {
+ loggedProcesses
+ .collect { list ->
+ backgroundErrorProcesses.value = setOf()
+ list
+ .filter { it.traceContext == NON_INTERACTIVE_ROOT_TRACE_CONTEXT }
+ .forEach { process ->
+ launch {
+ process.exitInfo.collect {
+ val exitValue = it?.exitValue
+ if (exitValue != null && exitValue != 0) {
+ backgroundErrorProcesses.value += process.id
+ } else {
+ backgroundErrorProcesses.value -= process.id
+ }
+ }
+ }
+ }
+ }
+ }
+
+ combine(
+ backgroundErrorProcesses,
+ loggedProcesses,
+ snapshotFlow { processTreeUiState.searchState.text },
+ snapshotFlow { processTreeUiState.filters.toSet() },
+ )
+ { backgroundErrorProcesses, processList, search, filters ->
+ val lowercaseSearch = search.toString().trim().lowercase()
+ var filteredProcesses =
+ processList
+ .reversed()
+ .filter {
+ it.shortenedCommandString
+ .lowercase()
+ .contains(lowercaseSearch)
+ }
+
+ if (!filters.contains(TreeFilter.ShowBackgroundProcesses)) {
+ filteredProcesses = filteredProcesses.filter {
+ it.traceContext != NON_INTERACTIVE_ROOT_TRACE_CONTEXT
+ || backgroundErrorProcesses.contains(it.id)
+ }
+ }
+
+ data class Node(
+ val traceContext: TraceContext? = null,
+ val process: LoggedProcess? = null,
+ val children: MutableList = mutableListOf(),
+ )
+
+ val root = mutableListOf()
+
+ filteredProcesses.forEach { process ->
+ when (val traceContext = process.traceContext) {
+ null, NON_INTERACTIVE_ROOT_TRACE_CONTEXT -> root += Node(process = process)
+ else -> {
+ val hierarchy = traceContext.hierarchy()
+ var currentRoot = root
+
+ hierarchy.forEach { currentContext ->
+ val node =
+ currentRoot
+ .firstOrNull { node ->
+ node.traceContext == currentContext
+ }
+ ?: run {
+ Node(traceContext = currentContext).also {
+ currentRoot += it
+ }
+ }
+
+ currentRoot = node.children
+ }
+
+ currentRoot += Node(process = process)
+ }
+ }
+ }
+
+ fun TreeGeneratorScope.buildNodeTree(root: List) {
+ root.forEach { (traceContext, process, children) ->
+ if (traceContext != null) {
+ addNode(TreeNode.Context(traceContext), traceContext) {
+ buildNodeTree(children)
+ }
+ } else if (process != null) {
+ addLeaf(TreeNode.Process(process), process)
+ }
+ }
+ }
+
+ processTree.value = buildTree {
+ buildNodeTree(root)
+ }
+ }.launchIn(coroutineScope)
+ }
+
+ @OptIn(FlowPreview::class)
+ private fun ensureProcessTreeScroll() {
+ coroutineScope.launch(Dispatchers.EDT) {
+ var prevCanScrollBackwards = false
+ var prevLastItem: Any? = null
+
+ combine(
+ snapshotFlow { processTreeUiState.treeState.canScrollBackward },
+ loggedProcesses,
+ ) { canScrollBackwards, processes -> canScrollBackwards to processes }
+ .debounce(100.milliseconds)
+ .collect { (canScrollBackwards, processes) ->
+ val lastItem = processes.lastOrNull()
+
+ // scroll to the top if an item was added when the list tree fully scrolled to
+ // the top
+ if (canScrollBackwards && !prevCanScrollBackwards && lastItem != prevLastItem) {
+ processTreeUiState.selectableLazyListState.lazyListState.scrollToItem(0)
+ } else {
+ prevCanScrollBackwards = canScrollBackwards
+ }
+
+ prevLastItem = lastItem
+ }
+ }
+ }
+}
+
+internal object Tag {
+ const val ERROR = "error"
+ const val OUTPUT = "output"
+ const val EXIT = "exit"
+
+ val maxLength: Int =
+ Tag::class.java.declaredFields
+ .filter { it.type == String::class.java }
+ .fastMaxOfOrDefault(0) { (it.get(null) as String).length }
+}
+
+private fun TraceContext.hierarchy(): List {
+ val hierarchy = mutableListOf()
+ var currentContext: TraceContext? = this
+
+ while (currentContext != null) {
+ hierarchy.add(0, currentContext)
+ currentContext = currentContext.parentTraceContext
+ }
+
+ return hierarchy
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputToolWindowFactory.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputToolWindowFactory.kt
new file mode 100644
index 000000000000..35a41109daec
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputToolWindowFactory.kt
@@ -0,0 +1,38 @@
+package com.intellij.python.processOutput.impl
+
+import androidx.compose.runtime.remember
+import com.intellij.openapi.components.service
+import com.intellij.openapi.project.DumbAware
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.wm.ToolWindow
+import com.intellij.openapi.wm.ToolWindowFactory
+import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
+import com.intellij.python.processOutput.impl.ui.components.ToolWindow
+import org.jetbrains.annotations.ApiStatus
+import org.jetbrains.jewel.bridge.addComposeTab
+
+internal const val TOOL_WINDOW_ID = "PythonProcessOutput"
+
+@ApiStatus.Internal
+class ProcessOutputToolWindowFactory : ToolWindowFactory, DumbAware {
+ override fun init(toolWindow: ToolWindow) {
+ // pre-initialize the service to warm up the logged processes flow
+ toolWindow.project.service()
+
+ toolWindow.setStripeTitleProvider { message("process.output.title") }
+ toolWindow.setStripeShortTitleProvider { message("process.output.title") }
+ }
+
+ override fun createToolWindowContent(
+ project: Project,
+ toolWindow: ToolWindow,
+ ) {
+ toolWindow.addComposeTab(focusOnClickInside = true) {
+ val service = remember { project.service() }
+
+ ToolWindow(service)
+ }
+ }
+}
+
+
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputUsageCollector.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputUsageCollector.kt
new file mode 100644
index 000000000000..5e7fff7f1146
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputUsageCollector.kt
@@ -0,0 +1,124 @@
+package com.intellij.python.processOutput.impl
+
+import com.intellij.internal.statistic.eventLog.EventLogGroup
+import com.intellij.internal.statistic.eventLog.events.EventFields
+import com.intellij.internal.statistic.service.fus.collectors.CounterUsagesCollector
+import org.jetbrains.annotations.ApiStatus
+
+@ApiStatus.Internal
+object ProcessOutputUsageCollector : CounterUsagesCollector() {
+ private val GROUP: EventLogGroup = EventLogGroup(
+ "pycharm.processOutputToolWindow",
+ version = 1,
+ recorder = "FUS",
+ description = "Statistics for Python's process output toolwindowk",
+ )
+
+ private val TOGGLED_FIELD = EventFields.Boolean("enabled")
+
+ private val TREE_PROCESS_SELECTED = GROUP.registerEvent(
+ "tree.processSelected",
+ "Process selected",
+ )
+ private val TREE_SEARCH_EDITED = GROUP.registerEvent(
+ "tree.searchEdited",
+ "Process search field edited",
+ )
+ private val TREE_FILTER_TIME_TOGGLED = GROUP.registerVarargEvent(
+ "tree.filter.timeToggled",
+ "Time filter toggled",
+ TOGGLED_FIELD,
+ )
+ private val TREE_FILTER_BACKGROUND_PROCESSES_TOGGLED = GROUP.registerVarargEvent(
+ "tree.filter.backgroundProcessesToggled",
+ "Background processes filter toggled",
+ TOGGLED_FIELD,
+ )
+ private val TREE_EXPAND_ALL_CLICKED = GROUP.registerEvent(
+ "tree.expandAllClicked",
+ "Expand all clicked",
+ )
+ private val TREE_COLLAPSE_ALL_CLICKED = GROUP.registerEvent(
+ "tree.collapseAllClicked",
+ "Collapse all clicked",
+ )
+ private val OUTPUT_FILTER_SHOW_TAGS_TOGGLED = GROUP.registerVarargEvent(
+ "output.filter.showTagsToggled",
+ "Show tags filter toggled",
+ TOGGLED_FIELD,
+ )
+ private val OUTPUT_COPY_CLICKED = GROUP.registerVarargEvent(
+ "output.copyClicked",
+ "Copy clicked",
+ )
+ private val OUTPUT_PROCESS_INFO_TOGGLED = GROUP.registerVarargEvent(
+ "output.processInfoToggled",
+ "Process info section toggled",
+ TOGGLED_FIELD,
+ )
+ private val OUTPUT_PROCESS_OUTPUT_TOGGLED = GROUP.registerVarargEvent(
+ "output.processOutputToggled",
+ "Process output section toggled",
+ TOGGLED_FIELD,
+ )
+ private val TOOLWINDOW_OPENED_DUE_TO_ERROR = GROUP.registerEvent(
+ "toolwindow.openedDueToError",
+ "Toolwindow opened due to error",
+ )
+
+ override fun getGroup(): EventLogGroup = GROUP
+
+ fun treeProcessSelected() {
+ TREE_PROCESS_SELECTED.log()
+ }
+
+ fun treeSearchEdited() {
+ TREE_SEARCH_EDITED.log()
+ }
+
+ fun treeFilterTimeToggled(enabled: Boolean) {
+ TREE_FILTER_TIME_TOGGLED.log(
+ TOGGLED_FIELD.with(enabled),
+ )
+ }
+
+ fun treeFilterBackgroundProcessesToggled(enabled: Boolean) {
+ TREE_FILTER_BACKGROUND_PROCESSES_TOGGLED.log(
+ TOGGLED_FIELD.with(enabled),
+ )
+ }
+
+ fun treeExpandAllClicked() {
+ TREE_EXPAND_ALL_CLICKED.log()
+ }
+
+ fun treeCollapseAllClicked() {
+ TREE_COLLAPSE_ALL_CLICKED.log()
+ }
+
+ fun outputFilterShowTagsToggled(enabled: Boolean) {
+ OUTPUT_FILTER_SHOW_TAGS_TOGGLED.log(
+ TOGGLED_FIELD.with(enabled),
+ )
+ }
+
+ fun outputCopyClicked() {
+ OUTPUT_COPY_CLICKED.log()
+ }
+
+ fun outputProcessInfoToggled(enabled: Boolean) {
+ OUTPUT_PROCESS_INFO_TOGGLED.log(
+ TOGGLED_FIELD.with(enabled),
+ )
+ }
+
+ fun outputProcessOutputToggled(enabled: Boolean) {
+ OUTPUT_PROCESS_OUTPUT_TOGGLED.log(
+ TOGGLED_FIELD.with(enabled),
+ )
+ }
+
+ fun toolwindowOpenedDueToError() {
+ TOOLWINDOW_OPENED_DUE_TO_ERROR.log()
+ }
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/PythonProcessOutputIcons.java b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/PythonProcessOutputIcons.java
new file mode 100644
index 000000000000..93d3c0d8bee5
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/PythonProcessOutputIcons.java
@@ -0,0 +1,22 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.python.processOutput.impl;
+
+import com.intellij.ui.IconManager;
+import org.jetbrains.annotations.NotNull;
+
+import javax.swing.*;
+
+/**
+ * NOTE THIS FILE IS AUTO-GENERATED
+ * DO NOT EDIT IT BY HAND, run "Generate icon classes" configuration instead
+ */
+public final class PythonProcessOutputIcons {
+ private static @NotNull Icon load(@NotNull String path, int cacheKey, int flags) {
+ return IconManager.getInstance().loadRasterizedIcon(path, PythonProcessOutputIcons.class.getClassLoader(), cacheKey, flags);
+ }
+ /** 16x16 */ public static final @NotNull Icon CommandQueue = load("icons/commandQueue.svg", 1434828820, 2);
+ /** 16x16 */ public static final @NotNull Icon Process = load("icons/process.svg", -2015894419, 2);
+ /** 16x16 */ public static final @NotNull Icon ProcessBack = load("icons/processBack.svg", 1055076489, 2);
+ /** 16x16 */ public static final @NotNull Icon ProcessBackError = load("icons/processBackError.svg", -1366278226, 2);
+ /** 16x16 */ public static final @NotNull Icon ProcessError = load("icons/processError.svg", 1713815623, 2);
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/formatters.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/formatters.kt
new file mode 100644
index 000000000000..2c79a73fd933
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/formatters.kt
@@ -0,0 +1,17 @@
+package com.intellij.python.processOutput.impl
+
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import kotlin.time.Instant
+import kotlin.time.toJavaInstant
+
+private val fullFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")
+ .withZone(ZoneId.systemDefault())
+private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
+ .withZone(ZoneId.systemDefault())
+
+internal fun Instant.formatFull(): String =
+ fullFormatter.format(this.toJavaInstant())
+
+internal fun Instant.formatTime(): String =
+ timeFormatter.format(this.toJavaInstant())
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/Colors.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/Colors.kt
new file mode 100644
index 000000000000..0565fbe01e86
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/Colors.kt
@@ -0,0 +1,24 @@
+package com.intellij.python.processOutput.impl.ui
+
+import org.jetbrains.jewel.bridge.retrieveColorOrUnspecified
+
+internal object Colors {
+ object Tree {
+ val Selected
+ get() = retrieveColorOrUnspecified("List.selectionBackground")
+
+ val Hovered
+ get() = retrieveColorOrUnspecified("ColorPalette.Gray3")
+
+ val Info
+ get() = retrieveColorOrUnspecified("Component.infoForeground")
+ }
+
+ object Output {
+ val ErrorText
+ get() = retrieveColorOrUnspecified("Label.errorForeground")
+
+ val Info
+ get() = retrieveColorOrUnspecified("Label.disabledForeground")
+ }
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/Icons.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/Icons.kt
new file mode 100644
index 000000000000..7269bf85448c
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/Icons.kt
@@ -0,0 +1,29 @@
+package com.intellij.python.processOutput.impl.ui
+
+import com.intellij.python.processOutput.impl.PythonProcessOutputIcons
+import org.jetbrains.jewel.ui.icon.PathIconKey
+import org.jetbrains.jewel.ui.icons.AllIconsKeys
+
+internal object Icons {
+ @JvmField val CommandQueue = PythonProcessOutputIcons.CommandQueue
+
+ object Keys {
+ val Process = PathIconKey("/icons/process.svg", Icons::class.java)
+ val ProcessBack = PathIconKey("/icons/processBack.svg", Icons::class.java)
+ val ProcessBackError = PathIconKey("/icons/processBackError.svg", Icons::class.java)
+ val ProcessError = PathIconKey("/icons/processError.svg", Icons::class.java)
+ val Filter = AllIconsKeys.Actions.Show
+ val Dropdown = AllIconsKeys.General.Dropdown
+ val ExpandAll = AllIconsKeys.Actions.Expandall
+ val CollapseAll = AllIconsKeys.Actions.Collapseall
+ val Checked = AllIconsKeys.Actions.Checked
+ val Search = AllIconsKeys.Actions.Search
+ val Close = AllIconsKeys.Actions.Close
+ val CloseHovered = AllIconsKeys.Actions.CloseHovered
+ val Error = AllIconsKeys.General.Error
+ val ChevronDown = AllIconsKeys.General.ChevronDown
+ val ChevronRight = AllIconsKeys.General.ChevronRight
+ val Copy = AllIconsKeys.General.Copy
+ val Folder = AllIconsKeys.Nodes.Folder
+ }
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/ActionIconButton.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/ActionIconButton.kt
new file mode 100644
index 000000000000..c7764c068a3e
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/ActionIconButton.kt
@@ -0,0 +1,75 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
+import com.intellij.python.processOutput.impl.ui.Icons
+import org.jetbrains.jewel.foundation.modifier.thenIf
+import org.jetbrains.jewel.ui.component.Icon
+import org.jetbrains.jewel.ui.component.IconButton
+import org.jetbrains.jewel.ui.component.Text
+import org.jetbrains.jewel.ui.component.Tooltip
+import org.jetbrains.jewel.ui.disabledAppearance
+import org.jetbrains.jewel.ui.icon.IconKey
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+internal fun ActionIconButton(
+ modifier: Modifier = Modifier,
+ iconKey: IconKey,
+ tooltipText: String,
+ enabled: Boolean = true,
+ onClick: () -> Unit = {},
+ iconModifier: Modifier = Modifier,
+ isDropdown: Boolean = false,
+) {
+ Tooltip(
+ tooltip = {
+ Row {
+ Text(tooltipText)
+ }
+ },
+ ) {
+ IconButton(
+ modifier = modifier.size(26.dp)
+ .testTag(ActionIconButtonTestTags.BUTTON),
+ enabled = enabled,
+ onClick = onClick,
+ ) {
+ Icon(
+ modifier = iconModifier
+ .testTag(ActionIconButtonTestTags.ICON)
+ .thenIf(!enabled) {
+ disabledAppearance()
+ },
+ key = iconKey,
+ contentDescription = tooltipText,
+ )
+
+ if (isDropdown) {
+ Icon(
+ modifier = iconModifier
+ .offset(x = 1.dp, y = 1.dp)
+ .testTag(ActionIconButtonTestTags.DROPDOWN_ICON)
+ .thenIf(!enabled) {
+ disabledAppearance()
+ },
+ key = Icons.Keys.Dropdown,
+ contentDescription = message("process.output.icon.description.dropdown"),
+ )
+ }
+ }
+ }
+}
+
+internal object ActionIconButtonTestTags {
+ const val BUTTON = "ProcessOutput.ActionIconButton.Button"
+ const val ICON = "ProcessOutput.ActionIconButton.Icon"
+ const val DROPDOWN_ICON = "ProcessOutput.ActionIconButton.DropdownIcon"
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/CollapsibleListSection.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/CollapsibleListSection.kt
new file mode 100644
index 000000000000..e1e753d66398
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/CollapsibleListSection.kt
@@ -0,0 +1,80 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
+import com.intellij.python.processOutput.impl.ui.Icons
+import com.intellij.python.processOutput.impl.ui.expandable
+import com.intellij.python.processOutput.impl.ui.isExpanded
+import org.jetbrains.jewel.ui.component.Icon
+import org.jetbrains.jewel.ui.component.scrollbarContentSafePadding
+
+@Composable
+internal fun CollapsibleListSection(
+ text: String,
+ modifier: Modifier = Modifier,
+ isExpanded: Boolean,
+ onToggle: () -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+
+ Row(
+ modifier =
+ modifier.fillMaxWidth()
+ .height(28.dp)
+ .padding(start = 8.dp, end = scrollbarContentSafePadding())
+ .expandable(interactionSource, onToggle)
+ .semantics { this.isExpanded = isExpanded },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val (key, contentDescription, testTag) =
+ if (isExpanded) {
+ Triple(
+ Icons.Keys.ChevronDown,
+ message("process.output.icon.description.chevronDown"),
+ CollapsibleListSectionTestTags.CHEVRON_DOWN,
+ )
+ } else {
+ Triple(
+ Icons.Keys.ChevronRight,
+ message("process.output.icon.description.chevronRight"),
+ CollapsibleListSectionTestTags.CHEVRON_RIGHT,
+ )
+ }
+
+ Icon(
+ key = key,
+ contentDescription = contentDescription,
+ modifier = Modifier
+ .padding(horizontal = 4.dp)
+ .size(16.dp)
+ .testTag(testTag),
+ )
+
+ InterText(
+ text = text,
+ overflow = TextOverflow.Ellipsis,
+ fontWeight = FontWeight.Normal,
+ )
+ }
+}
+
+internal object CollapsibleListSectionTestTags {
+ const val CHEVRON_RIGHT = "ProcessOutput.CollapsibleListSection.ChevronRight"
+ const val CHEVRON_DOWN = "ProcessOutput.CollapsibleListSection.ChevronDown"
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/EmptyContainerNotice.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/EmptyContainerNotice.kt
new file mode 100644
index 000000000000..67c4157ff360
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/EmptyContainerNotice.kt
@@ -0,0 +1,26 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.intellij.python.processOutput.impl.ui.Colors
+import org.jetbrains.jewel.ui.component.Text
+
+@Composable
+internal fun EmptyContainerNotice(
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = text,
+ modifier = modifier,
+ color = Colors.Output.Info,
+ )
+ }
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/FilterActionGroup.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/FilterActionGroup.kt
new file mode 100644
index 000000000000..b83a462dbd1d
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/FilterActionGroup.kt
@@ -0,0 +1,129 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.InputMode
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
+import com.intellij.python.processOutput.impl.ui.Icons
+import com.intellij.python.processOutput.impl.ui.thenIfNotNull
+import kotlinx.collections.immutable.ImmutableList
+import org.jetbrains.annotations.ApiStatus
+import org.jetbrains.jewel.foundation.Stroke
+import org.jetbrains.jewel.foundation.modifier.border
+import org.jetbrains.jewel.foundation.modifier.onHover
+import org.jetbrains.jewel.foundation.modifier.thenIf
+import org.jetbrains.jewel.foundation.theme.JewelTheme
+import org.jetbrains.jewel.ui.component.Icon
+import org.jetbrains.jewel.ui.component.PopupMenu
+import org.jetbrains.jewel.ui.component.Text
+import org.jetbrains.jewel.ui.component.items
+import org.jetbrains.jewel.ui.theme.iconButtonStyle
+
+@Composable
+internal fun FilterActionGroup(
+ tooltipText: String,
+ items: ImmutableList>,
+ isSelected: (T) -> Boolean,
+ onItemClick: (T) -> Unit,
+ enabled: Boolean = true,
+ modifier: Modifier = Modifier,
+ menuModifier: Modifier = Modifier,
+) {
+ var isMenuOpen by remember { mutableStateOf(false) }
+
+ Box {
+ ActionIconButton(
+ modifier = modifier
+ .thenIf(isMenuOpen) {
+ background(
+ color = JewelTheme.iconButtonStyle.colors.backgroundPressed,
+ shape = RoundedCornerShape(JewelTheme.iconButtonStyle.metrics.cornerSize),
+ )
+ .border(
+ alignment = Stroke.Alignment.Inside,
+ width = JewelTheme.iconButtonStyle.metrics.borderWidth,
+ color = JewelTheme.iconButtonStyle.colors.backgroundPressed,
+ shape = RoundedCornerShape(JewelTheme.iconButtonStyle.metrics.cornerSize),
+ )
+ },
+ iconKey = Icons.Keys.Filter,
+ tooltipText = tooltipText,
+ enabled = enabled,
+ onClick = { isMenuOpen = !isMenuOpen },
+ isDropdown = true,
+ )
+
+ if (isMenuOpen) {
+ var isHovered by remember { mutableStateOf(false) }
+
+ PopupMenu(
+ onDismissRequest = {
+ if (it == InputMode.Touch && !isHovered) {
+ isMenuOpen = false
+ true
+ } else {
+ false
+ }
+ },
+ horizontalAlignment = Alignment.Start,
+ modifier = menuModifier.onHover { isHovered = it },
+ ) {
+ items(
+ items = items,
+ isSelected = { isSelected(it.item) },
+ onItemClick = { onItemClick(it.item) },
+ ) {
+ Row(
+ modifier = Modifier.thenIfNotNull(it.testTag) { tag ->
+ testTag(tag)
+ },
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (isSelected(it.item)) {
+ Icon(
+ Icons.Keys.Checked,
+ message("process.output.icon.description.checked"),
+ modifier = Modifier.testTag(FilterActionGroupTestTags.CHECKED_ICON),
+ )
+ } else {
+ Spacer(Modifier.width(16.dp))
+ }
+
+ Text(it.item.title)
+ }
+ }
+ }
+ }
+ }
+}
+
+@ApiStatus.Internal
+interface FilterItem {
+ val title: String
+}
+
+internal data class FilterEntry(
+ val item: T,
+ val testTag: String? = null,
+)
+
+internal object FilterActionGroupTestTags {
+ const val CHECKED_ICON = "ProcessOutput.FilterActionGroup.CheckedIcon"
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/InterText.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/InterText.kt
new file mode 100644
index 000000000000..380a2aefc116
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/InterText.kt
@@ -0,0 +1,52 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import org.jetbrains.jewel.ui.component.Text
+
+@Composable
+internal fun InterText(
+ text: String,
+ modifier: Modifier = Modifier,
+ color: Color = Color.Unspecified,
+ overflow: TextOverflow = TextOverflow.Clip,
+ fontWeight: FontWeight? = null,
+ textAlign: TextAlign = TextAlign.Unspecified,
+) {
+ InterText(
+ text = AnnotatedString(text),
+ modifier = modifier,
+ color = color,
+ overflow = overflow,
+ fontWeight = fontWeight,
+ textAlign = textAlign,
+ )
+}
+
+@OptIn(ExperimentalTextApi::class)
+@Composable
+internal fun InterText(
+ text: AnnotatedString,
+ modifier: Modifier = Modifier,
+ color: Color = Color.Unspecified,
+ overflow: TextOverflow = TextOverflow.Clip,
+ fontWeight: FontWeight? = null,
+ textAlign: TextAlign = TextAlign.Unspecified,
+) {
+ Text(
+ text = text,
+ modifier = modifier,
+ color = color,
+ overflow = overflow,
+ fontFamily = FontFamily("Inter"),
+ fontWeight = fontWeight,
+ textAlign = textAlign,
+ )
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/OutputSection.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/OutputSection.kt
new file mode 100644
index 000000000000..dab1a1d1ea19
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/OutputSection.kt
@@ -0,0 +1,384 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.text.selection.DisableSelection
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import org.jetbrains.jewel.ui.component.Text
+import org.jetbrains.jewel.ui.component.VerticallyScrollableContainer
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import com.intellij.python.community.execService.impl.LoggedProcessLine
+import com.intellij.python.processOutput.impl.ui.Icons
+import com.intellij.python.processOutput.impl.OutputFilter
+import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
+import com.intellij.python.processOutput.impl.ProcessOutputController
+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 kotlinx.collections.immutable.persistentListOf
+import org.jetbrains.jewel.foundation.theme.JewelTheme
+import org.jetbrains.jewel.ui.component.scrollbarContentSafePadding
+
+@Composable
+internal fun OutputSection(controller: ProcessOutputController) {
+ val listState = remember { controller.processOutputUiState.lazyListState }
+
+ val selectedProcess by controller.selectedProcess.collectAsState()
+
+ Column {
+ Toolbar {
+ Box(modifier = Modifier.weight(1f)) {
+ selectedProcess?.also {
+ InterText(
+ text = it.shortenedCommandString,
+ overflow = TextOverflow.Ellipsis,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+
+ FilterActionGroup(
+ tooltipText = message("process.output.viewOptions.tooltip"),
+ items = persistentListOf(
+ FilterEntry(
+ item = OutputFilter.ShowTags,
+ testTag = OutputSectionTestTags.FILTERS_TAGS,
+ ),
+ ),
+ isSelected = { controller.processOutputUiState.filters.contains(it) },
+ onItemClick = { controller.toggleOutputFilter(it) },
+ modifier = Modifier.testTag(OutputSectionTestTags.FILTERS_BUTTON),
+ menuModifier = Modifier.testTag(OutputSectionTestTags.FILTERS_MENU),
+ )
+
+ ActionIconButton(
+ modifier = Modifier.testTag(OutputSectionTestTags.COPY_OUTPUT_BUTTON),
+ iconKey = Icons.Keys.Copy,
+ tooltipText = message("process.output.output.buttons.copyOutput"),
+ enabled = selectedProcess != null,
+ onClick = {
+ selectedProcess?.let {
+ controller.copyOutputToClipboard(it)
+ }
+ },
+ )
+ }
+
+ selectedProcess?.let {
+ VerticallyScrollableContainer(
+ modifier = Modifier.fillMaxSize(),
+ scrollState = listState as ScrollableState,
+ ) {
+ val lines by it.lines.collectReplayAsState()
+ val exitInfo by it.exitInfo.collectAsState()
+ val isInfoExpandedState =
+ controller.processOutputUiState.isInfoExpanded.collectAsState()
+ val isOutputExpandedState =
+ controller.processOutputUiState.isOutputExpanded.collectAsState()
+
+ val isDisplayTags = controller.processOutputUiState.filters.contains(
+ OutputFilter.ShowTags,
+ )
+
+ SelectionContainer {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ state = listState,
+ ) {
+ collapsibleSectionItem(
+ title = message("process.output.output.sections.info"),
+ modifier = Modifier.testTag(OutputSectionTestTags.INFO_SECTION),
+ isExpandedState = isInfoExpandedState,
+ onToggle = { controller.toggleProcessInfo() },
+ ) {
+ infoLineItems(
+ InfoLine.Single(
+ message("process.output.output.sections.info.started"),
+ it.startedAt.formatFull(),
+ ),
+ InfoLine.Single(
+ message("process.output.output.sections.info.command"),
+ it.commandString,
+ ),
+ it.pid?.let { pid ->
+ InfoLine.Single(
+ message("process.output.output.sections.info.pid"),
+ pid.toString(),
+ )
+ },
+ it.cwd?.let { cwd ->
+ InfoLine.Single(
+ message("process.output.output.sections.info.cwd"),
+ cwd,
+ )
+ },
+ InfoLine.Multi(
+ message("process.output.output.sections.info.env"),
+ it.env.entries.map { (key, value) -> "$key=$value" },
+ ),
+ )
+
+ item(key = "blank") { Text("") }
+ }
+
+ collapsibleSectionItem(
+ title = message("process.output.output.sections.output"),
+ modifier = Modifier.testTag(OutputSectionTestTags.OUTPUT_SECTION),
+ isExpandedState = isOutputExpandedState,
+ onToggle = { controller.toggleProcessOutput() },
+ ) {
+ itemsIndexed(
+ items = lines,
+ key = { index, _ -> index },
+ ) { index, line ->
+ val outputColor = when (line.kind) {
+ LoggedProcessLine.Kind.OUT -> Color.Unspecified
+ LoggedProcessLine.Kind.ERR -> Colors.Output.ErrorText
+ }
+
+ OutputLine(
+ displayTags = isDisplayTags,
+ tag = line.kind.tag.takeIf { tag ->
+ lines.getOrNull(index - 1)?.kind?.tag != tag
+ },
+ text = line.text,
+ textStyle = SpanStyle(
+ color = outputColor,
+ ),
+ )
+ }
+
+ exitInfo?.also { exitInfo ->
+ item(key = "exit") {
+ OutputLine(
+ displayTags = true,
+ tag = Tag.EXIT,
+ text = buildString {
+ append(exitInfo.exitValue)
+
+ exitInfo.additionalMessageToUser?.also { message ->
+ append(": ")
+ append(message)
+ }
+ },
+ textStyle = SpanStyle(
+ color =
+ if (exitInfo.exitValue != 0) {
+ Colors.Output.ErrorText
+ } else {
+ Color.Unspecified
+ },
+ ),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ ?: EmptyContainerNotice(
+ text = message("process.output.output.blankMessage"),
+ modifier = Modifier.testTag(OutputSectionTestTags.NOT_SELECTED_TEXT),
+ )
+ }
+}
+
+@Composable
+private fun OutputLine(
+ displayTags: Boolean,
+ tag: String? = null,
+ text: String,
+ textStyle: SpanStyle = SpanStyle(),
+) {
+ Column {
+ if (tag != null) {
+ LineSpacer()
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ .padding(end = scrollbarContentSafePadding()),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ DisableSelection {
+ val padding = Tag.maxLength + 3
+
+ Text(
+ text =
+ if (displayTags && tag != null) {
+ "$tag:".padStart(padding, ' ')
+ } else {
+ " ".repeat(padding)
+ },
+ style = JewelTheme.consoleTextStyle,
+ fontWeight = FontWeight.Thin,
+ )
+ }
+
+ Text(
+ text = buildAnnotatedString {
+ withStyle(style = textStyle) {
+ append(text)
+ }
+ },
+ style = JewelTheme.consoleTextStyle,
+ modifier = Modifier.fillMaxWidth()
+ .weight(1f),
+ )
+
+ }
+ }
+}
+
+private fun LazyListScope.collapsibleSectionItem(
+ title: String,
+ modifier: Modifier = Modifier,
+ isExpandedState: State,
+ onToggle: () -> Unit,
+ content: LazyListScope.() -> Unit,
+) {
+ item(key = "collapsibleSection $title") {
+ val isExpanded by isExpandedState
+
+ CollapsibleListSection(
+ text = title,
+ modifier = modifier,
+ isExpanded = isExpanded,
+ onToggle = onToggle,
+ )
+ }
+
+ if (isExpandedState.value) {
+ this.content()
+ }
+}
+
+private fun LazyListScope.infoLineItems(
+ vararg infoLines: InfoLine?,
+) {
+ val maxLength = infoLines.maxOfOrNull {
+ when (it) {
+ is InfoLine.Single -> it.key.length
+ else -> 0
+ }
+ } ?: 0
+ val padding = maxLength + 2
+
+ infoLines.forEach { infoLine ->
+ when (infoLine) {
+ is InfoLine.Single ->
+ infoLineItemSingle(infoLine.key, infoLine.key, infoLine.value, padding)
+ is InfoLine.Multi -> {
+ infoLineItemSingle(
+ infoLine.key,
+ infoLine.key,
+ infoLine.values.takeIf { it.isNotEmpty() }?.let { it[0] },
+ padding,
+ )
+
+ infoLine.values.drop(1).forEachIndexed { index, value ->
+ infoLineItemSingle("${infoLine.key} $index", null, value, padding)
+ }
+ }
+ null -> {}
+ }
+ }
+}
+
+private fun LazyListScope.infoLineItemSingle(
+ id: Any,
+ key: String?,
+ value: String?,
+ padding: Int,
+) {
+ item(key = "infoLineItem ${id}") {
+ Column {
+ if (key != null) {
+ LineSpacer()
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ .padding(end = scrollbarContentSafePadding()),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text(
+ text =
+ if (key != null) {
+ "${key}:".padStart(padding)
+ } else {
+ " ".repeat(padding)
+ },
+ style = JewelTheme.consoleTextStyle,
+ fontWeight = FontWeight.Thin,
+ )
+
+ Text(
+ text = value ?: "(empty)",
+ modifier = Modifier.fillMaxWidth(),
+ color = if (value == null) {
+ Colors.Output.Info
+ } else {
+ Color.Unspecified
+ },
+ style = JewelTheme.consoleTextStyle,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun LineSpacer() {
+ Spacer(modifier = Modifier.height(4.dp))
+}
+
+private sealed class InfoLine {
+ abstract val key: String
+
+ data class Single(override val key: String, val value: String?) : InfoLine()
+ data class Multi(override val key: String, val values: List) : InfoLine()
+}
+
+private val LoggedProcessLine.Kind.tag
+ get() =
+ when (this) {
+ LoggedProcessLine.Kind.ERR -> Tag.ERROR
+ LoggedProcessLine.Kind.OUT -> Tag.OUTPUT
+ }
+
+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 FILTERS_TAGS = "ProcessOutput.Output.FiltersTags"
+ const val FILTERS_BUTTON = "ProcessOutput.Output.FiltersButton"
+ const val FILTERS_MENU = "ProcessOutput.Output.FiltersMenu"
+ const val COPY_OUTPUT_BUTTON = "ProcessOutput.Output.CopyButton"
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/ToolWindow.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/ToolWindow.kt
new file mode 100644
index 000000000000..659deb4a32ec
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/ToolWindow.kt
@@ -0,0 +1,21 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.intellij.python.processOutput.impl.ProcessOutputController
+import org.jetbrains.jewel.ui.component.HorizontalSplitLayout
+import org.jetbrains.jewel.ui.component.rememberSplitLayoutState
+
+@Composable
+internal fun ToolWindow(controller: ProcessOutputController) {
+ HorizontalSplitLayout(
+ first = { TreeSection(controller) },
+ second = { OutputSection(controller) },
+ modifier = Modifier.fillMaxSize(),
+ firstPaneMinWidth = 300.dp,
+ secondPaneMinWidth = 300.dp,
+ state = rememberSplitLayoutState(.15f),
+ )
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/Toolbar.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/Toolbar.kt
new file mode 100644
index 000000000000..7767cff48675
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/Toolbar.kt
@@ -0,0 +1,38 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.jetbrains.jewel.ui.Orientation
+import org.jetbrains.jewel.ui.component.Divider
+
+@Composable
+internal fun Toolbar(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .height(36.dp)
+ .padding(horizontal = 7.dp, vertical = 2.dp)
+ .then(modifier),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ this@Column.content()
+ }
+
+ Row {
+ Divider(
+ orientation = Orientation.Horizontal,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/TreeSection.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/TreeSection.kt
new file mode 100644
index 000000000000..28ce945d9ac8
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/TreeSection.kt
@@ -0,0 +1,360 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsHoveredAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.input.clearText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.pointer.PointerIcon
+import androidx.compose.ui.input.pointer.pointerHoverIcon
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
+import com.intellij.python.processOutput.impl.ProcessOutputController
+import com.intellij.python.processOutput.impl.TreeFilter
+import com.intellij.python.processOutput.impl.TreeNode
+import com.intellij.python.processOutput.impl.formatTime
+import com.intellij.python.processOutput.impl.ui.Colors
+import com.intellij.python.processOutput.impl.ui.Icons
+import com.intellij.python.processOutput.impl.ui.processIsBackground
+import com.intellij.python.processOutput.impl.ui.processIsError
+import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
+import kotlin.time.Instant
+import kotlinx.collections.immutable.persistentListOf
+import org.jetbrains.jewel.foundation.ExperimentalJewelApi
+import org.jetbrains.jewel.foundation.theme.JewelTheme
+import org.jetbrains.jewel.ui.component.Icon
+import org.jetbrains.jewel.ui.component.LazyTree
+import org.jetbrains.jewel.ui.component.Text
+import org.jetbrains.jewel.ui.component.TextField
+import org.jetbrains.jewel.ui.component.Tooltip
+import org.jetbrains.jewel.ui.component.VerticallyScrollableContainer
+import org.jetbrains.jewel.ui.component.styling.LazyTreeMetrics
+import org.jetbrains.jewel.ui.component.styling.LazyTreeStyle
+import org.jetbrains.jewel.ui.component.styling.SimpleListItemMetrics
+import org.jetbrains.jewel.ui.theme.treeStyle
+
+@OptIn(ExperimentalJewelApi::class)
+@Composable
+internal fun TreeSection(controller: ProcessOutputController) {
+ val selectableLazyListState = remember { controller.processTreeUiState.selectableLazyListState }
+
+ Column(modifier = Modifier.fillMaxSize()) {
+ val tree by controller.processTreeUiState.tree.collectAsState()
+ val filters = remember { controller.processTreeUiState.filters }
+ val isTreeEmpty = remember(tree) { tree.isEmpty() }
+
+ TreeToolbar(
+ controller = controller,
+ areExpansionActionsEnabled = !isTreeEmpty,
+ )
+
+ tree.takeIf { !it.isEmpty() }
+ ?.also {
+ VerticallyScrollableContainer(
+ scrollState = selectableLazyListState.lazyListState as ScrollableState,
+ ) {
+ val treeState = remember { controller.processTreeUiState.treeState }
+ val style = JewelTheme.treeStyle
+
+ Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) {
+ LazyTree(
+ tree = tree,
+ modifier = Modifier.fillMaxSize(),
+ treeState = treeState,
+ onSelectionChange = {
+ val node = it.firstOrNull()?.data
+
+ if (node is TreeNode.Process) {
+ controller.selectProcess(node.process)
+ }
+ },
+ style = LazyTreeStyle(
+ colors = style.colors,
+ metrics = LazyTreeMetrics(
+ indentSize = style.metrics.indentSize,
+ elementMinHeight = style.metrics.elementMinHeight,
+ chevronContentGap = style.metrics.chevronContentGap,
+ simpleListItemMetrics = SimpleListItemMetrics(
+ innerPadding = PaddingValues(horizontal = 4.dp),
+ outerPadding = PaddingValues(1.dp),
+ selectionBackgroundCornerSize =
+ style.metrics.simpleListItemMetrics.selectionBackgroundCornerSize,
+ iconTextGap = style.metrics.simpleListItemMetrics.iconTextGap,
+ ),
+ ),
+ icons = style.icons,
+ ),
+ interactionSource = remember { MutableInteractionSource() },
+ ) {
+ TreeRow(it.data, filters.contains(TreeFilter.ShowTime))
+ }
+ }
+ }
+ }
+ ?: EmptyContainerNotice(
+ text = message("process.output.tree.blankMessage"),
+ modifier = Modifier.testTag(TreeSectionTestTags.EMPTY_TREE_TEXT),
+ )
+ }
+}
+
+@Composable
+private fun TreeToolbar(
+ controller: ProcessOutputController,
+ areExpansionActionsEnabled: Boolean,
+) {
+ val clearInteractionSource = remember { MutableInteractionSource() }
+ val isClearHovered by clearInteractionSource.collectIsHoveredAsState()
+ val inputState = remember { controller.processTreeUiState.searchState }
+ val focusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ Toolbar {
+ Icon(
+ key = Icons.Keys.Search,
+ contentDescription = message("process.output.icon.description.search"),
+ )
+
+ TextField(
+ state = inputState,
+ modifier =
+ Modifier
+ .padding(start = 1.dp, top = 2.dp, bottom = 2.dp)
+ .weight(1f)
+ .focusRequester(focusRequester),
+ placeholder = { Text(message("process.output.tree.search.placeholder")) },
+ undecorated = true,
+ )
+
+ if (inputState.text.isNotBlank()) {
+ Icon(
+ key =
+ if (isClearHovered) {
+ Icons.Keys.CloseHovered
+ } else {
+ Icons.Keys.Close
+ },
+ contentDescription = message("process.output.icon.description.clear"),
+ modifier = Modifier
+ .pointerHoverIcon(PointerIcon.Default)
+ .clickable(
+ interactionSource = clearInteractionSource,
+ indication = null,
+ role = Role.Button,
+ ) {
+ inputState.clearText()
+ },
+ )
+ } else {
+ Spacer(modifier = Modifier.width(16.dp))
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ FilterActionGroup(
+ tooltipText = message("process.output.viewOptions.tooltip"),
+ items = persistentListOf(
+ FilterEntry(
+ item = TreeFilter.ShowTime,
+ testTag = TreeSectionTestTags.FILTERS_TIME,
+ ),
+ FilterEntry(
+ item = TreeFilter.ShowBackgroundProcesses,
+ testTag = TreeSectionTestTags.FILTERS_BACKGROUND,
+ ),
+ ),
+ isSelected = { controller.processTreeUiState.filters.contains(it) },
+ onItemClick = { controller.toggleTreeFilter(it) },
+ modifier = Modifier.testTag(TreeSectionTestTags.FILTERS_BUTTON),
+ menuModifier = Modifier.testTag(TreeSectionTestTags.FILTERS_MENU),
+ )
+
+ ActionIconButton(
+ modifier = Modifier.testTag(TreeSectionTestTags.EXPAND_ALL_BUTTON),
+ iconKey = Icons.Keys.ExpandAll,
+ tooltipText = message("process.output.tree.buttons.expandAll"),
+ enabled = areExpansionActionsEnabled,
+ onClick = { controller.expandAllContexts() },
+ )
+
+ ActionIconButton(
+ modifier = Modifier.testTag(TreeSectionTestTags.COLLAPSE_ALL_BUTTON),
+ iconKey = Icons.Keys.CollapseAll,
+ tooltipText = message("process.output.tree.buttons.collapseAll"),
+ enabled = areExpansionActionsEnabled,
+ onClick = { controller.collapseAllContexts() },
+ )
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun TreeRow(
+ node: TreeNode,
+ isTimeDisplayed: Boolean,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(24.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ when (node) {
+ is TreeNode.Context -> {
+ Tooltip(
+ tooltip = {
+ Row {
+ Text(node.traceContext.title)
+ }
+ },
+ modifier = Modifier.weight(1f),
+ ) {
+ Row(
+ modifier = Modifier.padding(start = 3.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Icon(
+ key = Icons.Keys.Folder,
+ contentDescription =
+ message("process.output.icon.description.folder"),
+ modifier = Modifier.testTag(
+ TreeSectionTestTags.FOLDER_ITEM_ICON,
+ ),
+ )
+
+ InterText(
+ text = node.traceContext.title,
+ modifier = Modifier.weight(1f),
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ is TreeNode.Process -> {
+ val exitInfo by node.process.exitInfo.collectAsState()
+ val isError = remember(exitInfo) {
+ exitInfo?.takeIf { it.exitValue != 0 } != null
+ }
+ val isBackground = node.process.traceContext == NON_INTERACTIVE_ROOT_TRACE_CONTEXT
+
+ Tooltip(
+ tooltip = {
+ Row {
+ Text(node.process.shortenedCommandString)
+ }
+ },
+ modifier = Modifier.weight(1f),
+ ) {
+ Row(
+ modifier = Modifier.padding(start = 3.dp)
+ .semantics {
+ processIsError = isError
+ processIsBackground = isBackground
+ },
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ when {
+ isBackground && isError ->
+ Icon(
+ key = Icons.Keys.ProcessBackError,
+ contentDescription =
+ message("process.output.icon.description.processBackError"),
+ modifier = Modifier.testTag(
+ TreeSectionTestTags.PROCESS_ITEM_BACK_ERROR_ICON,
+ ),
+ )
+ isBackground ->
+ Icon(
+ key = Icons.Keys.ProcessBack,
+ contentDescription =
+ message("process.output.icon.description.processBack"),
+ modifier = Modifier.testTag(
+ TreeSectionTestTags.PROCESS_ITEM_BACK_ICON,
+ ),
+ )
+ isError ->
+ Icon(
+ key = Icons.Keys.ProcessError,
+ contentDescription =
+ message("process.output.icon.description.processError"),
+ modifier = Modifier.testTag(
+ TreeSectionTestTags.PROCESS_ITEM_ERROR_ICON,
+ ),
+ )
+ else ->
+ Icon(
+ key = Icons.Keys.Process,
+ contentDescription =
+ message("process.output.icon.description.process"),
+ modifier = Modifier.testTag(
+ TreeSectionTestTags.PROCESS_ITEM_ICON,
+ ),
+ )
+ }
+
+ InterText(
+ text = node.process.shortenedCommandString,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ }
+
+ val instant = when (node) {
+ is TreeNode.Context -> Instant.fromEpochMilliseconds(node.traceContext.timestamp)
+ is TreeNode.Process -> node.process.startedAt
+ }
+
+ if (isTimeDisplayed) {
+ InterText(
+ text = instant.formatTime(),
+ modifier = Modifier.padding(end = 6.dp),
+ color = Colors.Tree.Info,
+ )
+ }
+ }
+}
+
+internal object TreeSectionTestTags {
+ const val EMPTY_TREE_TEXT = "ProcessOutput.Tree.EmptyTreeText"
+ const val PROCESS_ITEM_BACK_ERROR_ICON = "ProcessOutput.Tree.ProcessItemBackErrorIcon"
+ const val PROCESS_ITEM_BACK_ICON = "ProcessOutput.Tree.ProcessItemBackIcon"
+ const val PROCESS_ITEM_ERROR_ICON = "ProcessOutput.Tree.ProcessItemErrorIcon"
+ const val PROCESS_ITEM_ICON = "ProcessOutput.Tree.ProcessItemIcon"
+ const val FOLDER_ITEM_ICON = "ProcessOutput.Tree.FolderItemIcon"
+ const val EXPAND_ALL_BUTTON = "ProcessOutput.Tree.ExpandAllButton"
+ const val COLLAPSE_ALL_BUTTON = "ProcessOutput.Tree.CollapseAllButton"
+ const val FILTERS_BUTTON = "ProcessOutput.Tree.FiltersButton"
+ const val FILTERS_MENU = "ProcessOutput.Tree.FiltersMenu"
+ const val FILTERS_BACKGROUND = "ProcessOutput.Tree.FiltersBackground"
+ const val FILTERS_TIME = "ProcessOutput.Tree.FiltersTime"
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/ext.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/ext.kt
new file mode 100644
index 000000000000..75a41ed61ad9
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/ext.kt
@@ -0,0 +1,60 @@
+package com.intellij.python.processOutput.impl.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.hoverable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.PointerIcon
+import androidx.compose.ui.input.pointer.pointerHoverIcon
+import java.awt.Cursor
+import kotlinx.coroutines.flow.SharedFlow
+
+internal inline fun Modifier.thenIfNotNull(
+ nullable: T?,
+ action: Modifier.(T) -> Modifier,
+): Modifier =
+ nullable?.let { action(it) } ?: this
+
+internal fun Modifier.expandable(
+ interactionSource: MutableInteractionSource,
+ onToggle: () -> Unit,
+): Modifier =
+ this.clickable(
+ indication = null,
+ interactionSource = interactionSource,
+ onClick = { onToggle() },
+ )
+ .hoverable(interactionSource)
+ .pointerHoverIcon(
+ PointerIcon(
+ Cursor.getPredefinedCursor(Cursor.HAND_CURSOR),
+ ),
+ )
+
+internal fun MutableSet.toggle(value: T) {
+ if (contains(value)) {
+ remove(value)
+ } else {
+ add(value)
+ }
+}
+
+@Composable
+internal fun SharedFlow.collectReplayAsState(): State> {
+ val data = remember(this) { mutableStateOf(replayCache) }
+
+ LaunchedEffect(this) {
+ collect { _ ->
+ data.value = replayCache
+ }
+ }
+
+ return data
+}
diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/semantics.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/semantics.kt
new file mode 100644
index 000000000000..5cae9d406899
--- /dev/null
+++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/semantics.kt
@@ -0,0 +1,13 @@
+package com.intellij.python.processOutput.impl.ui
+
+import androidx.compose.ui.semantics.SemanticsPropertyKey
+import androidx.compose.ui.semantics.SemanticsPropertyReceiver
+
+internal val ProcessIsErrorKey = SemanticsPropertyKey("ProcessIsError")
+internal var SemanticsPropertyReceiver.processIsError by ProcessIsErrorKey
+
+internal val ProcessIsBackgroundKey = SemanticsPropertyKey("ProcessIsBackground")
+internal var SemanticsPropertyReceiver.processIsBackground by ProcessIsBackgroundKey
+
+internal val IsExpanded = SemanticsPropertyKey("IsExpanded")
+internal var SemanticsPropertyReceiver.isExpanded by IsExpanded
diff --git a/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/ActionIconButtonTest.kt b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/ActionIconButtonTest.kt
new file mode 100644
index 000000000000..d0632f5c661d
--- /dev/null
+++ b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/ActionIconButtonTest.kt
@@ -0,0 +1,133 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performMouseInput
+import com.intellij.python.processOutput.impl.ProcessOutputTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import org.jetbrains.jewel.ui.icon.IconKey
+import org.jetbrains.jewel.ui.icons.AllIconsKeys
+
+internal class ActionIconButtonTest : ProcessOutputTest() {
+ @BeforeTest
+ fun beforeTest() {
+ scaffoldTestContent {
+ OutputSection(controller)
+ }
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun `tooltip should be displayed as expected`() = processOutputTest {
+ val tooltipText = "test tooltip text"
+ scaffold(tooltipText = tooltipText)
+
+ // no text displayed at first
+ onAllNodesWithText(tooltipText).assertCountEquals(0)
+
+ // hovering over the button
+ onNodeWithTag(ActionIconButtonTestTags.BUTTON).performMouseInput {
+ moveTo(Offset(5f, 5f))
+ }
+
+ // tooltip should be displayed
+ waitUntilAtLeastOneExists(hasText(tooltipText))
+ }
+
+ @Test
+ fun `enabled true should propagate`() = processOutputTest {
+ scaffold(enabled = true)
+
+ // button should be enabled
+ onNodeWithTag(ActionIconButtonTestTags.BUTTON).assertIsEnabled()
+ }
+
+ @Test
+ fun `enabled false should propagate`() = processOutputTest {
+ scaffold(enabled = false)
+
+ // button should be enabled
+ onNodeWithTag(ActionIconButtonTestTags.BUTTON).assertIsNotEnabled()
+ }
+
+ @Test
+ fun `icon should be displayed`() = processOutputTest {
+ scaffold()
+
+ // icon should be displayed
+ onAllNodesWithTag(
+ ActionIconButtonTestTags.ICON,
+ useUnmergedTree = true,
+ ).assertCountEquals(1)
+ }
+
+ @Test
+ fun `dropdown icon should not be displayed when not a dropdown`() = processOutputTest {
+ scaffold(isDropdown = false)
+
+ // dropdown icon should not be displayed
+ onAllNodesWithTag(
+ ActionIconButtonTestTags.DROPDOWN_ICON,
+ useUnmergedTree = true,
+ ).assertCountEquals(0)
+ }
+
+ @Test
+ fun `dropdown icon should be displayed when dropdown is true`() = processOutputTest {
+ scaffold(isDropdown = true)
+
+ // dropdown icon should be displayed
+ onAllNodesWithTag(
+ ActionIconButtonTestTags.DROPDOWN_ICON,
+ useUnmergedTree = true,
+ ).assertCountEquals(1)
+ }
+
+ @Test
+ fun `onClick should be triggered when the button is clicked`() = processOutputTest {
+ var clicks = 0
+ scaffold(onClick = { clicks += 1 })
+
+ // no clicks should have been made at first
+ assertEquals(0, clicks)
+
+ // performing a click
+ onNodeWithTag(ActionIconButtonTestTags.BUTTON).performClick()
+
+ // should have been called once
+ assertEquals(1, clicks)
+ }
+
+ private fun scaffold(
+ modifier: Modifier = Modifier,
+ iconKey: IconKey = AllIconsKeys.General.Menu,
+ tooltipText: String = "tooltip",
+ enabled: Boolean = true,
+ onClick: () -> Unit = {},
+ iconModifier: Modifier = Modifier,
+ isDropdown: Boolean = false,
+ ) {
+ scaffoldTestContent {
+ ActionIconButton(
+ modifier = modifier,
+ iconKey = iconKey,
+ tooltipText = tooltipText,
+ enabled = enabled,
+ onClick = onClick,
+ iconModifier = iconModifier,
+ isDropdown = isDropdown,
+ )
+ }
+ }
+}
diff --git a/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/CollapsibleListSectionTest.kt b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/CollapsibleListSectionTest.kt
new file mode 100644
index 000000000000..00b441feeabf
--- /dev/null
+++ b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/CollapsibleListSectionTest.kt
@@ -0,0 +1,89 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.unit.dp
+import com.intellij.python.processOutput.impl.ProcessOutputTest
+import com.intellij.python.processOutput.impl.ui.IsExpanded
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+internal class CollapsibleListSectionTest : ProcessOutputTest() {
+ @Test
+ fun `section text should be correctly displayed`() = processOutputTest {
+ scaffold(text = "some section text")
+
+ // section text should be correct
+ onAllNodesWithText("some section text").assertCountEquals(1)
+ }
+
+ @Test
+ fun `collapsed section should have correct icon and semantics`() = processOutputTest {
+ scaffold(isExpanded = false)
+
+ // should have isExpanded semantics set to false
+ onNodeWithText(DEFAULT_SECTION_TEXT)
+ .assert(SemanticsMatcher.expectValue(IsExpanded, false))
+
+ // icon should be chevron right
+ onAllNodesWithTag(CollapsibleListSectionTestTags.CHEVRON_RIGHT, useUnmergedTree = true)
+ .assertCountEquals(1)
+ }
+
+ @Test
+ fun `expanded section should have correct icon and semantics`() = processOutputTest {
+ scaffold(isExpanded = true)
+
+ // should have isExpanded semantics set to true
+ onNodeWithText(DEFAULT_SECTION_TEXT)
+ .assert(SemanticsMatcher.expectValue(IsExpanded, true))
+
+ // icon should be chevron down
+ onAllNodesWithTag(CollapsibleListSectionTestTags.CHEVRON_DOWN, useUnmergedTree = true)
+ .assertCountEquals(1)
+ }
+
+ @Test
+ fun `onToggle should be triggered on click`() = processOutputTest {
+ var clicks = 0
+ scaffold { clicks += 1 }
+
+ // no calls should have been made at first
+ assertEquals(0, clicks)
+
+ // click on the section
+ onNodeWithText(DEFAULT_SECTION_TEXT).performClick()
+
+ // should have been called exactly once
+ assertEquals(1, clicks)
+ }
+
+ private fun scaffold(
+ text: String = DEFAULT_SECTION_TEXT,
+ modifier: Modifier = Modifier.testTag(""),
+ isExpanded: Boolean = false,
+ onToggle: () -> Unit = {},
+ ) {
+ scaffoldTestContent {
+ Box(modifier = Modifier.size(256.dp)) {
+ CollapsibleListSection(
+ text = text,
+ modifier = modifier,
+ isExpanded = isExpanded,
+ onToggle = onToggle,
+ )
+ }
+ }
+ }
+}
+
+private const val DEFAULT_SECTION_TEXT = "section text"
diff --git a/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/EmptyContainerNoticeTest.kt b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/EmptyContainerNoticeTest.kt
new file mode 100644
index 000000000000..7e5bed258cdb
--- /dev/null
+++ b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/EmptyContainerNoticeTest.kt
@@ -0,0 +1,36 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.unit.dp
+import com.intellij.python.processOutput.impl.ProcessOutputTest
+import kotlin.test.Test
+
+internal class EmptyContainerNoticeTest : ProcessOutputTest() {
+ @Test
+ fun `should display correct text`() = processOutputTest {
+ scaffold()
+
+ // text should be correct
+ onAllNodesWithText(DEFAULT_NOTICE_TEXT).assertCountEquals(1)
+ }
+
+ private fun scaffold(
+ text: String = DEFAULT_NOTICE_TEXT,
+ modifier: Modifier = Modifier,
+ ) {
+ scaffoldTestContent {
+ Box(modifier = Modifier.size(256.dp)) {
+ EmptyContainerNotice(
+ text = text,
+ modifier = modifier,
+ )
+ }
+ }
+ }
+}
+
+private const val DEFAULT_NOTICE_TEXT = "empty container notice text"
diff --git a/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/FilterActionGroupTest.kt b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/FilterActionGroupTest.kt
new file mode 100644
index 000000000000..d564a5233ae4
--- /dev/null
+++ b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/FilterActionGroupTest.kt
@@ -0,0 +1,204 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.mutableStateSetOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshots.SnapshotStateSet
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.click
+import androidx.compose.ui.test.filter
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onSiblings
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performMouseInput
+import androidx.compose.ui.unit.dp
+import com.intellij.python.processOutput.impl.ProcessOutputTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlinx.collections.immutable.persistentListOf
+
+internal class FilterActionGroupTest : ProcessOutputTest() {
+ @Test
+ fun `filters menu expands when clicked`() = processOutputTest {
+ scaffold()
+
+ // menu is not displayed at first
+ onAllNodesWithTag(TestTags.MENU, useUnmergedTree = true).assertCountEquals(0)
+
+ // clicking the view filters button
+ onNodeWithTag(TestTags.BUTTON).performClick()
+
+ // menu is now visible
+ onAllNodesWithTag(TestTags.MENU, useUnmergedTree = true).assertCountEquals(1)
+ }
+
+ @Test
+ fun `view filters menu disappears when clicked outside`() = processOutputTest {
+ scaffold()
+
+ // clicking the view filters button
+ onNodeWithTag(TestTags.BUTTON).performClick()
+
+ // menu is visible
+ onAllNodesWithTag(TestTags.MENU).assertCountEquals(1)
+
+ // click outside the menu (-10f on both coordinates relative to top left corner)
+ onNodeWithTag(TestTags.MENU).performMouseInput {
+ click(position = Offset(-10f, -10f))
+ }
+
+ // menu is now invisible
+ onAllNodesWithTag(TestTags.MENU).assertCountEquals(0)
+ }
+
+ @Test
+ fun `view filters menu doesn't disappear when clicked inside`() = processOutputTest {
+ scaffold()
+
+ // clicking the view filters button
+ onNodeWithTag(TestTags.BUTTON).performClick()
+
+ // menu is visible
+ onAllNodesWithTag(TestTags.MENU).assertCountEquals(1)
+
+ // click inside the menu (+10f on both coordinates relative to top left corner)
+ onNodeWithTag(TestTags.MENU).performMouseInput {
+ click(position = Offset(10f, 10f))
+ }
+
+ // menu is still visible
+ onAllNodesWithTag(TestTags.MENU).assertCountEquals(1)
+ }
+
+ @Test
+ fun `view filters buttons call their respective functions`() = processOutputTest {
+ val clicks = mutableMapOf(
+ TestFilter.Option1 to 0,
+ TestFilter.Option2 to 0,
+ )
+ scaffold(
+ onItemClick = {
+ clicks[it] = clicks[it]!!.plus(1)
+ },
+ )
+
+ // clicking the view filters button
+ onNodeWithTag(TestTags.BUTTON).performClick()
+
+ // no menu buttons were clicked, no functions were called
+ assertEquals(0, clicks[TestFilter.Option1])
+ assertEquals(0, clicks[TestFilter.Option2])
+
+ // clicking on option1
+ onNodeWithText(
+ TestFilter.Option1.title,
+ useUnmergedTree = true,
+ ).performClick()
+
+ // option1 should have been called, but not option2
+ assertEquals(1, clicks[TestFilter.Option1])
+ assertEquals(0, clicks[TestFilter.Option2])
+
+ // clicking on option2
+ onNodeWithText(
+ TestFilter.Option2.title,
+ useUnmergedTree = true,
+ ).performClick()
+
+ // both filters should have been called
+ assertEquals(1, clicks[TestFilter.Option1])
+ assertEquals(1, clicks[TestFilter.Option2])
+ }
+
+ @Test
+ fun `view filters buttons include checked icon when selected`() = processOutputTest {
+ val selected = mutableStateSetOf(TestFilter.Option2)
+ scaffold(selectedItems = selected)
+
+ // clicking the view filters button
+ onNodeWithTag(TestTags.BUTTON).performClick()
+
+ // option1 disabled, option2 enabled
+ onNodeWithText(TestFilter.Option1.title, useUnmergedTree = true)
+ .onSiblings()
+ .filter(hasTestTag(FilterActionGroupTestTags.CHECKED_ICON))
+ .assertCountEquals(0)
+
+ onNodeWithText(TestFilter.Option2.title, useUnmergedTree = true)
+ .onSiblings()
+ .filter(hasTestTag(FilterActionGroupTestTags.CHECKED_ICON))
+ .assertCountEquals(1)
+
+ // enabling option1, disabling option2
+ selected.remove(TestFilter.Option2)
+ selected.add(TestFilter.Option1)
+
+ // option1 enabled, option2 disabled
+ onNodeWithText(TestFilter.Option1.title, useUnmergedTree = true)
+ .onSiblings()
+ .filter(hasTestTag(FilterActionGroupTestTags.CHECKED_ICON))
+ .assertCountEquals(1)
+
+ onNodeWithText(TestFilter.Option2.title, useUnmergedTree = true)
+ .onSiblings()
+ .filter(hasTestTag(FilterActionGroupTestTags.CHECKED_ICON))
+ .assertCountEquals(0)
+ }
+
+ private fun scaffold(
+ selectedItems: SnapshotStateSet = mutableStateSetOf(),
+ onItemClick: (TestFilter) -> Unit = {},
+ ) {
+ scaffoldTestContent {
+ val selected = remember { selectedItems }
+
+ Box(modifier = Modifier.size(256.dp).padding(16.dp)) {
+ FilterActionGroup(
+ tooltipText = DEFAULT_TOOLTIP_TEXT,
+ items = persistentListOf(
+ FilterEntry(
+ item = TestFilter.Option1,
+ testTag = TestTags.OPTION_1,
+ ),
+ FilterEntry(
+ item = TestFilter.Option2,
+ testTag = TestTags.OPTION_2,
+ ),
+ ),
+ isSelected = { selected.contains(it) },
+ onItemClick = onItemClick,
+ modifier = Modifier.testTag(TestTags.BUTTON),
+ menuModifier = Modifier.testTag(TestTags.MENU),
+ )
+ }
+ }
+ }
+}
+
+private abstract class TestFilter : FilterItem {
+ object Option1 : TestFilter() {
+ override val title = "option1"
+ }
+
+ object Option2 : TestFilter() {
+ override val title = "option2"
+ }
+}
+
+private object TestTags {
+ const val BUTTON = "Button"
+ const val MENU = "Menu"
+ const val OPTION_1 = "Option1"
+ const val OPTION_2 = "Option2"
+}
+
+private const val DEFAULT_TOOLTIP_TEXT = "filter action tooltip text"
+
diff --git a/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/InterTextTest.kt b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/InterTextTest.kt
new file mode 100644
index 000000000000..aec711f21258
--- /dev/null
+++ b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/InterTextTest.kt
@@ -0,0 +1,31 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.text.AnnotatedString
+import com.intellij.python.processOutput.impl.ProcessOutputTest
+import kotlin.test.Test
+
+internal class InterTextTest : ProcessOutputTest() {
+ @Test
+ fun `string text is properly rendered`() = processOutputTest {
+ scaffoldTestContent {
+ InterText(text = DEFAULT_TEXT)
+ }
+
+ // should reflect the test text string
+ onAllNodesWithText(DEFAULT_TEXT).assertCountEquals(1)
+ }
+
+ @Test
+ fun `annotated string text is properly rendered`() = processOutputTest {
+ scaffoldTestContent {
+ InterText(text = AnnotatedString(DEFAULT_TEXT))
+ }
+
+ // should reflect the test text annotated string
+ onAllNodesWithText(DEFAULT_TEXT).assertCountEquals(1)
+ }
+}
+
+private const val DEFAULT_TEXT = "inter text"
diff --git a/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/OutputSectionTest.kt b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/OutputSectionTest.kt
new file mode 100644
index 000000000000..8e9d920e006a
--- /dev/null
+++ b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/OutputSectionTest.kt
@@ -0,0 +1,123 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import com.intellij.python.community.execService.impl.LoggedProcess
+import com.intellij.python.processOutput.impl.OutputFilter
+import com.intellij.python.processOutput.impl.ProcessOutputTest
+import io.mockk.called
+import io.mockk.verify
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+
+internal class OutputSectionTest : ProcessOutputTest() {
+ @BeforeTest
+ fun beforeTest() {
+ scaffoldTestContent {
+ OutputSection(controller)
+ }
+ }
+
+ @Test
+ fun `output area displays placeholder text when no process is selected`() = processOutputTest {
+ // no process is selected at first, text should be shown
+ onAllNodesWithTag(OutputSectionTestTags.NOT_SELECTED_TEXT).assertCountEquals(1)
+ }
+
+ @Test
+ fun `action buttons are disabled when no process is selected`() = processOutputTest {
+ // no process is selected at first, buttons should be disabled
+ onNodeWithTag(OutputSectionTestTags.COPY_OUTPUT_BUTTON).assertIsNotEnabled()
+ }
+
+ @Test
+ fun `action buttons are enabled when process is selected`() = processOutputTest {
+ // selecting a process
+ setSelectedProcess(process("process0"))
+
+ // process is selected, should be enabled
+ onNodeWithTag(OutputSectionTestTags.COPY_OUTPUT_BUTTON).assertIsEnabled()
+ }
+
+ @Test
+ fun `action buttons call appropriate functions when clicked`() = processOutputTest {
+ // selecting a process
+ val testProcess = selectTestProcess()
+
+ // no calls at first
+ verify(exactly = 0) { controllerSpy.copyOutputToClipboard(testProcess) }
+
+ // clicking on the section
+ onNodeWithTag(OutputSectionTestTags.COPY_OUTPUT_BUTTON).performClick()
+
+ // one call should have been made
+ verify(exactly = 1) { controllerSpy.copyOutputToClipboard(testProcess) }
+ }
+
+ @Test
+ fun `view filters buttons call their respective functions`() = processOutputTest {
+ // clicking the view filters button
+ onNodeWithTag(OutputSectionTestTags.FILTERS_BUTTON).performClick()
+
+ // no menu buttons were clicked, no functions were called
+ verify { controllerSpy wasNot called }
+
+ // clicking on tags
+ onNodeWithTag(
+ OutputSectionTestTags.FILTERS_TAGS,
+ useUnmergedTree = true,
+ ).performClick()
+
+ // tags should have been called
+ verify(exactly = 1) { controllerSpy.toggleOutputFilter(OutputFilter.ShowTags) }
+ }
+
+ @Test
+ fun `info section calls appropriate function when clicked`() = processOutputTest {
+ // selecting a process
+ selectTestProcess()
+
+ // no calls at first
+ verify(exactly = 0) { controllerSpy.toggleProcessInfo() }
+
+ // clicking on the section
+ onNodeWithTag(OutputSectionTestTags.INFO_SECTION).performClick()
+
+ // one call should have been made
+ verify(exactly = 1) { controllerSpy.toggleProcessInfo() }
+ }
+
+ @Test
+ fun `output section calls appropriate function when clicked`() = processOutputTest {
+ // selecting a process
+ selectTestProcess()
+
+ // no calls at first
+ verify(exactly = 0) { controllerSpy.toggleProcessOutput() }
+
+ // clicking on the section
+ onNodeWithTag(OutputSectionTestTags.OUTPUT_SECTION).performClick()
+
+ // one call should have been made
+ verify(exactly = 1) { controllerSpy.toggleProcessOutput() }
+ }
+
+ private suspend fun selectTestProcess(): LoggedProcess {
+ val newProcess =
+ process(
+ "process0",
+ "arg1",
+ "--flag1",
+ "-f",
+ cwd = "some/random/path",
+ )
+
+ setSelectedProcess(newProcess)
+
+ return newProcess
+ }
+}
diff --git a/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/ToolbarTest.kt b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/ToolbarTest.kt
new file mode 100644
index 000000000000..57fb486b4ac3
--- /dev/null
+++ b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/ToolbarTest.kt
@@ -0,0 +1,23 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.onAllNodesWithText
+import com.intellij.python.processOutput.impl.ProcessOutputTest
+import kotlin.test.Test
+import org.jetbrains.jewel.ui.component.Text
+
+internal class ToolbarTest : ProcessOutputTest() {
+ @Test
+ fun `should render passed content`() = processOutputTest {
+ scaffoldTestContent {
+ Toolbar {
+ Text(text = TEST_TEXT)
+ }
+ }
+
+ // should have rendered text with test content
+ onAllNodesWithText(TEST_TEXT).assertCountEquals(1)
+ }
+}
+
+private const val TEST_TEXT = "some text content"
diff --git a/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/TreeSectionTest.kt b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/TreeSectionTest.kt
new file mode 100644
index 000000000000..ddc19d9a05e3
--- /dev/null
+++ b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/TreeSectionTest.kt
@@ -0,0 +1,310 @@
+package com.intellij.python.processOutput.impl.ui.components
+
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.filter
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.isNotEnabled
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onChildren
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onParent
+import androidx.compose.ui.test.performClick
+import com.intellij.python.processOutput.impl.ProcessOutputTest
+import com.intellij.python.processOutput.impl.TreeFilter
+import com.intellij.python.processOutput.impl.finish
+import com.intellij.python.processOutput.impl.formatTime
+import com.intellij.python.processOutput.impl.ui.ProcessIsBackgroundKey
+import com.intellij.python.processOutput.impl.ui.ProcessIsErrorKey
+import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
+import com.jetbrains.python.TraceContext
+import io.mockk.called
+import io.mockk.verify
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Instant
+
+internal class TreeSectionTest : ProcessOutputTest() {
+ @BeforeTest
+ fun beforeTest() {
+ scaffoldTestContent {
+ TreeSection(controller)
+ }
+ }
+
+ @Test
+ fun `tree process item labels contain correct text`() = processOutputTest {
+ // adding test processes
+ setTree {
+ addProcess("process", "segment", "--flag1", "-f")
+ addProcess("exec", "--flag", "segment2")
+ }
+
+ // command string should be displayed
+ onAllNodesWithText("process segment --flag1 -f").assertCountEquals(1)
+ onAllNodesWithText("exec --flag segment2").assertCountEquals(1)
+ }
+
+ @Test
+ fun `tree should display contexts and its children`() = processOutputTest {
+ val context = TraceContext("context")
+ val subcontext = TraceContext("subcontext")
+
+ // adding test processes
+ setTree {
+ addProcess("process0")
+ addProcess("process1")
+ addContext(context) {
+ addProcess("process2")
+ addProcess("process3")
+ addContext(subcontext) {
+ addProcess("process4")
+ addProcess("process5")
+ }
+ }
+ }
+
+ // should display process0, process1 and context, but not the rest
+ listOf("process0", "process1", "context").forEach {
+ onAllNodesWithText(it).assertCountEquals(1)
+ }
+ listOf("process2", "process3", "subcontext", "process4", "process5").forEach {
+ onAllNodesWithText(it).assertCountEquals(0)
+ }
+
+ // expanding context
+ expandContext(context)
+
+ // should display process0, process1, context, process2, process3 and subcontext,
+ // but not the rest
+ listOf("process0", "process1", "context", "process2", "process3", "subcontext").forEach {
+ onAllNodesWithText(it).assertCountEquals(1)
+ }
+ listOf("process4", "process5").forEach {
+ onAllNodesWithText(it).assertCountEquals(0)
+ }
+
+ // expanding subcontext
+ expandContext(subcontext)
+ awaitIdle()
+
+ // should display everything
+ listOf(
+ "process0",
+ "process1",
+ "context",
+ "process2",
+ "process3",
+ "subcontext",
+ "process4",
+ "process5",
+ ).forEach {
+ onAllNodesWithText(it).assertCountEquals(1)
+ }
+ }
+
+ @Test
+ fun `tree process items should have correct semantics on error code`() = processOutputTest {
+ val runningProcess = process("running")
+ val successProcess = process("success").finish(0)
+ val failProcess = process("fail").finish(1)
+
+ // adding processes
+ setTree {
+ addProcess(runningProcess)
+ addProcess(successProcess)
+ addProcess(failProcess)
+ }
+
+ // running should have IsError semantic set to false & not display error icon
+ onNodeWithText("running", useUnmergedTree = true)
+ .onParent()
+ .assert(SemanticsMatcher.expectValue(ProcessIsErrorKey, false))
+ .onChildren()
+ .filter(hasTestTag(TreeSectionTestTags.PROCESS_ITEM_ERROR_ICON))
+ .assertCountEquals(0)
+
+ // success should have IsError semantic set to false & not display error icon
+ onNodeWithText("success", useUnmergedTree = true)
+ .onParent()
+ .assert(SemanticsMatcher.expectValue(ProcessIsErrorKey, false))
+ .onChildren()
+ .filter(hasTestTag(TreeSectionTestTags.PROCESS_ITEM_ERROR_ICON))
+ .assertCountEquals(0)
+
+ // fail should have IsError semantic set to true & display error icon
+ onNodeWithText("fail", useUnmergedTree = true)
+ .onParent()
+ .assert(SemanticsMatcher.expectValue(ProcessIsErrorKey, true))
+ .onChildren()
+ .filter(hasTestTag(TreeSectionTestTags.PROCESS_ITEM_ERROR_ICON))
+ .assertCountEquals(1)
+ }
+
+ @Test
+ fun `tree process items should display time when the filter is enabled`() = processOutputTest {
+ val startingTime = Instant.parse("2023-01-02T00:00:00+00:00")
+
+ // adding some processes
+ setTree {
+ repeat(10) {
+ addProcess(
+ "process$it",
+ startedAt = startingTime + it.minutes,
+ )
+ }
+ }
+
+ // should display time by default
+ repeat(10) {
+ onAllNodesWithText((startingTime + it.minutes).formatTime()).assertCountEquals(1)
+ }
+
+ // turn off display time filter
+ toggleTreeFilter(TreeFilter.ShowTime)
+
+ // should not display time when the filter is turned off
+ repeat(10) {
+ onAllNodesWithText((startingTime + it.minutes).formatTime()).assertCountEquals(0)
+ }
+ }
+
+ @Test
+ fun `tree background process items should have correct semantic`() = processOutputTest {
+ // adding background and non-background processes
+ setTree {
+ repeat(5) {
+ addProcess("process$it")
+ }
+ repeat(5) {
+ addProcess(
+ "background$it",
+ traceContext = NON_INTERACTIVE_ROOT_TRACE_CONTEXT,
+ )
+ }
+ }
+
+ // background should have IsBackground semantic, non-background processes shouldn't
+ repeat(5) {
+ onNodeWithText("process$it").assert(
+ SemanticsMatcher.expectValue(
+ ProcessIsBackgroundKey,
+ false,
+ ),
+ )
+ onNodeWithText("background$it").assert(
+ SemanticsMatcher.expectValue(
+ ProcessIsBackgroundKey,
+ true,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `tree process items should call the selectProcess() on click`() = processOutputTest {
+ val processes = (0..3).map { process("process$it") }
+
+ // adding some processes
+ setTree {
+ processes.forEach {
+ addProcess(it)
+ }
+ }
+
+ // no processes were clicked, no functions were called
+ verify { controllerSpy wasNot called }
+
+ // clicking the first item
+ onNodeWithText("process0").performClick()
+
+ // selectProcess() should have been called with the first process
+ verify(exactly = 1) { controllerSpy.selectProcess(processes[0]) }
+ verify(exactly = 0) { controllerSpy.selectProcess(processes[1]) }
+ verify(exactly = 0) { controllerSpy.selectProcess(processes[2]) }
+
+ // clicking the second item
+ onNodeWithText("process1").performClick()
+
+ // selectProcess() should have been called with the first and second process
+ verify(exactly = 1) { controllerSpy.selectProcess(processes[0]) }
+ verify(exactly = 1) { controllerSpy.selectProcess(processes[1]) }
+ verify(exactly = 0) { controllerSpy.selectProcess(processes[2]) }
+
+ // clicking the third item
+ onNodeWithText("process2").performClick()
+
+ // selectProcess() should have been called with the first and second process
+ verify(exactly = 1) { controllerSpy.selectProcess(processes[0]) }
+ verify(exactly = 1) { controllerSpy.selectProcess(processes[1]) }
+ verify(exactly = 1) { controllerSpy.selectProcess(processes[2]) }
+ }
+
+ @Test
+ fun `tree expand all and collapse all buttons should be disabled when tree is empty`() =
+ processOutputTest {
+ // tree is empty by default; expand/collapse should be disabled
+ onNodeWithTag(TreeSectionTestTags.EXPAND_ALL_BUTTON).assert(isNotEnabled())
+ onNodeWithTag(TreeSectionTestTags.COLLAPSE_ALL_BUTTON).assert(isNotEnabled())
+ }
+
+ @Test
+ fun `list expand all and collapse all buttons call their respective functions`() =
+ processOutputTest {
+ // adding some processes & enabling categorization by coroutine
+ setTree {
+ repeat(5) {
+ addProcess("process$it")
+ }
+ }
+
+ // no buttons were clicked, no functions were called
+ verify { controllerSpy wasNot called }
+
+ // clicking on expand
+ onNodeWithTag(TreeSectionTestTags.EXPAND_ALL_BUTTON).performClick()
+
+ // expand should have been called, but not collapse
+ verify(exactly = 1) { controllerSpy.expandAllContexts() }
+ verify(exactly = 0) { controllerSpy.collapseAllContexts() }
+
+ // clicking on collapse
+ onNodeWithTag(TreeSectionTestTags.COLLAPSE_ALL_BUTTON).performClick()
+
+ // expand should have been called, but not collapse
+ verify(exactly = 1) { controllerSpy.expandAllContexts() }
+ verify(exactly = 1) { controllerSpy.collapseAllContexts() }
+ }
+
+ @Test
+ fun `view filters buttons call their respective functions`() = processOutputTest {
+ // clicking the view filters button
+ onNodeWithTag(TreeSectionTestTags.FILTERS_BUTTON).performClick()
+
+ // no menu buttons were clicked, no functions were called
+ verify { controllerSpy wasNot called }
+
+ // clicking on categorize
+ onNodeWithTag(
+ TreeSectionTestTags.FILTERS_BACKGROUND,
+ useUnmergedTree = true,
+ ).performClick()
+
+ // background should have been called, but not time
+ verify(exactly = 1) { controllerSpy.toggleTreeFilter(TreeFilter.ShowBackgroundProcesses) }
+ verify(exactly = 0) { controllerSpy.toggleTreeFilter(TreeFilter.ShowTime) }
+
+ // clicking on time
+ onNodeWithTag(
+ TreeSectionTestTags.FILTERS_TIME,
+ useUnmergedTree = true,
+ ).performClick()
+
+ // both filters should have been called
+ verify(exactly = 1) { controllerSpy.toggleTreeFilter(TreeFilter.ShowBackgroundProcesses) }
+ verify(exactly = 1) { controllerSpy.toggleTreeFilter(TreeFilter.ShowTime) }
+ }
+}
diff --git a/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/util.kt b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/util.kt
new file mode 100644
index 000000000000..0fa6d8f7cc65
--- /dev/null
+++ b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/util.kt
@@ -0,0 +1,220 @@
+package com.intellij.python.processOutput.impl
+
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableStateSetOf
+import androidx.compose.runtime.snapshots.SnapshotStateSet
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.junit4.createComposeRule
+import com.intellij.python.community.execService.impl.LoggedProcess
+import com.intellij.python.community.execService.impl.LoggedProcessExe
+import com.intellij.python.community.execService.impl.LoggedProcessExitInfo
+import com.intellij.python.processOutput.impl.ui.toggle
+import com.jetbrains.python.TraceContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.jetbrains.jewel.foundation.lazy.SelectableLazyListState
+import io.mockk.spyk
+import java.util.UUID
+import kotlin.time.Clock
+import kotlin.time.Instant
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
+import org.jetbrains.jewel.foundation.lazy.tree.ChildrenGeneratorScope
+import org.jetbrains.jewel.foundation.lazy.tree.TreeBuilder
+import org.jetbrains.jewel.foundation.lazy.tree.TreeGeneratorScope
+import org.jetbrains.jewel.foundation.lazy.tree.TreeState
+import org.jetbrains.jewel.foundation.lazy.tree.buildTree
+import org.jetbrains.jewel.foundation.theme.LocalThemeInstanceUuid
+import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
+import org.junit.Rule
+
+internal abstract class ProcessOutputTest {
+ private val processTree = MutableStateFlow(buildTree {})
+ private val processTreeFilters: SnapshotStateSet = mutableStateSetOf(
+ TreeFilter.ShowTime,
+ )
+
+ private val processOutputFilters: SnapshotStateSet = mutableStateSetOf()
+ private val processOutputInfoExpanded = MutableStateFlow(false)
+ private val processOutputOutputExpanded = MutableStateFlow(true)
+
+ private val testSelectedProcess: MutableStateFlow = MutableStateFlow(null)
+ private val testProcessTreeUiState: TreeUiState = run {
+ val selectableLazyListState = SelectableLazyListState(LazyListState())
+ TreeUiState(
+ filters = processTreeFilters,
+ searchState = TextFieldState(),
+ selectableLazyListState = selectableLazyListState,
+ treeState = TreeState(selectableLazyListState),
+ tree = processTree,
+ )
+ }
+ private val testProcessOutputUiState: OutputUiState = OutputUiState(
+ filters = processOutputFilters,
+ isInfoExpanded = processOutputInfoExpanded,
+ isOutputExpanded = processOutputOutputExpanded,
+ lazyListState = LazyListState(),
+ )
+
+ @get:Rule
+ val rule: ComposeContentTestRule = createComposeRule()
+
+ val controllerSpy = spyk()
+
+ val controller = object : ProcessOutputController {
+ override val selectedProcess: StateFlow = testSelectedProcess
+ override val processTreeUiState: TreeUiState = testProcessTreeUiState
+ override val processOutputUiState: OutputUiState = testProcessOutputUiState
+
+ override fun collapseAllContexts() {
+ controllerSpy.collapseAllContexts()
+ }
+
+ override fun expandAllContexts() {
+ controllerSpy.expandAllContexts()
+ }
+
+ override fun selectProcess(process: LoggedProcess) {
+ controllerSpy.selectProcess(process)
+ }
+
+ override fun toggleTreeFilter(filter: TreeFilter) {
+ controllerSpy.toggleTreeFilter(filter)
+ }
+
+ override fun toggleOutputFilter(filter: OutputFilter) {
+ controllerSpy.toggleOutputFilter(filter)
+ }
+
+ override fun toggleProcessInfo() {
+ controllerSpy.toggleProcessInfo()
+ }
+
+ override fun toggleProcessOutput() {
+ controllerSpy.toggleProcessOutput()
+ }
+
+ override fun copyOutputToClipboard(loggedProcess: LoggedProcess) {
+ controllerSpy.copyOutputToClipboard(loggedProcess)
+ }
+
+ override fun specifyAdditionalMessageToUser(logId: Int, message: String) {
+ controllerSpy.specifyAdditionalMessageToUser(logId, message)
+ }
+
+ override fun tryOpenLogInToolWindow(logId: Int): Boolean {
+ return controllerSpy.tryOpenLogInToolWindow(logId)
+ }
+ }
+
+ fun scaffoldTestContent(content: @Composable () -> Unit) {
+ rule.setContent {
+ CompositionLocalProvider(LocalThemeInstanceUuid provides UUID.randomUUID()) {
+ IntUiTheme {
+ content()
+ }
+ }
+ }
+ }
+
+ fun processOutputTest(body: suspend ComposeContentTestRule.() -> Unit) =
+ runTest {
+ rule.body()
+ }
+
+ fun setTree(builder: suspend TreeBuilder.() -> Unit) {
+ processTree.value = buildTree {
+ runBlocking {
+ builder()
+ }
+ }
+ }
+
+ fun expandContext(vararg traceContexts: TraceContext) {
+ testProcessTreeUiState.treeState.openNodes(traceContexts.toList())
+ }
+
+ fun toggleTreeFilter(filter: TreeFilter) {
+ processTreeFilters.toggle(filter)
+ }
+
+ fun setSelectedProcess(process: LoggedProcess) {
+ testSelectedProcess.value = process
+ }
+
+ fun setInfoSectionExpanded(value: Boolean) {
+ processOutputInfoExpanded.value = value
+ }
+
+ fun setOutputSectionExpanded(value: Boolean) {
+ processOutputOutputExpanded.value = value
+ }
+
+ suspend fun process(
+ vararg command: String,
+ traceContext: TraceContext? = null,
+ startedAt: Instant = Clock.System.now(),
+ cwd: String? = null,
+ ): LoggedProcess =
+ LoggedProcess(
+ traceContext = traceContext ?: TraceContext("some title"),
+ pid = 123,
+ startedAt = startedAt,
+ cwd = cwd,
+ exe = LoggedProcessExe(
+ path = command.first(),
+ parts = command.first().split(Regex("[/\\\\]+")),
+ ),
+ args = command.drop(1),
+ env = mapOf(),
+ lines = MutableSharedFlow(),
+ exitInfo = MutableStateFlow(null),
+ )
+
+ suspend fun TreeGeneratorScope.addProcess(
+ vararg command: String,
+ traceContext: TraceContext? = null,
+ startedAt: Instant = Clock.System.now(),
+ cwd: String? = null,
+ ) {
+ addProcess(
+ process(
+ *command,
+ traceContext = traceContext,
+ startedAt = startedAt,
+ cwd = cwd,
+ ),
+ )
+ }
+
+ fun TreeGeneratorScope.addProcess(process: LoggedProcess) {
+ addLeaf(TreeNode.Process(process), process)
+ }
+
+ fun TreeGeneratorScope.addContext(
+ context: TraceContext,
+ childrenGenerator: suspend ChildrenGeneratorScope.() -> Unit,
+ ) {
+ addNode(TreeNode.Context(context), context) {
+ runBlocking {
+ childrenGenerator()
+ }
+ }
+ }
+}
+
+internal fun LoggedProcess.finish(
+ exitValue: Int,
+ exitedAt: Instant = Clock.System.now(),
+): LoggedProcess {
+ exitInfo.value = LoggedProcessExitInfo(
+ exitedAt = exitedAt,
+ exitValue = exitValue,
+ )
+
+ return this
+}
diff --git a/python/python-process-output/intellij.python.processOutput.iml b/python/python-process-output/intellij.python.processOutput.iml
new file mode 100644
index 000000000000..a1d61bd6c7f0
--- /dev/null
+++ b/python/python-process-output/intellij.python.processOutput.iml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/python/python-process-output/resources/intellij.python.processOutput.xml b/python/python-process-output/resources/intellij.python.processOutput.xml
new file mode 100644
index 000000000000..5a4afa39b65e
--- /dev/null
+++ b/python/python-process-output/resources/intellij.python.processOutput.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/python/python-process-output/src/com/intellij/python/processOutput/ProcessOutputApi.kt b/python/python-process-output/src/com/intellij/python/processOutput/ProcessOutputApi.kt
new file mode 100644
index 000000000000..95cf54ed5a53
--- /dev/null
+++ b/python/python-process-output/src/com/intellij/python/processOutput/ProcessOutputApi.kt
@@ -0,0 +1,18 @@
+package com.intellij.python.processOutput
+
+import com.intellij.openapi.extensions.ExtensionPointName
+import com.intellij.openapi.project.Project
+import org.jetbrains.annotations.Nls
+
+interface ProcessOutputApi {
+ companion object {
+ private val EP_NAME = ExtensionPointName("com.intellij.python.processOutput.processOutputApi")
+
+ fun getInstance(): ProcessOutputApi? =
+ EP_NAME.extensionList.firstOrNull()
+ }
+
+ fun specifyAdditionalMessageToUser(project: Project, logId: Int, text: @Nls String)
+
+ fun tryOpenLogInToolWindow(project: Project, logId: Int): Boolean
+}
\ No newline at end of file
diff --git a/python/services/system-python/src/com/intellij/python/community/services/systemPython/impl/SystemPythonInitialLoader.kt b/python/services/system-python/src/com/intellij/python/community/services/systemPython/impl/SystemPythonInitialLoader.kt
index 4f9debaeaed6..b6badad43a8a 100644
--- a/python/services/system-python/src/com/intellij/python/community/services/systemPython/impl/SystemPythonInitialLoader.kt
+++ b/python/services/system-python/src/com/intellij/python/community/services/systemPython/impl/SystemPythonInitialLoader.kt
@@ -7,6 +7,8 @@ import com.intellij.openapi.startup.ProjectActivity
import com.intellij.platform.eel.provider.getEelDescriptor
import com.intellij.python.community.services.systemPython.SystemPythonService
import com.intellij.python.community.services.systemPython.getCacheTimeout
+import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
+import kotlinx.coroutines.withContext
private val logger = fileLogger()
@@ -15,6 +17,8 @@ internal class SystemPythonInitialLoader : ProjectActivity {
override suspend fun execute(project: Project) {
if (getCacheTimeout() == null) return // Cache is disabled, no need to preload it
logger.debug("Preloading pythons for $project")
- SystemPythonService().findSystemPythons(project.getEelDescriptor().toEelApi())
+ withContext(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
+ SystemPythonService().findSystemPythons(project.getEelDescriptor().toEelApi())
+ }
}
}
\ No newline at end of file
diff --git a/python/services/system-python/src/com/intellij/python/community/services/systemPython/systemPythonServiceImpl.kt b/python/services/system-python/src/com/intellij/python/community/services/systemPython/systemPythonServiceImpl.kt
index 2135cf12f723..6ecbdb1fdd53 100644
--- a/python/services/system-python/src/com/intellij/python/community/services/systemPython/systemPythonServiceImpl.kt
+++ b/python/services/system-python/src/com/intellij/python/community/services/systemPython/systemPythonServiceImpl.kt
@@ -16,6 +16,7 @@ import com.jetbrains.python.PyToolUIInfo
import com.intellij.python.community.services.systemPython.SystemPythonServiceImpl.MyServiceState
import com.intellij.python.community.services.systemPython.impl.Cache
import com.intellij.python.community.services.systemPython.impl.PySystemPythonBundle
+import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
import com.jetbrains.python.PythonBinary
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyResult
@@ -56,7 +57,9 @@ internal class SystemPythonServiceImpl(scope: CoroutineScope) : SystemPythonServ
scope.launch {
_cacheImpl.complete(getCacheTimeout()?.let { interval ->
Cache(scope, interval) { eelDescriptor ->
- searchPythonsPhysicallyNoCache(eelDescriptor.toEelApi())
+ withContext(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
+ searchPythonsPhysicallyNoCache(eelDescriptor.toEelApi())
+ }
}
})
}
diff --git a/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt b/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt
index 661059ffd1be..c68b4edc1cf8 100644
--- a/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt
+++ b/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt
@@ -8,6 +8,7 @@ import com.intellij.openapi.ui.DialogWrapper
import com.intellij.openapi.ui.Messages
import com.intellij.platform.eel.provider.utils.stderrString
import com.intellij.platform.eel.provider.utils.stdoutString
+import com.intellij.python.processOutput.ProcessOutputApi
import com.intellij.ui.IdeBorderFactory
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLabel
@@ -57,6 +58,22 @@ fun showProcessExecutionErrorDialog(
) {
check(project == null || !project.isDisposed)
+ val logId = execError.loggedProcessId
+
+ if (project != null && logId != null) {
+ ProcessOutputApi.getInstance()?.also { api ->
+ val foundAndOpened = api.tryOpenLogInToolWindow(project, logId)
+
+ if (foundAndOpened) {
+ execError.additionalMessageToUser?.also {
+ api.specifyAdditionalMessageToUser(project, logId, it)
+ }
+
+ return
+ }
+ }
+ }
+
val errorMessageText = PyBundle.message("dialog.message.command.could.not.complete")
// HTML format for text in `JBLabel` enables text wrapping
val errorMessageLabel = JBLabel(UIUtil.toHtml(errorMessageText), Messages.getErrorIcon(), SwingConstants.LEFT)
diff --git a/python/src/com/jetbrains/python/newProject/NewProjectWizardPythonData.kt b/python/src/com/jetbrains/python/newProject/NewProjectWizardPythonData.kt
index 5733058d6450..e18c96545360 100644
--- a/python/src/com/jetbrains/python/newProject/NewProjectWizardPythonData.kt
+++ b/python/src/com/jetbrains/python/newProject/NewProjectWizardPythonData.kt
@@ -15,6 +15,7 @@ import com.intellij.ui.dsl.builder.Panel
import com.jetbrains.python.PyBundle
import com.jetbrains.python.PythonModuleTypeBase
import com.jetbrains.python.errorProcessing.ErrorSink
+import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.newProjectWizard.projectPath.ProjectPathFlows
import com.jetbrains.python.onFailure
import com.jetbrains.python.sdk.ModuleOrProject
@@ -122,14 +123,14 @@ class NewPythonProjectStep(parent: NewProjectWizardStep, val createPythonModuleS
moduleOrProject.moduleIfExists?.takeIf { createPythonModuleStructure }?.let { module ->
runWithModalProgressBlocking(project, PyBundle.message("python.sdk.creating.python.module.structure")) {
pySdkCreator.createPythonModuleStructure(module).onFailure {
- errorSink.emit(it)
+ errorSink.emit(it, project)
}
}
}
runWithModalProgressBlocking(project, PyBundle.message("python.sdk.creating.python.sdk")) {
val (sdk, _) = pySdkCreator.getSdk(moduleOrProject).getOr {
- errorSink.emit(it.error)
+ errorSink.emit(it.error, project)
return@runWithModalProgressBlocking
}
pythonSdk = sdk
diff --git a/python/src/com/jetbrains/python/newProjectWizard/PyV3ProjectBaseGenerator.kt b/python/src/com/jetbrains/python/newProjectWizard/PyV3ProjectBaseGenerator.kt
index b3a5a2431055..4a37acef01bc 100644
--- a/python/src/com/jetbrains/python/newProjectWizard/PyV3ProjectBaseGenerator.kt
+++ b/python/src/com/jetbrains/python/newProjectWizard/PyV3ProjectBaseGenerator.kt
@@ -17,6 +17,7 @@ import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.jetbrains.python.PyBundle
import com.jetbrains.python.Result
+import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.newProjectWizard.collector.PyProjectTypeGenerator
import com.jetbrains.python.newProjectWizard.collector.PythonNewProjectWizardCollector.logPythonNewProjectGenerated
import com.jetbrains.python.newProjectWizard.impl.PyV3GeneratorPeer
@@ -71,7 +72,7 @@ abstract class PyV3ProjectBaseGenerator PyResult?),
): T? = withBackgroundProgress(project = project, title, cancellable = true) {
- runPackagingOperationMaybeShowErrorDialog(errorSink) {
+ runPackagingOperationMaybeShowErrorDialog(errorSink, project) {
withContext(Dispatchers.Default) {
-
operation()
}
}
@@ -35,11 +35,12 @@ internal object PythonPackageManagerUIHelpers {
private suspend fun runPackagingOperationMaybeShowErrorDialog(
errorSink: ErrorSink?,
+ project: Project,
operation: suspend (() -> PyResult?),
): T? {
val pyResult = operation() ?: return null
return pyResult.onFailure {
- errorSink?.emit(it)
+ errorSink?.emit(it, project)
}.getOrNull()
}
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt
index 4e6349b19f65..c25c7a2ba54f 100644
--- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt
@@ -20,7 +20,10 @@ import com.intellij.openapi.roots.ModuleRootEvent
import com.intellij.openapi.roots.ModuleRootListener
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vfs.VirtualFileManager
+import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
+import com.jetbrains.python.PyBundle
import com.jetbrains.python.PyBundle.message
+import com.jetbrains.python.TraceContext
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.getOrNull
import com.jetbrains.python.packaging.PyPackageName
@@ -197,36 +200,42 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou
}
suspend fun installPackage(installRequest: PythonPackageInstallRequest, options: List = emptyList()) {
- PythonPackagesToolwindowStatisticsCollector.installPackageEvent.log(project)
- managerUI.installPackagesRequestBackground(installRequest, options)?.let {
- handleActionCompleted(
- text = message("python.packaging.notification.installed", installRequest.title),
- displayId = PYTHON_PACKAGE_INSTALLED
- )
+ withContext(TraceContext(message("tracecontext.packaging.tool.window.install"))) {
+ PythonPackagesToolwindowStatisticsCollector.installPackageEvent.log(project)
+ managerUI.installPackagesRequestBackground(installRequest, options)?.let {
+ handleActionCompleted(
+ text = message("python.packaging.notification.installed", installRequest.title),
+ displayId = PYTHON_PACKAGE_INSTALLED
+ )
+ }
+ toolWindowPanel?.clearFocus()
}
- toolWindowPanel?.clearFocus()
}
suspend fun installPackage(pkg: PythonPackage, options: List = emptyList()) {
- val installRequest = manager.findPackageSpecification(pkg.name, pkg.version)?.toInstallRequest() ?: return
- PythonPackagesToolwindowStatisticsCollector.installPackageEvent.log(project)
- managerUI.installPackagesRequestBackground(installRequest, options)?.let {
- handleActionCompleted(
- text = message("python.packaging.notification.installed", installRequest.title),
- displayId = PYTHON_PACKAGE_INSTALLED
- )
+ withContext(TraceContext(message("tracecontext.packaging.tool.window.install"))) {
+ val installRequest = manager.findPackageSpecification(pkg.name, pkg.version)?.toInstallRequest() ?: return@withContext
+ PythonPackagesToolwindowStatisticsCollector.installPackageEvent.log(project)
+ managerUI.installPackagesRequestBackground(installRequest, options)?.let {
+ handleActionCompleted(
+ text = message("python.packaging.notification.installed", installRequest.title),
+ displayId = PYTHON_PACKAGE_INSTALLED
+ )
+ }
+ toolWindowPanel?.clearFocus()
}
- toolWindowPanel?.clearFocus()
}
suspend fun deletePackage(vararg selectedPackages: InstalledPackage) {
- PythonPackagesToolwindowStatisticsCollector.uninstallPackageEvent.log(project)
- managerUI.uninstallPackagesBackground(selectedPackages.map { it.instance.name }) ?: return
- handleActionCompleted(
- text = message("python.packaging.notification.deleted", selectedPackages.joinToString(", ") { it.name }),
- displayId = PYTHON_PACKAGE_DELETED
- )
- toolWindowPanel?.clearFocus()
+ withContext(TraceContext(message("tracecontext.packaging.tool.window.delete"))) {
+ PythonPackagesToolwindowStatisticsCollector.uninstallPackageEvent.log(project)
+ managerUI.uninstallPackagesBackground(selectedPackages.map { it.instance.name }) ?: return@withContext
+ handleActionCompleted(
+ text = message("python.packaging.notification.deleted", selectedPackages.joinToString(", ") { it.name }),
+ displayId = PYTHON_PACKAGE_DELETED
+ )
+ toolWindowPanel?.clearFocus()
+ }
}
@ApiStatus.Internal
@@ -257,20 +266,23 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou
toolWindowPanel?.setEmpty()
}
}
- refreshInstalledPackages()
+
+ withContext(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
+ refreshInstalledPackages()
+ }
}
private fun subscribeToChanges() {
val connection = project.messageBus.connect(this)
connection.subscribe(PythonPackageManager.PACKAGE_MANAGEMENT_TOPIC, object : PythonPackageManagementListener {
override fun packagesChanged(sdk: Sdk) {
- if (currentSdk == sdk) serviceScope.launch(Dispatchers.Main) {
+ if (currentSdk == sdk) serviceScope.launch(Dispatchers.Main + NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
refreshInstalledPackages()
}
}
override fun outdatedPackagesChanged(sdk: Sdk) {
- if (currentSdk == sdk) serviceScope.launch(Dispatchers.Main) {
+ if (currentSdk == sdk) serviceScope.launch(Dispatchers.Main + NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
refreshInstalledPackages()
}
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/modules/PyPackagesSdkController.kt b/python/src/com/jetbrains/python/packaging/toolwindow/modules/PyPackagesSdkController.kt
index 08281fff1b81..5c5d88d6f21f 100644
--- a/python/src/com/jetbrains/python/packaging/toolwindow/modules/PyPackagesSdkController.kt
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/modules/PyPackagesSdkController.kt
@@ -19,6 +19,7 @@ import com.intellij.ui.ScrollPaneFactory
import com.intellij.ui.SimpleListCellRenderer
import com.intellij.ui.components.JBList
import com.intellij.util.asDisposable
+import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
import com.jetbrains.python.PyBundle
import com.jetbrains.python.TraceContext
import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
@@ -39,8 +40,8 @@ internal class PyPackagesSdkController(private val project: Project) : Disposabl
private val packagingScope: CoroutineScope = PyPackageCoroutine.getScope(project)
.childScope("Packages SDK Controller", TraceContext(PyBundle.message("tracecontext.packages.sdk.controller"), null)).also {
- Disposer.register(this, it.asDisposable())
- }
+ Disposer.register(this, it.asDisposable())
+ }
private val toolWindowService: PyPackagingToolWindowService
get() = project.service()
diff --git a/python/src/com/jetbrains/python/sdk/PySdkExt.kt b/python/src/com/jetbrains/python/sdk/PySdkExt.kt
index 30a6d4dcebb4..70f069805b81 100644
--- a/python/src/com/jetbrains/python/sdk/PySdkExt.kt
+++ b/python/src/com/jetbrains/python/sdk/PySdkExt.kt
@@ -34,6 +34,7 @@ import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.intellij.webcore.packaging.PackagesNotificationPanel
import com.jetbrains.python.PyBundle
import com.jetbrains.python.errorProcessing.PyResult
+import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.isCondaVirtualEnv
import com.jetbrains.python.isVirtualEnv
import com.jetbrains.python.packaging.ui.PyPackageManagementService
@@ -332,7 +333,7 @@ suspend fun PyDetectedSdk.setupSdk(
doAssociate: Boolean,
) {
val newSdk = setupAssociated(existingSdks, module.basePath, doAssociate).getOr {
- ShowingMessageErrorSync.emit(it.error)
+ ShowingMessageErrorSync.emit(it.error, module.project)
return
}
withContext(Dispatchers.EDT) {
diff --git a/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt b/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt
index 6f6ab58c3ecc..17d26ded338c 100644
--- a/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt
+++ b/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt
@@ -12,6 +12,7 @@ import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.PyResult
+import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.newProject.collector.InterpreterStatisticsInfo
import com.jetbrains.python.sdk.*
import com.jetbrains.python.sdk.flavors.PythonSdkFlavor
diff --git a/python/src/com/jetbrains/python/sdk/add/v2/PythonAddLocalInterpreterPresenter.kt b/python/src/com/jetbrains/python/sdk/add/v2/PythonAddLocalInterpreterPresenter.kt
index 560d323dbd9e..a4d2d4b52dfd 100644
--- a/python/src/com/jetbrains/python/sdk/add/v2/PythonAddLocalInterpreterPresenter.kt
+++ b/python/src/com/jetbrains/python/sdk/add/v2/PythonAddLocalInterpreterPresenter.kt
@@ -5,6 +5,7 @@ import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.io.toNioPathOrNull
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.ErrorSink
+import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.sdk.ModuleOrProject
import com.jetbrains.python.sdk.add.collector.PythonNewInterpreterAddedCollector
import com.jetbrains.python.sdk.rootManager
@@ -39,7 +40,7 @@ class PythonAddLocalInterpreterPresenter(val moduleOrProject: ModuleOrProject, v
suspend fun okClicked(addEnvironment: PythonAddEnvironment) {
when (val r = addEnvironment.getOrCreateSdkWithModal(moduleOrProject)) {
is Result.Failure -> {
- errorSink.emit(r.error)
+ errorSink.emit(r.error, moduleOrProject.project)
return
}
is Result.Success -> {
diff --git a/python/src/com/jetbrains/python/sdk/add/v2/models.kt b/python/src/com/jetbrains/python/sdk/add/v2/models.kt
index a8e9a5c68dfc..fd6c92d1e481 100644
--- a/python/src/com/jetbrains/python/sdk/add/v2/models.kt
+++ b/python/src/com/jetbrains/python/sdk/add/v2/models.kt
@@ -17,6 +17,8 @@ import com.intellij.util.concurrency.annotations.RequiresEdt
import com.jetbrains.python.*
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.errorProcessing.PyResult
+import com.jetbrains.python.errorProcessing.emit
+import com.jetbrains.python.getOrNull
import com.jetbrains.python.newProjectWizard.projectPath.ProjectPathFlows
import com.jetbrains.python.psi.LanguageLevel
import com.jetbrains.python.sdk.PySdkToInstall
diff --git a/python/src/com/jetbrains/python/sdk/add/v2/uiUtils.kt b/python/src/com/jetbrains/python/sdk/add/v2/uiUtils.kt
index eeeb4ee23728..d2eb92b5aa47 100644
--- a/python/src/com/jetbrains/python/sdk/add/v2/uiUtils.kt
+++ b/python/src/com/jetbrains/python/sdk/add/v2/uiUtils.kt
@@ -31,6 +31,7 @@ import com.jetbrains.python.PyBundle
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.PyResult
+import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.onFailure
import com.jetbrains.python.parser.icons.PythonParserIcons
import com.jetbrains.python.sdk.add.v2.PythonInterpreterSelectionMethod.CREATE_NEW
diff --git a/python/src/com/jetbrains/python/sdk/conda/PyAddCondaTools.kt b/python/src/com/jetbrains/python/sdk/conda/PyAddCondaTools.kt
index d62894dca2fb..b81d91f4aace 100644
--- a/python/src/com/jetbrains/python/sdk/conda/PyAddCondaTools.kt
+++ b/python/src/com/jetbrains/python/sdk/conda/PyAddCondaTools.kt
@@ -22,6 +22,7 @@ import com.jetbrains.python.conda.loadLocalPythonCondaPath
import com.jetbrains.python.conda.saveLocalPythonCondaPath
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.errorProcessing.asPythonResult
+import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.getOrThrow
import com.jetbrains.python.onFailure
import com.jetbrains.python.psi.LanguageLevel
@@ -84,7 +85,7 @@ suspend fun PyCondaCommand.createCondaSdkFromExistingEnv(
// homePath is not required by conda, but used by lots of tools all over the code and required by CondaPathFix
// Because homePath is not set yet, CondaPathFix does not work
sdkModificator.homePath = getCondaPythonBinaryPath(project, condaEnv, targetConfig).onFailure {
- ShowingMessageErrorSync.emit(it)
+ ShowingMessageErrorSync.emit(it, project)
}.getOrThrow()
edtWriteAction {
sdkModificator.commitChanges()
diff --git a/python/src/com/jetbrains/python/sdk/configuration/PyProjectSdkConfiguration.kt b/python/src/com/jetbrains/python/sdk/configuration/PyProjectSdkConfiguration.kt
index 7c87b85bd111..aeba3052e8ea 100644
--- a/python/src/com/jetbrains/python/sdk/configuration/PyProjectSdkConfiguration.kt
+++ b/python/src/com/jetbrains/python/sdk/configuration/PyProjectSdkConfiguration.kt
@@ -18,6 +18,8 @@ import com.intellij.openapi.wm.ex.WelcomeScreenProjectProvider
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.jetbrains.python.PyBundle
import com.jetbrains.python.PythonPluginDisposable
+import com.jetbrains.python.errorProcessing.PyResult
+import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.packaging.utils.PyPackageCoroutine
import com.jetbrains.python.sdk.PySdkPopupFactory
import com.jetbrains.python.sdk.configuration.suppressors.PyInterpreterInspectionSuppressor
@@ -48,7 +50,7 @@ object PyProjectSdkConfiguration {
thisLogger().debug("Configuring sdk using ${createSdkInfoWithTool.toolId}")
val sdk = createSdkInfoWithTool.createSdkInfo.sdkCreator(needsConfirmation).getOr {
- ShowingMessageErrorSync.emit(it.error)
+ ShowingMessageErrorSync.emit(it.error, module.project)
return@withContext true
} ?: return@withContext false
@@ -106,4 +108,4 @@ object PyProjectSdkConfiguration {
notify(project)
}
}
-}
\ No newline at end of file
+}
diff --git a/python/src/com/jetbrains/python/sdk/pipenv/PipEnvPipFileWatcher.kt b/python/src/com/jetbrains/python/sdk/pipenv/PipEnvPipFileWatcher.kt
index 7a417bb035b8..31473b88b537 100644
--- a/python/src/com/jetbrains/python/sdk/pipenv/PipEnvPipFileWatcher.kt
+++ b/python/src/com/jetbrains/python/sdk/pipenv/PipEnvPipFileWatcher.kt
@@ -22,6 +22,7 @@ import com.intellij.openapi.util.NlsContexts
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.jetbrains.python.PyBundle
+import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.onFailure
import com.jetbrains.python.packaging.utils.PyPackageCoroutine
import com.jetbrains.python.sdk.*
@@ -108,7 +109,7 @@ internal class PipEnvPipFileWatcher : EditorFactoryListener {
withBackgroundProgress(module.project, description) {
val sdk = module.pythonSdk ?: return@withBackgroundProgress
runPipEnv(sdk.associatedModulePath?.let { Path.of(it) }, *args.toTypedArray()).onFailure {
- ShowingMessageErrorSync.emit(it)
+ ShowingMessageErrorSync.emit(it, module.project)
}
withContext(Dispatchers.Default) {
diff --git a/python/src/com/jetbrains/python/sdk/uv/run/UvRunConfigurationState.kt b/python/src/com/jetbrains/python/sdk/uv/run/UvRunConfigurationState.kt
index 2981b13846ff..59511514ef54 100644
--- a/python/src/com/jetbrains/python/sdk/uv/run/UvRunConfigurationState.kt
+++ b/python/src/com/jetbrains/python/sdk/uv/run/UvRunConfigurationState.kt
@@ -77,7 +77,8 @@ fun canRun(
null -> isError = true
}
}
- } else {
+ }
+ else {
isError = true
}
diff --git a/python/src/com/jetbrains/python/target/PythonLanguageRuntimeUI.kt b/python/src/com/jetbrains/python/target/PythonLanguageRuntimeUI.kt
index 50f995b5ccb0..bb6ea201c46c 100644
--- a/python/src/com/jetbrains/python/target/PythonLanguageRuntimeUI.kt
+++ b/python/src/com/jetbrains/python/target/PythonLanguageRuntimeUI.kt
@@ -20,6 +20,7 @@ import com.intellij.util.ui.launchOnShow
import com.jetbrains.python.PyBundle
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.errorProcessing.ErrorSink
+import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.newProjectWizard.projectPath.ProjectPathFlows
import com.jetbrains.python.onFailure
import com.jetbrains.python.sdk.ModuleOrProject
diff --git a/python/src/com/jetbrains/python/util/ShowingMessageErrorSync.kt b/python/src/com/jetbrains/python/util/ShowingMessageErrorSync.kt
index 1e6526e320af..0b0c6c468acb 100644
--- a/python/src/com/jetbrains/python/util/ShowingMessageErrorSync.kt
+++ b/python/src/com/jetbrains/python/util/ShowingMessageErrorSync.kt
@@ -8,7 +8,7 @@ import com.jetbrains.python.PyBundle
import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.ExecError
import com.jetbrains.python.errorProcessing.MessageError
-import com.jetbrains.python.errorProcessing.PyError
+import com.jetbrains.python.errorProcessing.PyErrorDetail
import com.jetbrains.python.packaging.PyExecutionException
import com.jetbrains.python.showProcessExecutionErrorDialog
import kotlinx.coroutines.Dispatchers
@@ -20,18 +20,21 @@ import org.jetbrains.annotations.ApiStatus
*/
@ApiStatus.Internal
object ShowingMessageErrorSync : ErrorSink {
- override suspend fun emit(error: PyError) {
- //In unit tests dialogs are not supported
+ override suspend fun emit(value: PyErrorDetail) {
+ val (error, project) = value
+
+ // In unit tests dialogs are not supported
if (ApplicationManager.getApplication().isUnitTestMode) {
throw PyExecutionException(error)
}
+
withContext(Dispatchers.EDT + ModalityState.any().asContextElement()) {
thisLogger().warn(error.message)
// Platform doesn't allow dialogs without a lock for now, fix later
writeIntentReadAction {
when (val e = error) {
is ExecError -> {
- showProcessExecutionErrorDialog(null, e)
+ showProcessExecutionErrorDialog(project, e)
}
is MessageError -> {
Messages.showErrorDialog(error.message, PyBundle.message("python.error"))