Skip to content

Commit c94b3b4

Browse files
committed
feat(shell): add async shell execution with progress tracking #335
- Introduce `ProcessExecutor` for handling shell command execution with coroutines. - Enhance `ShellRunService` to support async execution with progress indicators. - Update `AutoDevRunAction` to extend `AnAction` instead of `DumbAwareAction`.
1 parent 5ecf871 commit c94b3b4

File tree

3 files changed

+194
-7
lines changed

3 files changed

+194
-7
lines changed

core/src/main/kotlin/cc/unitmesh/devti/gui/snippet/AutoDevRunAction.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import cc.unitmesh.devti.provider.RunService
77
import com.intellij.ide.scratch.ScratchRootType
88
import com.intellij.json.JsonLanguage
99
import com.intellij.openapi.actionSystem.ActionUpdateThread
10+
import com.intellij.openapi.actionSystem.AnAction
1011
import com.intellij.openapi.actionSystem.AnActionEvent
1112
import com.intellij.openapi.actionSystem.PlatformDataKeys
1213
import com.intellij.openapi.application.runWriteAction
@@ -22,7 +23,7 @@ import java.io.File
2223
import java.io.IOException
2324

2425

25-
class AutoDevRunAction : DumbAwareAction(AutoDevBundle.message("autodev.run.action")) {
26+
class AutoDevRunAction : AnAction(AutoDevBundle.message("autodev.run.action")) {
2627
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
2728

2829
override fun update(e: AnActionEvent) {
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
* Copyright (C) 2020 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package cc.unitmesh.devti.language.compiler.service
17+
18+
import com.intellij.execution.configurations.PtyCommandLine
19+
import com.intellij.openapi.project.ProjectManager
20+
import com.intellij.openapi.util.text.Strings
21+
import kotlinx.coroutines.CoroutineDispatcher
22+
import kotlinx.coroutines.async
23+
import kotlinx.coroutines.ensureActive
24+
import kotlinx.coroutines.withContext
25+
import kotlinx.coroutines.yield
26+
import java.io.BufferedReader
27+
import java.io.InputStream
28+
import java.io.InputStreamReader
29+
import java.io.OutputStream
30+
import java.io.Writer
31+
import kotlin.text.Charsets.UTF_8
32+
33+
object ProcessExecutor {
34+
suspend fun exec(shellScript: String,
35+
stdWriter: Writer,
36+
errWriter: Writer,
37+
dispatcher: CoroutineDispatcher): Int = withContext(dispatcher) {
38+
val process = createProcess(shellScript)
39+
40+
41+
val errOutput = async { consumeProcessOutput(process.errorStream, errWriter, process, dispatcher) }
42+
val stdOutput = async { consumeProcessOutput(process.inputStream, stdWriter, process, dispatcher) }
43+
44+
val exitCode = async { process.waitFor() }
45+
stdOutput.await()
46+
errOutput.await()
47+
exitCode.await()
48+
}
49+
50+
/**
51+
* for share process
52+
*/
53+
fun createInteractiveProcess(): Process {
54+
val commandLine = PtyCommandLine()
55+
commandLine.withConsoleMode(false)
56+
commandLine.withUnixOpenTtyToPreserveOutputAfterTermination(true)
57+
commandLine.withInitialColumns(240)
58+
commandLine.withInitialRows(80)
59+
commandLine.withEnvironment("TERM", "dumb")
60+
commandLine.withEnvironment("BASH_SILENCE_DEPRECATION_WARNING", "1")
61+
commandLine.withEnvironment("GIT_PAGER", "cat")
62+
val commands: List<String> = listOf("bash", "--noprofile", "--norc", "-i")
63+
return commandLine.startProcessWithPty(commands)
64+
}
65+
66+
private fun createProcess(shellScript: String): Process {
67+
val basedir = ProjectManager.getInstance().openProjects.firstOrNull()?.basePath
68+
val commandLine = PtyCommandLine()
69+
commandLine.withConsoleMode(false)
70+
// commandLine.withExePath("/bin/bash")
71+
// .withParameters("-c", formatCommand(shellScript))
72+
// .withCharset(UTF_8)
73+
// .withRedirectErrorStream(true)
74+
75+
commandLine.withUnixOpenTtyToPreserveOutputAfterTermination(true)
76+
commandLine.withInitialColumns(240)
77+
commandLine.withInitialRows(80)
78+
commandLine.withEnvironment("TERM", "dumb")
79+
commandLine.withEnvironment("BASH_SILENCE_DEPRECATION_WARNING", "1")
80+
commandLine.withEnvironment("GIT_PAGER", "cat")
81+
val commands: List<String> = listOf("bash", "--noprofile", "--norc", "-c", formatCommand(shellScript))
82+
83+
84+
if (basedir != null) {
85+
commandLine.withWorkDirectory(basedir)
86+
}
87+
88+
return commandLine.startProcessWithPty(commands)
89+
}
90+
91+
92+
private fun formatCommand(command: String): String {
93+
return "{ $command; EXIT_CODE=$?; } 2>&1 && echo \"EXIT_CODE: ${'$'}EXIT_CODE\""
94+
}
95+
96+
// Feeds input lines to the process' outputStream
97+
private fun feedProcessInput(outputStream: OutputStream, inputLines: List<String>) {
98+
outputStream.writer(UTF_8).use { writer ->
99+
inputLines.forEach { line ->
100+
writer.write(line + System.lineSeparator())
101+
writer.flush()
102+
}
103+
}
104+
}
105+
106+
// Consumes output stream as the process is being executed - otherwise on Windows the process would block when the output buffer is full.
107+
private suspend fun consumeProcessOutput(source: InputStream?,
108+
outputWriter: Writer,
109+
process: Process,
110+
dispatcher: CoroutineDispatcher) = withContext(dispatcher) {
111+
if (source == null) return@withContext
112+
113+
var isFirstLine = true
114+
BufferedReader(InputStreamReader(source, UTF_8.name())).use { reader ->
115+
do {
116+
val line = reader.readLine()
117+
if (Strings.isNotEmpty(line)) {
118+
if (!isFirstLine) outputWriter.append(System.lineSeparator())
119+
isFirstLine = false
120+
outputWriter.append(line)
121+
}
122+
else {
123+
yield()
124+
}
125+
ensureActive()
126+
}
127+
while (process.isAlive || line != null)
128+
}
129+
}
130+
}

exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/service/ShellRunService.kt

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
package cc.unitmesh.devti.language.compiler.service
22

3+
import cc.unitmesh.devti.gui.AutoDevToolWindowFactory
4+
import cc.unitmesh.devti.gui.chat.message.ChatActionType
35
import cc.unitmesh.devti.provider.RunService
6+
import cc.unitmesh.devti.sketch.ui.patch.readText
47
import com.intellij.execution.RunManager
58
import com.intellij.execution.configurations.RunConfiguration
69
import com.intellij.execution.configurations.RunProfile
710
import com.intellij.openapi.application.ApplicationManager
811
import com.intellij.openapi.application.runReadAction
12+
import com.intellij.openapi.progress.ProgressIndicator
13+
import com.intellij.openapi.progress.ProgressManager
14+
import com.intellij.openapi.progress.Task
15+
import com.intellij.openapi.progress.impl.BackgroundableProcessIndicator
916
import com.intellij.openapi.project.Project
1017
import com.intellij.openapi.vfs.VirtualFile
1118
import com.intellij.psi.PsiElement
@@ -14,16 +21,44 @@ import com.intellij.sh.psi.ShFile
1421
import com.intellij.sh.run.ShConfigurationType
1522
import com.intellij.sh.run.ShRunConfiguration
1623
import com.intellij.sh.run.ShRunner
17-
import com.intellij.testFramework.LightVirtualFile
24+
import kotlinx.coroutines.CoroutineDispatcher
25+
import kotlinx.coroutines.asCoroutineDispatcher
26+
import kotlinx.coroutines.runBlocking
27+
import org.jetbrains.ide.PooledThreadExecutor
28+
import java.io.StringWriter
29+
import java.util.concurrent.CompletableFuture
30+
import java.util.concurrent.TimeUnit
1831

1932
class ShellRunService : RunService {
20-
override fun isApplicable(project: Project, file: VirtualFile): Boolean {
21-
return file.extension == "sh" || file.extension == "bash"
22-
}
33+
override fun isApplicable(project: Project, file: VirtualFile) = file.extension == "sh" || file.extension == "bash"
2334

24-
override fun runFile(project: Project, virtualFile: VirtualFile, psiElement: PsiElement?, isFromToolAction: Boolean): String? {
35+
override fun runFile(
36+
project: Project,
37+
virtualFile: VirtualFile,
38+
psiElement: PsiElement?,
39+
isFromToolAction: Boolean
40+
): String? {
2541
val workingDirectory = project.basePath ?: return "Project base path not found"
2642

43+
val code = virtualFile.readText()
44+
if (isFromToolAction) {
45+
val taskExecutor = PooledThreadExecutor.INSTANCE
46+
val future: CompletableFuture<String?> = CompletableFuture()
47+
val task = object : Task.Backgroundable(project, "Running shell command") {
48+
override fun run(indicator: ProgressIndicator) {
49+
runBlocking(taskExecutor.asCoroutineDispatcher()) {
50+
val result = executeCodeInIdeaTask(project, code, taskExecutor.asCoroutineDispatcher())
51+
future.complete(result ?: "")
52+
}
53+
}
54+
}
55+
56+
ProgressManager.getInstance()
57+
.runProcessWithProgressAsynchronously(task, BackgroundableProcessIndicator(task))
58+
59+
return future.get(120, TimeUnit.SECONDS)
60+
}
61+
2762
val shRunner = ApplicationManager.getApplication().getService(ShRunner::class.java)
2863
?: return "Shell runner not found"
2964

@@ -34,6 +69,27 @@ class ShellRunService : RunService {
3469
return "Running shell command: ${virtualFile.path}"
3570
}
3671

72+
private suspend fun executeCodeInIdeaTask(project: Project, code: String, dispatcher: CoroutineDispatcher): String? {
73+
val outputWriter = StringWriter()
74+
val errWriter = StringWriter()
75+
76+
outputWriter.use {
77+
val exitCode = ProcessExecutor.exec(code, outputWriter, errWriter, dispatcher)
78+
val stdOutput = outputWriter.toString()
79+
val errOutput = errWriter.toString()
80+
81+
return if (exitCode == 0) {
82+
stdOutput
83+
} else {
84+
AutoDevToolWindowFactory.Companion.sendToSketchToolWindow(project, ChatActionType.SKETCH) { ui, _ ->
85+
ui.sendInput(errOutput)
86+
}
87+
88+
"Error executing shell command: $errOutput"
89+
}
90+
}
91+
}
92+
3793
override fun runConfigurationClass(project: Project): Class<out RunProfile> = ShRunConfiguration::class.java
3894
override fun createConfiguration(project: Project, virtualFile: VirtualFile): RunConfiguration? {
3995
val psiFile = runReadAction {
@@ -48,4 +104,4 @@ class ShellRunService : RunService {
48104
configuration.scriptPath = virtualFile.path
49105
return configurationSetting.configuration
50106
}
51-
}
107+
}

0 commit comments

Comments
 (0)