release: promote v1.31.0-rc.2 to main (#1354)
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Lx-204 <15377891490@163.com> Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com> Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
This commit is contained in:
111 files changed
+10563
-998
No files matched your search
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.InstallDesktop
|
||||
@@ -67,6 +69,7 @@ import com.m57.hermescontrol.ui.common.listContentPadding
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
import com.m57.hermescontrol.ui.common.toDetailRows
|
||||
import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView
|
||||
import com.m57.hermescontrol.ui.plugins.components.PluginDetailDialog
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -120,6 +123,37 @@ fun PluginsScreen(
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
state.updateConsent?.let { consent ->
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::cancelPluginUpdate,
|
||||
title = { Text(stringResource(R.string.plugins_update_consent_title)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.plugins_update_consent_text, consent.name))
|
||||
val lines = consent.result.capabilityDelta
|
||||
if (lines.isEmpty()) {
|
||||
Text(consent.result.error ?: stringResource(R.string.plugins_update_consent_missing))
|
||||
} else {
|
||||
lines.forEach { Text(it) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::confirmPluginUpdate, enabled = state.rowBusy == null) {
|
||||
Text(stringResource(R.string.plugins_update_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::cancelPluginUpdate) {
|
||||
Text(stringResource(R.string.common_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Remove confirmation dialog
|
||||
if (state.removeConfirmPlugin != null) {
|
||||
AlertDialog(
|
||||
@@ -240,7 +274,7 @@ fun PluginsScreen(
|
||||
plugin = plugin,
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
onClick = { showDetail = plugin },
|
||||
onClick = { viewModel.openPluginDetail(plugin) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -256,7 +290,10 @@ fun PluginsScreen(
|
||||
)
|
||||
}
|
||||
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
|
||||
OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin })
|
||||
OrphanPluginCard(
|
||||
plugin = plugin,
|
||||
onClick = { viewModel.openPluginDetail(plugin) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,11 +317,19 @@ fun PluginsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
showDetail?.let { plugin ->
|
||||
DetailDialog(
|
||||
title = plugin.name,
|
||||
rows = plugin.toDetailRows(),
|
||||
onDismiss = { showDetail = null },
|
||||
state.activeDetailPlugin?.let { plugin ->
|
||||
PluginDetailDialog(
|
||||
plugin = plugin,
|
||||
agentPluginRow = state.pluginDetailRow,
|
||||
isLoading = state.isPluginDetailLoading,
|
||||
isSaving = state.isPluginDetailSaving,
|
||||
edits = state.pluginDetailEdits,
|
||||
onFieldChange = { key, value -> viewModel.setPluginDetailEdit(key, value) },
|
||||
onSaveSettings = { viewModel.savePluginSettings() },
|
||||
onDismiss = {
|
||||
showDetail = null
|
||||
viewModel.closePluginDetail()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,22 @@ import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
||||
import com.m57.hermescontrol.data.model.EnvVarUpdate
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.PluginInfo
|
||||
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
|
||||
import com.m57.hermescontrol.data.model.PluginUpdateRequest
|
||||
import com.m57.hermescontrol.data.model.PluginUpdateResult
|
||||
import com.m57.hermescontrol.data.model.PluginsHubResponse
|
||||
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.AgentPluginRow
|
||||
import com.m57.hermescontrol.data.ws.PluginManageRepository
|
||||
import com.m57.hermescontrol.data.ws.PluginRemovalRepository
|
||||
import com.m57.hermescontrol.data.ws.PluginSettingField
|
||||
import com.m57.hermescontrol.data.ws.PluginSettingFieldType
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchAction
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
@@ -27,12 +34,24 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
enum class PluginsTab {
|
||||
INSTALLED,
|
||||
CATALOG,
|
||||
}
|
||||
|
||||
data class PluginUpdateConsent(
|
||||
val name: String,
|
||||
val result: PluginUpdateResult,
|
||||
val scope: DataScope?,
|
||||
)
|
||||
|
||||
data class PluginsUiState(
|
||||
val selectedTab: PluginsTab = PluginsTab.INSTALLED,
|
||||
val isLoading: Boolean = false,
|
||||
@@ -55,6 +74,13 @@ data class PluginsUiState(
|
||||
val rowBusy: String? = null,
|
||||
// Confirm remove dialog
|
||||
val removeConfirmPlugin: String? = null,
|
||||
val updateConsent: PluginUpdateConsent? = null,
|
||||
// Active plugin detail with settings & diagnostics
|
||||
val activeDetailPlugin: PluginInfo? = null,
|
||||
val isPluginDetailLoading: Boolean = false,
|
||||
val pluginDetailRow: AgentPluginRow? = null,
|
||||
val pluginDetailEdits: Map<String, String> = emptyMap(),
|
||||
val isPluginDetailSaving: Boolean = false,
|
||||
// Rescan
|
||||
val rescanBusy: Boolean = false,
|
||||
// Catalog state
|
||||
@@ -76,6 +102,7 @@ data class PluginsUiState(
|
||||
|
||||
class PluginsViewModel(
|
||||
private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(),
|
||||
private val pluginManage: PluginManageRepository = PluginManageRepository(),
|
||||
) : ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(PluginsUiState())
|
||||
@@ -99,6 +126,10 @@ class PluginsViewModel(
|
||||
providerBusy = false,
|
||||
rowBusy = null,
|
||||
removeConfirmPlugin = null,
|
||||
updateConsent = null,
|
||||
activeDetailPlugin = null,
|
||||
pluginDetailRow = null,
|
||||
pluginDetailEdits = emptyMap(),
|
||||
rescanBusy = false,
|
||||
)
|
||||
}
|
||||
@@ -450,23 +481,83 @@ class PluginsViewModel(
|
||||
}
|
||||
|
||||
fun updatePlugin(name: String) {
|
||||
viewModelScope.launch {
|
||||
setRowBusy(name)
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.updatePlugin(name) }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
|
||||
clearRowBusy(name)
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
if (_uiState.value.updateConsent != null) return
|
||||
performPluginUpdate(name, acceptCapabilities = false)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") }
|
||||
clearRowBusy(name)
|
||||
fun cancelPluginUpdate() {
|
||||
_uiState.update { it.copy(updateConsent = null) }
|
||||
}
|
||||
|
||||
fun confirmPluginUpdate() {
|
||||
val consent = _uiState.value.updateConsent ?: return
|
||||
if (_uiState.value.rowBusy != null) return
|
||||
cancelPluginUpdate()
|
||||
if (consent.scope != runCatching { AuthManager.currentDataScope() }.getOrNull()) return
|
||||
performPluginUpdate(consent.name, acceptCapabilities = true)
|
||||
}
|
||||
|
||||
private fun performPluginUpdate(
|
||||
name: String,
|
||||
acceptCapabilities: Boolean,
|
||||
) {
|
||||
if (_uiState.value.rowBusy != null) return
|
||||
val generation = scopeGeneration
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val api = ApiClient.hermesApi
|
||||
setRowBusy(name)
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result = safeApiCall { api.updatePlugin(name, PluginUpdateRequest(acceptCapabilities)) }
|
||||
if (generation != scopeGeneration ||
|
||||
requestScope != runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val update = result.data
|
||||
// #1282: HTTP success is not update success; consent must be explicit.
|
||||
when {
|
||||
update.consentRequired -> {
|
||||
_uiState.update {
|
||||
it.copy(updateConsent = PluginUpdateConsent(name, update, requestScope))
|
||||
}
|
||||
}
|
||||
|
||||
!update.ok -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage = "Failed to update plugin: ${update.error ?: "Update refused"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage =
|
||||
if (update.unchanged) {
|
||||
"Plugin is already up to date"
|
||||
} else {
|
||||
"Plugin updated successfully"
|
||||
},
|
||||
)
|
||||
}
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(toastMessage = "Failed to update plugin: ${result.error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (generation == scopeGeneration) clearRowBusy(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -614,6 +705,198 @@ class PluginsViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
fun openPluginDetail(plugin: PluginInfo) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
activeDetailPlugin = plugin,
|
||||
isPluginDetailLoading = true,
|
||||
pluginDetailRow = null,
|
||||
pluginDetailEdits = emptyMap(),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val profile = AuthManager.activeProfileId.value
|
||||
val rows = pluginManage.listPlugins(profile)
|
||||
val matchingRow = rows.firstOrNull { it.key == plugin.name || it.name == plugin.name }
|
||||
val initialEdits = mutableMapOf<String, String>()
|
||||
matchingRow?.settingsSchema?.forEach { field ->
|
||||
if (field.type != PluginSettingFieldType.SECRET) {
|
||||
val strVal =
|
||||
when (val v = field.value ?: field.default) {
|
||||
null -> ""
|
||||
is JsonPrimitive -> v.content
|
||||
else -> v.toString()
|
||||
}
|
||||
initialEdits[field.key] = strVal
|
||||
}
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isPluginDetailLoading = false,
|
||||
pluginDetailRow = matchingRow,
|
||||
pluginDetailEdits = initialEdits,
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isPluginDetailLoading = false,
|
||||
pluginDetailRow = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closePluginDetail() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
activeDetailPlugin = null,
|
||||
pluginDetailRow = null,
|
||||
pluginDetailEdits = emptyMap(),
|
||||
isPluginDetailSaving = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setPluginDetailEdit(
|
||||
key: String,
|
||||
value: String,
|
||||
) {
|
||||
_uiState.update {
|
||||
it.copy(pluginDetailEdits = it.pluginDetailEdits + (key to value))
|
||||
}
|
||||
}
|
||||
|
||||
fun savePluginSettings() {
|
||||
val plugin = _uiState.value.pluginDetailRow ?: return
|
||||
val schema = plugin.settingsSchema ?: return
|
||||
val canonicalKey = plugin.key ?: plugin.name
|
||||
val currentEdits = _uiState.value.pluginDetailEdits
|
||||
|
||||
_uiState.update { it.copy(isPluginDetailSaving = true) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val profile = AuthManager.activeProfileId.value
|
||||
val valuesToSave = mutableMapOf<String, JsonElement>()
|
||||
|
||||
for (field in schema) {
|
||||
val raw = currentEdits[field.key] ?: ""
|
||||
when (field.type) {
|
||||
PluginSettingFieldType.SECRET -> {
|
||||
val envVar = field.env
|
||||
if (raw.isNotBlank() && !envVar.isNullOrBlank()) {
|
||||
val envResult =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.updateEnvVar(EnvVarUpdate(key = envVar, value = raw))
|
||||
}
|
||||
if (envResult is NetworkResult.Failure) {
|
||||
throw IllegalStateException(
|
||||
"Failed to update secret $envVar: ${envResult.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PluginSettingFieldType.STRING -> {
|
||||
valuesToSave[field.key] = JsonPrimitive(raw)
|
||||
}
|
||||
|
||||
PluginSettingFieldType.NUMBER -> {
|
||||
val num =
|
||||
raw.trim().toLongOrNull()
|
||||
?: raw.trim().toDoubleOrNull()
|
||||
if (num != null) {
|
||||
valuesToSave[field.key] =
|
||||
if (num is Long) JsonPrimitive(num) else JsonPrimitive(num as Double)
|
||||
} else if (raw.trim().isNotEmpty()) {
|
||||
throw IllegalArgumentException("${field.label}: expected a number")
|
||||
}
|
||||
}
|
||||
|
||||
PluginSettingFieldType.BOOLEAN -> {
|
||||
valuesToSave[field.key] = JsonPrimitive(raw.trim().equals("true", ignoreCase = true))
|
||||
}
|
||||
|
||||
PluginSettingFieldType.ENUM -> {
|
||||
if (!field.choices.isNullOrEmpty() && raw.trim() !in field.choices) {
|
||||
throw IllegalArgumentException(
|
||||
"${field.label}: must be one of ${field.choices.joinToString()}",
|
||||
)
|
||||
}
|
||||
valuesToSave[field.key] = JsonPrimitive(raw.trim())
|
||||
}
|
||||
|
||||
PluginSettingFieldType.JSON -> {
|
||||
val parsed =
|
||||
if (raw.trim().isEmpty()) {
|
||||
JsonPrimitive("")
|
||||
} else {
|
||||
Json.parseToJsonElement(raw.trim())
|
||||
}
|
||||
valuesToSave[field.key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (valuesToSave.isNotEmpty()) {
|
||||
val result = pluginManage.saveSettings(canonicalKey, valuesToSave, profile)
|
||||
if (!result.ok) {
|
||||
throw IllegalStateException(result.error ?: "Failed to save settings")
|
||||
}
|
||||
if (result.plugin != null) {
|
||||
val refreshed = result.plugin
|
||||
val newEdits = mutableMapOf<String, String>()
|
||||
refreshed.settingsSchema?.forEach { f ->
|
||||
if (f.type != PluginSettingFieldType.SECRET) {
|
||||
val strVal =
|
||||
when (val v = f.value ?: f.default) {
|
||||
null -> ""
|
||||
is JsonPrimitive -> v.content
|
||||
else -> v.toString()
|
||||
}
|
||||
newEdits[f.key] = strVal
|
||||
}
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isPluginDetailSaving = false,
|
||||
pluginDetailRow = refreshed,
|
||||
pluginDetailEdits = newEdits,
|
||||
toastMessage = "Plugin settings saved",
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
// If only secrets were saved or result plugin is null
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isPluginDetailSaving = false,
|
||||
pluginDetailEdits =
|
||||
it.pluginDetailEdits.filterKeys { k ->
|
||||
schema.none { f -> f.key == k && f.type == PluginSettingFieldType.SECRET }
|
||||
},
|
||||
toastMessage = "Plugin settings saved",
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isPluginDetailSaving = false,
|
||||
toastMessage = "Failed to save settings: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
package com.m57.hermescontrol.ui.plugins.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.PluginInfo
|
||||
import com.m57.hermescontrol.data.ws.AgentPluginRow
|
||||
import com.m57.hermescontrol.data.ws.PluginServerRow
|
||||
import com.m57.hermescontrol.data.ws.PluginServerState
|
||||
import com.m57.hermescontrol.data.ws.PluginSettingField
|
||||
import com.m57.hermescontrol.data.ws.PluginSettingFieldType
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.common.DetailRow
|
||||
import com.m57.hermescontrol.ui.common.DetailRowTone
|
||||
import com.m57.hermescontrol.ui.common.ExposedDropdownField
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.common.toDetailRows
|
||||
import com.m57.hermescontrol.ui.common.toneForStatus
|
||||
|
||||
@Composable
|
||||
fun PluginDetailDialog(
|
||||
plugin: PluginInfo,
|
||||
agentPluginRow: AgentPluginRow?,
|
||||
isLoading: Boolean,
|
||||
isSaving: Boolean,
|
||||
edits: Map<String, String>,
|
||||
onFieldChange: (String, String) -> Unit,
|
||||
onSaveSettings: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
HermesScaffold(
|
||||
title = { Text(plugin.name, style = MaterialTheme.typography.titleLarge) },
|
||||
navigationIcon = NavIcon.Back(onDismiss),
|
||||
isRefreshing = false,
|
||||
) { _ ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Standard metadata rows
|
||||
val rows = plugin.toDetailRows().filter { !it.value.isNullOrBlank() }
|
||||
rows.forEachIndexed { index, (label, value, tone) ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = value ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
when (tone) {
|
||||
DetailRowTone.SUCCESS -> statusColors.success
|
||||
DetailRowTone.WARNING -> statusColors.warning
|
||||
DetailRowTone.ERROR -> statusColors.error
|
||||
DetailRowTone.INFO -> statusColors.info
|
||||
DetailRowTone.NEUTRAL -> statusColors.neutral
|
||||
DetailRowTone.NONE -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_settings_loading),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostics section (only if servers present)
|
||||
val servers = agentPluginRow?.servers.orEmpty()
|
||||
if (servers.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_diagnostics_heading),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
servers.forEach { server ->
|
||||
PluginServerDiagnosticRow(server = server)
|
||||
}
|
||||
}
|
||||
|
||||
// Settings section (only if schema present)
|
||||
val schema = agentPluginRow?.settingsSchema.orEmpty()
|
||||
if (schema.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_settings_heading),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
schema.forEach { field ->
|
||||
PluginSettingFieldRow(
|
||||
field = field,
|
||||
value = edits[field.key] ?: "",
|
||||
enabled = !isSaving,
|
||||
onChange = { onFieldChange(field.key, it) },
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onSaveSettings,
|
||||
enabled = !isSaving,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
if (isSaving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(stringResource(R.string.plugins_settings_save))
|
||||
}
|
||||
}
|
||||
|
||||
// Close action
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_close))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginServerDiagnosticRow(server: PluginServerRow) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val (statusLabel, statusColor, badgeType) =
|
||||
when (server.state) {
|
||||
PluginServerState.CONNECTED -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_connected),
|
||||
statusColors.success,
|
||||
StatusBadgeType.SUCCESS,
|
||||
)
|
||||
}
|
||||
|
||||
PluginServerState.APP_NOT_RUNNING -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_app_not_running),
|
||||
statusColors.warning,
|
||||
StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
|
||||
PluginServerState.ENDPOINT_UNAVAILABLE -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_endpoint_unavailable),
|
||||
statusColors.warning,
|
||||
StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
|
||||
PluginServerState.NO_INTERACTIVE_SESSION -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_no_interactive_session),
|
||||
statusColors.warning,
|
||||
StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
|
||||
PluginServerState.VERSION_TOO_OLD -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_version_too_old),
|
||||
statusColors.error,
|
||||
StatusBadgeType.ERROR,
|
||||
)
|
||||
}
|
||||
|
||||
PluginServerState.MISSING_APP -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_missing_app),
|
||||
statusColors.error,
|
||||
StatusBadgeType.ERROR,
|
||||
)
|
||||
}
|
||||
|
||||
PluginServerState.UNKNOWN -> {
|
||||
Triple(
|
||||
stringResource(R.string.plugins_server_state_unknown),
|
||||
statusColors.warning,
|
||||
StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = server.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatusBadge(text = statusLabel, status = badgeType)
|
||||
}
|
||||
if (server.sentence.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = server.sentence,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginSettingFieldRow(
|
||||
field: PluginSettingField,
|
||||
value: String,
|
||||
enabled: Boolean,
|
||||
onChange: (String) -> Unit,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = field.label.ifEmpty { field.key },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (field.type == PluginSettingFieldType.SECRET) {
|
||||
val isSet = field.hasValue == true
|
||||
val badgeRes =
|
||||
if (isSet) {
|
||||
R.string.plugins_settings_secret_set
|
||||
} else {
|
||||
R.string.plugins_settings_secret_not_set
|
||||
}
|
||||
StatusBadge(
|
||||
text = stringResource(badgeRes),
|
||||
status = if (isSet) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (field.description.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = field.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (field.type == PluginSettingFieldType.SECRET && !field.env.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_settings_secret_hint, field.env),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
when (field.type) {
|
||||
PluginSettingFieldType.ENUM -> {
|
||||
ExposedDropdownField(
|
||||
label = field.label.ifEmpty { field.key },
|
||||
options = field.choices.orEmpty().ifEmpty { listOf(value) },
|
||||
selectedValue = value,
|
||||
onOptionSelected = onChange,
|
||||
)
|
||||
}
|
||||
|
||||
PluginSettingFieldType.BOOLEAN -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Switch(
|
||||
checked = value.equals("true", ignoreCase = true),
|
||||
onCheckedChange = { onChange(it.toString()) },
|
||||
enabled = enabled,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text =
|
||||
if (value.equals("true", ignoreCase = true)) {
|
||||
stringResource(R.string.memory_provider_bool_on)
|
||||
} else {
|
||||
stringResource(R.string.memory_provider_bool_off)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onChange,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = {
|
||||
Text(
|
||||
if (field.type == PluginSettingFieldType.SECRET) {
|
||||
stringResource(R.string.memory_provider_secret_label)
|
||||
} else {
|
||||
field.label.ifEmpty { field.key }
|
||||
},
|
||||
)
|
||||
},
|
||||
singleLine = field.type != PluginSettingFieldType.JSON,
|
||||
visualTransformation =
|
||||
if (field.type == PluginSettingFieldType.SECRET) {
|
||||
PasswordVisualTransformation()
|
||||
} else {
|
||||
androidx.compose.ui.text.input.VisualTransformation.None
|
||||
},
|
||||
keyboardOptions =
|
||||
if (field.type == PluginSettingFieldType.NUMBER) {
|
||||
KeyboardOptions(keyboardType = KeyboardType.Decimal)
|
||||
} else {
|
||||
KeyboardOptions.Default
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user