Skip to content

Commit 98b9ba1

Browse files
authored
ADFA-3580: (feat) Plugin Build Actions & Custom Scripts System (#1150)
* feat/ADFA-3580 Plugin Build Actions & Custom Scripts System Adds first-class API for plugins to declare build actions (shell commands and Gradle tasks), execute them with streaming output to the Build Output panel, and control toolbar action visibility. Includes custom scripts support via .codeonthego/scripts.json — plugins can auto-detect project type (Node.js, Python, Rust, Go, Make, Ruby) and bootstrap user-editable run scripts on first project open. New plugin-api interfaces: BuildActionExtension, IdeCommandService, CommandExecution. New plugin-manager implementations: IdeCommandServiceImpl (ProcessBuilder-based with Termux env injection), PluginBuildActionManager (singleton orchestrator). New app integration: PluginBuildActionItem * feat/ADFA-3580 Plugin Build Actions & Custom Scripts System Adds first-class API for plugins to declare build actions (shell commands and Gradle tasks), execute them with streaming output to the Build Output panel, and control toolbar action visibility. Includes custom scripts support via .codeonthego/scripts.json — plugins can auto-detect project type (Node.js, Python, Rust, Go, Make, Ruby) and bootstrap user-editable run scripts on first project open. New plugin-api interfaces: BuildActionExtension, IdeCommandService, CommandExecution. New plugin-manager implementations: IdeCommandServiceImpl (ProcessBuilder-based with Termux env injection), PluginBuildActionManager (singleton orchestrator). New app integration: PluginBuildActionItem * fix: address CodeRabbit review findings for plugin build actions * fixes * fix: remove dead createPluginContext duplicate from PluginManager * fixes
1 parent 5133b73 commit 98b9ba1

File tree

12 files changed

+840
-155
lines changed

12 files changed

+840
-155
lines changed

app/src/main/java/com/itsaky/androidide/actions/EditorActivityAction.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ abstract class EditorActivityAction : ActionItem {
4444
super.prepare(data)
4545
if (!data.hasRequiredData(Context::class.java)) {
4646
markInvisible()
47+
return
4748
}
4849
}
4950

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package com.itsaky.androidide.actions.build
2+
3+
import android.content.Context
4+
import android.graphics.ColorFilter
5+
import android.graphics.PorterDuff
6+
import android.graphics.PorterDuffColorFilter
7+
import android.graphics.drawable.Drawable
8+
import androidx.core.content.ContextCompat
9+
import com.itsaky.androidide.actions.ActionData
10+
import com.itsaky.androidide.actions.ActionItem
11+
import com.itsaky.androidide.actions.BaseBuildAction
12+
import com.itsaky.androidide.actions.getContext
13+
import com.itsaky.androidide.plugins.extensions.CommandOutput
14+
import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager
15+
import com.itsaky.androidide.plugins.manager.build.RegisteredBuildAction
16+
import com.itsaky.androidide.plugins.manager.core.PluginManager
17+
import com.itsaky.androidide.plugins.manager.ui.PluginDrawableResolver
18+
import com.itsaky.androidide.plugins.services.IdeCommandService
19+
import com.itsaky.androidide.resources.R
20+
import com.itsaky.androidide.utils.resolveAttr
21+
import com.itsaky.androidide.viewmodel.BottomSheetViewModel
22+
import com.google.android.material.bottomsheet.BottomSheetBehavior
23+
import androidx.lifecycle.lifecycleScope
24+
import com.itsaky.androidide.activities.editor.EditorHandlerActivity
25+
import kotlinx.coroutines.CancellationException
26+
import kotlinx.coroutines.Dispatchers
27+
import kotlinx.coroutines.launch
28+
import kotlinx.coroutines.withContext
29+
30+
class PluginBuildActionItem(
31+
context: Context,
32+
private val registered: RegisteredBuildAction,
33+
override val order: Int
34+
) : BaseBuildAction() {
35+
36+
override val id: String = "plugin.build.${registered.pluginId}.${registered.action.id}"
37+
38+
init {
39+
label = registered.action.name
40+
icon = resolvePluginIcon(context)
41+
location = ActionItem.Location.EDITOR_TOOLBAR
42+
requiresUIThread = true
43+
}
44+
45+
override fun prepare(data: ActionData) {
46+
val context = data.getActivity()
47+
if (context == null) {
48+
visible = false
49+
return
50+
}
51+
visible = true
52+
53+
val manager = PluginBuildActionManager.getInstance()
54+
val isRunning = manager.isActionRunning(registered.pluginId, registered.action.id)
55+
56+
if (isRunning) {
57+
label = "Cancel ${registered.action.name}"
58+
icon = ContextCompat.getDrawable(context, R.drawable.ic_stop)
59+
enabled = true
60+
} else {
61+
label = registered.action.name
62+
icon = resolvePluginIcon(context)
63+
enabled = true
64+
}
65+
}
66+
67+
override fun createColorFilter(data: ActionData): ColorFilter? {
68+
val context = data.getContext() ?: return null
69+
val isRunning = PluginBuildActionManager.getInstance()
70+
.isActionRunning(registered.pluginId, registered.action.id)
71+
val attr = if (isRunning) R.attr.colorError else R.attr.colorOnSurface
72+
return PorterDuffColorFilter(
73+
context.resolveAttr(attr),
74+
PorterDuff.Mode.SRC_ATOP
75+
)
76+
}
77+
78+
private fun resolvePluginIcon(fallbackContext: Context): Drawable? {
79+
val iconResId = registered.action.icon ?: return ContextCompat.getDrawable(fallbackContext, R.drawable.ic_run_outline)
80+
return PluginDrawableResolver.resolve(iconResId, registered.pluginId, fallbackContext)
81+
?: ContextCompat.getDrawable(fallbackContext, R.drawable.ic_run_outline)
82+
}
83+
84+
override suspend fun execAction(data: ActionData): Any {
85+
val manager = PluginBuildActionManager.getInstance()
86+
val pluginId = registered.pluginId
87+
val actionId = registered.action.id
88+
89+
if (manager.isActionRunning(pluginId, actionId)) {
90+
manager.cancelAction(pluginId, actionId)
91+
data.getActivity()?.let { resetProgressIfIdle(it) }
92+
return true
93+
}
94+
95+
val activity = data.getActivity() ?: return false
96+
97+
val pluginManager = PluginManager.getInstance() ?: return false
98+
val loadedPlugin = pluginManager.getLoadedPlugin(pluginId) ?: return false
99+
val commandService = loadedPlugin.context.services.get(IdeCommandService::class.java)
100+
?: return false
101+
102+
val execution = manager.executeAction(pluginId, actionId, commandService) ?: return false
103+
104+
activity.editorViewModel.isBuildInProgress = true
105+
val currentSheetState = activity.bottomSheetViewModel.sheetBehaviorState
106+
val targetState = if (currentSheetState == BottomSheetBehavior.STATE_HIDDEN)
107+
BottomSheetBehavior.STATE_COLLAPSED else currentSheetState
108+
activity.bottomSheetViewModel.setSheetState(
109+
sheetState = targetState,
110+
currentTab = BottomSheetViewModel.TAB_BUILD_OUTPUT
111+
)
112+
activity.appendBuildOutput("━━━ ${registered.action.name} ━━━")
113+
activity.invalidateOptionsMenu()
114+
115+
activity.lifecycleScope.launch(Dispatchers.Default) {
116+
runCatching {
117+
execution.output.collect { output ->
118+
val line = when (output) {
119+
is CommandOutput.StdOut -> output.line
120+
is CommandOutput.StdErr -> output.line
121+
is CommandOutput.ExitCode ->
122+
if (output.code != 0) "Process failed with code ${output.code}" else null
123+
}
124+
if (line != null) {
125+
withContext(Dispatchers.Main) {
126+
activity.appendBuildOutput(line)
127+
}
128+
}
129+
}
130+
131+
val result = execution.await()
132+
manager.notifyActionCompleted(pluginId, actionId, result)
133+
withContext(Dispatchers.Main) { resetProgressIfIdle(activity) }
134+
}.onFailure { e ->
135+
if (e is CancellationException) {
136+
manager.cancelAction(pluginId, actionId)
137+
throw e
138+
}
139+
withContext(Dispatchers.Main) { resetProgressIfIdle(activity) }
140+
}
141+
}
142+
143+
return true
144+
}
145+
146+
private fun resetProgressIfIdle(activity: EditorHandlerActivity) {
147+
val manager = PluginBuildActionManager.getInstance()
148+
if (buildService?.isBuildInProgress != true && !manager.hasActiveExecutions()) {
149+
activity.editorViewModel.isBuildInProgress = false
150+
}
151+
activity.invalidateOptionsMenu()
152+
}
153+
}

app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import com.itsaky.androidide.models.OpenedFile
6363
import com.itsaky.androidide.models.OpenedFilesCache
6464
import com.itsaky.androidide.models.Range
6565
import com.itsaky.androidide.models.SaveResult
66+
import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager
6667
import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory
6768
import com.itsaky.androidide.plugins.manager.ui.PluginDrawableResolver
6869
import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager
@@ -423,12 +424,15 @@ open class EditorHandlerActivity :
423424
content.projectActionsToolbar.clearMenu()
424425

425426
val actions = getInstance().getActions(EDITOR_TOOLBAR)
427+
val hiddenIds = PluginBuildActionManager.getInstance().getHiddenActionIds()
426428
actions.onEachIndexed { index, entry ->
427429
val action = entry.value
428430
val isLast = index == actions.size - 1
429431

430432
action.prepare(data)
431433

434+
if (action.id in hiddenIds || !action.visible) return@onEachIndexed
435+
432436
action.icon?.apply {
433437
colorFilter = action.createColorFilter(data)
434438
alpha = if (action.enabled) 255 else 76

app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,13 @@ import com.itsaky.androidide.actions.filetree.RenameAction
5757
import com.itsaky.androidide.actions.text.RedoAction
5858
import com.itsaky.androidide.actions.text.UndoAction
5959
import com.itsaky.androidide.actions.PluginActionItem
60+
import com.itsaky.androidide.actions.build.PluginBuildActionItem
6061
import com.itsaky.androidide.actions.etc.GenerateXMLAction
6162
import com.itsaky.androidide.plugins.extensions.UIExtension
63+
import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager
6264
import com.itsaky.androidide.plugins.manager.core.PluginManager
6365

66+
6467
/**
6568
* Takes care of registering actions to the actions registry for the editor activity.
6669
*
@@ -104,6 +107,7 @@ class EditorActivityActions {
104107

105108
// Plugin contributions
106109
order = registerPluginActions(context, registry, order)
110+
order = registerPluginBuildActions(context, registry, order)
107111

108112
// editor text actions
109113
registry.registerAction(ExpandSelectionAction(context, order++))
@@ -157,7 +161,8 @@ class EditorActivityActions {
157161
registry.clearActionsExceptWhere(EDITOR_TOOLBAR) { action ->
158162
action.id == QuickRunAction.ID ||
159163
action.id == RunTasksAction.ID ||
160-
action.id == ProjectSyncAction.ID
164+
action.id == ProjectSyncAction.ID ||
165+
action.id.startsWith("plugin.build.")
161166
}
162167
}
163168

@@ -185,13 +190,28 @@ class EditorActivityActions {
185190
registry.registerAction(action)
186191
}
187192
} catch (e: Exception) {
188-
// Continue with other plugins if one fails
189-
System.err.println("")
190-
Log.d("plugin_debug", "Failed to register menu items for plugin: ${plugin.javaClass.simpleName} - ${e.message}")
193+
Log.w("plugin_debug", "Failed to register menu items for plugin: ${plugin.javaClass.simpleName}", e)
191194
}
192195
}
193196

194197
return order
195198
}
199+
200+
@JvmStatic
201+
private fun registerPluginBuildActions(context: Context, registry: ActionsRegistry, startOrder: Int): Int {
202+
var order = startOrder
203+
204+
PluginBuildActionManager.getInstance().getAllBuildActions().forEach { registered ->
205+
runCatching {
206+
registry.registerAction(PluginBuildActionItem(context, registered, order++))
207+
Log.d("plugin_debug", "Registered build action: ${registered.action.id} from plugin: ${registered.pluginId}")
208+
}.onFailure { e ->
209+
Log.w("plugin_debug", "Failed to register build action: ${registered.action.id}", e)
210+
}
211+
}
212+
213+
return order
214+
}
215+
196216
}
197217
}

plugin-api/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ dependencies {
2929
compileOnly("androidx.appcompat:appcompat:1.6.1")
3030
compileOnly("androidx.fragment:fragment-ktx:1.6.2")
3131
compileOnly("com.google.android.material:material:1.11.0")
32+
33+
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
3234
}
3335

3436
tasks.register<Copy>("createPluginApiJar") {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.itsaky.androidide.plugins.extensions
2+
3+
import com.itsaky.androidide.plugins.IPlugin
4+
5+
interface BuildActionExtension : IPlugin {
6+
fun getBuildActions(): List<PluginBuildAction>
7+
fun toolbarActionsToHide(): Set<String> = emptySet()
8+
fun onActionStarted(actionId: String) {}
9+
fun onActionCompleted(actionId: String, result: CommandResult) {}
10+
}
11+
12+
object ToolbarActionIds {
13+
const val QUICK_RUN = "ide.editor.build.quickRun"
14+
const val PROJECT_SYNC = "ide.editor.syncProject"
15+
const val DEBUG = "ide.editor.build.debug"
16+
const val RUN_TASKS = "ide.editor.build.runTasks"
17+
const val UNDO = "ide.editor.code.text.undo"
18+
const val REDO = "ide.editor.code.text.redo"
19+
const val SAVE = "ide.editor.files.saveAll"
20+
const val PREVIEW_LAYOUT = "ide.editor.previewLayout"
21+
const val FIND = "ide.editor.find"
22+
const val FIND_IN_FILE = "ide.editor.find.inFile"
23+
const val FIND_IN_PROJECT = "ide.editor.find.inProject"
24+
const val LAUNCH_APP = "ide.editor.launchInstalledApp"
25+
const val DISCONNECT_LOG_SENDERS = "ide.editor.service.logreceiver.disconnectSenders"
26+
const val GENERATE_XML = "ide.editor.generatexml"
27+
28+
val ALL: Set<String> = setOf(
29+
QUICK_RUN, PROJECT_SYNC, DEBUG, RUN_TASKS,
30+
UNDO, REDO, SAVE, PREVIEW_LAYOUT,
31+
FIND, FIND_IN_FILE, FIND_IN_PROJECT,
32+
LAUNCH_APP, DISCONNECT_LOG_SENDERS, GENERATE_XML
33+
)
34+
}
35+
36+
data class PluginBuildAction(
37+
val id: String,
38+
val name: String,
39+
val description: String,
40+
val icon: Int? = null,
41+
val category: BuildActionCategory = BuildActionCategory.CUSTOM,
42+
val command: CommandSpec,
43+
val timeoutMs: Long = 600_000
44+
)
45+
46+
sealed class CommandSpec {
47+
data class ShellCommand(
48+
val executable: String,
49+
val arguments: List<String> = emptyList(),
50+
val workingDirectory: String? = null,
51+
val environment: Map<String, String> = emptyMap()
52+
) : CommandSpec()
53+
54+
data class GradleTask(
55+
val taskPath: String,
56+
val arguments: List<String> = emptyList()
57+
) : CommandSpec()
58+
}
59+
60+
sealed class CommandOutput {
61+
data class StdOut(val line: String) : CommandOutput()
62+
data class StdErr(val line: String) : CommandOutput()
63+
data class ExitCode(val code: Int) : CommandOutput()
64+
}
65+
66+
sealed class CommandResult {
67+
data class Success(
68+
val exitCode: Int,
69+
val stdout: String,
70+
val stderr: String,
71+
val durationMs: Long
72+
) : CommandResult()
73+
74+
data class Failure(
75+
val exitCode: Int,
76+
val stdout: String,
77+
val stderr: String,
78+
val error: String?,
79+
val durationMs: Long
80+
) : CommandResult()
81+
82+
data class Cancelled(
83+
val partialStdout: String,
84+
val partialStderr: String
85+
) : CommandResult()
86+
}
87+
88+
enum class BuildActionCategory { BUILD, TEST, DEPLOY, LINT, CUSTOM }
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.itsaky.androidide.plugins.services
2+
3+
import com.itsaky.androidide.plugins.extensions.CommandOutput
4+
import com.itsaky.androidide.plugins.extensions.CommandResult
5+
import com.itsaky.androidide.plugins.extensions.CommandSpec
6+
import kotlinx.coroutines.flow.Flow
7+
8+
interface IdeCommandService {
9+
fun executeCommand(spec: CommandSpec, timeoutMs: Long = 600_000): CommandExecution
10+
fun isCommandRunning(executionId: String): Boolean
11+
fun cancelCommand(executionId: String): Boolean
12+
fun getRunningCommandCount(): Int
13+
}
14+
15+
interface CommandExecution {
16+
val executionId: String
17+
val output: Flow<CommandOutput>
18+
suspend fun await(): CommandResult
19+
fun cancel()
20+
}

0 commit comments

Comments
 (0)