IJ-MR-176106-to-253

[pycharm] PY-81494 Fix deadlocking code


Merge-request: IJ-MR-179432
Merged-by: David Lysenko <david.lysenko@jetbrains.com>
[pycharm] PY-81494 Config fixes

PY-81494

[pycharm] PY-81494 Fix further flakiness

[pycharm] PY-81494 Address feedback

[pycharm] PY-81494 Fix flakiness

[pycharm] PY-81494 Separate modules

[pycharm] PY-81494 Fix tests

[pycharm] PY-81494 Change waitFor to awaitExit for coroutines

[pycharm] PY-81494 Configuration fixes

[pycharm] PY-81494 Add usage statistics

[pycharm] PY-81494 Address feedback

[pycharm] PY-81494 Add more limit tests

[pycharm] PY-81494 Post-rebase fixes

[pycharm] PY-81494 Implement logging tests

[pycharm] PY-81494 Final design adjustments

[pycharm] PY-81494 Refactor flows

[pycharm] PY-81494 Add more OutputSection tests

[pycharm] PY-81494 Add Toolbar tests

[pycharm] PY-81494 Add InterText tests

[pycharm] PY-81494 Add FilterActionGroup tests

[pycharm] PY-81494 Add EmptyContainerNotice tests

[pycharm] PY-81494 Add CollapsibleListSection tests

[pycharm] PY-81494 Add ActionIconButton tests

[pycharm] PY-81494 Address feedback

[pycharm] PY-81494 Address feedback

[pycharm] PY-81494 Post-rebase fixes

[pycharm] PY-81494 Address initial feedback

[pycharm] PY-81494 Fix existing tests & add new to tree

[pycharm] PY-81494 Implement copy to clipboard button

[pycharm] PY-81494 Amend design

[pycharm] PY-81494 Begin implementing output tests

[pycharm] PY-81494 Refactor file structure

[pycharm] PY-81494 Implement tests for process list

[pycharm] PY-81494 wip tests for process list

[pycharm] PY-81494 Finishing touches

[pycharm] PY-81494 Consolidate list logic in the model

[pycharm] PY-81494 Add logging limits

[pycharm] PY-81494 Implement open tool window on exec service error

[pycharm] PY-81494 Implement open command in terminal

[pycharm] PY-81494 Add expansion actions

[pycharm] PY-81494 Implement categorization by coroutine names

[pycharm] PY-81494 Memorize expansion states between tool window openings

[pycharm] PY-81494 Memorize scroll state between tool window openings

[pycharm] PY-81494 Introduce collapsible section for process info

[pycharm] PY-81494 Implement view setting filtering

[pycharm] PY-81494 Refactor process logging to use shared flows

[pycharm] PY-81494 Implement tests for ProcessList composable

[pycharm] PY-81494 Implement process toolwindow prototype

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


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

