feat(plugins): browse and install official plugins from catalog (#1088) (#1096)

This commit is contained in:
M57 authored and GitHub committed 2026-09-12 22:32:52 +03:00
1 parent 47b6955c9a
commit d774a63663
8 files changed
+840 -71

No files matched your search

@@ -80,9 +80,63 @@ data class PluginsHubResponse(
@Serializable @Serializable
data class AgentPluginInstallBody( data class AgentPluginInstallBody(
val identifier: String, val identifier: String = "",
val force: Boolean = false, val force: Boolean = false,
val enable: Boolean = true, val enable: Boolean = true,
@SerialName("catalog_name") val catalogName: String? = null,
val ref: String? = null,
)
@Serializable
data class PluginCatalogCapabilities(
@SerialName("provides_tools") val providesTools: List<String> = emptyList(),
@SerialName("provides_hooks") val providesHooks: List<String> = emptyList(),
@SerialName("provides_middleware") val providesMiddleware: List<String> = emptyList(),
@SerialName("requires_env") val requiresEnv: List<String> = emptyList(),
)
@Serializable
data class PluginCatalogRemovedEntry(
val name: String? = null,
val repo: String? = null,
val reason: String? = null,
val date: String? = null,
)
@Serializable
data class PluginCatalogEntry(
val name: String,
@SerialName("catalog_name") val catalogName: String? = null,
val repo: String? = null,
val sha: String? = null,
@SerialName("sha_short") val shaShort: String? = null,
val description: String? = null,
val maintainer: String? = null,
val tier: String? = null,
val category: String? = null,
@SerialName("requires_hermes") val requiresHermes: String? = null,
val subdir: String? = null,
@SerialName("docs_url") val docsUrl: String? = null,
val platforms: List<String> = emptyList(),
val capabilities: PluginCatalogCapabilities? = null,
@SerialName("capability_summary") val capabilitySummary: String? = null,
val installed: Boolean = false,
@SerialName("installed_sha") val installedSha: String? = null,
@SerialName("update_available") val updateAvailable: Boolean = false,
@SerialName("runtime_status") val runtimeStatus: String? = null,
) {
val displayName: String
get() = catalogName?.takeIf { it.isNotBlank() } ?: name
val isOfficial: Boolean
get() = tier?.equals("official", ignoreCase = true) == true
}
@Serializable
data class PluginCatalogResponse(
val entries: List<PluginCatalogEntry> = emptyList(),
val removed: List<PluginCatalogRemovedEntry> = emptyList(),
@SerialName("generated_at") val generatedAt: String? = null,
) )
@Serializable @Serializable
@@ -80,6 +80,7 @@ import com.m57.hermescontrol.data.model.OAuthSubmitResponse
import com.m57.hermescontrol.data.model.PairingApproveRequest import com.m57.hermescontrol.data.model.PairingApproveRequest
import com.m57.hermescontrol.data.model.PairingResponse import com.m57.hermescontrol.data.model.PairingResponse
import com.m57.hermescontrol.data.model.PairingRevokeRequest import com.m57.hermescontrol.data.model.PairingRevokeRequest
import com.m57.hermescontrol.data.model.PluginCatalogResponse
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.model.PortalResponse import com.m57.hermescontrol.data.model.PortalResponse
@@ -625,6 +626,9 @@ interface HermesApiService {
@GET("api/dashboard/plugins/hub") @GET("api/dashboard/plugins/hub")
suspend fun getPlugins(): Response<PluginsHubResponse> suspend fun getPlugins(): Response<PluginsHubResponse>
@GET("api/dashboard/plugins/catalog")
suspend fun getPluginCatalog(): Response<PluginCatalogResponse>
@POST("api/dashboard/agent-plugins/install") @POST("api/dashboard/agent-plugins/install")
suspend fun installPlugin( suspend fun installPlugin(
@Body body: AgentPluginInstallBody, @Body body: AgentPluginInstallBody,
@@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringResource
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.Toolset import com.m57.hermescontrol.data.model.Toolset
@@ -68,6 +69,26 @@ fun PluginInfo.toDetailRows(): List<DetailRow> =
DetailRow(stringResource(R.string.detail_dialog_description), description), DetailRow(stringResource(R.string.detail_dialog_description), description),
) )
@Composable
fun PluginCatalogEntry.toDetailRows(): List<DetailRow> =
listOfNotNull(
tier?.let { DetailRow(stringResource(R.string.detail_dialog_tier), it) },
maintainer?.let { DetailRow(stringResource(R.string.detail_dialog_maintainer), it) },
category?.let { DetailRow(stringResource(R.string.detail_dialog_category), it) },
repo?.let { DetailRow(stringResource(R.string.detail_dialog_repository), it) },
shaShort?.let { DetailRow(stringResource(R.string.detail_dialog_commit), it) },
requiresHermes?.takeIf { it.isNotBlank() }?.let {
DetailRow(stringResource(R.string.detail_dialog_requires_hermes), it)
},
docsUrl?.takeIf { it.isNotBlank() }?.let {
DetailRow(stringResource(R.string.detail_dialog_docs), it)
},
capabilitySummary?.takeIf { it.isNotBlank() }?.let {
DetailRow(stringResource(R.string.detail_dialog_capabilities), it)
},
description?.let { DetailRow(stringResource(R.string.detail_dialog_description), it) },
)
@Composable @Composable
fun Toolset.toDetailRows(): List<DetailRow> = fun Toolset.toDetailRows(): List<DetailRow> =
listOf( listOf(
@@ -29,6 +29,9 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -47,6 +50,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.MemoryProviderDetailKey import com.m57.hermescontrol.MemoryProviderDetailKey
import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.DetailDialog import com.m57.hermescontrol.ui.common.DetailDialog
@@ -61,6 +65,7 @@ import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.common.toDetailRows import com.m57.hermescontrol.ui.common.toDetailRows
import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -73,6 +78,7 @@ fun PluginsScreen(
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<PluginInfo?>(null) } var showDetail by remember { mutableStateOf<PluginInfo?>(null) }
var showCatalogDetail by remember { mutableStateOf<PluginCatalogEntry?>(null) }
val filteredPlugins = val filteredPlugins =
remember(query, state.plugins) { remember(query, state.plugins) {
@@ -82,6 +88,29 @@ fun PluginsScreen(
} }
} }
val filteredCatalogEntries =
remember(state.catalogEntries, state.catalogQuery, state.catalogTierFilter) {
state.catalogEntries.filter { entry ->
val matchesQuery =
state.catalogQuery.isBlank() ||
entry.displayName.contains(state.catalogQuery, ignoreCase = true) ||
entry.description?.contains(state.catalogQuery, ignoreCase = true) == true ||
entry.maintainer?.contains(state.catalogQuery, ignoreCase = true) == true ||
entry.capabilities?.providesTools?.any {
it.contains(
state.catalogQuery,
ignoreCase = true,
)
} == true
val matchesTier =
state.catalogTierFilter == null ||
entry.tier?.equals(state.catalogTierFilter, ignoreCase = true) == true
matchesQuery && matchesTier
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadPlugins() viewModel.loadPlugins()
} }
@@ -120,85 +149,130 @@ fun PluginsScreen(
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_plugins)) }, title = { Text(stringResource(R.string.screen_plugins)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = if (state.selectedTab == PluginsTab.INSTALLED) state.isLoading else state.isCatalogLoading,
onRefresh = { viewModel.loadPlugins() }, onRefresh = {
) { paddingValues -> if (state.selectedTab == PluginsTab.INSTALLED) {
when { viewModel.loadPlugins()
state.isLoading && state.plugins.isEmpty() -> { } else {
SkeletonListState(modifier = Modifier.padding(paddingValues)) viewModel.loadCatalog(isRefresh = true)
} }
},
state.errorMessage != null -> { ) { _ ->
ErrorState( Column(modifier = Modifier.fillMaxSize()) {
message = state.errorMessage ?: "", SingleChoiceSegmentedButtonRow(
onRetry = { viewModel.loadPlugins() }, modifier =
modifier = Modifier.padding(paddingValues), Modifier
) .fillMaxWidth()
} .padding(horizontal = 16.dp, vertical = 8.dp),
) {
state.plugins.isEmpty() && state.orphanPlugins.isEmpty() -> { SegmentedButton(
EmptyState( selected = state.selectedTab == PluginsTab.INSTALLED,
title = stringResource(R.string.plugins_empty_title), onClick = { viewModel.setTab(PluginsTab.INSTALLED) },
subtitle = stringResource(R.string.plugins_empty_desc), shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
onAction = { viewModel.loadPlugins() },
actionLabel = stringResource(R.string.content_desc_refresh),
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) { ) {
// Provider selection section Text(stringResource(R.string.plugins_tab_installed))
if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) { }
item(key = "providers") { SegmentedButton(
ProviderSelectionSection(state, viewModel) selected = state.selectedTab == PluginsTab.CATALOG,
onClick = { viewModel.setTab(PluginsTab.CATALOG) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
) {
Text(stringResource(R.string.plugins_tab_catalog))
}
}
when (state.selectedTab) {
PluginsTab.INSTALLED -> {
when {
state.isLoading && state.plugins.isEmpty() -> {
SkeletonListState()
} }
}
// Install section state.errorMessage != null && state.plugins.isEmpty() -> {
item(key = "install") { ErrorState(
InstallSection(state, viewModel) message = state.errorMessage ?: "",
} onRetry = { viewModel.loadPlugins() },
// Search bar
item(key = "search") {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.plugins_search_placeholder),
)
}
// Plugin list
items(filteredPlugins, key = { it.name }) { plugin ->
PluginCard(
plugin = plugin,
state = state,
viewModel = viewModel,
onClick = { showDetail = plugin },
)
}
// Orphan dashboard plugins section
if (state.orphanPlugins.isNotEmpty()) {
item(key = "orphan-header") {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.plugins_orphan_heading),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
) )
} }
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin }) state.plugins.isEmpty() && state.orphanPlugins.isEmpty() -> {
EmptyState(
title = stringResource(R.string.plugins_empty_title),
subtitle = stringResource(R.string.plugins_empty_desc),
onAction = { viewModel.loadPlugins() },
actionLabel = stringResource(R.string.content_desc_refresh),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
// Provider selection section
if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) {
item(key = "providers") {
ProviderSelectionSection(state, viewModel)
}
}
// Install section
item(key = "install") {
InstallSection(state, viewModel)
}
// Search bar
item(key = "search") {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.plugins_search_placeholder),
)
}
// Plugin list
items(filteredPlugins, key = { it.name }) { plugin ->
PluginCard(
plugin = plugin,
state = state,
viewModel = viewModel,
onClick = { showDetail = plugin },
)
}
// Orphan dashboard plugins section
if (state.orphanPlugins.isNotEmpty()) {
item(key = "orphan-header") {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.plugins_orphan_heading),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
}
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin })
}
}
}
} }
} }
} }
PluginsTab.CATALOG -> {
CatalogPluginsView(
state = state,
filteredEntries = filteredCatalogEntries,
onQueryChange = viewModel::setCatalogQuery,
onTierFilterChange = viewModel::setCatalogTierFilter,
onInstall = { viewModel.installCatalogPlugin(it) },
onUpdate = { viewModel.installCatalogPlugin(it, force = true) },
onShowDetail = { showCatalogDetail = it },
onRetry = { viewModel.loadCatalog(isRefresh = true) },
)
}
} }
} }
} }
@@ -210,6 +284,14 @@ fun PluginsScreen(
onDismiss = { showDetail = null }, onDismiss = { showDetail = null },
) )
} }
showCatalogDetail?.let { entry ->
DetailDialog(
title = entry.displayName,
rows = entry.toDetailRows(),
onDismiss = { showCatalogDetail = null },
)
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.plugins
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
import com.m57.hermescontrol.data.model.ProviderOption import com.m57.hermescontrol.data.model.ProviderOption
@@ -9,6 +11,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost 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.safeLaunchLoad
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,7 +21,13 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
enum class PluginsTab {
INSTALLED,
CATALOG,
}
data class PluginsUiState( data class PluginsUiState(
val selectedTab: PluginsTab = PluginsTab.INSTALLED,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val plugins: List<PluginInfo> = emptyList(), val plugins: List<PluginInfo> = emptyList(),
val orphanPlugins: List<PluginInfo> = emptyList(), val orphanPlugins: List<PluginInfo> = emptyList(),
@@ -41,6 +50,13 @@ data class PluginsUiState(
val removeConfirmPlugin: String? = null, val removeConfirmPlugin: String? = null,
// Rescan // Rescan
val rescanBusy: Boolean = false, val rescanBusy: Boolean = false,
// Catalog state
val catalogEntries: List<PluginCatalogEntry> = emptyList(),
val isCatalogLoading: Boolean = false,
val catalogErrorMessage: String? = null,
val catalogInstallingName: String? = null,
val catalogQuery: String = "",
val catalogTierFilter: String? = null,
) { ) {
/** Built-in memory provider sentinel — empty string means "use config defaults" */ /** Built-in memory provider sentinel — empty string means "use config defaults" */
val isMemoryBuiltin: Boolean val isMemoryBuiltin: Boolean
@@ -382,6 +398,93 @@ class PluginsViewModel :
_uiState.update { if (it.rowBusy == name) it.copy(rowBusy = null) else it } _uiState.update { if (it.rowBusy == name) it.copy(rowBusy = null) else it }
} }
fun setTab(tab: PluginsTab) {
_uiState.update { it.copy(selectedTab = tab) }
if (tab == PluginsTab.CATALOG && _uiState.value.catalogEntries.isEmpty()) {
loadCatalog()
}
}
fun loadCatalog(isRefresh: Boolean = false) {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getPluginCatalog() } },
onStart = {
_uiState.update {
it.copy(
isCatalogLoading = true,
catalogErrorMessage = if (isRefresh) it.catalogErrorMessage else null,
)
}
},
onSuccess = { response ->
_uiState.update {
it.copy(
isCatalogLoading = false,
catalogEntries = response.entries,
catalogErrorMessage = null,
)
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isCatalogLoading = false,
catalogErrorMessage = "Failed to load catalog: $errorMsg",
)
}
},
)
}
fun setCatalogQuery(query: String) {
_uiState.update { it.copy(catalogQuery = query) }
}
fun setCatalogTierFilter(tier: String?) {
_uiState.update { it.copy(catalogTierFilter = tier) }
}
fun installCatalogPlugin(
entry: PluginCatalogEntry,
force: Boolean = false,
) {
val catalogName = entry.name
safeLaunchAction(
onStart = { _uiState.update { it.copy(catalogInstallingName = catalogName) } },
apiCall = {
safeApiCall {
ApiClient.hermesApi.installPlugin(
AgentPluginInstallBody(
identifier = entry.repo ?: "",
catalogName = catalogName,
force = force,
enable = true,
),
)
}
},
onSuccess = {
_uiState.update {
it.copy(
toastMessage = "Plugin \"${entry.displayName}\" installed successfully",
)
}
loadPlugins()
loadCatalog(isRefresh = true)
},
onError = { errorMsg ->
_uiState.update {
it.copy(
toastMessage = "Failed to install \"${entry.displayName}\": $errorMsg",
)
}
},
onComplete = {
_uiState.update { it.copy(catalogInstallingName = null) }
},
)
}
override fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
@@ -0,0 +1,289 @@
package com.m57.hermescontrol.ui.plugins.components
import androidx.compose.foundation.clickable
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.fillMaxSize
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.InstallDesktop
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.FilterChipRow
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.plugins.PluginsUiState
@Composable
fun CatalogPluginsView(
state: PluginsUiState,
filteredEntries: List<PluginCatalogEntry>,
onQueryChange: (String) -> Unit,
onTierFilterChange: (String?) -> Unit,
onInstall: (PluginCatalogEntry) -> Unit,
onUpdate: (PluginCatalogEntry) -> Unit,
onShowDetail: (PluginCatalogEntry) -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxSize()) {
SearchBar(
query = state.catalogQuery,
onQueryChange = onQueryChange,
placeholder = stringResource(R.string.plugins_catalog_search_placeholder),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
FilterChipRow(
chips = listOf(null, "official", "community"),
selectedChip = state.catalogTierFilter,
onChipSelected = onTierFilterChange,
modifier = Modifier.padding(vertical = 2.dp),
chipLabel = { tier ->
Text(
text =
when (tier) {
null -> stringResource(R.string.plugins_catalog_filter_all)
"official" -> stringResource(R.string.plugins_catalog_filter_official)
"community" -> stringResource(R.string.plugins_catalog_filter_community)
else -> tier
},
)
},
)
when {
state.isCatalogLoading && state.catalogEntries.isEmpty() -> {
SkeletonListState()
}
state.catalogErrorMessage != null && state.catalogEntries.isEmpty() -> {
ErrorState(
message = state.catalogErrorMessage,
onRetry = onRetry,
)
}
filteredEntries.isEmpty() -> {
EmptyState(
title = stringResource(R.string.plugins_catalog_empty_title),
subtitle = stringResource(R.string.plugins_catalog_empty_desc),
onAction = onRetry,
actionLabel = stringResource(R.string.content_desc_refresh),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(filteredEntries, key = { it.name }) { entry ->
CatalogPluginCard(
entry = entry,
isInstalling = state.catalogInstallingName == entry.name,
onInstall = { onInstall(entry) },
onUpdate = { onUpdate(entry) },
onClick = { onShowDetail(entry) },
)
}
}
}
}
}
}
@Composable
fun CatalogPluginCard(
entry: PluginCatalogEntry,
isInstalling: Boolean,
onInstall: () -> Unit,
onUpdate: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val statusColors = LocalHermesStatusColors.current
Card(
modifier =
modifier
.fillMaxWidth()
.clickable(onClick = onClick),
) {
Column(modifier = Modifier.padding(16.dp)) {
// Header row: Title + Maintainer on left, Tier badge on right
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.displayName,
style = MaterialTheme.typography.titleMedium,
)
if (!entry.maintainer.isNullOrBlank()) {
Text(
text = stringResource(R.string.plugins_catalog_maintainer, entry.maintainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
val isOfficial = entry.isOfficial
Surface(
shape = RoundedCornerShape(12.dp),
color =
if (isOfficial) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
},
) {
Text(
text =
if (isOfficial) {
stringResource(R.string.plugins_catalog_tier_official)
} else {
stringResource(R.string.plugins_catalog_tier_community)
},
style = MaterialTheme.typography.labelSmall,
color =
if (isOfficial) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}
Spacer(modifier = Modifier.height(6.dp))
// Description
entry.description?.let { desc ->
Text(
text = desc,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(8.dp))
}
// Bottom row: Status badge on left, Action button on right
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (entry.installed) {
Text(
text = stringResource(R.string.plugins_catalog_installed_badge),
style = MaterialTheme.typography.labelSmall,
color = statusColors.success,
)
}
if (entry.updateAvailable) {
Text(
text = stringResource(R.string.plugins_catalog_update),
style = MaterialTheme.typography.labelSmall,
color = statusColors.warning,
)
}
}
if (entry.installed && entry.updateAvailable) {
OutlinedButton(
onClick = onUpdate,
enabled = !isInstalling,
) {
if (isInstalling) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.plugins_catalog_update))
}
} else if (entry.installed) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(vertical = 8.dp),
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = statusColors.success,
)
Text(
text = stringResource(R.string.plugins_catalog_installed_badge),
style = MaterialTheme.typography.labelMedium,
color = statusColors.success,
)
}
} else {
Button(
onClick = onInstall,
enabled = !isInstalling,
) {
if (isInstalling) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
} else {
Icon(
imageVector = Icons.Default.InstallDesktop,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.plugins_action_install))
}
}
}
}
}
}
+22
View File
@@ -796,6 +796,21 @@
<string name="plugins_orphan_heading">Orphaned Dashboard Plugins</string> <string name="plugins_orphan_heading">Orphaned Dashboard Plugins</string>
<string name="plugins_show_in_sidebar">Show in sidebar</string> <string name="plugins_show_in_sidebar">Show in sidebar</string>
<string name="plugins_hide_from_sidebar">Hide from sidebar</string> <string name="plugins_hide_from_sidebar">Hide from sidebar</string>
<string name="plugins_tab_installed">Installed</string>
<string name="plugins_tab_catalog">Catalog</string>
<string name="plugins_catalog_search_placeholder">Search catalog plugins...</string>
<string name="plugins_catalog_empty_title">No catalog plugins found</string>
<string name="plugins_catalog_empty_desc">No plugins match your filter or search query</string>
<string name="plugins_catalog_installing">Installing %1$s…</string>
<string name="plugins_catalog_installed_success">Plugin \"%1$s\" installed successfully</string>
<string name="plugins_catalog_update">Update</string>
<string name="plugins_catalog_installed_badge">Installed</string>
<string name="plugins_catalog_filter_all">All</string>
<string name="plugins_catalog_filter_official">Official</string>
<string name="plugins_catalog_filter_community">Community</string>
<string name="plugins_catalog_maintainer">by %1$s</string>
<string name="plugins_catalog_tier_official">Official</string>
<string name="plugins_catalog_tier_community">Community</string>
<!-- Shared detail dialog (issue #500) --> <!-- Shared detail dialog (issue #500) -->
<string name="detail_dialog_category">Category</string> <string name="detail_dialog_category">Category</string>
@@ -819,6 +834,13 @@
<string name="detail_dialog_status_enabled">Enabled</string> <string name="detail_dialog_status_enabled">Enabled</string>
<string name="detail_dialog_status_disabled">Disabled</string> <string name="detail_dialog_status_disabled">Disabled</string>
<string name="detail_dialog_trust_level">Trust Level</string> <string name="detail_dialog_trust_level">Trust Level</string>
<string name="detail_dialog_tier">Tier</string>
<string name="detail_dialog_maintainer">Maintainer</string>
<string name="detail_dialog_repository">Repository</string>
<string name="detail_dialog_commit">Commit SHA</string>
<string name="detail_dialog_requires_hermes">Requires Hermes</string>
<string name="detail_dialog_docs">Documentation</string>
<string name="detail_dialog_capabilities">Capabilities</string>
<!-- McpServers Screen --> <!-- McpServers Screen -->
<string name="mcp_servers_label_transport">Transport: %1$s</string> <string name="mcp_servers_label_transport">Transport: %1$s</string>
@@ -0,0 +1,194 @@
package com.m57.hermescontrol.ui.plugins
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.PluginCatalogCapabilities
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginCatalogResponse
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class PluginsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
private val sampleCatalogEntry =
PluginCatalogEntry(
name = "snyk",
catalogName = "snyk",
repo = "https://github.com/NousResearch/hermes-plugin-snyk",
sha = "30e0adfeaa181190bc1f81cf434a94aa6a56015f",
shaShort = "30e0adf",
description = "Security scanning for dependencies and code.",
maintainer = "NousResearch",
tier = "official",
capabilities =
PluginCatalogCapabilities(
providesTools = listOf("snyk_test"),
),
installed = false,
updateAvailable = false,
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockApi = mockk()
mockkObject(ApiClient)
every { ApiClient.hermesApi } returns mockApi
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `switch tab to CATALOG automatically triggers catalog load`() {
coEvery { mockApi.getPluginCatalog() } returns
Response.success(PluginCatalogResponse(entries = listOf(sampleCatalogEntry)))
val viewModel = PluginsViewModel()
viewModel.setTab(PluginsTab.CATALOG)
assertEquals(PluginsTab.CATALOG, viewModel.uiState.value.selectedTab)
assertTrue(viewModel.uiState.value.isCatalogLoading)
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(viewModel.uiState.value.isCatalogLoading)
assertEquals(1, viewModel.uiState.value.catalogEntries.size)
assertEquals(
"snyk",
viewModel.uiState.value.catalogEntries
.first()
.name,
)
assertTrue(
viewModel.uiState.value.catalogEntries
.first()
.isOfficial,
)
assertNull(viewModel.uiState.value.catalogErrorMessage)
coVerify(exactly = 1) { mockApi.getPluginCatalog() }
}
@Test
fun `loadCatalog handles api error gracefully`() {
coEvery { mockApi.getPluginCatalog() } returns
Response.error(500, "Internal Server Error".toResponseBody())
val viewModel = PluginsViewModel()
viewModel.loadCatalog()
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(viewModel.uiState.value.isCatalogLoading)
assertTrue(
viewModel.uiState.value.catalogEntries
.isEmpty(),
)
assertTrue(
viewModel.uiState.value.catalogErrorMessage
?.contains("500") == true,
)
}
@Test
fun `filter and query updates affect state`() {
val viewModel = PluginsViewModel()
viewModel.setCatalogQuery("security")
viewModel.setCatalogTierFilter("official")
assertEquals("security", viewModel.uiState.value.catalogQuery)
assertEquals("official", viewModel.uiState.value.catalogTierFilter)
}
@Test
fun `installCatalogPlugin executes install and refreshes lists`() {
coEvery {
mockApi.installPlugin(
AgentPluginInstallBody(
identifier = sampleCatalogEntry.repo ?: "",
catalogName = sampleCatalogEntry.name,
force = false,
enable = true,
),
)
} returns Response.success(Unit)
coEvery { mockApi.getPlugins() } returns
Response.success(PluginsHubResponse(plugins = listOf(PluginInfo(name = "snyk", runtimeStatus = "enabled"))))
coEvery { mockApi.getPluginCatalog() } returns
Response.success(
PluginCatalogResponse(
entries = listOf(sampleCatalogEntry.copy(installed = true)),
),
)
val viewModel = PluginsViewModel()
viewModel.installCatalogPlugin(sampleCatalogEntry)
assertEquals("snyk", viewModel.uiState.value.catalogInstallingName)
testDispatcher.scheduler.advanceUntilIdle()
assertNull(viewModel.uiState.value.catalogInstallingName)
assertEquals("Plugin \"snyk\" installed successfully", viewModel.uiState.value.toastMessage)
assertEquals(1, viewModel.uiState.value.plugins.size)
assertTrue(
viewModel.uiState.value.catalogEntries
.first()
.installed,
)
coVerify(exactly = 1) {
mockApi.installPlugin(
match { it.catalogName == "snyk" && it.enable && !it.force },
)
}
}
@Test
fun `installCatalogPlugin failure sets toast error`() {
coEvery {
mockApi.installPlugin(any())
} returns Response.error(400, "Clone failed".toResponseBody())
val viewModel = PluginsViewModel()
viewModel.installCatalogPlugin(sampleCatalogEntry)
testDispatcher.scheduler.advanceUntilIdle()
assertNull(viewModel.uiState.value.catalogInstallingName)
assertTrue(
viewModel.uiState.value.toastMessage
?.contains("Failed to install") == true,
)
}
}