fix(#1230,#1221): clarify inactive plugins and verify uninstall (#1236)

This commit is contained in:
m57 authored and GitHub committed 2026-09-23 12:25:21 +03:00
1 parent 721edef618
commit 33d87a11ec
13 files changed
+475 -94

No files matched your search

@@ -486,7 +486,7 @@ private fun InstallSection(
}
@Composable
private fun PluginCard(
internal fun PluginCard(
plugin: PluginInfo,
state: PluginsUiState,
viewModel: PluginsViewModel,
@@ -542,12 +542,7 @@ private fun PluginCard(
// Status badges
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val statusColor =
when {
plugin.enabled -> statusColors.success
plugin.installed -> statusColors.warning
else -> statusColors.error
}
val statusColor = if (plugin.enabled) statusColors.success else statusColors.warning
Text(
text = plugin.runtimeStatus ?: "inactive",
style = MaterialTheme.typography.labelSmall,
@@ -591,64 +586,63 @@ private fun PluginCard(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
if (plugin.installed) {
// Visibility toggle (only for plugins with dashboard manifest)
if (plugin.hasDashboardManifest) {
IconButton(
onClick = { viewModel.togglePluginVisibility(plugin) },
enabled = !busy,
modifier = Modifier.size(32.dp),
) {
Icon(
if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription =
if (plugin.userHidden) {
stringResource(R.string.plugins_show_in_sidebar)
} else {
stringResource(R.string.plugins_hide_from_sidebar)
},
modifier = Modifier.size(18.dp),
)
}
// Visibility toggle (only for active plugins with dashboard manifests)
if (plugin.installed && plugin.hasDashboardManifest) {
IconButton(
onClick = { viewModel.togglePluginVisibility(plugin) },
enabled = !busy,
modifier = Modifier.size(32.dp),
) {
Icon(
if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription =
if (plugin.userHidden) {
stringResource(R.string.plugins_show_in_sidebar)
} else {
stringResource(R.string.plugins_hide_from_sidebar)
},
modifier = Modifier.size(18.dp),
)
}
Spacer(modifier = Modifier.width(4.dp))
}
// Existing update affordance for active Git installs.
if (plugin.installed && plugin.canUpdateGit) {
OutlinedButton(
onClick = { viewModel.updatePlugin(plugin.name) },
enabled = !busy,
) {
Text(stringResource(R.string.plugins_action_update))
}
Spacer(modifier = Modifier.width(8.dp))
}
// Backend-provided removability, independent of runtime enabled state.
if (plugin.removable) {
OutlinedButton(
onClick = { viewModel.requestRemovePlugin(plugin.name) },
enabled = !busy,
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.plugins_action_uninstall))
}
// Update button
if (plugin.canUpdateGit) {
OutlinedButton(
onClick = { viewModel.updatePlugin(plugin.name) },
enabled = !busy,
) {
Text(stringResource(R.string.plugins_action_update))
}
Spacer(modifier = Modifier.width(8.dp))
}
// Remove button with confirmation
if (plugin.canRemove) {
OutlinedButton(
onClick = { viewModel.requestRemovePlugin(plugin.name) },
enabled = !busy,
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.plugins_action_uninstall))
}
}
} else {
}
if (!plugin.installed) {
Button(
onClick = { viewModel.activatePlugin(plugin) },
enabled = !busy,
) {
Text(stringResource(R.string.plugins_action_install))
Text(stringResource(R.string.plugins_action_enable))
}
}
}
@@ -14,10 +14,12 @@ import com.m57.hermescontrol.data.model.ProviderOption
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.PluginRemovalRepository
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchAction
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -72,15 +74,18 @@ data class PluginsUiState(
}
}
class PluginsViewModel :
ViewModel(),
class PluginsViewModel(
private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(),
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(PluginsUiState())
val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow()
private val pluginsCache = SwrCache<String, PluginsHubResponse>()
private var scopeGeneration = 0
fun clearScopeOwnedState() {
scopeGeneration++
_uiState.update {
it.copy(
isLoading = false,
@@ -333,26 +338,24 @@ class PluginsViewModel :
}
fun activatePlugin(plugin: PluginInfo) {
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
loadPlugins(forceRefresh = true)
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to enable plugin: ${result.error.message}") }
}
}
}
if (_uiState.value.rowBusy != null) return
safeLaunchAction(
onStart = { setRowBusy(plugin.name) },
apiCall = { safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) } },
onSuccess = {
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
loadPlugins(forceRefresh = true)
},
onError = { error ->
_uiState.update { it.copy(toastMessage = "Failed to enable plugin: $error") }
},
onComplete = { clearRowBusy(plugin.name) },
)
}
/** Show confirmation dialog for removing a plugin */
fun requestRemovePlugin(name: String) {
if (_uiState.value.plugins.none { it.name == name && it.removable }) return
_uiState.update { it.copy(removeConfirmPlugin = name) }
}
@@ -363,24 +366,85 @@ class PluginsViewModel :
fun confirmRemovePlugin() {
val name = _uiState.value.removeConfirmPlugin ?: return
if (_uiState.value.plugins.none { it.name == name && it.removable } || _uiState.value.rowBusy != null) {
cancelRemovePlugin()
return
}
_uiState.update { it.copy(removeConfirmPlugin = null) }
val generation = scopeGeneration
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
setRowBusy(name)
viewModelScope.launch {
setRowBusy(name)
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.uninstallPlugin(name) }
try {
val result = pluginRemoval.remove(name)
if (generation != scopeGeneration ||
(requestScope != null && AuthManager.currentDataScope() != requestScope)
) {
return@launch
}
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") }
clearRowBusy(name)
loadPlugins(forceRefresh = true)
if (!result.ok) {
_uiState.update {
it.copy(
toastMessage = "Failed to uninstall plugin: ${result.error ?: "Gateway refused removal"}",
)
}
return@launch
}
val refreshed = safeApiCall { ApiClient.hermesApi.getPlugins() }
if (generation != scopeGeneration ||
(requestScope != null && AuthManager.currentDataScope() != requestScope)
) {
return@launch
}
when (refreshed) {
is NetworkResult.Success -> {
val data = refreshed.data
requestScope?.let { pluginsCache.put(it.scopedKey("default"), data) }
_uiState.update { state ->
state.copy(
plugins = data.plugins,
orphanPlugins =
data.orphanDashboardPlugins.map { orphan ->
PluginInfo(
name = orphan.name ?: "unknown",
description =
orphan.description ?: orphan.label,
)
},
memoryProvider = data.providers?.memoryProvider ?: "",
memoryOptions = data.providers?.memoryOptions.orEmpty(),
contextEngine = data.providers?.contextEngine ?: "compressor",
contextOptions = data.providers?.contextOptions.orEmpty(),
toastMessage =
if (data.plugins.none { it.name == name }) {
"Plugin uninstalled successfully"
} else {
"Removal returned success, but $name still appears in the plugin list"
},
)
}
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${result.error.message}") }
clearRowBusy(name)
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
toastMessage = "Removal sent; could not verify plugin list: ${refreshed.error.message}",
)
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (generation == scopeGeneration) {
_uiState.update {
it.copy(
toastMessage = "Failed to uninstall plugin: ${e.message ?: "Gateway unavailable"}",
)
}
}
} finally {
if (generation == scopeGeneration) clearRowBusy(name)
}
}
}