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