GitOrigin-RevId: 44552a582dd628d206b207e02e6f24c7749b4d9f
This commit is contained in:
David Lysenko
2025-10-22 09:04:47 +00:00
committed by intellij-monorepo-bot
parent bf299d23c2
commit fc7d863a50
93 changed files with 4349 additions and 64 deletions
+2
View File
@@ -1399,6 +1399,8 @@
<module fileurl="file://$PROJECT_DIR$/python/python-markdown/intellij.python.markdown.iml" filepath="$PROJECT_DIR$/python/python-markdown/intellij.python.markdown.iml" />
<module fileurl="file://$PROJECT_DIR$/python/intellij.python.ml.features/intellij.python.ml.features.iml" filepath="$PROJECT_DIR$/python/intellij.python.ml.features/intellij.python.ml.features.iml" />
<module fileurl="file://$PROJECT_DIR$/python/python-parser/intellij.python.parser.iml" filepath="$PROJECT_DIR$/python/python-parser/intellij.python.parser.iml" />
<module fileurl="file://$PROJECT_DIR$/python/python-process-output/intellij.python.processOutput.iml" filepath="$PROJECT_DIR$/python/python-process-output/intellij.python.processOutput.iml" />
<module fileurl="file://$PROJECT_DIR$/python/python-process-output/impl/intellij.python.processOutput.impl.iml" filepath="$PROJECT_DIR$/python/python-process-output/impl/intellij.python.processOutput.impl.iml" />
<module fileurl="file://$PROJECT_DIR$/python/python-psi-api/intellij.python.psi.iml" filepath="$PROJECT_DIR$/python/python-psi-api/intellij.python.psi.iml" />
<module fileurl="file://$PROJECT_DIR$/python/python-psi-impl/intellij.python.psi.impl.iml" filepath="$PROJECT_DIR$/python/python-psi-impl/intellij.python.psi.impl.iml" />
<module fileurl="file://$PROJECT_DIR$/python/intellij.python.pydev.iml" filepath="$PROJECT_DIR$/python/intellij.python.pydev.iml" />
+1
View File
@@ -386,6 +386,7 @@ jvm_library(
"//platform/testFramework/monorepo",
"//platform/testFramework/monorepo:monorepo_test_lib",
"//plugins/textmate/tests:tests_test_lib",
"//python/python-process-output:processOutput",
],
runtime_deps = [
"//platform/main/intellij.platform.monolith.main:monolith-main",
+2
View File
@@ -1442,6 +1442,8 @@ python/python-grazie
python/python-hatch
python/python-markdown
python/python-parser
python/python-process-output
python/python-process-output/impl
python/python-psi-api
python/python-psi-impl
python/python-pyproject
+1
View File
@@ -255,5 +255,6 @@
<orderEntry type="module" module-name="intellij.maven.server.m40" scope="RUNTIME" />
<orderEntry type="module" module-name="intellij.mcpserver" scope="RUNTIME" />
<orderEntry type="module" module-name="intellij.textmate.tests" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.processOutput" scope="TEST" />
</component>
</module>
+1
View File
@@ -355,6 +355,7 @@ jvm_library(
"//python/python-exec-service/execService.python",
"//python/interpreters",
"//python/python-sdk-configurator/common",
"//python/python-process-output:processOutput",
],
exports = [
"//python/openapi:community",
@@ -25,6 +25,7 @@
<module name="intellij.python.community.services.internal.impl"/>
<module name="intellij.python.community.services.systemPython"/>
<module name="intellij.python.community.interpreters"/>
<module name="intellij.python.processOutput"/>
</dependencies>
<!-- Declare that we support core Python functionality -->
<module value="com.intellij.modules.python"/>
@@ -23,6 +23,7 @@ import com.jetbrains.python.PyBundle
import com.jetbrains.python.PyToolUIInfo
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
@@ -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)
}
}
}
@@ -190,5 +190,6 @@
<orderEntry type="module" module-name="intellij.python.community.execService.python" />
<orderEntry type="module" module-name="intellij.python.community.interpreters" />
<orderEntry type="module" module-name="intellij.python.sdkConfigurator.common" />
<orderEntry type="module" module-name="intellij.python.processOutput" />
</component>
</module>
@@ -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<PyError>
typealias ErrorSink = FlowCollector<PyErrorDetail>
data class PyErrorDetail(
val error: PyError,
val project: Project? = null,
)
suspend fun ErrorSink.emit(error: PyError, project: Project? = null) {
emit(PyErrorDetail(error, project))
}
@@ -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<String> =
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<T : ExecErrorReason>(
* 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(" ")
}
+4
View File
@@ -132,6 +132,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
@@ -181,4 +184,5 @@
modules:
- name: intellij.python.community.plugin
contentModules:
- name: intellij.python.processOutput.impl
- name: intellij.commandInterface
@@ -53,6 +53,8 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
<module name="intellij.python.community.services.systemPython" loading="required"/>
<module name="intellij.python.community.interpreters" loading="required"/>
<module name="intellij.python.community.aliasProvider" loading="required"/>
<module name="intellij.python.processOutput" loading="required"/>
<module name="intellij.python.processOutput.impl"/>
<!--Mini-IDes support community python only-->
<module name="intellij.python.community.plugin.minor"/> <!-- Python for Mini-IDEs-->
<module name="intellij.python.community.plugin.minorRider"/> <!-- Python special support for Rider -->
@@ -21,6 +21,7 @@
<module name="intellij.python.community.impl.venv"/>
<module name="intellij.python.community.services.systemPython"/>
<module name="intellij.python.sdkConfigurator.common"/>
<module name="intellij.python.processOutput"/>
</dependencies>
<resource-bundle>messages.PyBundle</resource-bundle>
@@ -1755,12 +1755,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
+2
View File
@@ -41,6 +41,7 @@ jvm_library(
"//libraries/kotlinx/serialization/json",
"//libraries/kotlinx/serialization/core",
"//platform/remote-servers/impl",
"@lib//:guava",
]
)
@@ -76,6 +77,7 @@ jvm_library(
"//libraries/kotlinx/serialization/json",
"//libraries/kotlinx/serialization/core",
"//platform/remote-servers/impl",
"@lib//:guava",
]
)
### auto-generated section `build intellij.python.community.execService` end
@@ -52,5 +52,6 @@
<orderEntry type="module" module-name="intellij.libraries.kotlinx.serialization.json" />
<orderEntry type="module" module-name="intellij.libraries.kotlinx.serialization.core" />
<orderEntry type="module" module-name="intellij.platform.remoteServers.impl" />
<orderEntry type="library" name="Guava" level="project" />
</component>
</module>
@@ -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 <T : ExecErrorReason> ProcessLauncher.createExecError(messageToUser: @Nls String, errorReason: T): Result.Failure<ExecErrorImpl<T>> =
private fun <T : ExecErrorReason> ProcessLauncher.createExecError(
messageToUser: @Nls String,
errorReason: T,
loggedProcessId: Int? = null,
): Result.Failure<ExecErrorImpl<T>> =
ExecErrorImpl(
exe = exeForError,
args = args.toTypedArray(),
additionalMessageToUser = messageToUser,
errorReason = errorReason
errorReason = errorReason,
loggedProcessId = loggedProcessId,
).logAndFail()
@@ -0,0 +1,240 @@
// 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.google.common.io.ByteStreams
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.util.io.awaitExit
import com.intellij.util.io.readLineAsync
import com.jetbrains.python.TraceContext
import com.jetbrains.python.errorProcessing.Exe
import kotlinx.coroutines.CoroutineScope
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 org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.Nls
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import java.io.OutputStream
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Clock
import kotlin.time.Instant
internal object LoggingLimits {
/**
* The maximum buffer size of a LoggingProcess
*/
const val MAX_OUTPUT_SIZE = 10_000_000
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<String>,
val env: Map<String, String>,
val lines: SharedFlow<LoggedProcessLine>,
val exitInfo: MutableStateFlow<LoggedProcessExitInfo?>,
) {
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<String>) =
segments.joinToString(" ")
}
}
@ApiStatus.Internal
data class LoggedProcessExe(
val path: String,
val parts: List<String>,
)
@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<LoggedProcess>()
val processes: Flow<LoggedProcess> = processesInternal.asSharedFlow()
}
@ApiStatus.Internal
class LoggingProcess(
private val backingProcess: Process,
traceContext: TraceContext?,
startedAt: Instant,
cwd: String?,
exe: Exe,
args: List<String>,
env: Map<String, String>,
) : Process() {
val loggedProcess: LoggedProcess
private val stdoutStream = LoggingInputStream(backingProcess.inputStream)
private val stderrStream = LoggingInputStream(backingProcess.errorStream)
init {
val service = ApplicationManager.getApplication().service<ExecLoggerService>()
val linesFlow = MutableSharedFlow<LoggedProcessLine>(replay = LoggingLimits.MAX_LINES)
val exitInfoFlow = MutableStateFlow<LoggedProcessExitInfo?>(null)
loggedProcess =
LoggedProcess(
traceContext,
try {
backingProcess.pid()
}
catch (_: UnsupportedOperationException) {
null
},
startedAt,
cwd,
LoggedProcessExe(
path = exe.toString(),
parts = exe.pathParts(),
),
args,
env,
linesFlow,
exitInfoFlow,
)
service.scope.launch {
service.processesInternal.emit(loggedProcess)
awaitExit()
val stdoutReader = BufferedReader(InputStreamReader(ByteArrayInputStream(stdoutStream.byteArray)))
val stderrReader = BufferedReader(InputStreamReader(ByteArrayInputStream(stderrStream.byteArray)))
collectOutputLines(stdoutReader, linesFlow, LoggedProcessLine.Kind.OUT)
collectOutputLines(stderrReader, linesFlow, LoggedProcessLine.Kind.ERR)
exitInfoFlow.value = LoggedProcessExitInfo(
exitedAt = Clock.System.now(),
exitValue = exitValue(),
)
}
}
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 bytes = ByteStreams.newDataOutput()
private var tail = 0
val byteArray
get() = bytes.toByteArray()
override fun read(): Int {
val byte = try {
backingInputStream.read()
}
catch (e: IOException) {
// 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
}
if (tail < LoggingLimits.MAX_OUTPUT_SIZE && byte != -1) {
bytes.write(byte)
tail += 1
}
return byte
}
}
private suspend fun collectOutputLines(
reader: BufferedReader,
linesFlow: MutableSharedFlow<LoggedProcessLine>,
kind: LoggedProcessLine.Kind,
) {
var line: String? = null
while (reader.readLineAsync()?.also { line = it } != null) {
linesFlow.emit(LoggedProcessLine(
text = line!!,
kind = kind,
))
}
}
@@ -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<String>,
private val processCommands: ProcessCommands,
) {
suspend fun start(): Result<Process, ExecErrorReason.CantStart> = processCommands.start()
suspend fun start(): Result<LoggingProcess, ExecErrorReason.CantStart> =
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<Process, ExecErrorReason.CantStart>
val processFunctions: ProcessFunctions
val scopeToBind: CoroutineScope
val env: Map<String, String>
val cwd: String?
}
internal data class LaunchRequest(
val scopeToBind: CoroutineScope,
val args: Args,
val env: Map<String, String>,
val usePty: TtySize?
)
val usePty: TtySize?,
)
@@ -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<String>,
private val env: Map<String, String>,
override val env: Map<String, String>,
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() }
@@ -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<String, String>
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<ExecErrorReason.CantStart> = Result.failure(ExecErrorReason.CantStart(null, localizedMessage))
private fun ExecutionException.asCantStart(): Result.Failure<ExecErrorReason.CantStart> = Result.failure(ExecErrorReason.CantStart(null, localizedMessage))
@@ -0,0 +1,240 @@
// 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.Disabled
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()
loggingProcess.errorStream.readAllBytes()
waitUntil { loggedProcess.lines.replayCache.size == 6 }
(1..3).forEach {
assert(loggedProcess.lines.replayCache[it - 1].text == "outline$it")
assert(loggedProcess.lines.replayCache[it - 1].kind == LoggedProcessLine.Kind.OUT)
}
(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)
}
@Disabled
@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()
loggingProcess.destroy()
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")
}
// todo: add limits test
}
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<String> = listOf("foo", "bar"),
env: Map<String, String> = mapOf("foo" to "bar"),
) =
LoggingProcess(
object : Process() {
val stdoutStream = ByteArrayInputStream(stdout.toByteArray())
val stderrStream = ByteArrayInputStream(stderr.toByteArray())
val stdinStream = ByteArrayOutputStream()
val destroyFuture = CompletableFuture<Int>()
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
)
}
}
@@ -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
@@ -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) {
+22
View File
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="FacetManager">
<facet type="kotlin-language" name="Kotlin">
<configuration version="5" platform="JVM 21" allPlatforms="JVM [21]" useProjectSettings="false">
<compilerSettings>
<option name="additionalArguments" value="-Xjvm-default=all -P plugin:androidx.compose.compiler.plugins.kotlin:generateFunctionKeyMetaAnnotations=true -XXLanguage:+AllowEagerSupertypeAccessibilityChecks -opt-in=kotlin.time.ExperimentalTime" />
</compilerSettings>
<compilerArguments>
<stringArguments>
<stringArg name="jvmTarget" arg="21" />
<stringArg name="apiVersion" arg="2.2" />
<stringArg name="languageVersion" arg="2.2" />
</stringArguments>
<arrayArguments>
<arrayArg name="pluginClasspaths">
<args>$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-compose-compiler-plugin/2.2.20/kotlin-compose-compiler-plugin-2.2.20.jar</args>
</arrayArg>
</arrayArguments>
</compilerArguments>
</configuration>
</facet>
</component>
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="kotlin-stdlib" level="project" />
<orderEntry type="module" module-name="intellij.platform.ide" />
<orderEntry type="module" module-name="intellij.platform.core" />
<orderEntry type="module" module-name="intellij.libraries.skiko" />
<orderEntry type="module" module-name="intellij.libraries.compose.foundation.desktop" />
<orderEntry type="module" module-name="intellij.platform.jewel.foundation" />
<orderEntry type="module" module-name="intellij.platform.jewel.ui" />
<orderEntry type="module" module-name="intellij.platform.jewel.ideLafBridge" />
<orderEntry type="module" module-name="intellij.platform.core.ui" />
<orderEntry type="module" module-name="intellij.python.community.execService" />
<orderEntry type="module" module-name="intellij.python.community" />
<orderEntry type="module" module-name="intellij.platform.util.ui" />
<orderEntry type="module" module-name="intellij.platform.editor.ui" />
<orderEntry type="module" module-name="intellij.libraries.compose.foundation.desktop.junit" scope="TEST" />
<orderEntry type="library" scope="TEST" name="kotlin-test" level="project" />
<orderEntry type="library" scope="TEST" name="kotlin-test-assertions-core-jvm" level="project" />
<orderEntry type="library" scope="TEST" name="kotlin-test-junit" level="project" />
<orderEntry type="module" module-name="intellij.platform.jewel.intUi.standalone" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.projectModel" />
<orderEntry type="module" module-name="intellij.platform.execution.impl" />
<orderEntry type="library" name="jediterm-core" level="project" />
<orderEntry type="library" scope="TEST" name="io.mockk" level="project" />
<orderEntry type="library" scope="TEST" name="io.mockk.jvm" level="project" />
<orderEntry type="library" name="kotlin-reflect" level="project" />
<orderEntry type="library" name="kotlinx-collections-immutable" level="project" />
<orderEntry type="module" module-name="intellij.platform.testFramework.junit5" scope="TEST" />
<orderEntry type="library" scope="TEST" name="JUnit5" level="project" />
<orderEntry type="module" module-name="intellij.platform.testFramework.common" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.statistics" />
<orderEntry type="module" module-name="intellij.python.processOutput" />
</component>
</module>
@@ -0,0 +1,6 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.7 4L14.7 8L10.7 12" stroke="#6C707E" stroke-linecap="round"/>
<path d="M6.1001 4L10.1001 8L6.1001 12" stroke="#6C707E" stroke-linecap="round"/>
<path d="M1.5 4L5.5 8L1.5 12" stroke="#6C707E" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 459 B

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.7 4L14.7 8L10.7 12" stroke="#CED0D6" stroke-linecap="round"/>
<path d="M6.1001 4L10.1001 8L6.1001 12" stroke="#CED0D6" stroke-linecap="round"/>
<path d="M1.5 4L5.5 8L1.5 12" stroke="#CED0D6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 459 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.3417 14.0488 3.65822 13.8535 3.85347L11.8535 5.85347C11.6583 6.04873 11.3417 6.04871 11.1465 5.85347C10.9512 5.65821 10.9512 5.3417 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00003 7.1045 3.89545 7.99996 5 7.99996H11C12.6569 7.99996 14 9.3431 14 11C14 12.6568 12.6568 14 11 14H5.93457C5.71233 14.8622 4.93169 15.5 4 15.5C2.89545 15.5 2.00003 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H11C12.1046 13 13 12.1045 13 11C13 9.89539 12.1046 8.99996 11 8.99996H5C3.34317 8.99996 2.00003 7.65679 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65821 10.9512 1.3417 11.1465 1.14644ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00003 14.0522 3.44773 14.5 4 14.5C4.55227 14.5 4.99997 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.3417 14.0488 3.65822 13.8535 3.85347L11.8535 5.85347C11.6583 6.04873 11.3417 6.04871 11.1465 5.85347C10.9512 5.65821 10.9512 5.3417 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00003 7.1045 3.89545 7.99996 5 7.99996H11C12.6569 7.99996 14 9.3431 14 11C14 12.6568 12.6568 14 11 14H5.93457C5.71233 14.8622 4.93169 15.5 4 15.5C2.89545 15.5 2.00003 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H11C12.1046 13 13 12.1045 13 11C13 9.89539 12.1046 8.99996 11 8.99996H5C3.34317 8.99996 2.00003 7.65679 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65821 10.9512 1.3417 11.1465 1.14644ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00003 14.0522 3.44773 14.5 4 14.5C4.55227 14.5 4.99997 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#A8ADBD"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12.5" cy="12.5" r="3.5" fill="#E55765"/>
<path d="M12.5 10C12.2239 10 12 10.2239 12 10.5V12.5C12 12.7761 12.2239 13 12.5 13C12.7761 13 13 12.7761 13 12.5V10.5C13 10.2239 12.7761 10 12.5 10Z" fill="white"/>
<path d="M12.5 15C12.7761 15 13 14.7761 13 14.5C13 14.2239 12.7761 14 12.5 14C12.2239 14 12 14.2239 12 14.5C12 14.7761 12.2239 15 12.5 15Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H8.02832L8 14L5.93457 14C5.7123 14.8622 4.93166 15.5 4 15.5C2.89548 15.5 2.00008 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00008 14.0522 3.44776 14.5 4 14.5C4.55224 14.5 4.99992 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#A8ADBD"/>
<path d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.34171 14.0487 3.65823 13.8535 3.85347L11.8535 5.85347C11.6583 6.04867 11.3417 6.04865 11.1465 5.85347C10.9512 5.65823 10.9513 5.34171 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00008 7.10446 3.89548 7.99996 5 7.99996H9.5C9.77614 7.99996 10 8.22381 10 8.49996C10 8.7761 9.77614 8.99996 9.5 8.99996H5C3.34319 8.99996 2.00008 7.65675 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65823 10.9513 1.34171 11.1465 1.14644Z" fill="#A8ADBD"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12.5" cy="12.5" r="3.5" fill="#DB5C5C"/>
<path d="M12.5 10C12.2239 10 12 10.2239 12 10.5V12.5C12 12.7761 12.2239 13 12.5 13C12.7761 13 13 12.7761 13 12.5V10.5C13 10.2239 12.7761 10 12.5 10Z" fill="white"/>
<path d="M12.5 15C12.7761 15 13 14.7761 13 14.5C13 14.2239 12.7761 14 12.5 14C12.2239 14 12 14.2239 12 14.5C12 14.7761 12.2239 15 12.5 15Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H8.02832L8 14L5.93457 14C5.7123 14.8622 4.93166 15.5 4 15.5C2.89548 15.5 2.00008 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00008 14.0522 3.44776 14.5 4 14.5C4.55224 14.5 4.99992 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#6F737A"/>
<path d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.34171 14.0487 3.65823 13.8535 3.85347L11.8535 5.85347C11.6583 6.04867 11.3417 6.04865 11.1465 5.85347C10.9512 5.65823 10.9513 5.34171 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00008 7.10446 3.89548 7.99996 5 7.99996H9.5C9.77614 7.99996 10 8.22381 10 8.49996C10 8.7761 9.77614 8.99996 9.5 8.99996H5C3.34319 8.99996 2.00008 7.65675 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65823 10.9513 1.34171 11.1465 1.14644Z" fill="#6F737A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.3417 14.0488 3.65822 13.8535 3.85347L11.8535 5.85347C11.6583 6.04873 11.3417 6.04871 11.1465 5.85347C10.9512 5.65821 10.9512 5.3417 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00003 7.1045 3.89545 7.99996 5 7.99996H11C12.6569 7.99996 14 9.3431 14 11C14 12.6568 12.6568 14 11 14H5.93457C5.71233 14.8622 4.93169 15.5 4 15.5C2.89545 15.5 2.00003 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H11C12.1046 13 13 12.1045 13 11C13 9.89539 12.1046 8.99996 11 8.99996H5C3.34317 8.99996 2.00003 7.65679 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65821 10.9512 1.3417 11.1465 1.14644ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00003 14.0522 3.44773 14.5 4 14.5C4.55227 14.5 4.99997 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#6F737A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12.5" cy="12.5" r="3.5" fill="#E55765"/>
<path d="M12.5 10C12.2239 10 12 10.2239 12 10.5V12.5C12 12.7761 12.2239 13 12.5 13C12.7761 13 13 12.7761 13 12.5V10.5C13 10.2239 12.7761 10 12.5 10Z" fill="white"/>
<path d="M12.5 15C12.7761 15 13 14.7761 13 14.5C13 14.2239 12.7761 14 12.5 14C12.2239 14 12 14.2239 12 14.5C12 14.7761 12.2239 15 12.5 15Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H8.02832L8 14L5.93457 14C5.7123 14.8622 4.93166 15.5 4 15.5C2.89548 15.5 2.00008 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00008 14.0522 3.44776 14.5 4 14.5C4.55224 14.5 4.99992 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#6C707E"/>
<path d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.34171 14.0487 3.65823 13.8535 3.85347L11.8535 5.85347C11.6583 6.04867 11.3417 6.04865 11.1465 5.85347C10.9512 5.65823 10.9513 5.34171 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00008 7.10446 3.89548 7.99996 5 7.99996H9.5C9.77614 7.99996 10 8.22381 10 8.49996C10 8.7761 9.77614 8.99996 9.5 8.99996H5C3.34319 8.99996 2.00008 7.65675 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65823 10.9513 1.34171 11.1465 1.14644Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12.5" cy="12.5" r="3.5" fill="#DB5C5C"/>
<path d="M12.5 10C12.2239 10 12 10.2239 12 10.5V12.5C12 12.7761 12.2239 13 12.5 13C12.7761 13 13 12.7761 13 12.5V10.5C13 10.2239 12.7761 10 12.5 10Z" fill="white"/>
<path d="M12.5 15C12.7761 15 13 14.7761 13 14.5C13 14.2239 12.7761 14 12.5 14C12.2239 14 12 14.2239 12 14.5C12 14.7761 12.2239 15 12.5 15Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H8.02832L8 14L5.93457 14C5.7123 14.8622 4.93166 15.5 4 15.5C2.89548 15.5 2.00008 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00008 14.0522 3.44776 14.5 4 14.5C4.55224 14.5 4.99992 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#CED0D6"/>
<path d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.34171 14.0487 3.65823 13.8535 3.85347L11.8535 5.85347C11.6583 6.04867 11.3417 6.04865 11.1465 5.85347C10.9512 5.65823 10.9513 5.34171 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00008 7.10446 3.89548 7.99996 5 7.99996H9.5C9.77614 7.99996 10 8.22381 10 8.49996C10 8.7761 9.77614 8.99996 9.5 8.99996H5C3.34319 8.99996 2.00008 7.65675 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65823 10.9513 1.34171 11.1465 1.14644Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.3417 14.0488 3.65822 13.8535 3.85347L11.8535 5.85347C11.6583 6.04873 11.3417 6.04871 11.1465 5.85347C10.9512 5.65821 10.9512 5.3417 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00003 7.1045 3.89545 7.99996 5 7.99996H11C12.6569 7.99996 14 9.3431 14 11C14 12.6568 12.6568 14 11 14H5.93457C5.71233 14.8622 4.93169 15.5 4 15.5C2.89545 15.5 2.00003 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H11C12.1046 13 13 12.1045 13 11C13 9.89539 12.1046 8.99996 11 8.99996H5C3.34317 8.99996 2.00003 7.65679 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65821 10.9512 1.3417 11.1465 1.14644ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00003 14.0522 3.44773 14.5 4 14.5C4.55227 14.5 4.99997 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,26 @@
<idea-plugin package="com.intellij.python.processOutput.impl">
<dependencies>
<module name="intellij.python.community"/>
<module name="intellij.python.sdk"/>
<module name="intellij.python.processOutput"/>
<module name="intellij.python.community.execService"/>
<module name="intellij.libraries.skiko"/>
<module name="intellij.libraries.compose.foundation.desktop"/>
<module name="intellij.platform.jewel.foundation"/>
<module name="intellij.platform.jewel.ui"/>
<module name="intellij.platform.jewel.ideLafBridge"/>
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<toolWindow id="PythonProcessOutput"
anchor="bottom"
secondary="false"
icon="com.intellij.python.processOutput.impl.ui.Icons.CommandQueue"
factoryClass="com.intellij.python.processOutput.impl.ProcessOutputToolWindowFactory"/>
<statistics.counterUsagesCollector implementationClass="com.intellij.python.processOutput.impl.ProcessOutputUsageCollector"/>
<python.processOutput.processOutputApi implementation="com.intellij.python.processOutput.impl.ProcessOutputApiImpl"/>
</extensions>
</idea-plugin>
@@ -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
@@ -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<ProcessOutputControllerService>()
service.specifyAdditionalMessageToUser(logId, text)
}
override fun tryOpenLogInToolWindow(
project: Project,
logId: Int,
): Boolean {
val service = project.service<ProcessOutputControllerService>()
return service.tryOpenLogInToolWindow(logId)
}
}
@@ -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)
}
}
@@ -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<LoggedProcess?>
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<TreeFilter>,
val searchState: TextFieldState,
val selectableLazyListState: SelectableLazyListState,
val treeState: TreeState,
val tree: StateFlow<Tree<TreeNode>>,
)
@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<OutputFilter>,
val isInfoExpanded: StateFlow<Boolean>,
val isOutputExpanded: StateFlow<Boolean>,
val lazyListState: LazyListState,
)
@ApiStatus.Internal
@Service(Service.Level.PROJECT)
class ProcessOutputControllerService(
private val project: Project,
private val coroutineScope: CoroutineScope,
) : ProcessOutputController {
private val loggedProcesses: StateFlow<List<LoggedProcess>> = run {
var processList = listOf<LoggedProcess>()
ApplicationManager.getApplication().service<ExecLoggerService>()
.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<TreeNode> {})
private val processTreeFilters: SnapshotStateSet<TreeFilter> = mutableStateSetOf(
TreeFilter.ShowTime,
)
private val processOutputFilters: SnapshotStateSet<OutputFilter> = mutableStateSetOf(
OutputFilter.ShowTags,
)
private val processOutputInfoExpanded = MutableStateFlow(false)
private val processOutputOutputExpanded = MutableStateFlow(true)
override val selectedProcess: MutableStateFlow<LoggedProcess?> = 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<Set<Int>>(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<Node> = mutableListOf(),
)
val root = mutableListOf<Node>()
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<TreeNode>.buildNodeTree(root: List<Node>) {
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<TraceContext> {
val hierarchy = mutableListOf<TraceContext>()
var currentContext: TraceContext? = this
while (currentContext != null) {
hierarchy.add(0, currentContext)
currentContext = currentContext.parentTraceContext
}
return hierarchy
}
@@ -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<ProcessOutputControllerService>()
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<ProcessOutputControllerService>() }
ToolWindow(service)
}
}
}
@@ -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()
}
}
@@ -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);
}
@@ -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())
@@ -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")
}
}
@@ -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
}
}
@@ -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"
}
@@ -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"
}
@@ -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,
)
}
}
@@ -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 <T : FilterItem> FilterActionGroup(
tooltipText: String,
items: ImmutableList<FilterEntry<T>>,
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<T : FilterItem>(
val item: T,
val testTag: String? = null,
)
internal object FilterActionGroupTestTags {
const val CHECKED_ICON = "ProcessOutput.FilterActionGroup.CheckedIcon"
}
@@ -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,
)
}
@@ -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<Boolean>,
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<String?>) : 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"
}
@@ -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),
)
}
@@ -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),
)
}
}
}
@@ -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"
}
@@ -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 <T> 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 <T> MutableSet<T>.toggle(value: T) {
if (contains(value)) {
remove(value)
} else {
add(value)
}
}
@Composable
internal fun <T> SharedFlow<T>.collectReplayAsState(): State<List<T>> {
val data = remember(this) { mutableStateOf(replayCache) }
LaunchedEffect(this) {
collect { _ ->
data.value = replayCache
}
}
return data
}
@@ -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<Boolean>("ProcessIsError")
internal var SemanticsPropertyReceiver.processIsError by ProcessIsErrorKey
internal val ProcessIsBackgroundKey = SemanticsPropertyKey<Boolean>("ProcessIsBackground")
internal var SemanticsPropertyReceiver.processIsBackground by ProcessIsBackgroundKey
internal val IsExpanded = SemanticsPropertyKey<Boolean>("IsExpanded")
internal var SemanticsPropertyReceiver.isExpanded by IsExpanded
@@ -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,
)
}
}
}
@@ -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"
@@ -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"
@@ -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>(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<TestFilter> = 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"
@@ -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"
@@ -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
}
}
@@ -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"
@@ -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) }
}
}
@@ -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<TreeNode> {})
private val processTreeFilters: SnapshotStateSet<TreeFilter> = mutableStateSetOf(
TreeFilter.ShowTime,
)
private val processOutputFilters: SnapshotStateSet<OutputFilter> = mutableStateSetOf()
private val processOutputInfoExpanded = MutableStateFlow(false)
private val processOutputOutputExpanded = MutableStateFlow(true)
private val testSelectedProcess: MutableStateFlow<LoggedProcess?> = 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<ProcessOutputController>()
val controller = object : ProcessOutputController {
override val selectedProcess: StateFlow<LoggedProcess?> = 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<TreeNode>.() -> 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<TreeNode>.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<TreeNode>.addProcess(process: LoggedProcess) {
addLeaf(TreeNode.Process(process), process)
}
fun TreeGeneratorScope<TreeNode>.addContext(
context: TraceContext,
childrenGenerator: suspend ChildrenGeneratorScope<TreeNode>.() -> 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
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="kotlin-stdlib" level="project" />
<orderEntry type="module" module-name="intellij.platform.ide" />
<orderEntry type="module" module-name="intellij.platform.core" />
</component>
</module>
@@ -0,0 +1,13 @@
<idea-plugin package="com.intellij.python.processOutput">
<dependencies>
<module name="intellij.python.community"/>
<module name="intellij.python.sdk"/>
</dependencies>
<extensionPoints>
<extensionPoint
qualifiedName="com.intellij.python.processOutput.processOutputApi"
interface="com.intellij.python.processOutput.ProcessOutputApi"
/>
</extensionPoints>
</idea-plugin>
@@ -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<ProcessOutputApi>("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
}
@@ -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())
}
}
}
@@ -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<EelDescriptor, SystemPython>(scope, interval) { eelDescriptor ->
searchPythonsPhysicallyNoCache(eelDescriptor.toEelApi())
withContext(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
searchPythonsPhysicallyNoCache(eelDescriptor.toEelApi())
}
}
})
}
@@ -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)
@@ -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
@@ -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<TYPE_SPECIFIC_SETTINGS : PyV3ProjectType
coroutineScope.launch {
val (sdk, interpreterStatistics) = settings.generateAndGetSdk(module, baseDir, supportsNotEmptyModuleStructure).getOr {
withContext(Dispatchers.EDT) {
uiServices.errorSink.emit(it.error)
uiServices.errorSink.emit(it.error, project)
}
return@launch // Since we failed to generate a project, we do not need to go any further
}
@@ -94,7 +95,7 @@ abstract class PyV3ProjectBaseGenerator<TYPE_SPECIFIC_SETTINGS : PyV3ProjectType
uiServices.expandProjectTreeView(project)
withBackgroundProgress(project, PyBundle.message("python.project.model.progress.title.generating"), cancellable = true) {
typeSpecificSettings.generateProject(module, baseDir, sdk).onFailure {
uiServices.errorSink.emit(it)
uiServices.errorSink.emit(it, project)
}
refreshPaths(project, sdk, "PyV3ProjectBaseGenerator.generateProject")
}
@@ -4,6 +4,7 @@
package com.jetbrains.python.packaging.management
import com.intellij.openapi.progress.runBlockingMaybeCancellable
import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
import com.jetbrains.python.PyBundle
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.packaging.PyPackageName
@@ -15,6 +16,7 @@ import com.jetbrains.python.packaging.pyRequirementVersionSpec
import com.jetbrains.python.packaging.repository.PyPackageRepository
import com.jetbrains.python.packaging.requirement.PyRequirementRelation
import com.jetbrains.python.packaging.requirement.PyRequirementVersionSpec
import kotlinx.coroutines.withContext
import org.jetbrains.annotations.ApiStatus
@@ -28,7 +30,9 @@ fun PythonPackageManager.waitInitBlocking() {
@ApiStatus.Internal
fun PythonPackageManager.reloadPackagesBlocking() {
runBlockingMaybeCancellable {
reloadPackages().orThrow()
withContext(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
reloadPackages().orThrow()
}
}
}
@@ -11,6 +11,7 @@ import com.intellij.ui.awt.RelativePoint
import com.intellij.util.ui.JBUI
import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.getOrNull
import com.jetbrains.python.onFailure
import kotlinx.coroutines.Dispatchers
@@ -25,9 +26,8 @@ internal object PythonPackageManagerUIHelpers {
@NlsContexts.ProgressTitle title: String,
operation: suspend (() -> PyResult<T>?),
): 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 <T> runPackagingOperationMaybeShowErrorDialog(
errorSink: ErrorSink?,
project: Project,
operation: suspend (() -> PyResult<T>?),
): T? {
val pyResult = operation() ?: return null
return pyResult.onFailure {
errorSink?.emit(it)
errorSink?.emit(it, project)
}.getOrNull()
}
@@ -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<String> = 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<String> = 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()
}
@@ -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<PyPackagingToolWindowService>()
@@ -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) {
@@ -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
@@ -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<PathHolder.Eel>) {
when (val r = addEnvironment.getOrCreateSdkWithModal(moduleOrProject)) {
is Result.Failure -> {
errorSink.emit(r.error)
errorSink.emit(r.error, moduleOrProject.project)
return
}
is Result.Success -> {
@@ -19,6 +19,8 @@ import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.Result.Companion.success
import com.jetbrains.python.errorProcessing.ErrorSink
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
@@ -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
@@ -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()
@@ -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 ${createSdkInfo.toolInfo.toolName}")
val sdk = 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)
}
}
}
}
@@ -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) {
@@ -77,7 +77,8 @@ fun canRun(
null -> isError = true
}
}
} else {
}
else {
isError = true
}
@@ -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
@@ -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"))