-
Notifications
You must be signed in to change notification settings - Fork 15
ADFA-3580: (feat) Plugin Build Actions & Custom Scripts System #1150
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stage
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| package com.itsaky.androidide.actions.build | ||
|
|
||
| import android.content.Context | ||
| import android.graphics.ColorFilter | ||
| import android.graphics.PorterDuff | ||
| import android.graphics.PorterDuffColorFilter | ||
| import android.graphics.drawable.Drawable | ||
| import androidx.core.content.ContextCompat | ||
| import com.itsaky.androidide.actions.ActionData | ||
| import com.itsaky.androidide.actions.ActionItem | ||
| import com.itsaky.androidide.actions.BaseBuildAction | ||
| import com.itsaky.androidide.actions.getContext | ||
| import com.itsaky.androidide.plugins.extensions.CommandOutput | ||
| import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager | ||
| import com.itsaky.androidide.plugins.manager.build.RegisteredBuildAction | ||
| import com.itsaky.androidide.plugins.manager.core.PluginManager | ||
| import com.itsaky.androidide.plugins.manager.ui.PluginDrawableResolver | ||
| import com.itsaky.androidide.plugins.services.IdeCommandService | ||
| import com.itsaky.androidide.resources.R | ||
| import com.itsaky.androidide.utils.resolveAttr | ||
| import com.itsaky.androidide.viewmodel.BottomSheetViewModel | ||
| import com.google.android.material.bottomsheet.BottomSheetBehavior | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.launch | ||
| import kotlinx.coroutines.withContext | ||
|
|
||
| class PluginBuildActionItem( | ||
| context: Context, | ||
| private val registered: RegisteredBuildAction, | ||
| override val order: Int | ||
| ) : BaseBuildAction() { | ||
|
|
||
| override val id: String = "plugin.build.${registered.pluginId}.${registered.action.id}" | ||
|
|
||
| init { | ||
| label = registered.action.name | ||
| icon = resolvePluginIcon(context) | ||
| location = ActionItem.Location.EDITOR_TOOLBAR | ||
| requiresUIThread = true | ||
| } | ||
|
|
||
| override fun prepare(data: ActionData) { | ||
| val context = data.getActivity() | ||
| if (context == null) { | ||
| visible = false | ||
| return | ||
| } | ||
| visible = true | ||
|
|
||
| val manager = PluginBuildActionManager.getInstance() | ||
| val isRunning = manager.isActionRunning(registered.pluginId, registered.action.id) | ||
|
|
||
| if (isRunning) { | ||
| label = "Cancel ${registered.action.name}" | ||
| icon = ContextCompat.getDrawable(context, R.drawable.ic_stop) | ||
| enabled = true | ||
| } else { | ||
| label = registered.action.name | ||
| icon = resolvePluginIcon(context) | ||
| enabled = true | ||
| } | ||
| } | ||
|
|
||
| override fun createColorFilter(data: ActionData): ColorFilter? { | ||
| val context = data.getContext() ?: return null | ||
| val isRunning = PluginBuildActionManager.getInstance() | ||
| .isActionRunning(registered.pluginId, registered.action.id) | ||
| val attr = if (isRunning) R.attr.colorError else com.google.android.material.R.attr.colorOnSurface | ||
| return PorterDuffColorFilter( | ||
| context.resolveAttr(attr), | ||
| PorterDuff.Mode.SRC_ATOP | ||
| ) | ||
| } | ||
|
|
||
| private fun resolvePluginIcon(fallbackContext: Context): Drawable? { | ||
| val iconResId = registered.action.icon ?: return ContextCompat.getDrawable(fallbackContext, R.drawable.ic_run_outline) | ||
| return PluginDrawableResolver.resolve(iconResId, registered.pluginId, fallbackContext) | ||
| ?: ContextCompat.getDrawable(fallbackContext, R.drawable.ic_run_outline) | ||
| } | ||
|
|
||
| override suspend fun execAction(data: ActionData): Any { | ||
| val manager = PluginBuildActionManager.getInstance() | ||
| val pluginId = registered.pluginId | ||
| val actionId = registered.action.id | ||
|
|
||
| if (manager.isActionRunning(pluginId, actionId)) { | ||
| manager.cancelAction(pluginId, actionId) | ||
| data.getActivity()?.let { resetProgressIfIdle(it) } | ||
| return true | ||
| } | ||
|
|
||
| val activity = data.getActivity() ?: return false | ||
|
|
||
| val pluginManager = PluginManager.getInstance() ?: return false | ||
| val loadedPlugin = pluginManager.getLoadedPlugin(pluginId) ?: return false | ||
| val commandService = loadedPlugin.context.services.get(IdeCommandService::class.java) | ||
| ?: return false | ||
|
|
||
| val execution = manager.executeAction(pluginId, actionId, commandService) ?: return false | ||
|
|
||
| activity.editorViewModel.isBuildInProgress = true | ||
| val currentSheetState = activity.bottomSheetViewModel.sheetBehaviorState | ||
| val targetState = if (currentSheetState == BottomSheetBehavior.STATE_HIDDEN) | ||
| BottomSheetBehavior.STATE_COLLAPSED else currentSheetState | ||
| activity.bottomSheetViewModel.setSheetState( | ||
| sheetState = targetState, | ||
| currentTab = BottomSheetViewModel.TAB_BUILD_OUTPUT | ||
| ) | ||
| activity.appendBuildOutput("━━━ ${registered.action.name} ━━━") | ||
| activity.invalidateOptionsMenu() | ||
|
|
||
| actionScope.launch { | ||
| execution.output.collect { output -> | ||
| val line = when (output) { | ||
| is CommandOutput.StdOut -> output.line | ||
| is CommandOutput.StdErr -> output.line | ||
| is CommandOutput.ExitCode -> | ||
| if (output.code != 0) "Process failed with code ${output.code}" else null | ||
| } | ||
| if (line != null) { | ||
| withContext(Dispatchers.Main) { | ||
| activity.appendBuildOutput(line) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| val result = execution.await() | ||
| manager.notifyActionCompleted(pluginId, actionId, result) | ||
| withContext(Dispatchers.Main) { resetProgressIfIdle(activity) } | ||
| } | ||
|
Comment on lines
+112
to
+130
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider lifecycle-safe coroutine handling. The coroutine launched in
🛡️ Proposed lifecycle-safe pattern- actionScope.launch {
- execution.output.collect { output ->
+ activity.lifecycleScope.launch {
+ try {
+ execution.output.collect { output ->
val line = when (output) {
is CommandOutput.StdOut -> output.line
is CommandOutput.StdErr -> output.line
is CommandOutput.ExitCode ->
if (output.code != 0) "Process failed with code ${output.code}" else null
}
if (line != null) {
- withContext(Dispatchers.Main) {
- activity.appendBuildOutput(line)
- }
+ activity.appendBuildOutput(line)
}
- }
+ }
- val result = execution.await()
- manager.notifyActionCompleted(pluginId, actionId, result)
- withContext(Dispatchers.Main) { resetProgressIfIdle(activity) }
+ val result = execution.await()
+ manager.notifyActionCompleted(pluginId, actionId, result)
+ resetProgressIfIdle(activity)
+ } catch (e: CancellationException) {
+ manager.cancelAction(pluginId, actionId)
+ throw e
+ }
}Note: Using 🤖 Prompt for AI Agents |
||
|
|
||
| return true | ||
| } | ||
|
|
||
| private fun resetProgressIfIdle(activity: com.itsaky.androidide.activities.editor.EditorHandlerActivity) { | ||
| if (buildService?.isBuildInProgress != true) { | ||
| activity.editorViewModel.isBuildInProgress = false | ||
| } | ||
| activity.invalidateOptionsMenu() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| package com.itsaky.androidide.plugins.extensions | ||
|
|
||
| import com.itsaky.androidide.plugins.IPlugin | ||
|
|
||
| interface BuildActionExtension : IPlugin { | ||
| fun getBuildActions(): List<PluginBuildAction> | ||
| fun toolbarActionsToHide(): Set<String> = emptySet() | ||
| fun onActionStarted(actionId: String) {} | ||
| fun onActionCompleted(actionId: String, result: CommandResult) {} | ||
| } | ||
|
|
||
| object ToolbarActionIds { | ||
| const val QUICK_RUN = "ide.editor.build.quickRun" | ||
| const val PROJECT_SYNC = "ide.editor.syncProject" | ||
| const val DEBUG = "ide.editor.build.debug" | ||
| const val RUN_TASKS = "ide.editor.build.runTasks" | ||
| const val UNDO = "ide.editor.code.text.undo" | ||
| const val REDO = "ide.editor.code.text.redo" | ||
| const val SAVE = "ide.editor.files.saveAll" | ||
| const val PREVIEW_LAYOUT = "ide.editor.previewLayout" | ||
| const val FIND = "ide.editor.find" | ||
| const val FIND_IN_FILE = "ide.editor.find.inFile" | ||
| const val FIND_IN_PROJECT = "ide.editor.find.inProject" | ||
| const val LAUNCH_APP = "ide.editor.launchInstalledApp" | ||
| const val DISCONNECT_LOG_SENDERS = "ide.editor.service.logreceiver.disconnectSenders" | ||
| const val GENERATE_XML = "ide.editor.generatexml" | ||
|
|
||
| val ALL: Set<String> = setOf( | ||
| QUICK_RUN, PROJECT_SYNC, DEBUG, RUN_TASKS, | ||
| UNDO, REDO, SAVE, PREVIEW_LAYOUT, | ||
| FIND, FIND_IN_FILE, FIND_IN_PROJECT, | ||
| LAUNCH_APP, DISCONNECT_LOG_SENDERS, GENERATE_XML | ||
| ) | ||
| } | ||
|
|
||
| data class PluginBuildAction( | ||
| val id: String, | ||
| val name: String, | ||
| val description: String, | ||
| val icon: Int? = null, | ||
| val category: BuildActionCategory = BuildActionCategory.CUSTOM, | ||
| val command: CommandSpec, | ||
| val timeoutMs: Long = 600_000 | ||
| ) | ||
|
|
||
| sealed class CommandSpec { | ||
| data class ShellCommand( | ||
| val executable: String, | ||
| val arguments: List<String> = emptyList(), | ||
| val workingDirectory: String? = null, | ||
| val environment: Map<String, String> = emptyMap() | ||
| ) : CommandSpec() | ||
|
|
||
| data class GradleTask( | ||
| val taskPath: String, | ||
| val arguments: List<String> = emptyList() | ||
| ) : CommandSpec() | ||
| } | ||
|
|
||
| sealed class CommandOutput { | ||
| data class StdOut(val line: String) : CommandOutput() | ||
| data class StdErr(val line: String) : CommandOutput() | ||
| data class ExitCode(val code: Int) : CommandOutput() | ||
| } | ||
|
|
||
| sealed class CommandResult { | ||
| data class Success( | ||
| val exitCode: Int, | ||
| val stdout: String, | ||
| val stderr: String, | ||
| val durationMs: Long | ||
| ) : CommandResult() | ||
|
|
||
| data class Failure( | ||
| val exitCode: Int, | ||
| val stdout: String, | ||
| val stderr: String, | ||
| val error: String?, | ||
| val durationMs: Long | ||
| ) : CommandResult() | ||
|
|
||
| data class Cancelled( | ||
| val partialStdout: String, | ||
| val partialStderr: String | ||
| ) : CommandResult() | ||
| } | ||
|
|
||
| enum class BuildActionCategory { BUILD, TEST, DEPLOY, LINT, CUSTOM } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package com.itsaky.androidide.plugins.services | ||
|
|
||
| import com.itsaky.androidide.plugins.extensions.CommandOutput | ||
| import com.itsaky.androidide.plugins.extensions.CommandResult | ||
| import com.itsaky.androidide.plugins.extensions.CommandSpec | ||
| import kotlinx.coroutines.flow.Flow | ||
|
|
||
| interface IdeCommandService { | ||
| fun executeCommand(spec: CommandSpec, timeoutMs: Long = 600_000): CommandExecution | ||
| fun isCommandRunning(executionId: String): Boolean | ||
| fun cancelCommand(executionId: String): Boolean | ||
| fun getRunningCommandCount(): Int | ||
| } | ||
|
|
||
| interface CommandExecution { | ||
| val executionId: String | ||
| val output: Flow<CommandOutput> | ||
| suspend fun await(): CommandResult | ||
| fun cancel() | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing
super.prepare(data)call.BaseBuildAction.prepare()performs context validation and setsvisible/enabledbased on build service state. Not callingsuper.prepare(data)bypasses these checks. While you're handling visibility manually, the base class behavior should be considered.🔧 Proposed fix
override fun prepare(data: ActionData) { + super.prepare(data) val context = data.getActivity() if (context == null) { visible = false return } visible = trueNote: If you intentionally want to bypass
BaseBuildAction'sbuildService.isBuildInProgresscheck (since plugin actions have their own running state), consider documenting this or extracting a different base class.📝 Committable suggestion
🤖 Prompt for AI Agents