IJ-MR-176106-to-253
[pycharm] PY-81494 Fix deadlocking code Merge-request: IJ-MR-179432 Merged-by: David Lysenko <david.lysenko@jetbrains.com> [pycharm] PY-81494 Config fixes PY-81494 [pycharm] PY-81494 Fix further flakiness [pycharm] PY-81494 Address feedback [pycharm] PY-81494 Fix flakiness [pycharm] PY-81494 Separate modules [pycharm] PY-81494 Fix tests [pycharm] PY-81494 Change waitFor to awaitExit for coroutines [pycharm] PY-81494 Configuration fixes [pycharm] PY-81494 Add usage statistics [pycharm] PY-81494 Address feedback [pycharm] PY-81494 Add more limit tests [pycharm] PY-81494 Post-rebase fixes [pycharm] PY-81494 Implement logging tests [pycharm] PY-81494 Final design adjustments [pycharm] PY-81494 Refactor flows [pycharm] PY-81494 Add more OutputSection tests [pycharm] PY-81494 Add Toolbar tests [pycharm] PY-81494 Add InterText tests [pycharm] PY-81494 Add FilterActionGroup tests [pycharm] PY-81494 Add EmptyContainerNotice tests [pycharm] PY-81494 Add CollapsibleListSection tests [pycharm] PY-81494 Add ActionIconButton tests [pycharm] PY-81494 Address feedback [pycharm] PY-81494 Address feedback [pycharm] PY-81494 Post-rebase fixes [pycharm] PY-81494 Address initial feedback [pycharm] PY-81494 Fix existing tests & add new to tree [pycharm] PY-81494 Implement copy to clipboard button [pycharm] PY-81494 Amend design [pycharm] PY-81494 Begin implementing output tests [pycharm] PY-81494 Refactor file structure [pycharm] PY-81494 Implement tests for process list [pycharm] PY-81494 wip tests for process list [pycharm] PY-81494 Finishing touches [pycharm] PY-81494 Consolidate list logic in the model [pycharm] PY-81494 Add logging limits [pycharm] PY-81494 Implement open tool window on exec service error [pycharm] PY-81494 Implement open command in terminal [pycharm] PY-81494 Add expansion actions [pycharm] PY-81494 Implement categorization by coroutine names [pycharm] PY-81494 Memorize expansion states between tool window openings [pycharm] PY-81494 Memorize scroll state between tool window openings [pycharm] PY-81494 Introduce collapsible section for process info [pycharm] PY-81494 Implement view setting filtering [pycharm] PY-81494 Refactor process logging to use shared flows [pycharm] PY-81494 Implement tests for ProcessList composable [pycharm] PY-81494 Implement process toolwindow prototype Merge-request: IJ-MR-176106 Merged-by: David Lysenko <david.lysenko@jetbrains.com> Merge-request: IJ-MR-179303 Merged-by: David Lysenko <david.lysenko@jetbrains.com> GitOrigin-RevId: 44552a582dd628d206b207e02e6f24c7749b4d9f
@@ -1399,6 +1399,8 @@
|
||||
<module fileurl="file://$PROJECT_DIR$/python/python-markdown/intellij.python.markdown.iml" filepath="$PROJECT_DIR$/python/python-markdown/intellij.python.markdown.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/python/intellij.python.ml.features/intellij.python.ml.features.iml" filepath="$PROJECT_DIR$/python/intellij.python.ml.features/intellij.python.ml.features.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/python/python-parser/intellij.python.parser.iml" filepath="$PROJECT_DIR$/python/python-parser/intellij.python.parser.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/python/python-process-output/intellij.python.processOutput.iml" filepath="$PROJECT_DIR$/python/python-process-output/intellij.python.processOutput.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/python/python-process-output/impl/intellij.python.processOutput.impl.iml" filepath="$PROJECT_DIR$/python/python-process-output/impl/intellij.python.processOutput.impl.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/python/python-psi-api/intellij.python.psi.iml" filepath="$PROJECT_DIR$/python/python-psi-api/intellij.python.psi.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/python/python-psi-impl/intellij.python.psi.impl.iml" filepath="$PROJECT_DIR$/python/python-psi-impl/intellij.python.psi.impl.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/python/intellij.python.pydev.iml" filepath="$PROJECT_DIR$/python/intellij.python.pydev.iml" />
|
||||
|
||||
@@ -386,6 +386,7 @@ jvm_library(
|
||||
"//platform/testFramework/monorepo",
|
||||
"//platform/testFramework/monorepo:monorepo_test_lib",
|
||||
"//plugins/textmate/tests:tests_test_lib",
|
||||
"//python/python-process-output:processOutput",
|
||||
],
|
||||
runtime_deps = [
|
||||
"//platform/main/intellij.platform.monolith.main:monolith-main",
|
||||
|
||||
@@ -1442,6 +1442,8 @@ python/python-grazie
|
||||
python/python-hatch
|
||||
python/python-markdown
|
||||
python/python-parser
|
||||
python/python-process-output
|
||||
python/python-process-output/impl
|
||||
python/python-psi-api
|
||||
python/python-psi-impl
|
||||
python/python-pyproject
|
||||
|
||||
@@ -255,5 +255,6 @@
|
||||
<orderEntry type="module" module-name="intellij.maven.server.m40" scope="RUNTIME" />
|
||||
<orderEntry type="module" module-name="intellij.mcpserver" scope="RUNTIME" />
|
||||
<orderEntry type="module" module-name="intellij.textmate.tests" scope="TEST" />
|
||||
<orderEntry type="module" module-name="intellij.python.processOutput" scope="TEST" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -355,6 +355,7 @@ jvm_library(
|
||||
"//python/python-exec-service/execService.python",
|
||||
"//python/interpreters",
|
||||
"//python/python-sdk-configurator/common",
|
||||
"//python/python-process-output:processOutput",
|
||||
],
|
||||
exports = [
|
||||
"//python/openapi:community",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<module name="intellij.python.community.services.internal.impl"/>
|
||||
<module name="intellij.python.community.services.systemPython"/>
|
||||
<module name="intellij.python.community.interpreters"/>
|
||||
<module name="intellij.python.processOutput"/>
|
||||
</dependencies>
|
||||
<!-- Declare that we support core Python functionality -->
|
||||
<module value="com.intellij.modules.python"/>
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.PyToolUIInfo
|
||||
import com.jetbrains.python.configuration.PyConfigurableInterpreterList
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.getOrNull
|
||||
import com.jetbrains.python.onSuccess
|
||||
import com.jetbrains.python.packaging.conda.environmentYml.CondaEnvironmentYmlSdkUtils
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.intellij.openapi.ui.MessageDialogBuilder
|
||||
import com.intellij.pycharm.community.ide.impl.PyCharmCommunityCustomizationBundle
|
||||
import com.intellij.pycharm.community.ide.impl.miscProject.impl.MiscProjectUsageCollector
|
||||
import com.jetbrains.python.Result
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.util.ShowingMessageErrorSync
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -41,7 +42,7 @@ class PyMiscService(private val scope: CoroutineScope) {
|
||||
}
|
||||
is Result.Failure -> {
|
||||
withContext(Dispatchers.EDT) {
|
||||
ShowingMessageErrorSync.emit(projectCreationResult.error)
|
||||
ShowingMessageErrorSync.emit(projectCreationResult.error, project)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,5 +190,6 @@
|
||||
<orderEntry type="module" module-name="intellij.python.community.execService.python" />
|
||||
<orderEntry type="module" module-name="intellij.python.community.interpreters" />
|
||||
<orderEntry type="module" module-name="intellij.python.sdkConfigurator.common" />
|
||||
<orderEntry type="module" module-name="intellij.python.processOutput" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.jetbrains.python.errorProcessing
|
||||
|
||||
import com.intellij.openapi.project.Project
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
|
||||
/**
|
||||
@@ -14,4 +15,13 @@ import kotlinx.coroutines.flow.FlowCollector
|
||||
*
|
||||
* See [PyError]
|
||||
*/
|
||||
typealias ErrorSink = FlowCollector<PyError>
|
||||
typealias ErrorSink = FlowCollector<PyErrorDetail>
|
||||
|
||||
data class PyErrorDetail(
|
||||
val error: PyError,
|
||||
val project: Project? = null,
|
||||
)
|
||||
|
||||
suspend fun ErrorSink.emit(error: PyError, project: Project? = null) {
|
||||
emit(PyErrorDetail(error, project))
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import com.jetbrains.python.PyCommunityBundle
|
||||
import org.jetbrains.annotations.Nls
|
||||
import kotlin.io.path.Path
|
||||
|
||||
private val separatorRegex = Regex("[/\\\\]+")
|
||||
|
||||
/**
|
||||
* Exe might sit on eel (new one) or on target (legacy)
|
||||
*/
|
||||
@@ -29,6 +31,12 @@ sealed interface Exe {
|
||||
}
|
||||
}
|
||||
|
||||
fun pathParts(): List<String> =
|
||||
when (this) {
|
||||
is OnEel -> eelPath.parts
|
||||
is OnTarget -> path.split(separatorRegex)
|
||||
}
|
||||
|
||||
data class OnEel(val eelPath: EelPath) : Exe {
|
||||
override fun toString(): String = eelPath.toString()
|
||||
}
|
||||
@@ -55,6 +63,11 @@ class ExecErrorImpl<T : ExecErrorReason>(
|
||||
* optional message to be displayed to the user: Why did we run this process. I.e "running pip to install package".
|
||||
*/
|
||||
val additionalMessageToUser: @NlsContexts.DialogTitle String? = null,
|
||||
|
||||
/**
|
||||
* Optional association with a [com.intellij.python.community.execService.impl.LoggedProcess] by its id.
|
||||
*/
|
||||
val loggedProcessId: Int? = null,
|
||||
) : PyError(getExecErrorMessage(exe.toString(), args, additionalMessageToUser, errorReason)) {
|
||||
val asCommand: String get() = (arrayOf(exe.toString()) + args).joinToString(" ")
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@
|
||||
- name: lib/modules/intellij.python.parser.jar
|
||||
contentModules:
|
||||
- name: intellij.python.parser
|
||||
- name: lib/modules/intellij.python.processOutput.jar
|
||||
contentModules:
|
||||
- name: intellij.python.processOutput
|
||||
- name: lib/modules/intellij.python.psi.impl.jar
|
||||
contentModules:
|
||||
- name: intellij.python.psi.impl
|
||||
@@ -181,4 +184,5 @@
|
||||
modules:
|
||||
- name: intellij.python.community.plugin
|
||||
contentModules:
|
||||
- name: intellij.python.processOutput.impl
|
||||
- name: intellij.commandInterface
|
||||
@@ -53,6 +53,8 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
|
||||
<module name="intellij.python.community.services.systemPython" loading="required"/>
|
||||
<module name="intellij.python.community.interpreters" loading="required"/>
|
||||
<module name="intellij.python.community.aliasProvider" loading="required"/>
|
||||
<module name="intellij.python.processOutput" loading="required"/>
|
||||
<module name="intellij.python.processOutput.impl"/>
|
||||
<!--Mini-IDes support community python only-->
|
||||
<module name="intellij.python.community.plugin.minor"/> <!-- Python for Mini-IDEs-->
|
||||
<module name="intellij.python.community.plugin.minorRider"/> <!-- Python special support for Rider -->
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<module name="intellij.python.community.impl.venv"/>
|
||||
<module name="intellij.python.community.services.systemPython"/>
|
||||
<module name="intellij.python.sdkConfigurator.common"/>
|
||||
<module name="intellij.python.processOutput"/>
|
||||
</dependencies>
|
||||
|
||||
<resource-bundle>messages.PyBundle</resource-bundle>
|
||||
|
||||
@@ -1755,12 +1755,15 @@ tracecontext.detecting.hatch.environments=Detecting Hatch Environments
|
||||
tracecontext.detecting.executable=Detecting {0} Executable
|
||||
tracecontext.detecting.venv=Detecting venv folder
|
||||
tracecontext.generating.git=Generating git
|
||||
tracecontext.packaging.tool.window=Packaging tool window
|
||||
tracecontext.packaging.tool.window=Packaging Tool Window
|
||||
tracecontext.packages.sdk.controller=Packages SDK Controller
|
||||
tracecontext.add.local.python.sdk.dialog=Add Local Python SDK Dialog
|
||||
tracecontext.new.project.wizard=New Project Wizard
|
||||
tracecontext.loading.interpreter.list=Loading Interpreter List
|
||||
tracecontext.detecting.conda.executable.and.environments=Detecting Conda Executable and environments
|
||||
tracecontext.packaging.tool.window.delete=Deleting Python Package
|
||||
tracecontext.packaging.tool.window.install=Installing Python Package
|
||||
|
||||
evolution.uv.executable.is.not.found=uv executable is not found
|
||||
evolution.pyproject.toml.file.is.required.for.poetry=pyproject.toml file is required for Poetry
|
||||
modal.progress.title.path.validation=Path validation
|
||||
|
||||
@@ -41,6 +41,7 @@ jvm_library(
|
||||
"//libraries/kotlinx/serialization/json",
|
||||
"//libraries/kotlinx/serialization/core",
|
||||
"//platform/remote-servers/impl",
|
||||
"@lib//:guava",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -76,6 +77,7 @@ jvm_library(
|
||||
"//libraries/kotlinx/serialization/json",
|
||||
"//libraries/kotlinx/serialization/core",
|
||||
"//platform/remote-servers/impl",
|
||||
"@lib//:guava",
|
||||
]
|
||||
)
|
||||
### auto-generated section `build intellij.python.community.execService` end
|
||||
|
||||
@@ -52,5 +52,6 @@
|
||||
<orderEntry type="module" module-name="intellij.libraries.kotlinx.serialization.json" />
|
||||
<orderEntry type="module" module-name="intellij.libraries.kotlinx.serialization.core" />
|
||||
<orderEntry type="module" module-name="intellij.platform.remoteServers.impl" />
|
||||
<orderEntry type="library" name="Guava" level="project" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -73,7 +73,8 @@ internal object ExecServiceImpl : ExecService {
|
||||
}
|
||||
return@withTimeout processLauncher.createExecError(
|
||||
messageToUser = additionalMessage,
|
||||
errorReason = ExecErrorReason.UnexpectedProcessTermination(output)
|
||||
errorReason = ExecErrorReason.UnexpectedProcessTermination(output),
|
||||
loggedProcessId = process.loggedProcess.id,
|
||||
)
|
||||
}
|
||||
Result.success(successResult)
|
||||
@@ -83,7 +84,8 @@ internal object ExecServiceImpl : ExecService {
|
||||
processLauncher.killAndJoin()
|
||||
processLauncher.createExecError(
|
||||
messageToUser = PyExecBundle.message("py.exec.timeout.error", description, options.timeout),
|
||||
errorReason = ExecErrorReason.Timeout
|
||||
errorReason = ExecErrorReason.Timeout,
|
||||
loggedProcessId = process.loggedProcess.id,
|
||||
)
|
||||
}
|
||||
return@coroutineScope result
|
||||
@@ -91,12 +93,17 @@ internal object ExecServiceImpl : ExecService {
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : ExecErrorReason> ProcessLauncher.createExecError(messageToUser: @Nls String, errorReason: T): Result.Failure<ExecErrorImpl<T>> =
|
||||
private fun <T : ExecErrorReason> ProcessLauncher.createExecError(
|
||||
messageToUser: @Nls String,
|
||||
errorReason: T,
|
||||
loggedProcessId: Int? = null,
|
||||
): Result.Failure<ExecErrorImpl<T>> =
|
||||
ExecErrorImpl(
|
||||
exe = exeForError,
|
||||
args = args.toTypedArray(),
|
||||
additionalMessageToUser = messageToUser,
|
||||
errorReason = errorReason
|
||||
errorReason = errorReason,
|
||||
loggedProcessId = loggedProcessId,
|
||||
).logAndFail()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.intellij.python.community.execService.impl
|
||||
|
||||
import com.google.common.io.ByteStreams
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.util.io.awaitExit
|
||||
import com.intellij.util.io.readLineAsync
|
||||
import com.jetbrains.python.TraceContext
|
||||
import com.jetbrains.python.errorProcessing.Exe
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
import org.jetbrains.annotations.Nls
|
||||
import java.io.BufferedReader
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
internal object LoggingLimits {
|
||||
/**
|
||||
* The maximum buffer size of a LoggingProcess
|
||||
*/
|
||||
const val MAX_OUTPUT_SIZE = 10_000_000
|
||||
const val MAX_LINES = 1024
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
data class LoggedProcess(
|
||||
val traceContext: TraceContext?,
|
||||
val pid: Long?,
|
||||
val startedAt: Instant,
|
||||
val cwd: String?,
|
||||
val exe: LoggedProcessExe,
|
||||
val args: List<String>,
|
||||
val env: Map<String, String>,
|
||||
val lines: SharedFlow<LoggedProcessLine>,
|
||||
val exitInfo: MutableStateFlow<LoggedProcessExitInfo?>,
|
||||
) {
|
||||
val id: Int = nextId.getAndAdd(1)
|
||||
|
||||
val commandString: String
|
||||
get() = commandFromSegments(listOf(exe.path) + args)
|
||||
|
||||
/**
|
||||
* Command string with the full path of the exe trimmed only to the latest segments. E.g., `/usr/bin/uv` -> `uv`.
|
||||
*/
|
||||
val shortenedCommandString: String
|
||||
get() = commandFromSegments(listOf(exe.parts.last()) + args)
|
||||
|
||||
companion object {
|
||||
private val nextId: AtomicInteger = AtomicInteger(0)
|
||||
|
||||
private fun commandFromSegments(segments: List<String>) =
|
||||
segments.joinToString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
data class LoggedProcessExe(
|
||||
val path: String,
|
||||
val parts: List<String>,
|
||||
)
|
||||
|
||||
@ApiStatus.Internal
|
||||
data class LoggedProcessExitInfo(
|
||||
val exitedAt: Instant,
|
||||
val exitValue: Int,
|
||||
val additionalMessageToUser: @Nls String? = null,
|
||||
)
|
||||
|
||||
@ApiStatus.Internal
|
||||
data class LoggedProcessLine(
|
||||
val text: String,
|
||||
val kind: Kind,
|
||||
) {
|
||||
enum class Kind {
|
||||
OUT,
|
||||
ERR
|
||||
}
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
@Service
|
||||
class ExecLoggerService(val scope: CoroutineScope) {
|
||||
internal val processesInternal = MutableSharedFlow<LoggedProcess>()
|
||||
val processes: Flow<LoggedProcess> = processesInternal.asSharedFlow()
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
class LoggingProcess(
|
||||
private val backingProcess: Process,
|
||||
traceContext: TraceContext?,
|
||||
startedAt: Instant,
|
||||
cwd: String?,
|
||||
exe: Exe,
|
||||
args: List<String>,
|
||||
env: Map<String, String>,
|
||||
) : Process() {
|
||||
val loggedProcess: LoggedProcess
|
||||
|
||||
private val stdoutStream = LoggingInputStream(backingProcess.inputStream)
|
||||
private val stderrStream = LoggingInputStream(backingProcess.errorStream)
|
||||
|
||||
init {
|
||||
val service = ApplicationManager.getApplication().service<ExecLoggerService>()
|
||||
val linesFlow = MutableSharedFlow<LoggedProcessLine>(replay = LoggingLimits.MAX_LINES)
|
||||
val exitInfoFlow = MutableStateFlow<LoggedProcessExitInfo?>(null)
|
||||
|
||||
loggedProcess =
|
||||
LoggedProcess(
|
||||
traceContext,
|
||||
try {
|
||||
backingProcess.pid()
|
||||
}
|
||||
catch (_: UnsupportedOperationException) {
|
||||
null
|
||||
},
|
||||
startedAt,
|
||||
cwd,
|
||||
LoggedProcessExe(
|
||||
path = exe.toString(),
|
||||
parts = exe.pathParts(),
|
||||
),
|
||||
args,
|
||||
env,
|
||||
linesFlow,
|
||||
exitInfoFlow,
|
||||
)
|
||||
|
||||
service.scope.launch {
|
||||
service.processesInternal.emit(loggedProcess)
|
||||
|
||||
awaitExit()
|
||||
|
||||
val stdoutReader = BufferedReader(InputStreamReader(ByteArrayInputStream(stdoutStream.byteArray)))
|
||||
val stderrReader = BufferedReader(InputStreamReader(ByteArrayInputStream(stderrStream.byteArray)))
|
||||
|
||||
collectOutputLines(stdoutReader, linesFlow, LoggedProcessLine.Kind.OUT)
|
||||
collectOutputLines(stderrReader, linesFlow, LoggedProcessLine.Kind.ERR)
|
||||
|
||||
exitInfoFlow.value = LoggedProcessExitInfo(
|
||||
exitedAt = Clock.System.now(),
|
||||
exitValue = exitValue(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getOutputStream(): OutputStream =
|
||||
backingProcess.outputStream
|
||||
|
||||
override fun getInputStream(): InputStream =
|
||||
stdoutStream
|
||||
|
||||
override fun getErrorStream(): InputStream =
|
||||
stderrStream
|
||||
|
||||
override fun waitFor(): Int {
|
||||
return backingProcess.waitFor()
|
||||
}
|
||||
|
||||
override fun waitFor(timeout: Long, unit: TimeUnit): Boolean {
|
||||
return backingProcess.waitFor(timeout, unit)
|
||||
}
|
||||
|
||||
override fun exitValue(): Int =
|
||||
backingProcess.exitValue()
|
||||
|
||||
override fun destroy(): Unit =
|
||||
backingProcess.destroy()
|
||||
|
||||
override fun destroyForcibly(): Process? =
|
||||
backingProcess.destroyForcibly()
|
||||
|
||||
override fun toHandle(): ProcessHandle? =
|
||||
backingProcess.toHandle()
|
||||
|
||||
override fun supportsNormalTermination(): Boolean =
|
||||
backingProcess.supportsNormalTermination()
|
||||
}
|
||||
|
||||
private class LoggingInputStream(
|
||||
private val backingInputStream: InputStream,
|
||||
) : InputStream() {
|
||||
private val bytes = ByteStreams.newDataOutput()
|
||||
private var tail = 0
|
||||
|
||||
val byteArray
|
||||
get() = bytes.toByteArray()
|
||||
|
||||
override fun read(): Int {
|
||||
val byte = try {
|
||||
backingInputStream.read()
|
||||
}
|
||||
catch (e: IOException) {
|
||||
// ugly hack; but the Process' `.destroy` methods abruptly close
|
||||
// the stream, making all pending readers throw an exception.
|
||||
// we can handle this case as legal here
|
||||
if (e.message == "Stream closed") {
|
||||
return -1
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
|
||||
if (tail < LoggingLimits.MAX_OUTPUT_SIZE && byte != -1) {
|
||||
bytes.write(byte)
|
||||
tail += 1
|
||||
}
|
||||
|
||||
return byte
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun collectOutputLines(
|
||||
reader: BufferedReader,
|
||||
linesFlow: MutableSharedFlow<LoggedProcessLine>,
|
||||
kind: LoggedProcessLine.Kind,
|
||||
) {
|
||||
var line: String? = null
|
||||
|
||||
while (reader.readLineAsync()?.also { line = it } != null) {
|
||||
linesFlow.emit(LoggedProcessLine(
|
||||
text = line!!,
|
||||
kind = kind,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,13 @@ import com.intellij.openapi.diagnostic.fileLogger
|
||||
import com.intellij.platform.eel.provider.utils.ProcessFunctions
|
||||
import com.intellij.python.community.execService.Args
|
||||
import com.intellij.python.community.execService.TtySize
|
||||
import com.intellij.python.community.execService.impl.LoggingProcess
|
||||
import com.jetbrains.python.Result
|
||||
import com.jetbrains.python.TraceContext
|
||||
import com.jetbrains.python.errorProcessing.Exe
|
||||
import com.jetbrains.python.errorProcessing.ExecErrorReason
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlin.time.Clock
|
||||
|
||||
private val logger = fileLogger()
|
||||
|
||||
@@ -17,7 +20,20 @@ internal class ProcessLauncher(
|
||||
val args: List<String>,
|
||||
private val processCommands: ProcessCommands,
|
||||
) {
|
||||
suspend fun start(): Result<Process, ExecErrorReason.CantStart> = processCommands.start()
|
||||
suspend fun start(): Result<LoggingProcess, ExecErrorReason.CantStart> =
|
||||
processCommands.start()
|
||||
.mapSuccess {
|
||||
LoggingProcess(
|
||||
it,
|
||||
processCommands.scopeToBind.coroutineContext[TraceContext.Key],
|
||||
Clock.System.now(),
|
||||
processCommands.cwd,
|
||||
exeForError,
|
||||
args,
|
||||
processCommands.env,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun killAndJoin() {
|
||||
processCommands.processFunctions.killAndJoin(logger, exeForError.toString())
|
||||
}
|
||||
@@ -26,11 +42,14 @@ internal class ProcessLauncher(
|
||||
internal interface ProcessCommands {
|
||||
suspend fun start(): Result<Process, ExecErrorReason.CantStart>
|
||||
val processFunctions: ProcessFunctions
|
||||
val scopeToBind: CoroutineScope
|
||||
val env: Map<String, String>
|
||||
val cwd: String?
|
||||
}
|
||||
|
||||
internal data class LaunchRequest(
|
||||
val scopeToBind: CoroutineScope,
|
||||
val args: Args,
|
||||
val env: Map<String, String>,
|
||||
val usePty: TtySize?
|
||||
)
|
||||
val usePty: TtySize?,
|
||||
)
|
||||
@@ -15,6 +15,8 @@ import com.jetbrains.python.Result
|
||||
import com.jetbrains.python.errorProcessing.Exe
|
||||
import com.jetbrains.python.errorProcessing.ExecErrorReason
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.pathString
|
||||
|
||||
internal suspend fun createProcessLauncherOnEel(binOnEel: BinOnEel, launchRequest: LaunchRequest): ProcessLauncher {
|
||||
val exePath: EelPath = with(binOnEel) {
|
||||
@@ -35,15 +37,18 @@ internal suspend fun createProcessLauncherOnEel(binOnEel: BinOnEel, launchReques
|
||||
}
|
||||
|
||||
private class EelProcessCommands(
|
||||
private val scopeToBind: CoroutineScope,
|
||||
override val scopeToBind: CoroutineScope,
|
||||
private val binOnEel: BinOnEel,
|
||||
private val path: EelPath,
|
||||
private val args: List<String>,
|
||||
private val env: Map<String, String>,
|
||||
override val env: Map<String, String>,
|
||||
private val tty: TtySize?,
|
||||
) : ProcessCommands {
|
||||
private var eelProcess: EelProcess? = null
|
||||
|
||||
override val cwd: String?
|
||||
get() = binOnEel.workDir?.toRealPath()?.pathString
|
||||
|
||||
override val processFunctions: ProcessFunctions = ProcessFunctions(
|
||||
waitForExit = { eelProcess?.exitCode?.await() },
|
||||
killProcess = { eelProcess?.kill() }
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.intellij.execution.target.local.LocalTargetEnvironmentRequest
|
||||
import com.intellij.execution.target.local.LocalTargetPtyOptions
|
||||
import com.intellij.openapi.diagnostic.fileLogger
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.openapi.util.io.toNioPathOrNull
|
||||
import com.intellij.platform.eel.provider.utils.ProcessFunctions
|
||||
import com.intellij.platform.eel.provider.utils.bindProcessToScopeImpl
|
||||
import com.intellij.python.community.execService.BinOnTarget
|
||||
@@ -24,6 +25,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.intellij.remoteServer.util.ServerRuntimeException
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.pathString
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -83,11 +85,16 @@ internal suspend fun createProcessLauncherOnTarget(binOnTarget: BinOnTarget, lau
|
||||
}
|
||||
|
||||
private class TargetProcessCommands(
|
||||
private val scopeToBind: CoroutineScope,
|
||||
override val scopeToBind: CoroutineScope,
|
||||
private val exePath: FullPathOnTarget,
|
||||
private val targetEnv: TargetEnvironment,
|
||||
private val cmdLine: TargetedCommandLine,
|
||||
) : ProcessCommands {
|
||||
override val env: Map<String, String>
|
||||
get() = cmdLine.environmentVariables
|
||||
|
||||
override val cwd: String?
|
||||
get() = cmdLine.workingDirectory
|
||||
|
||||
private var process: Process? = null
|
||||
|
||||
@@ -115,4 +122,4 @@ private class TargetProcessCommands(
|
||||
}
|
||||
}
|
||||
|
||||
private fun ExecutionException.asCantStart(): Result.Failure<ExecErrorReason.CantStart> = Result.failure(ExecErrorReason.CantStart(null, localizedMessage))
|
||||
private fun ExecutionException.asCantStart(): Result.Failure<ExecErrorReason.CantStart> = Result.failure(ExecErrorReason.CantStart(null, localizedMessage))
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.intellij.python.junit5Tests.unit
|
||||
|
||||
import com.intellij.python.community.execService.impl.LoggedProcess
|
||||
import com.intellij.python.community.execService.impl.LoggedProcessExe
|
||||
import com.intellij.python.community.execService.impl.LoggedProcessLine
|
||||
import com.intellij.python.community.execService.impl.LoggingLimits
|
||||
import com.intellij.python.community.execService.impl.LoggingProcess
|
||||
import com.intellij.testFramework.common.timeoutRunBlocking
|
||||
import com.intellij.testFramework.common.waitUntil
|
||||
import com.intellij.testFramework.junit5.TestApplication
|
||||
import com.jetbrains.python.TraceContext
|
||||
import com.jetbrains.python.errorProcessing.Exe
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Disabled
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
private class LoggingTest {
|
||||
@Nested
|
||||
inner class LoggedProcessTest {
|
||||
@Test
|
||||
fun `loggedProcess id should increment with each instantiation`() {
|
||||
val process1 = process("process1")
|
||||
val process2 = process("process2")
|
||||
val process3 = process("process2")
|
||||
|
||||
assert(process1.id + 1 == process2.id)
|
||||
assert(process2.id + 1 == process3.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commandString is constructed as expected`() {
|
||||
val process1 = process("/usr/bin/uv", "install", "requests")
|
||||
|
||||
assertEquals("/usr/bin/uv install requests", process1.commandString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shortenedCommandString is constructed as expected from multiple segments`() {
|
||||
val process1 = process("/usr/bin/uv", "install", "requests")
|
||||
|
||||
assertEquals("uv install requests", process1.shortenedCommandString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shortenedCommandString is constructed as expected from single segment`() {
|
||||
val process1 = process("uv", "install", "requests")
|
||||
|
||||
assertEquals("uv install requests", process1.shortenedCommandString)
|
||||
}
|
||||
}
|
||||
|
||||
@TestApplication
|
||||
@Nested
|
||||
inner class LoggingProcessTest {
|
||||
@Test
|
||||
fun `logged process gets created correctly`() = timeoutRunBlocking {
|
||||
val traceContext = TraceContext("some trace")
|
||||
val loggingProcess = fakeLoggingProcess(
|
||||
stdout = "stdout text",
|
||||
stderr = "stderr text",
|
||||
exitValue = 10,
|
||||
pid = 100,
|
||||
traceContext = traceContext,
|
||||
startedAt = Instant.fromEpochSeconds(100),
|
||||
cwd = "/some/cwd",
|
||||
pathToExe = "/usr/bin/exe",
|
||||
args = listOf("foo", "bar"),
|
||||
env = mapOf("foo" to "bar")
|
||||
)
|
||||
|
||||
val loggedProcess = loggingProcess.loggedProcess
|
||||
val stdout = loggingProcess.inputStream.readAllBytes().toString(charset = Charsets.UTF_8)
|
||||
val stderr = loggingProcess.errorStream.readAllBytes().toString(charset = Charsets.UTF_8)
|
||||
|
||||
assert(traceContext == loggedProcess.traceContext)
|
||||
assert(100L == loggedProcess.pid)
|
||||
assert(Instant.fromEpochSeconds(100) == loggedProcess.startedAt)
|
||||
assert("/some/cwd" == loggedProcess.cwd)
|
||||
assert(LoggedProcessExe(path = "/usr/bin/exe", listOf("usr", "bin", "exe")) == loggedProcess.exe)
|
||||
assert(listOf("foo", "bar") == loggedProcess.args)
|
||||
assert(mapOf("foo" to "bar") == loggedProcess.env)
|
||||
assert(stdout == "stdout text")
|
||||
assert(stderr == "stderr text")
|
||||
|
||||
loggingProcess.destroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lines get properly collected from out and err`() = timeoutRunBlocking {
|
||||
val loggingProcess = fakeLoggingProcess(
|
||||
"outline1\noutline2\noutline3",
|
||||
"errline1\nerrline2\nerrline3"
|
||||
)
|
||||
val loggedProcess = loggingProcess.loggedProcess
|
||||
|
||||
assert(loggedProcess.lines.replayCache.isEmpty())
|
||||
|
||||
loggingProcess.inputStream.readAllBytes()
|
||||
loggingProcess.errorStream.readAllBytes()
|
||||
|
||||
waitUntil { loggedProcess.lines.replayCache.size == 6 }
|
||||
|
||||
(1..3).forEach {
|
||||
assert(loggedProcess.lines.replayCache[it - 1].text == "outline$it")
|
||||
assert(loggedProcess.lines.replayCache[it - 1].kind == LoggedProcessLine.Kind.OUT)
|
||||
}
|
||||
|
||||
(4..6).forEach {
|
||||
assert(loggedProcess.lines.replayCache[it - 1].text == "errline${it - 3}")
|
||||
assert(loggedProcess.lines.replayCache[it - 1].kind == LoggedProcessLine.Kind.ERR)
|
||||
}
|
||||
|
||||
loggingProcess.destroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exit info gets properly populated`() = timeoutRunBlocking {
|
||||
val now = Clock.System.now()
|
||||
val loggingProcess = fakeLoggingProcess(
|
||||
exitValue = 30
|
||||
)
|
||||
val loggedProcess = loggingProcess.loggedProcess
|
||||
|
||||
loggingProcess.destroy()
|
||||
|
||||
waitUntil { loggedProcess.exitInfo.value != null }
|
||||
|
||||
assert(loggedProcess.exitInfo.value!!.exitValue == 30)
|
||||
assert(loggedProcess.exitInfo.value!!.exitedAt >= now)
|
||||
}
|
||||
|
||||
@Disabled
|
||||
@Test
|
||||
fun `old lines are evicted when the line limit is reached`() = timeoutRunBlocking {
|
||||
val loggingProcess = fakeLoggingProcess(
|
||||
stdout = buildString {
|
||||
repeat(LoggingLimits.MAX_LINES + 2) {
|
||||
appendLine("line$it")
|
||||
}
|
||||
},
|
||||
stderr = ""
|
||||
)
|
||||
val loggedProcess = loggingProcess.loggedProcess
|
||||
|
||||
loggingProcess.inputStream.readAllBytes()
|
||||
loggingProcess.errorStream.readAllBytes()
|
||||
|
||||
loggingProcess.destroy()
|
||||
|
||||
waitUntil { loggedProcess.lines.replayCache.last().text == "line${LoggingLimits.MAX_LINES + 1}" }
|
||||
|
||||
assert(loggedProcess.lines.replayCache.size == LoggingLimits.MAX_LINES)
|
||||
assert(loggedProcess.lines.replayCache[0].text == "line2")
|
||||
}
|
||||
|
||||
// todo: add limits test
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun process(vararg command: String) =
|
||||
LoggedProcess(
|
||||
traceContext = null,
|
||||
pid = 123,
|
||||
startedAt = Clock.System.now(),
|
||||
cwd = null,
|
||||
exe = LoggedProcessExe(
|
||||
path = command.first(),
|
||||
parts = command.first().split(Regex("[/\\\\]+"))
|
||||
),
|
||||
args = command.drop(1),
|
||||
env = mapOf(),
|
||||
lines = MutableSharedFlow(),
|
||||
exitInfo = MutableStateFlow(null),
|
||||
)
|
||||
|
||||
fun fakeLoggingProcess(
|
||||
stdout: String = "stdout",
|
||||
stderr: String = "stderr",
|
||||
exitValue: Int = 0,
|
||||
pid: Long = 0,
|
||||
traceContext: TraceContext? = null,
|
||||
startedAt: Instant = Instant.fromEpochSeconds(0),
|
||||
cwd: String? = "/some/cwd",
|
||||
pathToExe: String = "/usr/bin/exe",
|
||||
args: List<String> = listOf("foo", "bar"),
|
||||
env: Map<String, String> = mapOf("foo" to "bar"),
|
||||
) =
|
||||
LoggingProcess(
|
||||
object : Process() {
|
||||
val stdoutStream = ByteArrayInputStream(stdout.toByteArray())
|
||||
val stderrStream = ByteArrayInputStream(stderr.toByteArray())
|
||||
val stdinStream = ByteArrayOutputStream()
|
||||
val destroyFuture = CompletableFuture<Int>()
|
||||
|
||||
override fun getOutputStream(): OutputStream =
|
||||
stdinStream
|
||||
|
||||
override fun getInputStream(): InputStream =
|
||||
stdoutStream
|
||||
|
||||
override fun getErrorStream(): InputStream =
|
||||
stderrStream
|
||||
|
||||
override fun waitFor(): Int {
|
||||
destroyFuture.get()
|
||||
return exitValue
|
||||
}
|
||||
|
||||
override fun exitValue(): Int {
|
||||
return exitValue
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
destroyFuture.complete(10)
|
||||
}
|
||||
|
||||
override fun pid(): Long =
|
||||
pid
|
||||
},
|
||||
traceContext,
|
||||
startedAt,
|
||||
cwd,
|
||||
Exe.fromString(pathToExe),
|
||||
args,
|
||||
env
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.jetbrains.python.sdk.impl.PySdkBundle
|
||||
import com.jetbrains.python.Result
|
||||
import com.jetbrains.python.configuration.PyConfigurableInterpreterList
|
||||
import com.jetbrains.python.errorProcessing.ErrorSink
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.inspections.PyInterpreterInspection
|
||||
import com.jetbrains.python.projectCreation.createVenvAndSdk
|
||||
import com.jetbrains.python.sdk.PySdkToInstall
|
||||
@@ -90,7 +91,7 @@ internal class PythonLangSupport(private val errorSink: ErrorSink = ShowingMessa
|
||||
override fun getSdkForProject(project: Project, selectedSdk: Sdk?): Sdk = runWithModalProgressBlocking(project, "...") {
|
||||
when (val r = createVenvAndSdk(project)) {
|
||||
is Result.Failure -> {
|
||||
errorSink.emit(r.error)
|
||||
errorSink.emit(r.error, project)
|
||||
null
|
||||
}
|
||||
is Result.Success -> r.result
|
||||
|
||||
@@ -16,11 +16,13 @@ import com.intellij.python.junit5Tests.framework.winLockedFile.deleteCheckLockin
|
||||
import com.intellij.testFramework.common.timeoutRunBlocking
|
||||
import com.jetbrains.python.PythonBinary
|
||||
import com.jetbrains.python.errorProcessing.ErrorSink
|
||||
import com.jetbrains.python.errorProcessing.PyError
|
||||
import com.jetbrains.python.getOrThrow
|
||||
import com.jetbrains.python.sdk.pythonSdk
|
||||
import com.jetbrains.python.venvReader.VirtualEnvReader.Companion.DEFAULT_VIRTUALENV_DIRNAME
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.Assertions
|
||||
@@ -62,7 +64,7 @@ class PythonLangSupportTest {
|
||||
assert(learningProjectsPath.startsWith(temporarySystemPath)) { "$learningProjectsPath must reside in $temporarySystemPath" }
|
||||
|
||||
val sut = PythonLangSupport(ErrorSink {
|
||||
Assertions.fail(it.message)
|
||||
Assertions.fail(it.error.message)
|
||||
})
|
||||
|
||||
if (venvAlreadyExists) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
### auto-generated section `build intellij.python.processOutput` start
|
||||
load("@rules_jvm//:jvm.bzl", "jvm_library", "resourcegroup")
|
||||
|
||||
resourcegroup(
|
||||
name = "processOutput_resources",
|
||||
srcs = glob(["resources/**/*"]),
|
||||
strip_prefix = "resources"
|
||||
)
|
||||
|
||||
jvm_library(
|
||||
name = "processOutput",
|
||||
module_name = "intellij.python.processOutput",
|
||||
visibility = ["//visibility:public"],
|
||||
srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True),
|
||||
resources = [":processOutput_resources"],
|
||||
deps = [
|
||||
"@lib//:kotlin-stdlib",
|
||||
"//platform/platform-api:ide",
|
||||
"//platform/core-api:core",
|
||||
]
|
||||
)
|
||||
### auto-generated section `build intellij.python.processOutput` end
|
||||
@@ -0,0 +1,91 @@
|
||||
# This .editorconfig section approximates ktfmt's formatting rules. They are applied here to align
|
||||
# with the code style from the Compose AOSP repository.
|
||||
# See https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:buildSrc/private/src/main/kotlin/androidx/build/Ktfmt.kt;l=169;drc=8cf117b703d0ce3b9d31263451176d40a23ce828
|
||||
|
||||
[{*.kt,*.kts}]
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
max_line_length = 100
|
||||
ij_visual_guides = 100
|
||||
indent_size = 4
|
||||
ij_continuation_indent_size = 4
|
||||
ij_java_names_count_to_use_import_on_demand = 9999
|
||||
ij_kotlin_align_in_columns_case_branch = false
|
||||
ij_kotlin_align_multiline_binary_operation = false
|
||||
ij_kotlin_align_multiline_extends_list = false
|
||||
ij_kotlin_align_multiline_method_parentheses = false
|
||||
ij_kotlin_align_multiline_parameters = true
|
||||
ij_kotlin_align_multiline_parameters_in_calls = false
|
||||
ij_kotlin_allow_trailing_comma = true
|
||||
ij_kotlin_allow_trailing_comma_on_call_site = true
|
||||
ij_kotlin_assignment_wrap = normal
|
||||
ij_kotlin_blank_lines_after_class_header = 0
|
||||
ij_kotlin_blank_lines_around_block_when_branches = 0
|
||||
ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1
|
||||
ij_kotlin_block_comment_at_first_column = true
|
||||
ij_kotlin_call_parameters_new_line_after_left_paren = true
|
||||
ij_kotlin_call_parameters_right_paren_on_new_line = false
|
||||
ij_kotlin_call_parameters_wrap = on_every_item
|
||||
ij_kotlin_catch_on_new_line = false
|
||||
ij_kotlin_class_annotation_wrap = split_into_lines
|
||||
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
|
||||
ij_kotlin_continuation_indent_for_chained_calls = true
|
||||
ij_kotlin_continuation_indent_for_expression_bodies = true
|
||||
ij_kotlin_continuation_indent_in_argument_lists = true
|
||||
ij_kotlin_continuation_indent_in_elvis = false
|
||||
ij_kotlin_continuation_indent_in_if_conditions = false
|
||||
ij_kotlin_continuation_indent_in_parameter_lists = false
|
||||
ij_kotlin_continuation_indent_in_supertype_lists = false
|
||||
ij_kotlin_else_on_new_line = false
|
||||
ij_kotlin_enum_constants_wrap = off
|
||||
ij_kotlin_extends_list_wrap = normal
|
||||
ij_kotlin_field_annotation_wrap = off
|
||||
ij_kotlin_finally_on_new_line = false
|
||||
ij_kotlin_if_rparen_on_new_line = false
|
||||
ij_kotlin_import_nested_classes = false
|
||||
ij_kotlin_imports_layout = *
|
||||
ij_kotlin_insert_whitespaces_in_simple_one_line_method = true
|
||||
ij_kotlin_keep_blank_lines_before_right_brace = 0
|
||||
ij_kotlin_keep_blank_lines_in_code = 1
|
||||
ij_kotlin_keep_blank_lines_in_declarations = 1
|
||||
ij_kotlin_keep_first_column_comment = true
|
||||
ij_kotlin_keep_indents_on_empty_lines = false
|
||||
ij_kotlin_keep_line_breaks = true
|
||||
ij_kotlin_lbrace_on_next_line = false
|
||||
ij_kotlin_line_comment_add_space = false
|
||||
ij_kotlin_line_comment_at_first_column = true
|
||||
ij_kotlin_method_annotation_wrap = split_into_lines
|
||||
ij_kotlin_method_call_chain_wrap = normal
|
||||
ij_kotlin_method_parameters_new_line_after_left_paren = true
|
||||
ij_kotlin_method_parameters_right_paren_on_new_line = true
|
||||
ij_kotlin_method_parameters_wrap = on_every_item
|
||||
ij_kotlin_name_count_to_use_star_import = 9999
|
||||
ij_kotlin_name_count_to_use_star_import_for_members = 9999
|
||||
ij_kotlin_parameter_annotation_wrap = off
|
||||
ij_kotlin_space_after_comma = true
|
||||
ij_kotlin_space_after_extend_colon = true
|
||||
ij_kotlin_space_after_type_colon = true
|
||||
ij_kotlin_space_before_catch_parentheses = true
|
||||
ij_kotlin_space_before_comma = false
|
||||
ij_kotlin_space_before_extend_colon = true
|
||||
ij_kotlin_space_before_for_parentheses = true
|
||||
ij_kotlin_space_before_if_parentheses = true
|
||||
ij_kotlin_space_before_lambda_arrow = true
|
||||
ij_kotlin_space_before_type_colon = false
|
||||
ij_kotlin_space_before_when_parentheses = true
|
||||
ij_kotlin_space_before_while_parentheses = true
|
||||
ij_kotlin_spaces_around_additive_operators = true
|
||||
ij_kotlin_spaces_around_assignment_operators = true
|
||||
ij_kotlin_spaces_around_equality_operators = true
|
||||
ij_kotlin_spaces_around_function_type_arrow = true
|
||||
ij_kotlin_spaces_around_logical_operators = true
|
||||
ij_kotlin_spaces_around_multiplicative_operators = true
|
||||
ij_kotlin_spaces_around_range = false
|
||||
ij_kotlin_spaces_around_relational_operators = true
|
||||
ij_kotlin_spaces_around_unary_operator = false
|
||||
ij_kotlin_spaces_around_when_arrow = true
|
||||
ij_kotlin_variable_annotation_wrap = off
|
||||
ij_kotlin_while_on_new_line = false
|
||||
ij_kotlin_wrap_elvis_expressions = 1
|
||||
ij_kotlin_wrap_expression_body_functions = 1
|
||||
ij_kotlin_wrap_first_method_in_call_chain = false
|
||||
@@ -0,0 +1,105 @@
|
||||
### auto-generated section `build intellij.python.processOutput.impl` start
|
||||
load("//build:compiler-options.bzl", "create_kotlinc_options")
|
||||
load("@rules_jvm//:jvm.bzl", "jvm_library", "resourcegroup")
|
||||
|
||||
create_kotlinc_options(
|
||||
name = "custom_impl",
|
||||
opt_in = ["kotlin.time.ExperimentalTime"],
|
||||
plugin_options = ["plugin:androidx.compose.compiler.plugins.kotlin:generateFunctionKeyMetaAnnotations=true"]
|
||||
)
|
||||
|
||||
resourcegroup(
|
||||
name = "impl_resources",
|
||||
srcs = glob(["resources/**/*"]),
|
||||
strip_prefix = "resources"
|
||||
)
|
||||
|
||||
jvm_library(
|
||||
name = "impl",
|
||||
module_name = "intellij.python.processOutput.impl",
|
||||
visibility = ["//visibility:public"],
|
||||
srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True),
|
||||
resources = [":impl_resources"],
|
||||
kotlinc_opts = ":custom_impl",
|
||||
deps = [
|
||||
"@lib//:kotlin-stdlib",
|
||||
"//platform/platform-api:ide",
|
||||
"//platform/core-api:core",
|
||||
"//libraries/skiko",
|
||||
"//libraries/compose-foundation-desktop",
|
||||
"//platform/jewel/foundation",
|
||||
"//platform/jewel/ui",
|
||||
"//platform/jewel/ide-laf-bridge",
|
||||
"//platform/core-ui",
|
||||
"//python/python-exec-service:community-execService",
|
||||
"//python/openapi:community",
|
||||
"//platform/util:util-ui",
|
||||
"//platform/editor-ui-api:editor-ui",
|
||||
"//platform/projectModel-api:projectModel",
|
||||
"//platform/execution-impl",
|
||||
"@lib//:jediterm-core",
|
||||
"@lib//:kotlin-reflect",
|
||||
"@lib//:kotlinx-collections-immutable",
|
||||
"//platform/statistics",
|
||||
"//python/python-process-output:processOutput",
|
||||
],
|
||||
plugins = ["@lib//:compose-plugin"]
|
||||
)
|
||||
|
||||
jvm_library(
|
||||
name = "impl_test_lib",
|
||||
visibility = ["//visibility:public"],
|
||||
srcs = glob(["test/**/*.kt", "test/**/*.java", "test/**/*.form"], allow_empty = True),
|
||||
kotlinc_opts = ":custom_impl",
|
||||
associates = [":impl"],
|
||||
deps = [
|
||||
"@lib//:kotlin-stdlib",
|
||||
"//platform/platform-api:ide",
|
||||
"//platform/core-api:core",
|
||||
"//libraries/skiko",
|
||||
"//libraries/compose-foundation-desktop",
|
||||
"//platform/jewel/foundation",
|
||||
"//platform/jewel/foundation:foundation_test_lib",
|
||||
"//platform/jewel/ui",
|
||||
"//platform/jewel/ui:ui_test_lib",
|
||||
"//platform/jewel/ide-laf-bridge",
|
||||
"//platform/jewel/ide-laf-bridge:ide-laf-bridge_test_lib",
|
||||
"//platform/core-ui",
|
||||
"//python/python-exec-service:community-execService",
|
||||
"//python/python-exec-service:community-execService_test_lib",
|
||||
"//python/openapi:community",
|
||||
"//python/openapi:community_test_lib",
|
||||
"//platform/util:util-ui",
|
||||
"//platform/editor-ui-api:editor-ui",
|
||||
"//libraries/compose-foundation-desktop-junit",
|
||||
"@lib//:kotlin-test",
|
||||
"@lib//:kotlin-test-assertions-core-jvm",
|
||||
"@lib//:kotlin-test-junit",
|
||||
"//platform/jewel/int-ui/int-ui-standalone:jewel-intUi-standalone",
|
||||
"//platform/projectModel-api:projectModel",
|
||||
"//platform/execution-impl",
|
||||
"@lib//:jediterm-core",
|
||||
"@lib//:io-mockk",
|
||||
"@lib//:io-mockk-jvm",
|
||||
"@lib//:kotlin-reflect",
|
||||
"@lib//:kotlinx-collections-immutable",
|
||||
"//platform/testFramework/junit5",
|
||||
"//platform/testFramework/junit5:junit5_test_lib",
|
||||
"@lib//:junit5",
|
||||
"//platform/testFramework/common",
|
||||
"//platform/statistics",
|
||||
"//platform/statistics:statistics_test_lib",
|
||||
"//python/python-process-output:processOutput",
|
||||
],
|
||||
plugins = ["@lib//:compose-plugin"]
|
||||
)
|
||||
### auto-generated section `build intellij.python.processOutput.impl` end
|
||||
|
||||
### auto-generated section `test intellij.python.processOutput.impl` start
|
||||
load("@community//build:tests-options.bzl", "jps_test")
|
||||
|
||||
jps_test(
|
||||
name = "impl_test",
|
||||
runtime_deps = [":impl_test_lib"]
|
||||
)
|
||||
### auto-generated section `test intellij.python.processOutput.impl` end
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="FacetManager">
|
||||
<facet type="kotlin-language" name="Kotlin">
|
||||
<configuration version="5" platform="JVM 21" allPlatforms="JVM [21]" useProjectSettings="false">
|
||||
<compilerSettings>
|
||||
<option name="additionalArguments" value="-Xjvm-default=all -P plugin:androidx.compose.compiler.plugins.kotlin:generateFunctionKeyMetaAnnotations=true -XXLanguage:+AllowEagerSupertypeAccessibilityChecks -opt-in=kotlin.time.ExperimentalTime" />
|
||||
</compilerSettings>
|
||||
<compilerArguments>
|
||||
<stringArguments>
|
||||
<stringArg name="jvmTarget" arg="21" />
|
||||
<stringArg name="apiVersion" arg="2.2" />
|
||||
<stringArg name="languageVersion" arg="2.2" />
|
||||
</stringArguments>
|
||||
<arrayArguments>
|
||||
<arrayArg name="pluginClasspaths">
|
||||
<args>$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-compose-compiler-plugin/2.2.20/kotlin-compose-compiler-plugin-2.2.20.jar</args>
|
||||
</arrayArg>
|
||||
</arrayArguments>
|
||||
</compilerArguments>
|
||||
</configuration>
|
||||
</facet>
|
||||
</component>
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="library" name="kotlin-stdlib" level="project" />
|
||||
<orderEntry type="module" module-name="intellij.platform.ide" />
|
||||
<orderEntry type="module" module-name="intellij.platform.core" />
|
||||
<orderEntry type="module" module-name="intellij.libraries.skiko" />
|
||||
<orderEntry type="module" module-name="intellij.libraries.compose.foundation.desktop" />
|
||||
<orderEntry type="module" module-name="intellij.platform.jewel.foundation" />
|
||||
<orderEntry type="module" module-name="intellij.platform.jewel.ui" />
|
||||
<orderEntry type="module" module-name="intellij.platform.jewel.ideLafBridge" />
|
||||
<orderEntry type="module" module-name="intellij.platform.core.ui" />
|
||||
<orderEntry type="module" module-name="intellij.python.community.execService" />
|
||||
<orderEntry type="module" module-name="intellij.python.community" />
|
||||
<orderEntry type="module" module-name="intellij.platform.util.ui" />
|
||||
<orderEntry type="module" module-name="intellij.platform.editor.ui" />
|
||||
<orderEntry type="module" module-name="intellij.libraries.compose.foundation.desktop.junit" scope="TEST" />
|
||||
<orderEntry type="library" scope="TEST" name="kotlin-test" level="project" />
|
||||
<orderEntry type="library" scope="TEST" name="kotlin-test-assertions-core-jvm" level="project" />
|
||||
<orderEntry type="library" scope="TEST" name="kotlin-test-junit" level="project" />
|
||||
<orderEntry type="module" module-name="intellij.platform.jewel.intUi.standalone" scope="TEST" />
|
||||
<orderEntry type="module" module-name="intellij.platform.projectModel" />
|
||||
<orderEntry type="module" module-name="intellij.platform.execution.impl" />
|
||||
<orderEntry type="library" name="jediterm-core" level="project" />
|
||||
<orderEntry type="library" scope="TEST" name="io.mockk" level="project" />
|
||||
<orderEntry type="library" scope="TEST" name="io.mockk.jvm" level="project" />
|
||||
<orderEntry type="library" name="kotlin-reflect" level="project" />
|
||||
<orderEntry type="library" name="kotlinx-collections-immutable" level="project" />
|
||||
<orderEntry type="module" module-name="intellij.platform.testFramework.junit5" scope="TEST" />
|
||||
<orderEntry type="library" scope="TEST" name="JUnit5" level="project" />
|
||||
<orderEntry type="module" module-name="intellij.platform.testFramework.common" scope="TEST" />
|
||||
<orderEntry type="module" module-name="intellij.platform.statistics" />
|
||||
<orderEntry type="module" module-name="intellij.python.processOutput" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.7 4L14.7 8L10.7 12" stroke="#6C707E" stroke-linecap="round"/>
|
||||
<path d="M6.1001 4L10.1001 8L6.1001 12" stroke="#6C707E" stroke-linecap="round"/>
|
||||
<path d="M1.5 4L5.5 8L1.5 12" stroke="#6C707E" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 459 B |
@@ -0,0 +1,6 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.7 4L14.7 8L10.7 12" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
<path d="M6.1001 4L10.1001 8L6.1001 12" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
<path d="M1.5 4L5.5 8L1.5 12" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 459 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.3417 14.0488 3.65822 13.8535 3.85347L11.8535 5.85347C11.6583 6.04873 11.3417 6.04871 11.1465 5.85347C10.9512 5.65821 10.9512 5.3417 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00003 7.1045 3.89545 7.99996 5 7.99996H11C12.6569 7.99996 14 9.3431 14 11C14 12.6568 12.6568 14 11 14H5.93457C5.71233 14.8622 4.93169 15.5 4 15.5C2.89545 15.5 2.00003 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H11C12.1046 13 13 12.1045 13 11C13 9.89539 12.1046 8.99996 11 8.99996H5C3.34317 8.99996 2.00003 7.65679 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65821 10.9512 1.3417 11.1465 1.14644ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00003 14.0522 3.44773 14.5 4 14.5C4.55227 14.5 4.99997 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.3417 14.0488 3.65822 13.8535 3.85347L11.8535 5.85347C11.6583 6.04873 11.3417 6.04871 11.1465 5.85347C10.9512 5.65821 10.9512 5.3417 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00003 7.1045 3.89545 7.99996 5 7.99996H11C12.6569 7.99996 14 9.3431 14 11C14 12.6568 12.6568 14 11 14H5.93457C5.71233 14.8622 4.93169 15.5 4 15.5C2.89545 15.5 2.00003 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H11C12.1046 13 13 12.1045 13 11C13 9.89539 12.1046 8.99996 11 8.99996H5C3.34317 8.99996 2.00003 7.65679 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65821 10.9512 1.3417 11.1465 1.14644ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00003 14.0522 3.44773 14.5 4 14.5C4.55227 14.5 4.99997 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#A8ADBD"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12.5" cy="12.5" r="3.5" fill="#E55765"/>
|
||||
<path d="M12.5 10C12.2239 10 12 10.2239 12 10.5V12.5C12 12.7761 12.2239 13 12.5 13C12.7761 13 13 12.7761 13 12.5V10.5C13 10.2239 12.7761 10 12.5 10Z" fill="white"/>
|
||||
<path d="M12.5 15C12.7761 15 13 14.7761 13 14.5C13 14.2239 12.7761 14 12.5 14C12.2239 14 12 14.2239 12 14.5C12 14.7761 12.2239 15 12.5 15Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H8.02832L8 14L5.93457 14C5.7123 14.8622 4.93166 15.5 4 15.5C2.89548 15.5 2.00008 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00008 14.0522 3.44776 14.5 4 14.5C4.55224 14.5 4.99992 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#A8ADBD"/>
|
||||
<path d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.34171 14.0487 3.65823 13.8535 3.85347L11.8535 5.85347C11.6583 6.04867 11.3417 6.04865 11.1465 5.85347C10.9512 5.65823 10.9513 5.34171 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00008 7.10446 3.89548 7.99996 5 7.99996H9.5C9.77614 7.99996 10 8.22381 10 8.49996C10 8.7761 9.77614 8.99996 9.5 8.99996H5C3.34319 8.99996 2.00008 7.65675 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65823 10.9513 1.34171 11.1465 1.14644Z" fill="#A8ADBD"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12.5" cy="12.5" r="3.5" fill="#DB5C5C"/>
|
||||
<path d="M12.5 10C12.2239 10 12 10.2239 12 10.5V12.5C12 12.7761 12.2239 13 12.5 13C12.7761 13 13 12.7761 13 12.5V10.5C13 10.2239 12.7761 10 12.5 10Z" fill="white"/>
|
||||
<path d="M12.5 15C12.7761 15 13 14.7761 13 14.5C13 14.2239 12.7761 14 12.5 14C12.2239 14 12 14.2239 12 14.5C12 14.7761 12.2239 15 12.5 15Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H8.02832L8 14L5.93457 14C5.7123 14.8622 4.93166 15.5 4 15.5C2.89548 15.5 2.00008 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00008 14.0522 3.44776 14.5 4 14.5C4.55224 14.5 4.99992 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#6F737A"/>
|
||||
<path d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.34171 14.0487 3.65823 13.8535 3.85347L11.8535 5.85347C11.6583 6.04867 11.3417 6.04865 11.1465 5.85347C10.9512 5.65823 10.9513 5.34171 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00008 7.10446 3.89548 7.99996 5 7.99996H9.5C9.77614 7.99996 10 8.22381 10 8.49996C10 8.7761 9.77614 8.99996 9.5 8.99996H5C3.34319 8.99996 2.00008 7.65675 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65823 10.9513 1.34171 11.1465 1.14644Z" fill="#6F737A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.3417 14.0488 3.65822 13.8535 3.85347L11.8535 5.85347C11.6583 6.04873 11.3417 6.04871 11.1465 5.85347C10.9512 5.65821 10.9512 5.3417 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00003 7.1045 3.89545 7.99996 5 7.99996H11C12.6569 7.99996 14 9.3431 14 11C14 12.6568 12.6568 14 11 14H5.93457C5.71233 14.8622 4.93169 15.5 4 15.5C2.89545 15.5 2.00003 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H11C12.1046 13 13 12.1045 13 11C13 9.89539 12.1046 8.99996 11 8.99996H5C3.34317 8.99996 2.00003 7.65679 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65821 10.9512 1.3417 11.1465 1.14644ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00003 14.0522 3.44773 14.5 4 14.5C4.55227 14.5 4.99997 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#6F737A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12.5" cy="12.5" r="3.5" fill="#E55765"/>
|
||||
<path d="M12.5 10C12.2239 10 12 10.2239 12 10.5V12.5C12 12.7761 12.2239 13 12.5 13C12.7761 13 13 12.7761 13 12.5V10.5C13 10.2239 12.7761 10 12.5 10Z" fill="white"/>
|
||||
<path d="M12.5 15C12.7761 15 13 14.7761 13 14.5C13 14.2239 12.7761 14 12.5 14C12.2239 14 12 14.2239 12 14.5C12 14.7761 12.2239 15 12.5 15Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H8.02832L8 14L5.93457 14C5.7123 14.8622 4.93166 15.5 4 15.5C2.89548 15.5 2.00008 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00008 14.0522 3.44776 14.5 4 14.5C4.55224 14.5 4.99992 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#6C707E"/>
|
||||
<path d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.34171 14.0487 3.65823 13.8535 3.85347L11.8535 5.85347C11.6583 6.04867 11.3417 6.04865 11.1465 5.85347C10.9512 5.65823 10.9513 5.34171 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00008 7.10446 3.89548 7.99996 5 7.99996H9.5C9.77614 7.99996 10 8.22381 10 8.49996C10 8.7761 9.77614 8.99996 9.5 8.99996H5C3.34319 8.99996 2.00008 7.65675 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65823 10.9513 1.34171 11.1465 1.14644Z" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12.5" cy="12.5" r="3.5" fill="#DB5C5C"/>
|
||||
<path d="M12.5 10C12.2239 10 12 10.2239 12 10.5V12.5C12 12.7761 12.2239 13 12.5 13C12.7761 13 13 12.7761 13 12.5V10.5C13 10.2239 12.7761 10 12.5 10Z" fill="white"/>
|
||||
<path d="M12.5 15C12.7761 15 13 14.7761 13 14.5C13 14.2239 12.7761 14 12.5 14C12.2239 14 12 14.2239 12 14.5C12 14.7761 12.2239 15 12.5 15Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H8.02832L8 14L5.93457 14C5.7123 14.8622 4.93166 15.5 4 15.5C2.89548 15.5 2.00008 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00008 14.0522 3.44776 14.5 4 14.5C4.55224 14.5 4.99992 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#CED0D6"/>
|
||||
<path d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.34171 14.0487 3.65823 13.8535 3.85347L11.8535 5.85347C11.6583 6.04867 11.3417 6.04865 11.1465 5.85347C10.9512 5.65823 10.9513 5.34171 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00008 7.10446 3.89548 7.99996 5 7.99996H9.5C9.77614 7.99996 10 8.22381 10 8.49996C10 8.7761 9.77614 8.99996 9.5 8.99996H5C3.34319 8.99996 2.00008 7.65675 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65823 10.9513 1.34171 11.1465 1.14644Z" fill="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1465 1.14644C11.3417 0.951189 11.6583 0.951183 11.8535 1.14644L13.8535 3.14644C14.0487 3.3417 14.0488 3.65822 13.8535 3.85347L11.8535 5.85347C11.6583 6.04873 11.3417 6.04871 11.1465 5.85347C10.9512 5.65821 10.9512 5.3417 11.1465 5.14644L12.293 3.99996H5C3.89543 3.99996 3 4.89539 3 5.99996C3.00003 7.1045 3.89545 7.99996 5 7.99996H11C12.6569 7.99996 14 9.3431 14 11C14 12.6568 12.6568 14 11 14H5.93457C5.71233 14.8622 4.93169 15.5 4 15.5C2.89545 15.5 2.00003 14.6045 2 13.5C2 12.3954 2.89543 11.5 4 11.5C4.93173 11.5 5.71236 12.1376 5.93457 13H11C12.1046 13 13 12.1045 13 11C13 9.89539 12.1046 8.99996 11 8.99996H5C3.34317 8.99996 2.00003 7.65679 2 5.99996C2 4.3431 3.34315 2.99996 5 2.99996H12.293L11.1465 1.85347C10.9512 1.65821 10.9512 1.3417 11.1465 1.14644ZM4 12.5C3.44772 12.5 3 12.9477 3 13.5C3.00003 14.0522 3.44773 14.5 4 14.5C4.55227 14.5 4.99997 14.0522 5 13.5C5 12.9477 4.55228 12.5 4 12.5Z" fill="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,26 @@
|
||||
<idea-plugin package="com.intellij.python.processOutput.impl">
|
||||
<dependencies>
|
||||
<module name="intellij.python.community"/>
|
||||
<module name="intellij.python.sdk"/>
|
||||
|
||||
<module name="intellij.python.processOutput"/>
|
||||
|
||||
<module name="intellij.python.community.execService"/>
|
||||
|
||||
<module name="intellij.libraries.skiko"/>
|
||||
<module name="intellij.libraries.compose.foundation.desktop"/>
|
||||
<module name="intellij.platform.jewel.foundation"/>
|
||||
<module name="intellij.platform.jewel.ui"/>
|
||||
<module name="intellij.platform.jewel.ideLafBridge"/>
|
||||
</dependencies>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<toolWindow id="PythonProcessOutput"
|
||||
anchor="bottom"
|
||||
secondary="false"
|
||||
icon="com.intellij.python.processOutput.impl.ui.Icons.CommandQueue"
|
||||
factoryClass="com.intellij.python.processOutput.impl.ProcessOutputToolWindowFactory"/>
|
||||
<statistics.counterUsagesCollector implementationClass="com.intellij.python.processOutput.impl.ProcessOutputUsageCollector"/>
|
||||
<python.processOutput.processOutputApi implementation="com.intellij.python.processOutput.impl.ProcessOutputApiImpl"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
@@ -0,0 +1,38 @@
|
||||
process.output.title=Python Process Output
|
||||
|
||||
process.output.icon.description.processBackError=Background Process Error
|
||||
process.output.icon.description.processBack=Background Process
|
||||
process.output.icon.description.processError=Process Error
|
||||
process.output.icon.description.process=Process
|
||||
process.output.icon.description.folder=Folder
|
||||
process.output.icon.description.search=Search
|
||||
process.output.icon.description.clear=Clear
|
||||
process.output.icon.description.dropdown=Dropdown
|
||||
process.output.icon.description.chevronRight=Chevron Right
|
||||
process.output.icon.description.chevronDown=Chevron Down
|
||||
process.output.icon.description.checked=Checked
|
||||
|
||||
process.output.viewOptions.tooltip=View Options
|
||||
|
||||
process.output.tree.search.placeholder=Search
|
||||
process.output.tree.buttons.expandAll=Expand All
|
||||
process.output.tree.buttons.collapseAll=Collapse All
|
||||
|
||||
process.output.tree.blankMessage=No processes found
|
||||
|
||||
process.output.output.buttons.copyOutput=Copy Output
|
||||
|
||||
process.output.output.sections.info=Process Info
|
||||
process.output.output.sections.info.started=started
|
||||
process.output.output.sections.info.command=command
|
||||
process.output.output.sections.info.pid=pid
|
||||
process.output.output.sections.info.cwd=cwd
|
||||
process.output.output.sections.info.env=env
|
||||
|
||||
process.output.output.sections.output=Process Output
|
||||
|
||||
process.output.output.blankMessage=Select a process to view its output
|
||||
|
||||
process.output.filters.tree.time=Show start time
|
||||
process.output.filters.tree.backgroundProcesses=Show background processes
|
||||
process.output.filters.output.tags=Show tags
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.intellij.python.processOutput.impl
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.python.processOutput.ProcessOutputApi
|
||||
import org.jetbrains.annotations.Nls
|
||||
|
||||
internal class ProcessOutputApiImpl : ProcessOutputApi {
|
||||
override fun specifyAdditionalMessageToUser(
|
||||
project: Project,
|
||||
logId: Int,
|
||||
text: @Nls String,
|
||||
) {
|
||||
val service = project.service<ProcessOutputControllerService>()
|
||||
service.specifyAdditionalMessageToUser(logId, text)
|
||||
}
|
||||
|
||||
override fun tryOpenLogInToolWindow(
|
||||
project: Project,
|
||||
logId: Int,
|
||||
): Boolean {
|
||||
val service = project.service<ProcessOutputControllerService>()
|
||||
return service.tryOpenLogInToolWindow(logId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.intellij.python.processOutput.impl
|
||||
|
||||
import com.intellij.DynamicBundle
|
||||
import org.jetbrains.annotations.Nls
|
||||
import org.jetbrains.annotations.NonNls
|
||||
import org.jetbrains.annotations.PropertyKey
|
||||
|
||||
internal object ProcessOutputBundle {
|
||||
private const val BUNDLE_FQN: @NonNls String = "messages.ProcessOutputBundle"
|
||||
private val BUNDLE = DynamicBundle(ProcessOutputBundle::class.java, BUNDLE_FQN)
|
||||
|
||||
fun message(
|
||||
key: @PropertyKey(resourceBundle = BUNDLE_FQN) String,
|
||||
vararg params: Any,
|
||||
): @Nls String {
|
||||
return BUNDLE.getMessage(key, *params)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package com.intellij.python.processOutput.impl
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.runtime.mutableStateSetOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateSet
|
||||
import androidx.compose.ui.util.fastMaxOfOrDefault
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.EDT
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.ide.CopyPasteManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.wm.ToolWindowManager
|
||||
import com.intellij.python.community.execService.impl.ExecLoggerService
|
||||
import com.intellij.python.community.execService.impl.LoggedProcess
|
||||
import com.intellij.python.community.execService.impl.LoggedProcessLine
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
|
||||
import com.intellij.python.processOutput.impl.ui.components.FilterItem
|
||||
import com.intellij.python.processOutput.impl.ui.toggle
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
|
||||
import com.jetbrains.python.TraceContext
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
import org.jetbrains.annotations.Nls
|
||||
import org.jetbrains.jewel.foundation.lazy.SelectableLazyListState
|
||||
import org.jetbrains.jewel.foundation.lazy.tree.Tree
|
||||
import org.jetbrains.jewel.foundation.lazy.tree.TreeGeneratorScope
|
||||
import org.jetbrains.jewel.foundation.lazy.tree.TreeState
|
||||
import org.jetbrains.jewel.foundation.lazy.tree.buildTree
|
||||
|
||||
internal object ProcessOutputControllerServiceLimits {
|
||||
const val MAX_PROCESSES = 256
|
||||
}
|
||||
|
||||
internal interface ProcessOutputController {
|
||||
val selectedProcess: StateFlow<LoggedProcess?>
|
||||
val processTreeUiState: TreeUiState
|
||||
val processOutputUiState: OutputUiState
|
||||
|
||||
fun collapseAllContexts()
|
||||
fun expandAllContexts()
|
||||
fun selectProcess(process: LoggedProcess)
|
||||
fun toggleTreeFilter(filter: TreeFilter)
|
||||
fun toggleOutputFilter(filter: OutputFilter)
|
||||
fun toggleProcessInfo()
|
||||
fun toggleProcessOutput()
|
||||
fun specifyAdditionalMessageToUser(logId: Int, message: @Nls String)
|
||||
fun copyOutputToClipboard(loggedProcess: LoggedProcess)
|
||||
|
||||
@RequiresEdt
|
||||
fun tryOpenLogInToolWindow(logId: Int): Boolean
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
data class TreeUiState(
|
||||
val filters: Set<TreeFilter>,
|
||||
val searchState: TextFieldState,
|
||||
val selectableLazyListState: SelectableLazyListState,
|
||||
val treeState: TreeState,
|
||||
val tree: StateFlow<Tree<TreeNode>>,
|
||||
)
|
||||
|
||||
@ApiStatus.Internal
|
||||
sealed class TreeFilter : FilterItem {
|
||||
object ShowTime : TreeFilter() {
|
||||
override val title: String = message("process.output.filters.tree.time")
|
||||
}
|
||||
|
||||
object ShowBackgroundProcesses : TreeFilter() {
|
||||
override val title: String = message("process.output.filters.tree.backgroundProcesses")
|
||||
}
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
sealed interface TreeNode {
|
||||
data class Context(
|
||||
val traceContext: TraceContext,
|
||||
) : TreeNode
|
||||
|
||||
data class Process(
|
||||
val process: LoggedProcess,
|
||||
) : TreeNode
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
sealed class OutputFilter : FilterItem {
|
||||
object ShowTags : OutputFilter() {
|
||||
override val title: String = message("process.output.filters.output.tags")
|
||||
}
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
data class OutputUiState(
|
||||
val filters: Set<OutputFilter>,
|
||||
val isInfoExpanded: StateFlow<Boolean>,
|
||||
val isOutputExpanded: StateFlow<Boolean>,
|
||||
val lazyListState: LazyListState,
|
||||
)
|
||||
|
||||
@ApiStatus.Internal
|
||||
@Service(Service.Level.PROJECT)
|
||||
class ProcessOutputControllerService(
|
||||
private val project: Project,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) : ProcessOutputController {
|
||||
private val loggedProcesses: StateFlow<List<LoggedProcess>> = run {
|
||||
var processList = listOf<LoggedProcess>()
|
||||
ApplicationManager.getApplication().service<ExecLoggerService>()
|
||||
.processes
|
||||
.map {
|
||||
processList = processList + it
|
||||
|
||||
if (processList.size > ProcessOutputControllerServiceLimits.MAX_PROCESSES) {
|
||||
processList = processList.drop(
|
||||
processList.size - ProcessOutputControllerServiceLimits.MAX_PROCESSES,
|
||||
)
|
||||
}
|
||||
|
||||
it.traceContext
|
||||
?.takeIf { context -> context != NON_INTERACTIVE_ROOT_TRACE_CONTEXT }
|
||||
?.also { context ->
|
||||
processTreeUiState.treeState.openNodes(context.hierarchy())
|
||||
}
|
||||
|
||||
processList
|
||||
}
|
||||
.stateIn(
|
||||
coroutineScope + Dispatchers.EDT,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private val processTree = MutableStateFlow(buildTree<TreeNode> {})
|
||||
private val processTreeFilters: SnapshotStateSet<TreeFilter> = mutableStateSetOf(
|
||||
TreeFilter.ShowTime,
|
||||
)
|
||||
|
||||
private val processOutputFilters: SnapshotStateSet<OutputFilter> = mutableStateSetOf(
|
||||
OutputFilter.ShowTags,
|
||||
)
|
||||
private val processOutputInfoExpanded = MutableStateFlow(false)
|
||||
private val processOutputOutputExpanded = MutableStateFlow(true)
|
||||
|
||||
override val selectedProcess: MutableStateFlow<LoggedProcess?> = MutableStateFlow(null)
|
||||
override val processTreeUiState: TreeUiState = run {
|
||||
val selectableLazyListState = SelectableLazyListState(LazyListState())
|
||||
TreeUiState(
|
||||
filters = processTreeFilters,
|
||||
searchState = TextFieldState(),
|
||||
selectableLazyListState = selectableLazyListState,
|
||||
treeState = TreeState(selectableLazyListState),
|
||||
tree = processTree,
|
||||
)
|
||||
}
|
||||
override val processOutputUiState: OutputUiState = OutputUiState(
|
||||
filters = processOutputFilters,
|
||||
isInfoExpanded = processOutputInfoExpanded,
|
||||
isOutputExpanded = processOutputOutputExpanded,
|
||||
lazyListState = LazyListState(),
|
||||
)
|
||||
|
||||
init {
|
||||
collectSearchStats()
|
||||
collectProcessTree()
|
||||
ensureProcessTreeScroll()
|
||||
}
|
||||
|
||||
override fun collapseAllContexts() {
|
||||
processTreeUiState.treeState.openNodes.forEach {
|
||||
processTreeUiState.treeState.toggleNode(it)
|
||||
}
|
||||
|
||||
ProcessOutputUsageCollector.treeCollapseAllClicked()
|
||||
}
|
||||
|
||||
override fun expandAllContexts() {
|
||||
loggedProcesses.value
|
||||
.mapNotNull { it.traceContext }
|
||||
.toSet()
|
||||
.subtract(processTreeUiState.treeState.openNodes)
|
||||
.forEach {
|
||||
processTreeUiState.treeState.toggleNode(it)
|
||||
}
|
||||
|
||||
ProcessOutputUsageCollector.treeExpandAllClicked()
|
||||
}
|
||||
|
||||
override fun selectProcess(process: LoggedProcess) {
|
||||
selectedProcess.value = process
|
||||
ProcessOutputUsageCollector.treeProcessSelected()
|
||||
}
|
||||
|
||||
override fun toggleTreeFilter(filter: TreeFilter) {
|
||||
processTreeFilters.toggle(filter)
|
||||
|
||||
when (filter) {
|
||||
TreeFilter.ShowBackgroundProcesses ->
|
||||
ProcessOutputUsageCollector.treeFilterBackgroundProcessesToggled(
|
||||
processTreeFilters.contains(TreeFilter.ShowBackgroundProcesses),
|
||||
)
|
||||
TreeFilter.ShowTime ->
|
||||
ProcessOutputUsageCollector.treeFilterTimeToggled(
|
||||
processTreeFilters.contains(TreeFilter.ShowTime),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toggleOutputFilter(filter: OutputFilter) {
|
||||
processOutputFilters.toggle(filter)
|
||||
|
||||
when (filter) {
|
||||
OutputFilter.ShowTags ->
|
||||
ProcessOutputUsageCollector.outputFilterShowTagsToggled(
|
||||
processOutputFilters.contains(OutputFilter.ShowTags),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toggleProcessInfo() {
|
||||
val expanded = processOutputInfoExpanded.value
|
||||
|
||||
processOutputInfoExpanded.value = !expanded
|
||||
|
||||
ProcessOutputUsageCollector.outputProcessInfoToggled(!expanded)
|
||||
}
|
||||
|
||||
override fun toggleProcessOutput() {
|
||||
val expanded = processOutputOutputExpanded.value
|
||||
|
||||
processOutputOutputExpanded.value = !expanded
|
||||
|
||||
ProcessOutputUsageCollector.outputProcessOutputToggled(!expanded)
|
||||
}
|
||||
|
||||
override fun copyOutputToClipboard(loggedProcess: LoggedProcess) {
|
||||
val showTags = processOutputUiState.filters.contains(OutputFilter.ShowTags)
|
||||
|
||||
val stringToCopy = buildString {
|
||||
loggedProcess.lines.replayCache.forEach { line ->
|
||||
if (showTags) {
|
||||
val tag = when (line.kind) {
|
||||
LoggedProcessLine.Kind.ERR -> Tag.ERROR
|
||||
LoggedProcessLine.Kind.OUT -> Tag.OUTPUT
|
||||
}
|
||||
|
||||
append("[$tag] ".padStart(Tag.maxLength + 3))
|
||||
} else {
|
||||
repeat(Tag.maxLength + 3) {
|
||||
append(' ')
|
||||
}
|
||||
}
|
||||
|
||||
appendLine(line.text)
|
||||
}
|
||||
|
||||
loggedProcess.exitInfo.value?.also {
|
||||
append("[${Tag.EXIT}] ".padStart(Tag.maxLength + 3))
|
||||
append(it.exitValue)
|
||||
|
||||
it.additionalMessageToUser?.also { message ->
|
||||
append(": ")
|
||||
append(message)
|
||||
}
|
||||
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
CopyPasteManager.copyTextToClipboard(stringToCopy)
|
||||
|
||||
ProcessOutputUsageCollector.outputCopyClicked()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
override fun tryOpenLogInToolWindow(logId: Int): Boolean {
|
||||
val match = loggedProcesses.value.find { process -> process.id == logId }
|
||||
|
||||
if (match == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID)?.show()
|
||||
|
||||
coroutineScope.launch(Dispatchers.EDT) {
|
||||
val process = loggedProcesses.value.find { it.id == logId } ?: return@launch
|
||||
|
||||
// select the process
|
||||
selectedProcess.value = process
|
||||
|
||||
// open all the parent nodes of the process
|
||||
process.traceContext?.also {
|
||||
processTreeUiState.treeState.openNodes(it.hierarchy())
|
||||
}
|
||||
|
||||
// select the process in the list state
|
||||
processTreeUiState.treeState.selectedKeys = setOf(process)
|
||||
|
||||
// scroll to the top of the list
|
||||
processTreeUiState.selectableLazyListState.lazyListState.scrollToItem(0)
|
||||
|
||||
// clear search text
|
||||
processTreeUiState.searchState.clearText()
|
||||
|
||||
// expand process output section
|
||||
processOutputOutputExpanded.value = true
|
||||
|
||||
// wait until output has recomposed
|
||||
delay(100.milliseconds)
|
||||
|
||||
// scroll output all the way to the bottom
|
||||
val index = processOutputUiState.lazyListState.layoutInfo.totalItemsCount
|
||||
processOutputUiState.lazyListState.scrollToItem(index.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
ProcessOutputUsageCollector.toolwindowOpenedDueToError()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun specifyAdditionalMessageToUser(logId: Int, @Nls message: String) {
|
||||
val trimmed = message.trim()
|
||||
|
||||
if (trimmed.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
loggedProcesses.value.find { it.id == logId }?.exitInfo?.also { exitInfo ->
|
||||
exitInfo.value = exitInfo.value?.copy(additionalMessageToUser = message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectSearchStats() {
|
||||
coroutineScope.launch {
|
||||
snapshotFlow { processTreeUiState.searchState.text }
|
||||
.collect {
|
||||
ProcessOutputUsageCollector.treeSearchEdited()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectProcessTree() {
|
||||
val backgroundErrorProcesses = MutableStateFlow<Set<Int>>(setOf())
|
||||
|
||||
coroutineScope.launch {
|
||||
loggedProcesses
|
||||
.collect { list ->
|
||||
backgroundErrorProcesses.value = setOf()
|
||||
list
|
||||
.filter { it.traceContext == NON_INTERACTIVE_ROOT_TRACE_CONTEXT }
|
||||
.forEach { process ->
|
||||
launch {
|
||||
process.exitInfo.collect {
|
||||
val exitValue = it?.exitValue
|
||||
if (exitValue != null && exitValue != 0) {
|
||||
backgroundErrorProcesses.value += process.id
|
||||
} else {
|
||||
backgroundErrorProcesses.value -= process.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
combine(
|
||||
backgroundErrorProcesses,
|
||||
loggedProcesses,
|
||||
snapshotFlow { processTreeUiState.searchState.text },
|
||||
snapshotFlow { processTreeUiState.filters.toSet() },
|
||||
)
|
||||
{ backgroundErrorProcesses, processList, search, filters ->
|
||||
val lowercaseSearch = search.toString().trim().lowercase()
|
||||
var filteredProcesses =
|
||||
processList
|
||||
.reversed()
|
||||
.filter {
|
||||
it.shortenedCommandString
|
||||
.lowercase()
|
||||
.contains(lowercaseSearch)
|
||||
}
|
||||
|
||||
if (!filters.contains(TreeFilter.ShowBackgroundProcesses)) {
|
||||
filteredProcesses = filteredProcesses.filter {
|
||||
it.traceContext != NON_INTERACTIVE_ROOT_TRACE_CONTEXT
|
||||
|| backgroundErrorProcesses.contains(it.id)
|
||||
}
|
||||
}
|
||||
|
||||
data class Node(
|
||||
val traceContext: TraceContext? = null,
|
||||
val process: LoggedProcess? = null,
|
||||
val children: MutableList<Node> = mutableListOf(),
|
||||
)
|
||||
|
||||
val root = mutableListOf<Node>()
|
||||
|
||||
filteredProcesses.forEach { process ->
|
||||
when (val traceContext = process.traceContext) {
|
||||
null, NON_INTERACTIVE_ROOT_TRACE_CONTEXT -> root += Node(process = process)
|
||||
else -> {
|
||||
val hierarchy = traceContext.hierarchy()
|
||||
var currentRoot = root
|
||||
|
||||
hierarchy.forEach { currentContext ->
|
||||
val node =
|
||||
currentRoot
|
||||
.firstOrNull { node ->
|
||||
node.traceContext == currentContext
|
||||
}
|
||||
?: run {
|
||||
Node(traceContext = currentContext).also {
|
||||
currentRoot += it
|
||||
}
|
||||
}
|
||||
|
||||
currentRoot = node.children
|
||||
}
|
||||
|
||||
currentRoot += Node(process = process)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun TreeGeneratorScope<TreeNode>.buildNodeTree(root: List<Node>) {
|
||||
root.forEach { (traceContext, process, children) ->
|
||||
if (traceContext != null) {
|
||||
addNode(TreeNode.Context(traceContext), traceContext) {
|
||||
buildNodeTree(children)
|
||||
}
|
||||
} else if (process != null) {
|
||||
addLeaf(TreeNode.Process(process), process)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processTree.value = buildTree {
|
||||
buildNodeTree(root)
|
||||
}
|
||||
}.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun ensureProcessTreeScroll() {
|
||||
coroutineScope.launch(Dispatchers.EDT) {
|
||||
var prevCanScrollBackwards = false
|
||||
var prevLastItem: Any? = null
|
||||
|
||||
combine(
|
||||
snapshotFlow { processTreeUiState.treeState.canScrollBackward },
|
||||
loggedProcesses,
|
||||
) { canScrollBackwards, processes -> canScrollBackwards to processes }
|
||||
.debounce(100.milliseconds)
|
||||
.collect { (canScrollBackwards, processes) ->
|
||||
val lastItem = processes.lastOrNull()
|
||||
|
||||
// scroll to the top if an item was added when the list tree fully scrolled to
|
||||
// the top
|
||||
if (canScrollBackwards && !prevCanScrollBackwards && lastItem != prevLastItem) {
|
||||
processTreeUiState.selectableLazyListState.lazyListState.scrollToItem(0)
|
||||
} else {
|
||||
prevCanScrollBackwards = canScrollBackwards
|
||||
}
|
||||
|
||||
prevLastItem = lastItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal object Tag {
|
||||
const val ERROR = "error"
|
||||
const val OUTPUT = "output"
|
||||
const val EXIT = "exit"
|
||||
|
||||
val maxLength: Int =
|
||||
Tag::class.java.declaredFields
|
||||
.filter { it.type == String::class.java }
|
||||
.fastMaxOfOrDefault(0) { (it.get(null) as String).length }
|
||||
}
|
||||
|
||||
private fun TraceContext.hierarchy(): List<TraceContext> {
|
||||
val hierarchy = mutableListOf<TraceContext>()
|
||||
var currentContext: TraceContext? = this
|
||||
|
||||
while (currentContext != null) {
|
||||
hierarchy.add(0, currentContext)
|
||||
currentContext = currentContext.parentTraceContext
|
||||
}
|
||||
|
||||
return hierarchy
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.intellij.python.processOutput.impl
|
||||
|
||||
import androidx.compose.runtime.remember
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.wm.ToolWindow
|
||||
import com.intellij.openapi.wm.ToolWindowFactory
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
|
||||
import com.intellij.python.processOutput.impl.ui.components.ToolWindow
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
import org.jetbrains.jewel.bridge.addComposeTab
|
||||
|
||||
internal const val TOOL_WINDOW_ID = "PythonProcessOutput"
|
||||
|
||||
@ApiStatus.Internal
|
||||
class ProcessOutputToolWindowFactory : ToolWindowFactory, DumbAware {
|
||||
override fun init(toolWindow: ToolWindow) {
|
||||
// pre-initialize the service to warm up the logged processes flow
|
||||
toolWindow.project.service<ProcessOutputControllerService>()
|
||||
|
||||
toolWindow.setStripeTitleProvider { message("process.output.title") }
|
||||
toolWindow.setStripeShortTitleProvider { message("process.output.title") }
|
||||
}
|
||||
|
||||
override fun createToolWindowContent(
|
||||
project: Project,
|
||||
toolWindow: ToolWindow,
|
||||
) {
|
||||
toolWindow.addComposeTab(focusOnClickInside = true) {
|
||||
val service = remember { project.service<ProcessOutputControllerService>() }
|
||||
|
||||
ToolWindow(service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.intellij.python.processOutput.impl
|
||||
|
||||
import com.intellij.internal.statistic.eventLog.EventLogGroup
|
||||
import com.intellij.internal.statistic.eventLog.events.EventFields
|
||||
import com.intellij.internal.statistic.service.fus.collectors.CounterUsagesCollector
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
|
||||
@ApiStatus.Internal
|
||||
object ProcessOutputUsageCollector : CounterUsagesCollector() {
|
||||
private val GROUP: EventLogGroup = EventLogGroup(
|
||||
"pycharm.processOutputToolWindow",
|
||||
version = 1,
|
||||
recorder = "FUS",
|
||||
description = "Statistics for Python's process output toolwindowk",
|
||||
)
|
||||
|
||||
private val TOGGLED_FIELD = EventFields.Boolean("enabled")
|
||||
|
||||
private val TREE_PROCESS_SELECTED = GROUP.registerEvent(
|
||||
"tree.processSelected",
|
||||
"Process selected",
|
||||
)
|
||||
private val TREE_SEARCH_EDITED = GROUP.registerEvent(
|
||||
"tree.searchEdited",
|
||||
"Process search field edited",
|
||||
)
|
||||
private val TREE_FILTER_TIME_TOGGLED = GROUP.registerVarargEvent(
|
||||
"tree.filter.timeToggled",
|
||||
"Time filter toggled",
|
||||
TOGGLED_FIELD,
|
||||
)
|
||||
private val TREE_FILTER_BACKGROUND_PROCESSES_TOGGLED = GROUP.registerVarargEvent(
|
||||
"tree.filter.backgroundProcessesToggled",
|
||||
"Background processes filter toggled",
|
||||
TOGGLED_FIELD,
|
||||
)
|
||||
private val TREE_EXPAND_ALL_CLICKED = GROUP.registerEvent(
|
||||
"tree.expandAllClicked",
|
||||
"Expand all clicked",
|
||||
)
|
||||
private val TREE_COLLAPSE_ALL_CLICKED = GROUP.registerEvent(
|
||||
"tree.collapseAllClicked",
|
||||
"Collapse all clicked",
|
||||
)
|
||||
private val OUTPUT_FILTER_SHOW_TAGS_TOGGLED = GROUP.registerVarargEvent(
|
||||
"output.filter.showTagsToggled",
|
||||
"Show tags filter toggled",
|
||||
TOGGLED_FIELD,
|
||||
)
|
||||
private val OUTPUT_COPY_CLICKED = GROUP.registerVarargEvent(
|
||||
"output.copyClicked",
|
||||
"Copy clicked",
|
||||
)
|
||||
private val OUTPUT_PROCESS_INFO_TOGGLED = GROUP.registerVarargEvent(
|
||||
"output.processInfoToggled",
|
||||
"Process info section toggled",
|
||||
TOGGLED_FIELD,
|
||||
)
|
||||
private val OUTPUT_PROCESS_OUTPUT_TOGGLED = GROUP.registerVarargEvent(
|
||||
"output.processOutputToggled",
|
||||
"Process output section toggled",
|
||||
TOGGLED_FIELD,
|
||||
)
|
||||
private val TOOLWINDOW_OPENED_DUE_TO_ERROR = GROUP.registerEvent(
|
||||
"toolwindow.openedDueToError",
|
||||
"Toolwindow opened due to error",
|
||||
)
|
||||
|
||||
override fun getGroup(): EventLogGroup = GROUP
|
||||
|
||||
fun treeProcessSelected() {
|
||||
TREE_PROCESS_SELECTED.log()
|
||||
}
|
||||
|
||||
fun treeSearchEdited() {
|
||||
TREE_SEARCH_EDITED.log()
|
||||
}
|
||||
|
||||
fun treeFilterTimeToggled(enabled: Boolean) {
|
||||
TREE_FILTER_TIME_TOGGLED.log(
|
||||
TOGGLED_FIELD.with(enabled),
|
||||
)
|
||||
}
|
||||
|
||||
fun treeFilterBackgroundProcessesToggled(enabled: Boolean) {
|
||||
TREE_FILTER_BACKGROUND_PROCESSES_TOGGLED.log(
|
||||
TOGGLED_FIELD.with(enabled),
|
||||
)
|
||||
}
|
||||
|
||||
fun treeExpandAllClicked() {
|
||||
TREE_EXPAND_ALL_CLICKED.log()
|
||||
}
|
||||
|
||||
fun treeCollapseAllClicked() {
|
||||
TREE_COLLAPSE_ALL_CLICKED.log()
|
||||
}
|
||||
|
||||
fun outputFilterShowTagsToggled(enabled: Boolean) {
|
||||
OUTPUT_FILTER_SHOW_TAGS_TOGGLED.log(
|
||||
TOGGLED_FIELD.with(enabled),
|
||||
)
|
||||
}
|
||||
|
||||
fun outputCopyClicked() {
|
||||
OUTPUT_COPY_CLICKED.log()
|
||||
}
|
||||
|
||||
fun outputProcessInfoToggled(enabled: Boolean) {
|
||||
OUTPUT_PROCESS_INFO_TOGGLED.log(
|
||||
TOGGLED_FIELD.with(enabled),
|
||||
)
|
||||
}
|
||||
|
||||
fun outputProcessOutputToggled(enabled: Boolean) {
|
||||
OUTPUT_PROCESS_OUTPUT_TOGGLED.log(
|
||||
TOGGLED_FIELD.with(enabled),
|
||||
)
|
||||
}
|
||||
|
||||
fun toolwindowOpenedDueToError() {
|
||||
TOOLWINDOW_OPENED_DUE_TO_ERROR.log()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.intellij.python.processOutput.impl;
|
||||
|
||||
import com.intellij.ui.IconManager;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import javax.swing.*;
|
||||
|
||||
/**
|
||||
* NOTE THIS FILE IS AUTO-GENERATED
|
||||
* DO NOT EDIT IT BY HAND, run "Generate icon classes" configuration instead
|
||||
*/
|
||||
public final class PythonProcessOutputIcons {
|
||||
private static @NotNull Icon load(@NotNull String path, int cacheKey, int flags) {
|
||||
return IconManager.getInstance().loadRasterizedIcon(path, PythonProcessOutputIcons.class.getClassLoader(), cacheKey, flags);
|
||||
}
|
||||
/** 16x16 */ public static final @NotNull Icon CommandQueue = load("icons/commandQueue.svg", 1434828820, 2);
|
||||
/** 16x16 */ public static final @NotNull Icon Process = load("icons/process.svg", -2015894419, 2);
|
||||
/** 16x16 */ public static final @NotNull Icon ProcessBack = load("icons/processBack.svg", 1055076489, 2);
|
||||
/** 16x16 */ public static final @NotNull Icon ProcessBackError = load("icons/processBackError.svg", -1366278226, 2);
|
||||
/** 16x16 */ public static final @NotNull Icon ProcessError = load("icons/processError.svg", 1713815623, 2);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.intellij.python.processOutput.impl
|
||||
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
private val fullFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
|
||||
internal fun Instant.formatFull(): String =
|
||||
fullFormatter.format(this.toJavaInstant())
|
||||
|
||||
internal fun Instant.formatTime(): String =
|
||||
timeFormatter.format(this.toJavaInstant())
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.intellij.python.processOutput.impl.ui
|
||||
|
||||
import org.jetbrains.jewel.bridge.retrieveColorOrUnspecified
|
||||
|
||||
internal object Colors {
|
||||
object Tree {
|
||||
val Selected
|
||||
get() = retrieveColorOrUnspecified("List.selectionBackground")
|
||||
|
||||
val Hovered
|
||||
get() = retrieveColorOrUnspecified("ColorPalette.Gray3")
|
||||
|
||||
val Info
|
||||
get() = retrieveColorOrUnspecified("Component.infoForeground")
|
||||
}
|
||||
|
||||
object Output {
|
||||
val ErrorText
|
||||
get() = retrieveColorOrUnspecified("Label.errorForeground")
|
||||
|
||||
val Info
|
||||
get() = retrieveColorOrUnspecified("Label.disabledForeground")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.intellij.python.processOutput.impl.ui
|
||||
|
||||
import com.intellij.python.processOutput.impl.PythonProcessOutputIcons
|
||||
import org.jetbrains.jewel.ui.icon.PathIconKey
|
||||
import org.jetbrains.jewel.ui.icons.AllIconsKeys
|
||||
|
||||
internal object Icons {
|
||||
@JvmField val CommandQueue = PythonProcessOutputIcons.CommandQueue
|
||||
|
||||
object Keys {
|
||||
val Process = PathIconKey("/icons/process.svg", Icons::class.java)
|
||||
val ProcessBack = PathIconKey("/icons/processBack.svg", Icons::class.java)
|
||||
val ProcessBackError = PathIconKey("/icons/processBackError.svg", Icons::class.java)
|
||||
val ProcessError = PathIconKey("/icons/processError.svg", Icons::class.java)
|
||||
val Filter = AllIconsKeys.Actions.Show
|
||||
val Dropdown = AllIconsKeys.General.Dropdown
|
||||
val ExpandAll = AllIconsKeys.Actions.Expandall
|
||||
val CollapseAll = AllIconsKeys.Actions.Collapseall
|
||||
val Checked = AllIconsKeys.Actions.Checked
|
||||
val Search = AllIconsKeys.Actions.Search
|
||||
val Close = AllIconsKeys.Actions.Close
|
||||
val CloseHovered = AllIconsKeys.Actions.CloseHovered
|
||||
val Error = AllIconsKeys.General.Error
|
||||
val ChevronDown = AllIconsKeys.General.ChevronDown
|
||||
val ChevronRight = AllIconsKeys.General.ChevronRight
|
||||
val Copy = AllIconsKeys.General.Copy
|
||||
val Folder = AllIconsKeys.Nodes.Folder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
|
||||
import com.intellij.python.processOutput.impl.ui.Icons
|
||||
import org.jetbrains.jewel.foundation.modifier.thenIf
|
||||
import org.jetbrains.jewel.ui.component.Icon
|
||||
import org.jetbrains.jewel.ui.component.IconButton
|
||||
import org.jetbrains.jewel.ui.component.Text
|
||||
import org.jetbrains.jewel.ui.component.Tooltip
|
||||
import org.jetbrains.jewel.ui.disabledAppearance
|
||||
import org.jetbrains.jewel.ui.icon.IconKey
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun ActionIconButton(
|
||||
modifier: Modifier = Modifier,
|
||||
iconKey: IconKey,
|
||||
tooltipText: String,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit = {},
|
||||
iconModifier: Modifier = Modifier,
|
||||
isDropdown: Boolean = false,
|
||||
) {
|
||||
Tooltip(
|
||||
tooltip = {
|
||||
Row {
|
||||
Text(tooltipText)
|
||||
}
|
||||
},
|
||||
) {
|
||||
IconButton(
|
||||
modifier = modifier.size(26.dp)
|
||||
.testTag(ActionIconButtonTestTags.BUTTON),
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
modifier = iconModifier
|
||||
.testTag(ActionIconButtonTestTags.ICON)
|
||||
.thenIf(!enabled) {
|
||||
disabledAppearance()
|
||||
},
|
||||
key = iconKey,
|
||||
contentDescription = tooltipText,
|
||||
)
|
||||
|
||||
if (isDropdown) {
|
||||
Icon(
|
||||
modifier = iconModifier
|
||||
.offset(x = 1.dp, y = 1.dp)
|
||||
.testTag(ActionIconButtonTestTags.DROPDOWN_ICON)
|
||||
.thenIf(!enabled) {
|
||||
disabledAppearance()
|
||||
},
|
||||
key = Icons.Keys.Dropdown,
|
||||
contentDescription = message("process.output.icon.description.dropdown"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal object ActionIconButtonTestTags {
|
||||
const val BUTTON = "ProcessOutput.ActionIconButton.Button"
|
||||
const val ICON = "ProcessOutput.ActionIconButton.Icon"
|
||||
const val DROPDOWN_ICON = "ProcessOutput.ActionIconButton.DropdownIcon"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
|
||||
import com.intellij.python.processOutput.impl.ui.Icons
|
||||
import com.intellij.python.processOutput.impl.ui.expandable
|
||||
import com.intellij.python.processOutput.impl.ui.isExpanded
|
||||
import org.jetbrains.jewel.ui.component.Icon
|
||||
import org.jetbrains.jewel.ui.component.scrollbarContentSafePadding
|
||||
|
||||
@Composable
|
||||
internal fun CollapsibleListSection(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isExpanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
modifier.fillMaxWidth()
|
||||
.height(28.dp)
|
||||
.padding(start = 8.dp, end = scrollbarContentSafePadding())
|
||||
.expandable(interactionSource, onToggle)
|
||||
.semantics { this.isExpanded = isExpanded },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val (key, contentDescription, testTag) =
|
||||
if (isExpanded) {
|
||||
Triple(
|
||||
Icons.Keys.ChevronDown,
|
||||
message("process.output.icon.description.chevronDown"),
|
||||
CollapsibleListSectionTestTags.CHEVRON_DOWN,
|
||||
)
|
||||
} else {
|
||||
Triple(
|
||||
Icons.Keys.ChevronRight,
|
||||
message("process.output.icon.description.chevronRight"),
|
||||
CollapsibleListSectionTestTags.CHEVRON_RIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
key = key,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.size(16.dp)
|
||||
.testTag(testTag),
|
||||
)
|
||||
|
||||
InterText(
|
||||
text = text,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal object CollapsibleListSectionTestTags {
|
||||
const val CHEVRON_RIGHT = "ProcessOutput.CollapsibleListSection.ChevronRight"
|
||||
const val CHEVRON_DOWN = "ProcessOutput.CollapsibleListSection.ChevronDown"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.intellij.python.processOutput.impl.ui.Colors
|
||||
import org.jetbrains.jewel.ui.component.Text
|
||||
|
||||
@Composable
|
||||
internal fun EmptyContainerNotice(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier,
|
||||
color = Colors.Output.Info,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
|
||||
import com.intellij.python.processOutput.impl.ui.Icons
|
||||
import com.intellij.python.processOutput.impl.ui.thenIfNotNull
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
import org.jetbrains.jewel.foundation.Stroke
|
||||
import org.jetbrains.jewel.foundation.modifier.border
|
||||
import org.jetbrains.jewel.foundation.modifier.onHover
|
||||
import org.jetbrains.jewel.foundation.modifier.thenIf
|
||||
import org.jetbrains.jewel.foundation.theme.JewelTheme
|
||||
import org.jetbrains.jewel.ui.component.Icon
|
||||
import org.jetbrains.jewel.ui.component.PopupMenu
|
||||
import org.jetbrains.jewel.ui.component.Text
|
||||
import org.jetbrains.jewel.ui.component.items
|
||||
import org.jetbrains.jewel.ui.theme.iconButtonStyle
|
||||
|
||||
@Composable
|
||||
internal fun <T : FilterItem> FilterActionGroup(
|
||||
tooltipText: String,
|
||||
items: ImmutableList<FilterEntry<T>>,
|
||||
isSelected: (T) -> Boolean,
|
||||
onItemClick: (T) -> Unit,
|
||||
enabled: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
menuModifier: Modifier = Modifier,
|
||||
) {
|
||||
var isMenuOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
ActionIconButton(
|
||||
modifier = modifier
|
||||
.thenIf(isMenuOpen) {
|
||||
background(
|
||||
color = JewelTheme.iconButtonStyle.colors.backgroundPressed,
|
||||
shape = RoundedCornerShape(JewelTheme.iconButtonStyle.metrics.cornerSize),
|
||||
)
|
||||
.border(
|
||||
alignment = Stroke.Alignment.Inside,
|
||||
width = JewelTheme.iconButtonStyle.metrics.borderWidth,
|
||||
color = JewelTheme.iconButtonStyle.colors.backgroundPressed,
|
||||
shape = RoundedCornerShape(JewelTheme.iconButtonStyle.metrics.cornerSize),
|
||||
)
|
||||
},
|
||||
iconKey = Icons.Keys.Filter,
|
||||
tooltipText = tooltipText,
|
||||
enabled = enabled,
|
||||
onClick = { isMenuOpen = !isMenuOpen },
|
||||
isDropdown = true,
|
||||
)
|
||||
|
||||
if (isMenuOpen) {
|
||||
var isHovered by remember { mutableStateOf(false) }
|
||||
|
||||
PopupMenu(
|
||||
onDismissRequest = {
|
||||
if (it == InputMode.Touch && !isHovered) {
|
||||
isMenuOpen = false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
horizontalAlignment = Alignment.Start,
|
||||
modifier = menuModifier.onHover { isHovered = it },
|
||||
) {
|
||||
items(
|
||||
items = items,
|
||||
isSelected = { isSelected(it.item) },
|
||||
onItemClick = { onItemClick(it.item) },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.thenIfNotNull(it.testTag) { tag ->
|
||||
testTag(tag)
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isSelected(it.item)) {
|
||||
Icon(
|
||||
Icons.Keys.Checked,
|
||||
message("process.output.icon.description.checked"),
|
||||
modifier = Modifier.testTag(FilterActionGroupTestTags.CHECKED_ICON),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.width(16.dp))
|
||||
}
|
||||
|
||||
Text(it.item.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
interface FilterItem {
|
||||
val title: String
|
||||
}
|
||||
|
||||
internal data class FilterEntry<T : FilterItem>(
|
||||
val item: T,
|
||||
val testTag: String? = null,
|
||||
)
|
||||
|
||||
internal object FilterActionGroupTestTags {
|
||||
const val CHECKED_ICON = "ProcessOutput.FilterActionGroup.CheckedIcon"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import org.jetbrains.jewel.ui.component.Text
|
||||
|
||||
@Composable
|
||||
internal fun InterText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Color.Unspecified,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
fontWeight: FontWeight? = null,
|
||||
textAlign: TextAlign = TextAlign.Unspecified,
|
||||
) {
|
||||
InterText(
|
||||
text = AnnotatedString(text),
|
||||
modifier = modifier,
|
||||
color = color,
|
||||
overflow = overflow,
|
||||
fontWeight = fontWeight,
|
||||
textAlign = textAlign,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTextApi::class)
|
||||
@Composable
|
||||
internal fun InterText(
|
||||
text: AnnotatedString,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Color.Unspecified,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
fontWeight: FontWeight? = null,
|
||||
textAlign: TextAlign = TextAlign.Unspecified,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier,
|
||||
color = color,
|
||||
overflow = overflow,
|
||||
fontFamily = FontFamily("Inter"),
|
||||
fontWeight = fontWeight,
|
||||
textAlign = textAlign,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.foundation.gestures.ScrollableState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.selection.DisableSelection
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import org.jetbrains.jewel.ui.component.Text
|
||||
import org.jetbrains.jewel.ui.component.VerticallyScrollableContainer
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.intellij.python.community.execService.impl.LoggedProcessLine
|
||||
import com.intellij.python.processOutput.impl.ui.Icons
|
||||
import com.intellij.python.processOutput.impl.OutputFilter
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputController
|
||||
import com.intellij.python.processOutput.impl.Tag
|
||||
import com.intellij.python.processOutput.impl.formatFull
|
||||
import com.intellij.python.processOutput.impl.ui.Colors
|
||||
import com.intellij.python.processOutput.impl.ui.collectReplayAsState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.jetbrains.jewel.foundation.theme.JewelTheme
|
||||
import org.jetbrains.jewel.ui.component.scrollbarContentSafePadding
|
||||
|
||||
@Composable
|
||||
internal fun OutputSection(controller: ProcessOutputController) {
|
||||
val listState = remember { controller.processOutputUiState.lazyListState }
|
||||
|
||||
val selectedProcess by controller.selectedProcess.collectAsState()
|
||||
|
||||
Column {
|
||||
Toolbar {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
selectedProcess?.also {
|
||||
InterText(
|
||||
text = it.shortenedCommandString,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FilterActionGroup(
|
||||
tooltipText = message("process.output.viewOptions.tooltip"),
|
||||
items = persistentListOf(
|
||||
FilterEntry(
|
||||
item = OutputFilter.ShowTags,
|
||||
testTag = OutputSectionTestTags.FILTERS_TAGS,
|
||||
),
|
||||
),
|
||||
isSelected = { controller.processOutputUiState.filters.contains(it) },
|
||||
onItemClick = { controller.toggleOutputFilter(it) },
|
||||
modifier = Modifier.testTag(OutputSectionTestTags.FILTERS_BUTTON),
|
||||
menuModifier = Modifier.testTag(OutputSectionTestTags.FILTERS_MENU),
|
||||
)
|
||||
|
||||
ActionIconButton(
|
||||
modifier = Modifier.testTag(OutputSectionTestTags.COPY_OUTPUT_BUTTON),
|
||||
iconKey = Icons.Keys.Copy,
|
||||
tooltipText = message("process.output.output.buttons.copyOutput"),
|
||||
enabled = selectedProcess != null,
|
||||
onClick = {
|
||||
selectedProcess?.let {
|
||||
controller.copyOutputToClipboard(it)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
selectedProcess?.let {
|
||||
VerticallyScrollableContainer(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
scrollState = listState as ScrollableState,
|
||||
) {
|
||||
val lines by it.lines.collectReplayAsState()
|
||||
val exitInfo by it.exitInfo.collectAsState()
|
||||
val isInfoExpandedState =
|
||||
controller.processOutputUiState.isInfoExpanded.collectAsState()
|
||||
val isOutputExpandedState =
|
||||
controller.processOutputUiState.isOutputExpanded.collectAsState()
|
||||
|
||||
val isDisplayTags = controller.processOutputUiState.filters.contains(
|
||||
OutputFilter.ShowTags,
|
||||
)
|
||||
|
||||
SelectionContainer {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
) {
|
||||
collapsibleSectionItem(
|
||||
title = message("process.output.output.sections.info"),
|
||||
modifier = Modifier.testTag(OutputSectionTestTags.INFO_SECTION),
|
||||
isExpandedState = isInfoExpandedState,
|
||||
onToggle = { controller.toggleProcessInfo() },
|
||||
) {
|
||||
infoLineItems(
|
||||
InfoLine.Single(
|
||||
message("process.output.output.sections.info.started"),
|
||||
it.startedAt.formatFull(),
|
||||
),
|
||||
InfoLine.Single(
|
||||
message("process.output.output.sections.info.command"),
|
||||
it.commandString,
|
||||
),
|
||||
it.pid?.let { pid ->
|
||||
InfoLine.Single(
|
||||
message("process.output.output.sections.info.pid"),
|
||||
pid.toString(),
|
||||
)
|
||||
},
|
||||
it.cwd?.let { cwd ->
|
||||
InfoLine.Single(
|
||||
message("process.output.output.sections.info.cwd"),
|
||||
cwd,
|
||||
)
|
||||
},
|
||||
InfoLine.Multi(
|
||||
message("process.output.output.sections.info.env"),
|
||||
it.env.entries.map { (key, value) -> "$key=$value" },
|
||||
),
|
||||
)
|
||||
|
||||
item(key = "blank") { Text("") }
|
||||
}
|
||||
|
||||
collapsibleSectionItem(
|
||||
title = message("process.output.output.sections.output"),
|
||||
modifier = Modifier.testTag(OutputSectionTestTags.OUTPUT_SECTION),
|
||||
isExpandedState = isOutputExpandedState,
|
||||
onToggle = { controller.toggleProcessOutput() },
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = lines,
|
||||
key = { index, _ -> index },
|
||||
) { index, line ->
|
||||
val outputColor = when (line.kind) {
|
||||
LoggedProcessLine.Kind.OUT -> Color.Unspecified
|
||||
LoggedProcessLine.Kind.ERR -> Colors.Output.ErrorText
|
||||
}
|
||||
|
||||
OutputLine(
|
||||
displayTags = isDisplayTags,
|
||||
tag = line.kind.tag.takeIf { tag ->
|
||||
lines.getOrNull(index - 1)?.kind?.tag != tag
|
||||
},
|
||||
text = line.text,
|
||||
textStyle = SpanStyle(
|
||||
color = outputColor,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
exitInfo?.also { exitInfo ->
|
||||
item(key = "exit") {
|
||||
OutputLine(
|
||||
displayTags = true,
|
||||
tag = Tag.EXIT,
|
||||
text = buildString {
|
||||
append(exitInfo.exitValue)
|
||||
|
||||
exitInfo.additionalMessageToUser?.also { message ->
|
||||
append(": ")
|
||||
append(message)
|
||||
}
|
||||
},
|
||||
textStyle = SpanStyle(
|
||||
color =
|
||||
if (exitInfo.exitValue != 0) {
|
||||
Colors.Output.ErrorText
|
||||
} else {
|
||||
Color.Unspecified
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?: EmptyContainerNotice(
|
||||
text = message("process.output.output.blankMessage"),
|
||||
modifier = Modifier.testTag(OutputSectionTestTags.NOT_SELECTED_TEXT),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OutputLine(
|
||||
displayTags: Boolean,
|
||||
tag: String? = null,
|
||||
text: String,
|
||||
textStyle: SpanStyle = SpanStyle(),
|
||||
) {
|
||||
Column {
|
||||
if (tag != null) {
|
||||
LineSpacer()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(end = scrollbarContentSafePadding()),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
DisableSelection {
|
||||
val padding = Tag.maxLength + 3
|
||||
|
||||
Text(
|
||||
text =
|
||||
if (displayTags && tag != null) {
|
||||
"$tag:".padStart(padding, ' ')
|
||||
} else {
|
||||
" ".repeat(padding)
|
||||
},
|
||||
style = JewelTheme.consoleTextStyle,
|
||||
fontWeight = FontWeight.Thin,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(style = textStyle) {
|
||||
append(text)
|
||||
}
|
||||
},
|
||||
style = JewelTheme.consoleTextStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.weight(1f),
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.collapsibleSectionItem(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isExpandedState: State<Boolean>,
|
||||
onToggle: () -> Unit,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
item(key = "collapsibleSection $title") {
|
||||
val isExpanded by isExpandedState
|
||||
|
||||
CollapsibleListSection(
|
||||
text = title,
|
||||
modifier = modifier,
|
||||
isExpanded = isExpanded,
|
||||
onToggle = onToggle,
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpandedState.value) {
|
||||
this.content()
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.infoLineItems(
|
||||
vararg infoLines: InfoLine?,
|
||||
) {
|
||||
val maxLength = infoLines.maxOfOrNull {
|
||||
when (it) {
|
||||
is InfoLine.Single -> it.key.length
|
||||
else -> 0
|
||||
}
|
||||
} ?: 0
|
||||
val padding = maxLength + 2
|
||||
|
||||
infoLines.forEach { infoLine ->
|
||||
when (infoLine) {
|
||||
is InfoLine.Single ->
|
||||
infoLineItemSingle(infoLine.key, infoLine.key, infoLine.value, padding)
|
||||
is InfoLine.Multi -> {
|
||||
infoLineItemSingle(
|
||||
infoLine.key,
|
||||
infoLine.key,
|
||||
infoLine.values.takeIf { it.isNotEmpty() }?.let { it[0] },
|
||||
padding,
|
||||
)
|
||||
|
||||
infoLine.values.drop(1).forEachIndexed { index, value ->
|
||||
infoLineItemSingle("${infoLine.key} $index", null, value, padding)
|
||||
}
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.infoLineItemSingle(
|
||||
id: Any,
|
||||
key: String?,
|
||||
value: String?,
|
||||
padding: Int,
|
||||
) {
|
||||
item(key = "infoLineItem ${id}") {
|
||||
Column {
|
||||
if (key != null) {
|
||||
LineSpacer()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(end = scrollbarContentSafePadding()),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
if (key != null) {
|
||||
"${key}:".padStart(padding)
|
||||
} else {
|
||||
" ".repeat(padding)
|
||||
},
|
||||
style = JewelTheme.consoleTextStyle,
|
||||
fontWeight = FontWeight.Thin,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = value ?: "(empty)",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = if (value == null) {
|
||||
Colors.Output.Info
|
||||
} else {
|
||||
Color.Unspecified
|
||||
},
|
||||
style = JewelTheme.consoleTextStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LineSpacer() {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
private sealed class InfoLine {
|
||||
abstract val key: String
|
||||
|
||||
data class Single(override val key: String, val value: String?) : InfoLine()
|
||||
data class Multi(override val key: String, val values: List<String?>) : InfoLine()
|
||||
}
|
||||
|
||||
private val LoggedProcessLine.Kind.tag
|
||||
get() =
|
||||
when (this) {
|
||||
LoggedProcessLine.Kind.ERR -> Tag.ERROR
|
||||
LoggedProcessLine.Kind.OUT -> Tag.OUTPUT
|
||||
}
|
||||
|
||||
internal object OutputSectionTestTags {
|
||||
const val NOT_SELECTED_TEXT = "ProcessOutput.Output.NotSelectedText"
|
||||
const val INFO_SECTION = "ProcessOutput.Output.InfoSection"
|
||||
const val OUTPUT_SECTION = "ProcessOutput.Output.OutputSection"
|
||||
const val FILTERS_TAGS = "ProcessOutput.Output.FiltersTags"
|
||||
const val FILTERS_BUTTON = "ProcessOutput.Output.FiltersButton"
|
||||
const val FILTERS_MENU = "ProcessOutput.Output.FiltersMenu"
|
||||
const val COPY_OUTPUT_BUTTON = "ProcessOutput.Output.CopyButton"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputController
|
||||
import org.jetbrains.jewel.ui.component.HorizontalSplitLayout
|
||||
import org.jetbrains.jewel.ui.component.rememberSplitLayoutState
|
||||
|
||||
@Composable
|
||||
internal fun ToolWindow(controller: ProcessOutputController) {
|
||||
HorizontalSplitLayout(
|
||||
first = { TreeSection(controller) },
|
||||
second = { OutputSection(controller) },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
firstPaneMinWidth = 300.dp,
|
||||
secondPaneMinWidth = 300.dp,
|
||||
state = rememberSplitLayoutState(.15f),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.jewel.ui.Orientation
|
||||
import org.jetbrains.jewel.ui.component.Divider
|
||||
|
||||
@Composable
|
||||
internal fun Toolbar(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(36.dp)
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp)
|
||||
.then(modifier),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
this@Column.content()
|
||||
}
|
||||
|
||||
Row {
|
||||
Divider(
|
||||
orientation = Orientation.Horizontal,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.ScrollableState
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.pointer.PointerIcon
|
||||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputBundle.message
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputController
|
||||
import com.intellij.python.processOutput.impl.TreeFilter
|
||||
import com.intellij.python.processOutput.impl.TreeNode
|
||||
import com.intellij.python.processOutput.impl.formatTime
|
||||
import com.intellij.python.processOutput.impl.ui.Colors
|
||||
import com.intellij.python.processOutput.impl.ui.Icons
|
||||
import com.intellij.python.processOutput.impl.ui.processIsBackground
|
||||
import com.intellij.python.processOutput.impl.ui.processIsError
|
||||
import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.jetbrains.jewel.foundation.ExperimentalJewelApi
|
||||
import org.jetbrains.jewel.foundation.theme.JewelTheme
|
||||
import org.jetbrains.jewel.ui.component.Icon
|
||||
import org.jetbrains.jewel.ui.component.LazyTree
|
||||
import org.jetbrains.jewel.ui.component.Text
|
||||
import org.jetbrains.jewel.ui.component.TextField
|
||||
import org.jetbrains.jewel.ui.component.Tooltip
|
||||
import org.jetbrains.jewel.ui.component.VerticallyScrollableContainer
|
||||
import org.jetbrains.jewel.ui.component.styling.LazyTreeMetrics
|
||||
import org.jetbrains.jewel.ui.component.styling.LazyTreeStyle
|
||||
import org.jetbrains.jewel.ui.component.styling.SimpleListItemMetrics
|
||||
import org.jetbrains.jewel.ui.theme.treeStyle
|
||||
|
||||
@OptIn(ExperimentalJewelApi::class)
|
||||
@Composable
|
||||
internal fun TreeSection(controller: ProcessOutputController) {
|
||||
val selectableLazyListState = remember { controller.processTreeUiState.selectableLazyListState }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
val tree by controller.processTreeUiState.tree.collectAsState()
|
||||
val filters = remember { controller.processTreeUiState.filters }
|
||||
val isTreeEmpty = remember(tree) { tree.isEmpty() }
|
||||
|
||||
TreeToolbar(
|
||||
controller = controller,
|
||||
areExpansionActionsEnabled = !isTreeEmpty,
|
||||
)
|
||||
|
||||
tree.takeIf { !it.isEmpty() }
|
||||
?.also {
|
||||
VerticallyScrollableContainer(
|
||||
scrollState = selectableLazyListState.lazyListState as ScrollableState,
|
||||
) {
|
||||
val treeState = remember { controller.processTreeUiState.treeState }
|
||||
val style = JewelTheme.treeStyle
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
LazyTree(
|
||||
tree = tree,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
treeState = treeState,
|
||||
onSelectionChange = {
|
||||
val node = it.firstOrNull()?.data
|
||||
|
||||
if (node is TreeNode.Process) {
|
||||
controller.selectProcess(node.process)
|
||||
}
|
||||
},
|
||||
style = LazyTreeStyle(
|
||||
colors = style.colors,
|
||||
metrics = LazyTreeMetrics(
|
||||
indentSize = style.metrics.indentSize,
|
||||
elementMinHeight = style.metrics.elementMinHeight,
|
||||
chevronContentGap = style.metrics.chevronContentGap,
|
||||
simpleListItemMetrics = SimpleListItemMetrics(
|
||||
innerPadding = PaddingValues(horizontal = 4.dp),
|
||||
outerPadding = PaddingValues(1.dp),
|
||||
selectionBackgroundCornerSize =
|
||||
style.metrics.simpleListItemMetrics.selectionBackgroundCornerSize,
|
||||
iconTextGap = style.metrics.simpleListItemMetrics.iconTextGap,
|
||||
),
|
||||
),
|
||||
icons = style.icons,
|
||||
),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
TreeRow(it.data, filters.contains(TreeFilter.ShowTime))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?: EmptyContainerNotice(
|
||||
text = message("process.output.tree.blankMessage"),
|
||||
modifier = Modifier.testTag(TreeSectionTestTags.EMPTY_TREE_TEXT),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TreeToolbar(
|
||||
controller: ProcessOutputController,
|
||||
areExpansionActionsEnabled: Boolean,
|
||||
) {
|
||||
val clearInteractionSource = remember { MutableInteractionSource() }
|
||||
val isClearHovered by clearInteractionSource.collectIsHoveredAsState()
|
||||
val inputState = remember { controller.processTreeUiState.searchState }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Toolbar {
|
||||
Icon(
|
||||
key = Icons.Keys.Search,
|
||||
contentDescription = message("process.output.icon.description.search"),
|
||||
)
|
||||
|
||||
TextField(
|
||||
state = inputState,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 1.dp, top = 2.dp, bottom = 2.dp)
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester),
|
||||
placeholder = { Text(message("process.output.tree.search.placeholder")) },
|
||||
undecorated = true,
|
||||
)
|
||||
|
||||
if (inputState.text.isNotBlank()) {
|
||||
Icon(
|
||||
key =
|
||||
if (isClearHovered) {
|
||||
Icons.Keys.CloseHovered
|
||||
} else {
|
||||
Icons.Keys.Close
|
||||
},
|
||||
contentDescription = message("process.output.icon.description.clear"),
|
||||
modifier = Modifier
|
||||
.pointerHoverIcon(PointerIcon.Default)
|
||||
.clickable(
|
||||
interactionSource = clearInteractionSource,
|
||||
indication = null,
|
||||
role = Role.Button,
|
||||
) {
|
||||
inputState.clearText()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
FilterActionGroup(
|
||||
tooltipText = message("process.output.viewOptions.tooltip"),
|
||||
items = persistentListOf(
|
||||
FilterEntry(
|
||||
item = TreeFilter.ShowTime,
|
||||
testTag = TreeSectionTestTags.FILTERS_TIME,
|
||||
),
|
||||
FilterEntry(
|
||||
item = TreeFilter.ShowBackgroundProcesses,
|
||||
testTag = TreeSectionTestTags.FILTERS_BACKGROUND,
|
||||
),
|
||||
),
|
||||
isSelected = { controller.processTreeUiState.filters.contains(it) },
|
||||
onItemClick = { controller.toggleTreeFilter(it) },
|
||||
modifier = Modifier.testTag(TreeSectionTestTags.FILTERS_BUTTON),
|
||||
menuModifier = Modifier.testTag(TreeSectionTestTags.FILTERS_MENU),
|
||||
)
|
||||
|
||||
ActionIconButton(
|
||||
modifier = Modifier.testTag(TreeSectionTestTags.EXPAND_ALL_BUTTON),
|
||||
iconKey = Icons.Keys.ExpandAll,
|
||||
tooltipText = message("process.output.tree.buttons.expandAll"),
|
||||
enabled = areExpansionActionsEnabled,
|
||||
onClick = { controller.expandAllContexts() },
|
||||
)
|
||||
|
||||
ActionIconButton(
|
||||
modifier = Modifier.testTag(TreeSectionTestTags.COLLAPSE_ALL_BUTTON),
|
||||
iconKey = Icons.Keys.CollapseAll,
|
||||
tooltipText = message("process.output.tree.buttons.collapseAll"),
|
||||
enabled = areExpansionActionsEnabled,
|
||||
onClick = { controller.collapseAllContexts() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun TreeRow(
|
||||
node: TreeNode,
|
||||
isTimeDisplayed: Boolean,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (node) {
|
||||
is TreeNode.Context -> {
|
||||
Tooltip(
|
||||
tooltip = {
|
||||
Row {
|
||||
Text(node.traceContext.title)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
key = Icons.Keys.Folder,
|
||||
contentDescription =
|
||||
message("process.output.icon.description.folder"),
|
||||
modifier = Modifier.testTag(
|
||||
TreeSectionTestTags.FOLDER_ITEM_ICON,
|
||||
),
|
||||
)
|
||||
|
||||
InterText(
|
||||
text = node.traceContext.title,
|
||||
modifier = Modifier.weight(1f),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is TreeNode.Process -> {
|
||||
val exitInfo by node.process.exitInfo.collectAsState()
|
||||
val isError = remember(exitInfo) {
|
||||
exitInfo?.takeIf { it.exitValue != 0 } != null
|
||||
}
|
||||
val isBackground = node.process.traceContext == NON_INTERACTIVE_ROOT_TRACE_CONTEXT
|
||||
|
||||
Tooltip(
|
||||
tooltip = {
|
||||
Row {
|
||||
Text(node.process.shortenedCommandString)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 3.dp)
|
||||
.semantics {
|
||||
processIsError = isError
|
||||
processIsBackground = isBackground
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when {
|
||||
isBackground && isError ->
|
||||
Icon(
|
||||
key = Icons.Keys.ProcessBackError,
|
||||
contentDescription =
|
||||
message("process.output.icon.description.processBackError"),
|
||||
modifier = Modifier.testTag(
|
||||
TreeSectionTestTags.PROCESS_ITEM_BACK_ERROR_ICON,
|
||||
),
|
||||
)
|
||||
isBackground ->
|
||||
Icon(
|
||||
key = Icons.Keys.ProcessBack,
|
||||
contentDescription =
|
||||
message("process.output.icon.description.processBack"),
|
||||
modifier = Modifier.testTag(
|
||||
TreeSectionTestTags.PROCESS_ITEM_BACK_ICON,
|
||||
),
|
||||
)
|
||||
isError ->
|
||||
Icon(
|
||||
key = Icons.Keys.ProcessError,
|
||||
contentDescription =
|
||||
message("process.output.icon.description.processError"),
|
||||
modifier = Modifier.testTag(
|
||||
TreeSectionTestTags.PROCESS_ITEM_ERROR_ICON,
|
||||
),
|
||||
)
|
||||
else ->
|
||||
Icon(
|
||||
key = Icons.Keys.Process,
|
||||
contentDescription =
|
||||
message("process.output.icon.description.process"),
|
||||
modifier = Modifier.testTag(
|
||||
TreeSectionTestTags.PROCESS_ITEM_ICON,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
InterText(
|
||||
text = node.process.shortenedCommandString,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val instant = when (node) {
|
||||
is TreeNode.Context -> Instant.fromEpochMilliseconds(node.traceContext.timestamp)
|
||||
is TreeNode.Process -> node.process.startedAt
|
||||
}
|
||||
|
||||
if (isTimeDisplayed) {
|
||||
InterText(
|
||||
text = instant.formatTime(),
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
color = Colors.Tree.Info,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal object TreeSectionTestTags {
|
||||
const val EMPTY_TREE_TEXT = "ProcessOutput.Tree.EmptyTreeText"
|
||||
const val PROCESS_ITEM_BACK_ERROR_ICON = "ProcessOutput.Tree.ProcessItemBackErrorIcon"
|
||||
const val PROCESS_ITEM_BACK_ICON = "ProcessOutput.Tree.ProcessItemBackIcon"
|
||||
const val PROCESS_ITEM_ERROR_ICON = "ProcessOutput.Tree.ProcessItemErrorIcon"
|
||||
const val PROCESS_ITEM_ICON = "ProcessOutput.Tree.ProcessItemIcon"
|
||||
const val FOLDER_ITEM_ICON = "ProcessOutput.Tree.FolderItemIcon"
|
||||
const val EXPAND_ALL_BUTTON = "ProcessOutput.Tree.ExpandAllButton"
|
||||
const val COLLAPSE_ALL_BUTTON = "ProcessOutput.Tree.CollapseAllButton"
|
||||
const val FILTERS_BUTTON = "ProcessOutput.Tree.FiltersButton"
|
||||
const val FILTERS_MENU = "ProcessOutput.Tree.FiltersMenu"
|
||||
const val FILTERS_BACKGROUND = "ProcessOutput.Tree.FiltersBackground"
|
||||
const val FILTERS_TIME = "ProcessOutput.Tree.FiltersTime"
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.intellij.python.processOutput.impl.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerIcon
|
||||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import java.awt.Cursor
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
internal inline fun <T> Modifier.thenIfNotNull(
|
||||
nullable: T?,
|
||||
action: Modifier.(T) -> Modifier,
|
||||
): Modifier =
|
||||
nullable?.let { action(it) } ?: this
|
||||
|
||||
internal fun Modifier.expandable(
|
||||
interactionSource: MutableInteractionSource,
|
||||
onToggle: () -> Unit,
|
||||
): Modifier =
|
||||
this.clickable(
|
||||
indication = null,
|
||||
interactionSource = interactionSource,
|
||||
onClick = { onToggle() },
|
||||
)
|
||||
.hoverable(interactionSource)
|
||||
.pointerHoverIcon(
|
||||
PointerIcon(
|
||||
Cursor.getPredefinedCursor(Cursor.HAND_CURSOR),
|
||||
),
|
||||
)
|
||||
|
||||
internal fun <T> MutableSet<T>.toggle(value: T) {
|
||||
if (contains(value)) {
|
||||
remove(value)
|
||||
} else {
|
||||
add(value)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun <T> SharedFlow<T>.collectReplayAsState(): State<List<T>> {
|
||||
val data = remember(this) { mutableStateOf(replayCache) }
|
||||
|
||||
LaunchedEffect(this) {
|
||||
collect { _ ->
|
||||
data.value = replayCache
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.intellij.python.processOutput.impl.ui
|
||||
|
||||
import androidx.compose.ui.semantics.SemanticsPropertyKey
|
||||
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
|
||||
|
||||
internal val ProcessIsErrorKey = SemanticsPropertyKey<Boolean>("ProcessIsError")
|
||||
internal var SemanticsPropertyReceiver.processIsError by ProcessIsErrorKey
|
||||
|
||||
internal val ProcessIsBackgroundKey = SemanticsPropertyKey<Boolean>("ProcessIsBackground")
|
||||
internal var SemanticsPropertyReceiver.processIsBackground by ProcessIsBackgroundKey
|
||||
|
||||
internal val IsExpanded = SemanticsPropertyKey<Boolean>("IsExpanded")
|
||||
internal var SemanticsPropertyReceiver.isExpanded by IsExpanded
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performMouseInput
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import org.jetbrains.jewel.ui.icon.IconKey
|
||||
import org.jetbrains.jewel.ui.icons.AllIconsKeys
|
||||
|
||||
internal class ActionIconButtonTest : ProcessOutputTest() {
|
||||
@BeforeTest
|
||||
fun beforeTest() {
|
||||
scaffoldTestContent {
|
||||
OutputSection(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun `tooltip should be displayed as expected`() = processOutputTest {
|
||||
val tooltipText = "test tooltip text"
|
||||
scaffold(tooltipText = tooltipText)
|
||||
|
||||
// no text displayed at first
|
||||
onAllNodesWithText(tooltipText).assertCountEquals(0)
|
||||
|
||||
// hovering over the button
|
||||
onNodeWithTag(ActionIconButtonTestTags.BUTTON).performMouseInput {
|
||||
moveTo(Offset(5f, 5f))
|
||||
}
|
||||
|
||||
// tooltip should be displayed
|
||||
waitUntilAtLeastOneExists(hasText(tooltipText))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled true should propagate`() = processOutputTest {
|
||||
scaffold(enabled = true)
|
||||
|
||||
// button should be enabled
|
||||
onNodeWithTag(ActionIconButtonTestTags.BUTTON).assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled false should propagate`() = processOutputTest {
|
||||
scaffold(enabled = false)
|
||||
|
||||
// button should be enabled
|
||||
onNodeWithTag(ActionIconButtonTestTags.BUTTON).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `icon should be displayed`() = processOutputTest {
|
||||
scaffold()
|
||||
|
||||
// icon should be displayed
|
||||
onAllNodesWithTag(
|
||||
ActionIconButtonTestTags.ICON,
|
||||
useUnmergedTree = true,
|
||||
).assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dropdown icon should not be displayed when not a dropdown`() = processOutputTest {
|
||||
scaffold(isDropdown = false)
|
||||
|
||||
// dropdown icon should not be displayed
|
||||
onAllNodesWithTag(
|
||||
ActionIconButtonTestTags.DROPDOWN_ICON,
|
||||
useUnmergedTree = true,
|
||||
).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dropdown icon should be displayed when dropdown is true`() = processOutputTest {
|
||||
scaffold(isDropdown = true)
|
||||
|
||||
// dropdown icon should be displayed
|
||||
onAllNodesWithTag(
|
||||
ActionIconButtonTestTags.DROPDOWN_ICON,
|
||||
useUnmergedTree = true,
|
||||
).assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onClick should be triggered when the button is clicked`() = processOutputTest {
|
||||
var clicks = 0
|
||||
scaffold(onClick = { clicks += 1 })
|
||||
|
||||
// no clicks should have been made at first
|
||||
assertEquals(0, clicks)
|
||||
|
||||
// performing a click
|
||||
onNodeWithTag(ActionIconButtonTestTags.BUTTON).performClick()
|
||||
|
||||
// should have been called once
|
||||
assertEquals(1, clicks)
|
||||
}
|
||||
|
||||
private fun scaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
iconKey: IconKey = AllIconsKeys.General.Menu,
|
||||
tooltipText: String = "tooltip",
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit = {},
|
||||
iconModifier: Modifier = Modifier,
|
||||
isDropdown: Boolean = false,
|
||||
) {
|
||||
scaffoldTestContent {
|
||||
ActionIconButton(
|
||||
modifier = modifier,
|
||||
iconKey = iconKey,
|
||||
tooltipText = tooltipText,
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
iconModifier = iconModifier,
|
||||
isDropdown = isDropdown,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputTest
|
||||
import com.intellij.python.processOutput.impl.ui.IsExpanded
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
internal class CollapsibleListSectionTest : ProcessOutputTest() {
|
||||
@Test
|
||||
fun `section text should be correctly displayed`() = processOutputTest {
|
||||
scaffold(text = "some section text")
|
||||
|
||||
// section text should be correct
|
||||
onAllNodesWithText("some section text").assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collapsed section should have correct icon and semantics`() = processOutputTest {
|
||||
scaffold(isExpanded = false)
|
||||
|
||||
// should have isExpanded semantics set to false
|
||||
onNodeWithText(DEFAULT_SECTION_TEXT)
|
||||
.assert(SemanticsMatcher.expectValue(IsExpanded, false))
|
||||
|
||||
// icon should be chevron right
|
||||
onAllNodesWithTag(CollapsibleListSectionTestTags.CHEVRON_RIGHT, useUnmergedTree = true)
|
||||
.assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expanded section should have correct icon and semantics`() = processOutputTest {
|
||||
scaffold(isExpanded = true)
|
||||
|
||||
// should have isExpanded semantics set to true
|
||||
onNodeWithText(DEFAULT_SECTION_TEXT)
|
||||
.assert(SemanticsMatcher.expectValue(IsExpanded, true))
|
||||
|
||||
// icon should be chevron down
|
||||
onAllNodesWithTag(CollapsibleListSectionTestTags.CHEVRON_DOWN, useUnmergedTree = true)
|
||||
.assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onToggle should be triggered on click`() = processOutputTest {
|
||||
var clicks = 0
|
||||
scaffold { clicks += 1 }
|
||||
|
||||
// no calls should have been made at first
|
||||
assertEquals(0, clicks)
|
||||
|
||||
// click on the section
|
||||
onNodeWithText(DEFAULT_SECTION_TEXT).performClick()
|
||||
|
||||
// should have been called exactly once
|
||||
assertEquals(1, clicks)
|
||||
}
|
||||
|
||||
private fun scaffold(
|
||||
text: String = DEFAULT_SECTION_TEXT,
|
||||
modifier: Modifier = Modifier.testTag(""),
|
||||
isExpanded: Boolean = false,
|
||||
onToggle: () -> Unit = {},
|
||||
) {
|
||||
scaffoldTestContent {
|
||||
Box(modifier = Modifier.size(256.dp)) {
|
||||
CollapsibleListSection(
|
||||
text = text,
|
||||
modifier = modifier,
|
||||
isExpanded = isExpanded,
|
||||
onToggle = onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_SECTION_TEXT = "section text"
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputTest
|
||||
import kotlin.test.Test
|
||||
|
||||
internal class EmptyContainerNoticeTest : ProcessOutputTest() {
|
||||
@Test
|
||||
fun `should display correct text`() = processOutputTest {
|
||||
scaffold()
|
||||
|
||||
// text should be correct
|
||||
onAllNodesWithText(DEFAULT_NOTICE_TEXT).assertCountEquals(1)
|
||||
}
|
||||
|
||||
private fun scaffold(
|
||||
text: String = DEFAULT_NOTICE_TEXT,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
scaffoldTestContent {
|
||||
Box(modifier = Modifier.size(256.dp)) {
|
||||
EmptyContainerNotice(
|
||||
text = text,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_NOTICE_TEXT = "empty container notice text"
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.mutableStateSetOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateSet
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.click
|
||||
import androidx.compose.ui.test.filter
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onSiblings
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performMouseInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
internal class FilterActionGroupTest : ProcessOutputTest() {
|
||||
@Test
|
||||
fun `filters menu expands when clicked`() = processOutputTest {
|
||||
scaffold()
|
||||
|
||||
// menu is not displayed at first
|
||||
onAllNodesWithTag(TestTags.MENU, useUnmergedTree = true).assertCountEquals(0)
|
||||
|
||||
// clicking the view filters button
|
||||
onNodeWithTag(TestTags.BUTTON).performClick()
|
||||
|
||||
// menu is now visible
|
||||
onAllNodesWithTag(TestTags.MENU, useUnmergedTree = true).assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `view filters menu disappears when clicked outside`() = processOutputTest {
|
||||
scaffold()
|
||||
|
||||
// clicking the view filters button
|
||||
onNodeWithTag(TestTags.BUTTON).performClick()
|
||||
|
||||
// menu is visible
|
||||
onAllNodesWithTag(TestTags.MENU).assertCountEquals(1)
|
||||
|
||||
// click outside the menu (-10f on both coordinates relative to top left corner)
|
||||
onNodeWithTag(TestTags.MENU).performMouseInput {
|
||||
click(position = Offset(-10f, -10f))
|
||||
}
|
||||
|
||||
// menu is now invisible
|
||||
onAllNodesWithTag(TestTags.MENU).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `view filters menu doesn't disappear when clicked inside`() = processOutputTest {
|
||||
scaffold()
|
||||
|
||||
// clicking the view filters button
|
||||
onNodeWithTag(TestTags.BUTTON).performClick()
|
||||
|
||||
// menu is visible
|
||||
onAllNodesWithTag(TestTags.MENU).assertCountEquals(1)
|
||||
|
||||
// click inside the menu (+10f on both coordinates relative to top left corner)
|
||||
onNodeWithTag(TestTags.MENU).performMouseInput {
|
||||
click(position = Offset(10f, 10f))
|
||||
}
|
||||
|
||||
// menu is still visible
|
||||
onAllNodesWithTag(TestTags.MENU).assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `view filters buttons call their respective functions`() = processOutputTest {
|
||||
val clicks = mutableMapOf(
|
||||
TestFilter.Option1 to 0,
|
||||
TestFilter.Option2 to 0,
|
||||
)
|
||||
scaffold(
|
||||
onItemClick = {
|
||||
clicks[it] = clicks[it]!!.plus(1)
|
||||
},
|
||||
)
|
||||
|
||||
// clicking the view filters button
|
||||
onNodeWithTag(TestTags.BUTTON).performClick()
|
||||
|
||||
// no menu buttons were clicked, no functions were called
|
||||
assertEquals(0, clicks[TestFilter.Option1])
|
||||
assertEquals(0, clicks[TestFilter.Option2])
|
||||
|
||||
// clicking on option1
|
||||
onNodeWithText(
|
||||
TestFilter.Option1.title,
|
||||
useUnmergedTree = true,
|
||||
).performClick()
|
||||
|
||||
// option1 should have been called, but not option2
|
||||
assertEquals(1, clicks[TestFilter.Option1])
|
||||
assertEquals(0, clicks[TestFilter.Option2])
|
||||
|
||||
// clicking on option2
|
||||
onNodeWithText(
|
||||
TestFilter.Option2.title,
|
||||
useUnmergedTree = true,
|
||||
).performClick()
|
||||
|
||||
// both filters should have been called
|
||||
assertEquals(1, clicks[TestFilter.Option1])
|
||||
assertEquals(1, clicks[TestFilter.Option2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `view filters buttons include checked icon when selected`() = processOutputTest {
|
||||
val selected = mutableStateSetOf<TestFilter>(TestFilter.Option2)
|
||||
scaffold(selectedItems = selected)
|
||||
|
||||
// clicking the view filters button
|
||||
onNodeWithTag(TestTags.BUTTON).performClick()
|
||||
|
||||
// option1 disabled, option2 enabled
|
||||
onNodeWithText(TestFilter.Option1.title, useUnmergedTree = true)
|
||||
.onSiblings()
|
||||
.filter(hasTestTag(FilterActionGroupTestTags.CHECKED_ICON))
|
||||
.assertCountEquals(0)
|
||||
|
||||
onNodeWithText(TestFilter.Option2.title, useUnmergedTree = true)
|
||||
.onSiblings()
|
||||
.filter(hasTestTag(FilterActionGroupTestTags.CHECKED_ICON))
|
||||
.assertCountEquals(1)
|
||||
|
||||
// enabling option1, disabling option2
|
||||
selected.remove(TestFilter.Option2)
|
||||
selected.add(TestFilter.Option1)
|
||||
|
||||
// option1 enabled, option2 disabled
|
||||
onNodeWithText(TestFilter.Option1.title, useUnmergedTree = true)
|
||||
.onSiblings()
|
||||
.filter(hasTestTag(FilterActionGroupTestTags.CHECKED_ICON))
|
||||
.assertCountEquals(1)
|
||||
|
||||
onNodeWithText(TestFilter.Option2.title, useUnmergedTree = true)
|
||||
.onSiblings()
|
||||
.filter(hasTestTag(FilterActionGroupTestTags.CHECKED_ICON))
|
||||
.assertCountEquals(0)
|
||||
}
|
||||
|
||||
private fun scaffold(
|
||||
selectedItems: SnapshotStateSet<TestFilter> = mutableStateSetOf(),
|
||||
onItemClick: (TestFilter) -> Unit = {},
|
||||
) {
|
||||
scaffoldTestContent {
|
||||
val selected = remember { selectedItems }
|
||||
|
||||
Box(modifier = Modifier.size(256.dp).padding(16.dp)) {
|
||||
FilterActionGroup(
|
||||
tooltipText = DEFAULT_TOOLTIP_TEXT,
|
||||
items = persistentListOf(
|
||||
FilterEntry(
|
||||
item = TestFilter.Option1,
|
||||
testTag = TestTags.OPTION_1,
|
||||
),
|
||||
FilterEntry(
|
||||
item = TestFilter.Option2,
|
||||
testTag = TestTags.OPTION_2,
|
||||
),
|
||||
),
|
||||
isSelected = { selected.contains(it) },
|
||||
onItemClick = onItemClick,
|
||||
modifier = Modifier.testTag(TestTags.BUTTON),
|
||||
menuModifier = Modifier.testTag(TestTags.MENU),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private abstract class TestFilter : FilterItem {
|
||||
object Option1 : TestFilter() {
|
||||
override val title = "option1"
|
||||
}
|
||||
|
||||
object Option2 : TestFilter() {
|
||||
override val title = "option2"
|
||||
}
|
||||
}
|
||||
|
||||
private object TestTags {
|
||||
const val BUTTON = "Button"
|
||||
const val MENU = "Menu"
|
||||
const val OPTION_1 = "Option1"
|
||||
const val OPTION_2 = "Option2"
|
||||
}
|
||||
|
||||
private const val DEFAULT_TOOLTIP_TEXT = "filter action tooltip text"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputTest
|
||||
import kotlin.test.Test
|
||||
|
||||
internal class InterTextTest : ProcessOutputTest() {
|
||||
@Test
|
||||
fun `string text is properly rendered`() = processOutputTest {
|
||||
scaffoldTestContent {
|
||||
InterText(text = DEFAULT_TEXT)
|
||||
}
|
||||
|
||||
// should reflect the test text string
|
||||
onAllNodesWithText(DEFAULT_TEXT).assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotated string text is properly rendered`() = processOutputTest {
|
||||
scaffoldTestContent {
|
||||
InterText(text = AnnotatedString(DEFAULT_TEXT))
|
||||
}
|
||||
|
||||
// should reflect the test text annotated string
|
||||
onAllNodesWithText(DEFAULT_TEXT).assertCountEquals(1)
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_TEXT = "inter text"
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.intellij.python.community.execService.impl.LoggedProcess
|
||||
import com.intellij.python.processOutput.impl.OutputFilter
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputTest
|
||||
import io.mockk.called
|
||||
import io.mockk.verify
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
|
||||
internal class OutputSectionTest : ProcessOutputTest() {
|
||||
@BeforeTest
|
||||
fun beforeTest() {
|
||||
scaffoldTestContent {
|
||||
OutputSection(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `output area displays placeholder text when no process is selected`() = processOutputTest {
|
||||
// no process is selected at first, text should be shown
|
||||
onAllNodesWithTag(OutputSectionTestTags.NOT_SELECTED_TEXT).assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `action buttons are disabled when no process is selected`() = processOutputTest {
|
||||
// no process is selected at first, buttons should be disabled
|
||||
onNodeWithTag(OutputSectionTestTags.COPY_OUTPUT_BUTTON).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `action buttons are enabled when process is selected`() = processOutputTest {
|
||||
// selecting a process
|
||||
setSelectedProcess(process("process0"))
|
||||
|
||||
// process is selected, should be enabled
|
||||
onNodeWithTag(OutputSectionTestTags.COPY_OUTPUT_BUTTON).assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `action buttons call appropriate functions when clicked`() = processOutputTest {
|
||||
// selecting a process
|
||||
val testProcess = selectTestProcess()
|
||||
|
||||
// no calls at first
|
||||
verify(exactly = 0) { controllerSpy.copyOutputToClipboard(testProcess) }
|
||||
|
||||
// clicking on the section
|
||||
onNodeWithTag(OutputSectionTestTags.COPY_OUTPUT_BUTTON).performClick()
|
||||
|
||||
// one call should have been made
|
||||
verify(exactly = 1) { controllerSpy.copyOutputToClipboard(testProcess) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `view filters buttons call their respective functions`() = processOutputTest {
|
||||
// clicking the view filters button
|
||||
onNodeWithTag(OutputSectionTestTags.FILTERS_BUTTON).performClick()
|
||||
|
||||
// no menu buttons were clicked, no functions were called
|
||||
verify { controllerSpy wasNot called }
|
||||
|
||||
// clicking on tags
|
||||
onNodeWithTag(
|
||||
OutputSectionTestTags.FILTERS_TAGS,
|
||||
useUnmergedTree = true,
|
||||
).performClick()
|
||||
|
||||
// tags should have been called
|
||||
verify(exactly = 1) { controllerSpy.toggleOutputFilter(OutputFilter.ShowTags) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `info section calls appropriate function when clicked`() = processOutputTest {
|
||||
// selecting a process
|
||||
selectTestProcess()
|
||||
|
||||
// no calls at first
|
||||
verify(exactly = 0) { controllerSpy.toggleProcessInfo() }
|
||||
|
||||
// clicking on the section
|
||||
onNodeWithTag(OutputSectionTestTags.INFO_SECTION).performClick()
|
||||
|
||||
// one call should have been made
|
||||
verify(exactly = 1) { controllerSpy.toggleProcessInfo() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `output section calls appropriate function when clicked`() = processOutputTest {
|
||||
// selecting a process
|
||||
selectTestProcess()
|
||||
|
||||
// no calls at first
|
||||
verify(exactly = 0) { controllerSpy.toggleProcessOutput() }
|
||||
|
||||
// clicking on the section
|
||||
onNodeWithTag(OutputSectionTestTags.OUTPUT_SECTION).performClick()
|
||||
|
||||
// one call should have been made
|
||||
verify(exactly = 1) { controllerSpy.toggleProcessOutput() }
|
||||
}
|
||||
|
||||
private suspend fun selectTestProcess(): LoggedProcess {
|
||||
val newProcess =
|
||||
process(
|
||||
"process0",
|
||||
"arg1",
|
||||
"--flag1",
|
||||
"-f",
|
||||
cwd = "some/random/path",
|
||||
)
|
||||
|
||||
setSelectedProcess(newProcess)
|
||||
|
||||
return newProcess
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputTest
|
||||
import kotlin.test.Test
|
||||
import org.jetbrains.jewel.ui.component.Text
|
||||
|
||||
internal class ToolbarTest : ProcessOutputTest() {
|
||||
@Test
|
||||
fun `should render passed content`() = processOutputTest {
|
||||
scaffoldTestContent {
|
||||
Toolbar {
|
||||
Text(text = TEST_TEXT)
|
||||
}
|
||||
}
|
||||
|
||||
// should have rendered text with test content
|
||||
onAllNodesWithText(TEST_TEXT).assertCountEquals(1)
|
||||
}
|
||||
}
|
||||
|
||||
private const val TEST_TEXT = "some text content"
|
||||
@@ -0,0 +1,310 @@
|
||||
package com.intellij.python.processOutput.impl.ui.components
|
||||
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.filter
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.isNotEnabled
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onChildren
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onParent
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.intellij.python.processOutput.impl.ProcessOutputTest
|
||||
import com.intellij.python.processOutput.impl.TreeFilter
|
||||
import com.intellij.python.processOutput.impl.finish
|
||||
import com.intellij.python.processOutput.impl.formatTime
|
||||
import com.intellij.python.processOutput.impl.ui.ProcessIsBackgroundKey
|
||||
import com.intellij.python.processOutput.impl.ui.ProcessIsErrorKey
|
||||
import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
|
||||
import com.jetbrains.python.TraceContext
|
||||
import io.mockk.called
|
||||
import io.mockk.verify
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
internal class TreeSectionTest : ProcessOutputTest() {
|
||||
@BeforeTest
|
||||
fun beforeTest() {
|
||||
scaffoldTestContent {
|
||||
TreeSection(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tree process item labels contain correct text`() = processOutputTest {
|
||||
// adding test processes
|
||||
setTree {
|
||||
addProcess("process", "segment", "--flag1", "-f")
|
||||
addProcess("exec", "--flag", "segment2")
|
||||
}
|
||||
|
||||
// command string should be displayed
|
||||
onAllNodesWithText("process segment --flag1 -f").assertCountEquals(1)
|
||||
onAllNodesWithText("exec --flag segment2").assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tree should display contexts and its children`() = processOutputTest {
|
||||
val context = TraceContext("context")
|
||||
val subcontext = TraceContext("subcontext")
|
||||
|
||||
// adding test processes
|
||||
setTree {
|
||||
addProcess("process0")
|
||||
addProcess("process1")
|
||||
addContext(context) {
|
||||
addProcess("process2")
|
||||
addProcess("process3")
|
||||
addContext(subcontext) {
|
||||
addProcess("process4")
|
||||
addProcess("process5")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// should display process0, process1 and context, but not the rest
|
||||
listOf("process0", "process1", "context").forEach {
|
||||
onAllNodesWithText(it).assertCountEquals(1)
|
||||
}
|
||||
listOf("process2", "process3", "subcontext", "process4", "process5").forEach {
|
||||
onAllNodesWithText(it).assertCountEquals(0)
|
||||
}
|
||||
|
||||
// expanding context
|
||||
expandContext(context)
|
||||
|
||||
// should display process0, process1, context, process2, process3 and subcontext,
|
||||
// but not the rest
|
||||
listOf("process0", "process1", "context", "process2", "process3", "subcontext").forEach {
|
||||
onAllNodesWithText(it).assertCountEquals(1)
|
||||
}
|
||||
listOf("process4", "process5").forEach {
|
||||
onAllNodesWithText(it).assertCountEquals(0)
|
||||
}
|
||||
|
||||
// expanding subcontext
|
||||
expandContext(subcontext)
|
||||
awaitIdle()
|
||||
|
||||
// should display everything
|
||||
listOf(
|
||||
"process0",
|
||||
"process1",
|
||||
"context",
|
||||
"process2",
|
||||
"process3",
|
||||
"subcontext",
|
||||
"process4",
|
||||
"process5",
|
||||
).forEach {
|
||||
onAllNodesWithText(it).assertCountEquals(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tree process items should have correct semantics on error code`() = processOutputTest {
|
||||
val runningProcess = process("running")
|
||||
val successProcess = process("success").finish(0)
|
||||
val failProcess = process("fail").finish(1)
|
||||
|
||||
// adding processes
|
||||
setTree {
|
||||
addProcess(runningProcess)
|
||||
addProcess(successProcess)
|
||||
addProcess(failProcess)
|
||||
}
|
||||
|
||||
// running should have IsError semantic set to false & not display error icon
|
||||
onNodeWithText("running", useUnmergedTree = true)
|
||||
.onParent()
|
||||
.assert(SemanticsMatcher.expectValue(ProcessIsErrorKey, false))
|
||||
.onChildren()
|
||||
.filter(hasTestTag(TreeSectionTestTags.PROCESS_ITEM_ERROR_ICON))
|
||||
.assertCountEquals(0)
|
||||
|
||||
// success should have IsError semantic set to false & not display error icon
|
||||
onNodeWithText("success", useUnmergedTree = true)
|
||||
.onParent()
|
||||
.assert(SemanticsMatcher.expectValue(ProcessIsErrorKey, false))
|
||||
.onChildren()
|
||||
.filter(hasTestTag(TreeSectionTestTags.PROCESS_ITEM_ERROR_ICON))
|
||||
.assertCountEquals(0)
|
||||
|
||||
// fail should have IsError semantic set to true & display error icon
|
||||
onNodeWithText("fail", useUnmergedTree = true)
|
||||
.onParent()
|
||||
.assert(SemanticsMatcher.expectValue(ProcessIsErrorKey, true))
|
||||
.onChildren()
|
||||
.filter(hasTestTag(TreeSectionTestTags.PROCESS_ITEM_ERROR_ICON))
|
||||
.assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tree process items should display time when the filter is enabled`() = processOutputTest {
|
||||
val startingTime = Instant.parse("2023-01-02T00:00:00+00:00")
|
||||
|
||||
// adding some processes
|
||||
setTree {
|
||||
repeat(10) {
|
||||
addProcess(
|
||||
"process$it",
|
||||
startedAt = startingTime + it.minutes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// should display time by default
|
||||
repeat(10) {
|
||||
onAllNodesWithText((startingTime + it.minutes).formatTime()).assertCountEquals(1)
|
||||
}
|
||||
|
||||
// turn off display time filter
|
||||
toggleTreeFilter(TreeFilter.ShowTime)
|
||||
|
||||
// should not display time when the filter is turned off
|
||||
repeat(10) {
|
||||
onAllNodesWithText((startingTime + it.minutes).formatTime()).assertCountEquals(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tree background process items should have correct semantic`() = processOutputTest {
|
||||
// adding background and non-background processes
|
||||
setTree {
|
||||
repeat(5) {
|
||||
addProcess("process$it")
|
||||
}
|
||||
repeat(5) {
|
||||
addProcess(
|
||||
"background$it",
|
||||
traceContext = NON_INTERACTIVE_ROOT_TRACE_CONTEXT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// background should have IsBackground semantic, non-background processes shouldn't
|
||||
repeat(5) {
|
||||
onNodeWithText("process$it").assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
ProcessIsBackgroundKey,
|
||||
false,
|
||||
),
|
||||
)
|
||||
onNodeWithText("background$it").assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
ProcessIsBackgroundKey,
|
||||
true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tree process items should call the selectProcess() on click`() = processOutputTest {
|
||||
val processes = (0..3).map { process("process$it") }
|
||||
|
||||
// adding some processes
|
||||
setTree {
|
||||
processes.forEach {
|
||||
addProcess(it)
|
||||
}
|
||||
}
|
||||
|
||||
// no processes were clicked, no functions were called
|
||||
verify { controllerSpy wasNot called }
|
||||
|
||||
// clicking the first item
|
||||
onNodeWithText("process0").performClick()
|
||||
|
||||
// selectProcess() should have been called with the first process
|
||||
verify(exactly = 1) { controllerSpy.selectProcess(processes[0]) }
|
||||
verify(exactly = 0) { controllerSpy.selectProcess(processes[1]) }
|
||||
verify(exactly = 0) { controllerSpy.selectProcess(processes[2]) }
|
||||
|
||||
// clicking the second item
|
||||
onNodeWithText("process1").performClick()
|
||||
|
||||
// selectProcess() should have been called with the first and second process
|
||||
verify(exactly = 1) { controllerSpy.selectProcess(processes[0]) }
|
||||
verify(exactly = 1) { controllerSpy.selectProcess(processes[1]) }
|
||||
verify(exactly = 0) { controllerSpy.selectProcess(processes[2]) }
|
||||
|
||||
// clicking the third item
|
||||
onNodeWithText("process2").performClick()
|
||||
|
||||
// selectProcess() should have been called with the first and second process
|
||||
verify(exactly = 1) { controllerSpy.selectProcess(processes[0]) }
|
||||
verify(exactly = 1) { controllerSpy.selectProcess(processes[1]) }
|
||||
verify(exactly = 1) { controllerSpy.selectProcess(processes[2]) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tree expand all and collapse all buttons should be disabled when tree is empty`() =
|
||||
processOutputTest {
|
||||
// tree is empty by default; expand/collapse should be disabled
|
||||
onNodeWithTag(TreeSectionTestTags.EXPAND_ALL_BUTTON).assert(isNotEnabled())
|
||||
onNodeWithTag(TreeSectionTestTags.COLLAPSE_ALL_BUTTON).assert(isNotEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list expand all and collapse all buttons call their respective functions`() =
|
||||
processOutputTest {
|
||||
// adding some processes & enabling categorization by coroutine
|
||||
setTree {
|
||||
repeat(5) {
|
||||
addProcess("process$it")
|
||||
}
|
||||
}
|
||||
|
||||
// no buttons were clicked, no functions were called
|
||||
verify { controllerSpy wasNot called }
|
||||
|
||||
// clicking on expand
|
||||
onNodeWithTag(TreeSectionTestTags.EXPAND_ALL_BUTTON).performClick()
|
||||
|
||||
// expand should have been called, but not collapse
|
||||
verify(exactly = 1) { controllerSpy.expandAllContexts() }
|
||||
verify(exactly = 0) { controllerSpy.collapseAllContexts() }
|
||||
|
||||
// clicking on collapse
|
||||
onNodeWithTag(TreeSectionTestTags.COLLAPSE_ALL_BUTTON).performClick()
|
||||
|
||||
// expand should have been called, but not collapse
|
||||
verify(exactly = 1) { controllerSpy.expandAllContexts() }
|
||||
verify(exactly = 1) { controllerSpy.collapseAllContexts() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `view filters buttons call their respective functions`() = processOutputTest {
|
||||
// clicking the view filters button
|
||||
onNodeWithTag(TreeSectionTestTags.FILTERS_BUTTON).performClick()
|
||||
|
||||
// no menu buttons were clicked, no functions were called
|
||||
verify { controllerSpy wasNot called }
|
||||
|
||||
// clicking on categorize
|
||||
onNodeWithTag(
|
||||
TreeSectionTestTags.FILTERS_BACKGROUND,
|
||||
useUnmergedTree = true,
|
||||
).performClick()
|
||||
|
||||
// background should have been called, but not time
|
||||
verify(exactly = 1) { controllerSpy.toggleTreeFilter(TreeFilter.ShowBackgroundProcesses) }
|
||||
verify(exactly = 0) { controllerSpy.toggleTreeFilter(TreeFilter.ShowTime) }
|
||||
|
||||
// clicking on time
|
||||
onNodeWithTag(
|
||||
TreeSectionTestTags.FILTERS_TIME,
|
||||
useUnmergedTree = true,
|
||||
).performClick()
|
||||
|
||||
// both filters should have been called
|
||||
verify(exactly = 1) { controllerSpy.toggleTreeFilter(TreeFilter.ShowBackgroundProcesses) }
|
||||
verify(exactly = 1) { controllerSpy.toggleTreeFilter(TreeFilter.ShowTime) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.intellij.python.processOutput.impl
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableStateSetOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateSet
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import com.intellij.python.community.execService.impl.LoggedProcess
|
||||
import com.intellij.python.community.execService.impl.LoggedProcessExe
|
||||
import com.intellij.python.community.execService.impl.LoggedProcessExitInfo
|
||||
import com.intellij.python.processOutput.impl.ui.toggle
|
||||
import com.jetbrains.python.TraceContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.jetbrains.jewel.foundation.lazy.SelectableLazyListState
|
||||
import io.mockk.spyk
|
||||
import java.util.UUID
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jetbrains.jewel.foundation.lazy.tree.ChildrenGeneratorScope
|
||||
import org.jetbrains.jewel.foundation.lazy.tree.TreeBuilder
|
||||
import org.jetbrains.jewel.foundation.lazy.tree.TreeGeneratorScope
|
||||
import org.jetbrains.jewel.foundation.lazy.tree.TreeState
|
||||
import org.jetbrains.jewel.foundation.lazy.tree.buildTree
|
||||
import org.jetbrains.jewel.foundation.theme.LocalThemeInstanceUuid
|
||||
import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
|
||||
import org.junit.Rule
|
||||
|
||||
internal abstract class ProcessOutputTest {
|
||||
private val processTree = MutableStateFlow(buildTree<TreeNode> {})
|
||||
private val processTreeFilters: SnapshotStateSet<TreeFilter> = mutableStateSetOf(
|
||||
TreeFilter.ShowTime,
|
||||
)
|
||||
|
||||
private val processOutputFilters: SnapshotStateSet<OutputFilter> = mutableStateSetOf()
|
||||
private val processOutputInfoExpanded = MutableStateFlow(false)
|
||||
private val processOutputOutputExpanded = MutableStateFlow(true)
|
||||
|
||||
private val testSelectedProcess: MutableStateFlow<LoggedProcess?> = MutableStateFlow(null)
|
||||
private val testProcessTreeUiState: TreeUiState = run {
|
||||
val selectableLazyListState = SelectableLazyListState(LazyListState())
|
||||
TreeUiState(
|
||||
filters = processTreeFilters,
|
||||
searchState = TextFieldState(),
|
||||
selectableLazyListState = selectableLazyListState,
|
||||
treeState = TreeState(selectableLazyListState),
|
||||
tree = processTree,
|
||||
)
|
||||
}
|
||||
private val testProcessOutputUiState: OutputUiState = OutputUiState(
|
||||
filters = processOutputFilters,
|
||||
isInfoExpanded = processOutputInfoExpanded,
|
||||
isOutputExpanded = processOutputOutputExpanded,
|
||||
lazyListState = LazyListState(),
|
||||
)
|
||||
|
||||
@get:Rule
|
||||
val rule: ComposeContentTestRule = createComposeRule()
|
||||
|
||||
val controllerSpy = spyk<ProcessOutputController>()
|
||||
|
||||
val controller = object : ProcessOutputController {
|
||||
override val selectedProcess: StateFlow<LoggedProcess?> = testSelectedProcess
|
||||
override val processTreeUiState: TreeUiState = testProcessTreeUiState
|
||||
override val processOutputUiState: OutputUiState = testProcessOutputUiState
|
||||
|
||||
override fun collapseAllContexts() {
|
||||
controllerSpy.collapseAllContexts()
|
||||
}
|
||||
|
||||
override fun expandAllContexts() {
|
||||
controllerSpy.expandAllContexts()
|
||||
}
|
||||
|
||||
override fun selectProcess(process: LoggedProcess) {
|
||||
controllerSpy.selectProcess(process)
|
||||
}
|
||||
|
||||
override fun toggleTreeFilter(filter: TreeFilter) {
|
||||
controllerSpy.toggleTreeFilter(filter)
|
||||
}
|
||||
|
||||
override fun toggleOutputFilter(filter: OutputFilter) {
|
||||
controllerSpy.toggleOutputFilter(filter)
|
||||
}
|
||||
|
||||
override fun toggleProcessInfo() {
|
||||
controllerSpy.toggleProcessInfo()
|
||||
}
|
||||
|
||||
override fun toggleProcessOutput() {
|
||||
controllerSpy.toggleProcessOutput()
|
||||
}
|
||||
|
||||
override fun copyOutputToClipboard(loggedProcess: LoggedProcess) {
|
||||
controllerSpy.copyOutputToClipboard(loggedProcess)
|
||||
}
|
||||
|
||||
override fun specifyAdditionalMessageToUser(logId: Int, message: String) {
|
||||
controllerSpy.specifyAdditionalMessageToUser(logId, message)
|
||||
}
|
||||
|
||||
override fun tryOpenLogInToolWindow(logId: Int): Boolean {
|
||||
return controllerSpy.tryOpenLogInToolWindow(logId)
|
||||
}
|
||||
}
|
||||
|
||||
fun scaffoldTestContent(content: @Composable () -> Unit) {
|
||||
rule.setContent {
|
||||
CompositionLocalProvider(LocalThemeInstanceUuid provides UUID.randomUUID()) {
|
||||
IntUiTheme {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun processOutputTest(body: suspend ComposeContentTestRule.() -> Unit) =
|
||||
runTest {
|
||||
rule.body()
|
||||
}
|
||||
|
||||
fun setTree(builder: suspend TreeBuilder<TreeNode>.() -> Unit) {
|
||||
processTree.value = buildTree {
|
||||
runBlocking {
|
||||
builder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun expandContext(vararg traceContexts: TraceContext) {
|
||||
testProcessTreeUiState.treeState.openNodes(traceContexts.toList())
|
||||
}
|
||||
|
||||
fun toggleTreeFilter(filter: TreeFilter) {
|
||||
processTreeFilters.toggle(filter)
|
||||
}
|
||||
|
||||
fun setSelectedProcess(process: LoggedProcess) {
|
||||
testSelectedProcess.value = process
|
||||
}
|
||||
|
||||
fun setInfoSectionExpanded(value: Boolean) {
|
||||
processOutputInfoExpanded.value = value
|
||||
}
|
||||
|
||||
fun setOutputSectionExpanded(value: Boolean) {
|
||||
processOutputOutputExpanded.value = value
|
||||
}
|
||||
|
||||
suspend fun process(
|
||||
vararg command: String,
|
||||
traceContext: TraceContext? = null,
|
||||
startedAt: Instant = Clock.System.now(),
|
||||
cwd: String? = null,
|
||||
): LoggedProcess =
|
||||
LoggedProcess(
|
||||
traceContext = traceContext ?: TraceContext("some title"),
|
||||
pid = 123,
|
||||
startedAt = startedAt,
|
||||
cwd = cwd,
|
||||
exe = LoggedProcessExe(
|
||||
path = command.first(),
|
||||
parts = command.first().split(Regex("[/\\\\]+")),
|
||||
),
|
||||
args = command.drop(1),
|
||||
env = mapOf(),
|
||||
lines = MutableSharedFlow(),
|
||||
exitInfo = MutableStateFlow(null),
|
||||
)
|
||||
|
||||
suspend fun TreeGeneratorScope<TreeNode>.addProcess(
|
||||
vararg command: String,
|
||||
traceContext: TraceContext? = null,
|
||||
startedAt: Instant = Clock.System.now(),
|
||||
cwd: String? = null,
|
||||
) {
|
||||
addProcess(
|
||||
process(
|
||||
*command,
|
||||
traceContext = traceContext,
|
||||
startedAt = startedAt,
|
||||
cwd = cwd,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun TreeGeneratorScope<TreeNode>.addProcess(process: LoggedProcess) {
|
||||
addLeaf(TreeNode.Process(process), process)
|
||||
}
|
||||
|
||||
fun TreeGeneratorScope<TreeNode>.addContext(
|
||||
context: TraceContext,
|
||||
childrenGenerator: suspend ChildrenGeneratorScope<TreeNode>.() -> Unit,
|
||||
) {
|
||||
addNode(TreeNode.Context(context), context) {
|
||||
runBlocking {
|
||||
childrenGenerator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun LoggedProcess.finish(
|
||||
exitValue: Int,
|
||||
exitedAt: Instant = Clock.System.now(),
|
||||
): LoggedProcess {
|
||||
exitInfo.value = LoggedProcessExitInfo(
|
||||
exitedAt = exitedAt,
|
||||
exitValue = exitValue,
|
||||
)
|
||||
|
||||
return this
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="library" name="kotlin-stdlib" level="project" />
|
||||
<orderEntry type="module" module-name="intellij.platform.ide" />
|
||||
<orderEntry type="module" module-name="intellij.platform.core" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -0,0 +1,13 @@
|
||||
<idea-plugin package="com.intellij.python.processOutput">
|
||||
<dependencies>
|
||||
<module name="intellij.python.community"/>
|
||||
<module name="intellij.python.sdk"/>
|
||||
</dependencies>
|
||||
|
||||
<extensionPoints>
|
||||
<extensionPoint
|
||||
qualifiedName="com.intellij.python.processOutput.processOutputApi"
|
||||
interface="com.intellij.python.processOutput.ProcessOutputApi"
|
||||
/>
|
||||
</extensionPoints>
|
||||
</idea-plugin>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.intellij.python.processOutput
|
||||
|
||||
import com.intellij.openapi.extensions.ExtensionPointName
|
||||
import com.intellij.openapi.project.Project
|
||||
import org.jetbrains.annotations.Nls
|
||||
|
||||
interface ProcessOutputApi {
|
||||
companion object {
|
||||
private val EP_NAME = ExtensionPointName<ProcessOutputApi>("com.intellij.python.processOutput.processOutputApi")
|
||||
|
||||
fun getInstance(): ProcessOutputApi? =
|
||||
EP_NAME.extensionList.firstOrNull()
|
||||
}
|
||||
|
||||
fun specifyAdditionalMessageToUser(project: Project, logId: Int, text: @Nls String)
|
||||
|
||||
fun tryOpenLogInToolWindow(project: Project, logId: Int): Boolean
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import com.intellij.openapi.startup.ProjectActivity
|
||||
import com.intellij.platform.eel.provider.getEelDescriptor
|
||||
import com.intellij.python.community.services.systemPython.SystemPythonService
|
||||
import com.intellij.python.community.services.systemPython.getCacheTimeout
|
||||
import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val logger = fileLogger()
|
||||
|
||||
@@ -15,6 +17,8 @@ internal class SystemPythonInitialLoader : ProjectActivity {
|
||||
override suspend fun execute(project: Project) {
|
||||
if (getCacheTimeout() == null) return // Cache is disabled, no need to preload it
|
||||
logger.debug("Preloading pythons for $project")
|
||||
SystemPythonService().findSystemPythons(project.getEelDescriptor().toEelApi())
|
||||
withContext(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
|
||||
SystemPythonService().findSystemPythons(project.getEelDescriptor().toEelApi())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import com.jetbrains.python.PyToolUIInfo
|
||||
import com.intellij.python.community.services.systemPython.SystemPythonServiceImpl.MyServiceState
|
||||
import com.intellij.python.community.services.systemPython.impl.Cache
|
||||
import com.intellij.python.community.services.systemPython.impl.PySystemPythonBundle
|
||||
import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
|
||||
import com.jetbrains.python.PythonBinary
|
||||
import com.jetbrains.python.Result
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
@@ -56,7 +57,9 @@ internal class SystemPythonServiceImpl(scope: CoroutineScope) : SystemPythonServ
|
||||
scope.launch {
|
||||
_cacheImpl.complete(getCacheTimeout()?.let { interval ->
|
||||
Cache<EelDescriptor, SystemPython>(scope, interval) { eelDescriptor ->
|
||||
searchPythonsPhysicallyNoCache(eelDescriptor.toEelApi())
|
||||
withContext(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
|
||||
searchPythonsPhysicallyNoCache(eelDescriptor.toEelApi())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.intellij.openapi.ui.DialogWrapper
|
||||
import com.intellij.openapi.ui.Messages
|
||||
import com.intellij.platform.eel.provider.utils.stderrString
|
||||
import com.intellij.platform.eel.provider.utils.stdoutString
|
||||
import com.intellij.python.processOutput.ProcessOutputApi
|
||||
import com.intellij.ui.IdeBorderFactory
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.components.JBLabel
|
||||
@@ -57,6 +58,22 @@ fun showProcessExecutionErrorDialog(
|
||||
) {
|
||||
check(project == null || !project.isDisposed)
|
||||
|
||||
val logId = execError.loggedProcessId
|
||||
|
||||
if (project != null && logId != null) {
|
||||
ProcessOutputApi.getInstance()?.also { api ->
|
||||
val foundAndOpened = api.tryOpenLogInToolWindow(project, logId)
|
||||
|
||||
if (foundAndOpened) {
|
||||
execError.additionalMessageToUser?.also {
|
||||
api.specifyAdditionalMessageToUser(project, logId, it)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessageText = PyBundle.message("dialog.message.command.could.not.complete")
|
||||
// HTML format for text in `JBLabel` enables text wrapping
|
||||
val errorMessageLabel = JBLabel(UIUtil.toHtml(errorMessageText), Messages.getErrorIcon(), SwingConstants.LEFT)
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.intellij.ui.dsl.builder.Panel
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.PythonModuleTypeBase
|
||||
import com.jetbrains.python.errorProcessing.ErrorSink
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.newProjectWizard.projectPath.ProjectPathFlows
|
||||
import com.jetbrains.python.onFailure
|
||||
import com.jetbrains.python.sdk.ModuleOrProject
|
||||
@@ -122,14 +123,14 @@ class NewPythonProjectStep(parent: NewProjectWizardStep, val createPythonModuleS
|
||||
moduleOrProject.moduleIfExists?.takeIf { createPythonModuleStructure }?.let { module ->
|
||||
runWithModalProgressBlocking(project, PyBundle.message("python.sdk.creating.python.module.structure")) {
|
||||
pySdkCreator.createPythonModuleStructure(module).onFailure {
|
||||
errorSink.emit(it)
|
||||
errorSink.emit(it, project)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runWithModalProgressBlocking(project, PyBundle.message("python.sdk.creating.python.sdk")) {
|
||||
val (sdk, _) = pySdkCreator.getSdk(moduleOrProject).getOr {
|
||||
errorSink.emit(it.error)
|
||||
errorSink.emit(it.error, project)
|
||||
return@runWithModalProgressBlocking
|
||||
}
|
||||
pythonSdk = sdk
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.intellij.platform.ide.progress.withBackgroundProgress
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.Result
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.newProjectWizard.collector.PyProjectTypeGenerator
|
||||
import com.jetbrains.python.newProjectWizard.collector.PythonNewProjectWizardCollector.logPythonNewProjectGenerated
|
||||
import com.jetbrains.python.newProjectWizard.impl.PyV3GeneratorPeer
|
||||
@@ -71,7 +72,7 @@ abstract class PyV3ProjectBaseGenerator<TYPE_SPECIFIC_SETTINGS : PyV3ProjectType
|
||||
coroutineScope.launch {
|
||||
val (sdk, interpreterStatistics) = settings.generateAndGetSdk(module, baseDir, supportsNotEmptyModuleStructure).getOr {
|
||||
withContext(Dispatchers.EDT) {
|
||||
uiServices.errorSink.emit(it.error)
|
||||
uiServices.errorSink.emit(it.error, project)
|
||||
}
|
||||
return@launch // Since we failed to generate a project, we do not need to go any further
|
||||
}
|
||||
@@ -94,7 +95,7 @@ abstract class PyV3ProjectBaseGenerator<TYPE_SPECIFIC_SETTINGS : PyV3ProjectType
|
||||
uiServices.expandProjectTreeView(project)
|
||||
withBackgroundProgress(project, PyBundle.message("python.project.model.progress.title.generating"), cancellable = true) {
|
||||
typeSpecificSettings.generateProject(module, baseDir, sdk).onFailure {
|
||||
uiServices.errorSink.emit(it)
|
||||
uiServices.errorSink.emit(it, project)
|
||||
}
|
||||
refreshPaths(project, sdk, "PyV3ProjectBaseGenerator.generateProject")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.jetbrains.python.packaging.management
|
||||
|
||||
import com.intellij.openapi.progress.runBlockingMaybeCancellable
|
||||
import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
import com.jetbrains.python.packaging.PyPackageName
|
||||
@@ -15,6 +16,7 @@ import com.jetbrains.python.packaging.pyRequirementVersionSpec
|
||||
import com.jetbrains.python.packaging.repository.PyPackageRepository
|
||||
import com.jetbrains.python.packaging.requirement.PyRequirementRelation
|
||||
import com.jetbrains.python.packaging.requirement.PyRequirementVersionSpec
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
|
||||
|
||||
@@ -28,7 +30,9 @@ fun PythonPackageManager.waitInitBlocking() {
|
||||
@ApiStatus.Internal
|
||||
fun PythonPackageManager.reloadPackagesBlocking() {
|
||||
runBlockingMaybeCancellable {
|
||||
reloadPackages().orThrow()
|
||||
withContext(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
|
||||
reloadPackages().orThrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.intellij.ui.awt.RelativePoint
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.jetbrains.python.errorProcessing.ErrorSink
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.getOrNull
|
||||
import com.jetbrains.python.onFailure
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -25,9 +26,8 @@ internal object PythonPackageManagerUIHelpers {
|
||||
@NlsContexts.ProgressTitle title: String,
|
||||
operation: suspend (() -> PyResult<T>?),
|
||||
): T? = withBackgroundProgress(project = project, title, cancellable = true) {
|
||||
runPackagingOperationMaybeShowErrorDialog(errorSink) {
|
||||
runPackagingOperationMaybeShowErrorDialog(errorSink, project) {
|
||||
withContext(Dispatchers.Default) {
|
||||
|
||||
operation()
|
||||
}
|
||||
}
|
||||
@@ -35,11 +35,12 @@ internal object PythonPackageManagerUIHelpers {
|
||||
|
||||
private suspend fun <T> runPackagingOperationMaybeShowErrorDialog(
|
||||
errorSink: ErrorSink?,
|
||||
project: Project,
|
||||
operation: suspend (() -> PyResult<T>?),
|
||||
): T? {
|
||||
val pyResult = operation() ?: return null
|
||||
return pyResult.onFailure {
|
||||
errorSink?.emit(it)
|
||||
errorSink?.emit(it, project)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ import com.intellij.openapi.roots.ModuleRootEvent
|
||||
import com.intellij.openapi.roots.ModuleRootListener
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.openapi.vfs.VirtualFileManager
|
||||
import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.PyBundle.message
|
||||
import com.jetbrains.python.TraceContext
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
import com.jetbrains.python.getOrNull
|
||||
import com.jetbrains.python.packaging.PyPackageName
|
||||
@@ -197,36 +200,42 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou
|
||||
}
|
||||
|
||||
suspend fun installPackage(installRequest: PythonPackageInstallRequest, options: List<String> = emptyList()) {
|
||||
PythonPackagesToolwindowStatisticsCollector.installPackageEvent.log(project)
|
||||
managerUI.installPackagesRequestBackground(installRequest, options)?.let {
|
||||
handleActionCompleted(
|
||||
text = message("python.packaging.notification.installed", installRequest.title),
|
||||
displayId = PYTHON_PACKAGE_INSTALLED
|
||||
)
|
||||
withContext(TraceContext(message("tracecontext.packaging.tool.window.install"))) {
|
||||
PythonPackagesToolwindowStatisticsCollector.installPackageEvent.log(project)
|
||||
managerUI.installPackagesRequestBackground(installRequest, options)?.let {
|
||||
handleActionCompleted(
|
||||
text = message("python.packaging.notification.installed", installRequest.title),
|
||||
displayId = PYTHON_PACKAGE_INSTALLED
|
||||
)
|
||||
}
|
||||
toolWindowPanel?.clearFocus()
|
||||
}
|
||||
toolWindowPanel?.clearFocus()
|
||||
}
|
||||
|
||||
suspend fun installPackage(pkg: PythonPackage, options: List<String> = emptyList()) {
|
||||
val installRequest = manager.findPackageSpecification(pkg.name, pkg.version)?.toInstallRequest() ?: return
|
||||
PythonPackagesToolwindowStatisticsCollector.installPackageEvent.log(project)
|
||||
managerUI.installPackagesRequestBackground(installRequest, options)?.let {
|
||||
handleActionCompleted(
|
||||
text = message("python.packaging.notification.installed", installRequest.title),
|
||||
displayId = PYTHON_PACKAGE_INSTALLED
|
||||
)
|
||||
withContext(TraceContext(message("tracecontext.packaging.tool.window.install"))) {
|
||||
val installRequest = manager.findPackageSpecification(pkg.name, pkg.version)?.toInstallRequest() ?: return@withContext
|
||||
PythonPackagesToolwindowStatisticsCollector.installPackageEvent.log(project)
|
||||
managerUI.installPackagesRequestBackground(installRequest, options)?.let {
|
||||
handleActionCompleted(
|
||||
text = message("python.packaging.notification.installed", installRequest.title),
|
||||
displayId = PYTHON_PACKAGE_INSTALLED
|
||||
)
|
||||
}
|
||||
toolWindowPanel?.clearFocus()
|
||||
}
|
||||
toolWindowPanel?.clearFocus()
|
||||
}
|
||||
|
||||
suspend fun deletePackage(vararg selectedPackages: InstalledPackage) {
|
||||
PythonPackagesToolwindowStatisticsCollector.uninstallPackageEvent.log(project)
|
||||
managerUI.uninstallPackagesBackground(selectedPackages.map { it.instance.name }) ?: return
|
||||
handleActionCompleted(
|
||||
text = message("python.packaging.notification.deleted", selectedPackages.joinToString(", ") { it.name }),
|
||||
displayId = PYTHON_PACKAGE_DELETED
|
||||
)
|
||||
toolWindowPanel?.clearFocus()
|
||||
withContext(TraceContext(message("tracecontext.packaging.tool.window.delete"))) {
|
||||
PythonPackagesToolwindowStatisticsCollector.uninstallPackageEvent.log(project)
|
||||
managerUI.uninstallPackagesBackground(selectedPackages.map { it.instance.name }) ?: return@withContext
|
||||
handleActionCompleted(
|
||||
text = message("python.packaging.notification.deleted", selectedPackages.joinToString(", ") { it.name }),
|
||||
displayId = PYTHON_PACKAGE_DELETED
|
||||
)
|
||||
toolWindowPanel?.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
@@ -257,20 +266,23 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou
|
||||
toolWindowPanel?.setEmpty()
|
||||
}
|
||||
}
|
||||
refreshInstalledPackages()
|
||||
|
||||
withContext(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
|
||||
refreshInstalledPackages()
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeToChanges() {
|
||||
val connection = project.messageBus.connect(this)
|
||||
connection.subscribe(PythonPackageManager.PACKAGE_MANAGEMENT_TOPIC, object : PythonPackageManagementListener {
|
||||
override fun packagesChanged(sdk: Sdk) {
|
||||
if (currentSdk == sdk) serviceScope.launch(Dispatchers.Main) {
|
||||
if (currentSdk == sdk) serviceScope.launch(Dispatchers.Main + NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
|
||||
refreshInstalledPackages()
|
||||
}
|
||||
}
|
||||
|
||||
override fun outdatedPackagesChanged(sdk: Sdk) {
|
||||
if (currentSdk == sdk) serviceScope.launch(Dispatchers.Main) {
|
||||
if (currentSdk == sdk) serviceScope.launch(Dispatchers.Main + NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
|
||||
refreshInstalledPackages()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.intellij.ui.ScrollPaneFactory
|
||||
import com.intellij.ui.SimpleListCellRenderer
|
||||
import com.intellij.ui.components.JBList
|
||||
import com.intellij.util.asDisposable
|
||||
import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.TraceContext
|
||||
import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
|
||||
@@ -39,8 +40,8 @@ internal class PyPackagesSdkController(private val project: Project) : Disposabl
|
||||
|
||||
private val packagingScope: CoroutineScope = PyPackageCoroutine.getScope(project)
|
||||
.childScope("Packages SDK Controller", TraceContext(PyBundle.message("tracecontext.packages.sdk.controller"), null)).also {
|
||||
Disposer.register(this, it.asDisposable())
|
||||
}
|
||||
Disposer.register(this, it.asDisposable())
|
||||
}
|
||||
|
||||
private val toolWindowService: PyPackagingToolWindowService
|
||||
get() = project.service<PyPackagingToolWindowService>()
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
|
||||
import com.intellij.webcore.packaging.PackagesNotificationPanel
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.isCondaVirtualEnv
|
||||
import com.jetbrains.python.isVirtualEnv
|
||||
import com.jetbrains.python.packaging.ui.PyPackageManagementService
|
||||
@@ -332,7 +333,7 @@ suspend fun PyDetectedSdk.setupSdk(
|
||||
doAssociate: Boolean,
|
||||
) {
|
||||
val newSdk = setupAssociated(existingSdks, module.basePath, doAssociate).getOr {
|
||||
ShowingMessageErrorSync.emit(it.error)
|
||||
ShowingMessageErrorSync.emit(it.error, module.project)
|
||||
return
|
||||
}
|
||||
withContext(Dispatchers.EDT) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.jetbrains.python.PyBundle.message
|
||||
import com.jetbrains.python.Result
|
||||
import com.jetbrains.python.errorProcessing.ErrorSink
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.newProject.collector.InterpreterStatisticsInfo
|
||||
import com.jetbrains.python.sdk.*
|
||||
import com.jetbrains.python.sdk.flavors.PythonSdkFlavor
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.intellij.openapi.projectRoots.Sdk
|
||||
import com.intellij.openapi.util.io.toNioPathOrNull
|
||||
import com.jetbrains.python.Result
|
||||
import com.jetbrains.python.errorProcessing.ErrorSink
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.sdk.ModuleOrProject
|
||||
import com.jetbrains.python.sdk.add.collector.PythonNewInterpreterAddedCollector
|
||||
import com.jetbrains.python.sdk.rootManager
|
||||
@@ -39,7 +40,7 @@ class PythonAddLocalInterpreterPresenter(val moduleOrProject: ModuleOrProject, v
|
||||
suspend fun okClicked(addEnvironment: PythonAddEnvironment<PathHolder.Eel>) {
|
||||
when (val r = addEnvironment.getOrCreateSdkWithModal(moduleOrProject)) {
|
||||
is Result.Failure -> {
|
||||
errorSink.emit(r.error)
|
||||
errorSink.emit(r.error, moduleOrProject.project)
|
||||
return
|
||||
}
|
||||
is Result.Success -> {
|
||||
|
||||
@@ -19,6 +19,8 @@ import com.jetbrains.python.PyBundle.message
|
||||
import com.jetbrains.python.Result.Companion.success
|
||||
import com.jetbrains.python.errorProcessing.ErrorSink
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.getOrNull
|
||||
import com.jetbrains.python.newProjectWizard.projectPath.ProjectPathFlows
|
||||
import com.jetbrains.python.psi.LanguageLevel
|
||||
import com.jetbrains.python.sdk.PySdkToInstall
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.PyBundle.message
|
||||
import com.jetbrains.python.errorProcessing.ErrorSink
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.onFailure
|
||||
import com.jetbrains.python.parser.icons.PythonParserIcons
|
||||
import com.jetbrains.python.sdk.add.v2.PythonInterpreterSelectionMethod.CREATE_NEW
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.jetbrains.python.conda.loadLocalPythonCondaPath
|
||||
import com.jetbrains.python.conda.saveLocalPythonCondaPath
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
import com.jetbrains.python.errorProcessing.asPythonResult
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.getOrThrow
|
||||
import com.jetbrains.python.onFailure
|
||||
import com.jetbrains.python.psi.LanguageLevel
|
||||
@@ -84,7 +85,7 @@ suspend fun PyCondaCommand.createCondaSdkFromExistingEnv(
|
||||
// homePath is not required by conda, but used by lots of tools all over the code and required by CondaPathFix
|
||||
// Because homePath is not set yet, CondaPathFix does not work
|
||||
sdkModificator.homePath = getCondaPythonBinaryPath(project, condaEnv, targetConfig).onFailure {
|
||||
ShowingMessageErrorSync.emit(it)
|
||||
ShowingMessageErrorSync.emit(it, project)
|
||||
}.getOrThrow()
|
||||
edtWriteAction {
|
||||
sdkModificator.commitChanges()
|
||||
|
||||
@@ -18,6 +18,8 @@ import com.intellij.openapi.wm.ex.WelcomeScreenProjectProvider
|
||||
import com.intellij.platform.ide.progress.withBackgroundProgress
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.PythonPluginDisposable
|
||||
import com.jetbrains.python.errorProcessing.PyResult
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.packaging.utils.PyPackageCoroutine
|
||||
import com.jetbrains.python.sdk.PySdkPopupFactory
|
||||
import com.jetbrains.python.sdk.configuration.suppressors.PyInterpreterInspectionSuppressor
|
||||
@@ -48,7 +50,7 @@ object PyProjectSdkConfiguration {
|
||||
thisLogger().debug("Configuring sdk using ${createSdkInfo.toolInfo.toolName}")
|
||||
|
||||
val sdk = createSdkInfo.sdkCreator(needsConfirmation).getOr {
|
||||
ShowingMessageErrorSync.emit(it.error)
|
||||
ShowingMessageErrorSync.emit(it.error, module.project)
|
||||
return@withContext true
|
||||
} ?: return@withContext false
|
||||
|
||||
@@ -106,4 +108,4 @@ object PyProjectSdkConfiguration {
|
||||
notify(project)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.intellij.openapi.util.NlsContexts
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.platform.ide.progress.withBackgroundProgress
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.onFailure
|
||||
import com.jetbrains.python.packaging.utils.PyPackageCoroutine
|
||||
import com.jetbrains.python.sdk.*
|
||||
@@ -108,7 +109,7 @@ internal class PipEnvPipFileWatcher : EditorFactoryListener {
|
||||
withBackgroundProgress(module.project, description) {
|
||||
val sdk = module.pythonSdk ?: return@withBackgroundProgress
|
||||
runPipEnv(sdk.associatedModulePath?.let { Path.of(it) }, *args.toTypedArray()).onFailure {
|
||||
ShowingMessageErrorSync.emit(it)
|
||||
ShowingMessageErrorSync.emit(it, module.project)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Default) {
|
||||
|
||||
@@ -77,7 +77,8 @@ fun canRun(
|
||||
null -> isError = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
isError = true
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.intellij.util.ui.launchOnShow
|
||||
import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.PyBundle.message
|
||||
import com.jetbrains.python.errorProcessing.ErrorSink
|
||||
import com.jetbrains.python.errorProcessing.emit
|
||||
import com.jetbrains.python.newProjectWizard.projectPath.ProjectPathFlows
|
||||
import com.jetbrains.python.onFailure
|
||||
import com.jetbrains.python.sdk.ModuleOrProject
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.jetbrains.python.PyBundle
|
||||
import com.jetbrains.python.errorProcessing.ErrorSink
|
||||
import com.jetbrains.python.errorProcessing.ExecError
|
||||
import com.jetbrains.python.errorProcessing.MessageError
|
||||
import com.jetbrains.python.errorProcessing.PyError
|
||||
import com.jetbrains.python.errorProcessing.PyErrorDetail
|
||||
import com.jetbrains.python.packaging.PyExecutionException
|
||||
import com.jetbrains.python.showProcessExecutionErrorDialog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -20,18 +20,21 @@ import org.jetbrains.annotations.ApiStatus
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
object ShowingMessageErrorSync : ErrorSink {
|
||||
override suspend fun emit(error: PyError) {
|
||||
//In unit tests dialogs are not supported
|
||||
override suspend fun emit(value: PyErrorDetail) {
|
||||
val (error, project) = value
|
||||
|
||||
// In unit tests dialogs are not supported
|
||||
if (ApplicationManager.getApplication().isUnitTestMode) {
|
||||
throw PyExecutionException(error)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.EDT + ModalityState.any().asContextElement()) {
|
||||
thisLogger().warn(error.message)
|
||||
// Platform doesn't allow dialogs without a lock for now, fix later
|
||||
writeIntentReadAction {
|
||||
when (val e = error) {
|
||||
is ExecError -> {
|
||||
showProcessExecutionErrorDialog(null, e)
|
||||
showProcessExecutionErrorDialog(project, e)
|
||||
}
|
||||
is MessageError -> {
|
||||
Messages.showErrorDialog(error.message, PyBundle.message("python.error"))
|
||||
|
||||