diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt index 1ca609bd..5ba18ac5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt @@ -80,9 +80,63 @@ data class PluginsHubResponse( @Serializable data class AgentPluginInstallBody( - val identifier: String, + val identifier: String = "", val force: Boolean = false, 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 = emptyList(), + @SerialName("provides_hooks") val providesHooks: List = emptyList(), + @SerialName("provides_middleware") val providesMiddleware: List = emptyList(), + @SerialName("requires_env") val requiresEnv: List = 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 = 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 = emptyList(), + val removed: List = emptyList(), + @SerialName("generated_at") val generatedAt: String? = null, ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 90745c64..88abd60a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -80,6 +80,7 @@ import com.m57.hermescontrol.data.model.OAuthSubmitResponse import com.m57.hermescontrol.data.model.PairingApproveRequest import com.m57.hermescontrol.data.model.PairingResponse 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.PluginsHubResponse import com.m57.hermescontrol.data.model.PortalResponse @@ -625,6 +626,9 @@ interface HermesApiService { @GET("api/dashboard/plugins/hub") suspend fun getPlugins(): Response + @GET("api/dashboard/plugins/catalog") + suspend fun getPluginCatalog(): Response + @POST("api/dashboard/agent-plugins/install") suspend fun installPlugin( @Body body: AgentPluginInstallBody, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt index f1e82343..c5763b3b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringResource import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.HubSkill 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.Skill import com.m57.hermescontrol.data.model.Toolset @@ -68,6 +69,26 @@ fun PluginInfo.toDetailRows(): List = DetailRow(stringResource(R.string.detail_dialog_description), description), ) +@Composable +fun PluginCatalogEntry.toDetailRows(): List = + 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 fun Toolset.toDetailRows(): List = listOf( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index e62e5f86..9b391987 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -29,6 +29,9 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton 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.Text import androidx.compose.material3.TextButton @@ -47,6 +50,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.MemoryProviderDetailKey import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.theme.LocalHermesStatusColors 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.listItemSpacing import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -73,6 +78,7 @@ fun PluginsScreen( var query by remember { mutableStateOf("") } var showDetail by remember { mutableStateOf(null) } + var showCatalogDetail by remember { mutableStateOf(null) } val filteredPlugins = 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) { viewModel.loadPlugins() } @@ -120,85 +149,130 @@ fun PluginsScreen( HermesScaffold( title = { Text(stringResource(R.string.screen_plugins)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, - isRefreshing = state.isLoading, - onRefresh = { viewModel.loadPlugins() }, - ) { paddingValues -> - when { - state.isLoading && state.plugins.isEmpty() -> { - SkeletonListState(modifier = Modifier.padding(paddingValues)) + isRefreshing = if (state.selectedTab == PluginsTab.INSTALLED) state.isLoading else state.isCatalogLoading, + onRefresh = { + if (state.selectedTab == PluginsTab.INSTALLED) { + viewModel.loadPlugins() + } else { + viewModel.loadCatalog(isRefresh = true) } - - state.errorMessage != null -> { - ErrorState( - message = state.errorMessage ?: "", - onRetry = { viewModel.loadPlugins() }, - modifier = Modifier.padding(paddingValues), - ) - } - - 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), - modifier = Modifier.padding(paddingValues), - ) - } - - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listContentPadding, - verticalArrangement = listItemSpacing, + }, + ) { _ -> + Column(modifier = Modifier.fillMaxSize()) { + SingleChoiceSegmentedButtonRow( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + SegmentedButton( + selected = state.selectedTab == PluginsTab.INSTALLED, + onClick = { viewModel.setTab(PluginsTab.INSTALLED) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), ) { - // Provider selection section - if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) { - item(key = "providers") { - ProviderSelectionSection(state, viewModel) + Text(stringResource(R.string.plugins_tab_installed)) + } + SegmentedButton( + 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 - 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), + state.errorMessage != null && state.plugins.isEmpty() -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadPlugins() }, ) } - 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 }, ) } + + showCatalogDetail?.let { entry -> + DetailDialog( + title = entry.displayName, + rows = entry.toDetailRows(), + onDismiss = { showCatalogDetail = null }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index 90d92114..d61507bb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.plugins import androidx.lifecycle.ViewModel 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.PluginProvidersPutRequest 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.safeApiCall import com.m57.hermescontrol.ui.common.ToastHost +import com.m57.hermescontrol.ui.common.safeLaunchAction import com.m57.hermescontrol.ui.common.safeLaunchLoad import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -18,7 +21,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +enum class PluginsTab { + INSTALLED, + CATALOG, +} + data class PluginsUiState( + val selectedTab: PluginsTab = PluginsTab.INSTALLED, val isLoading: Boolean = false, val plugins: List = emptyList(), val orphanPlugins: List = emptyList(), @@ -41,6 +50,13 @@ data class PluginsUiState( val removeConfirmPlugin: String? = null, // Rescan val rescanBusy: Boolean = false, + // Catalog state + val catalogEntries: List = 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" */ val isMemoryBuiltin: Boolean @@ -382,6 +398,93 @@ class PluginsViewModel : _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() { _uiState.update { it.copy(toastMessage = null) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/CatalogPluginsView.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/CatalogPluginsView.kt new file mode 100644 index 00000000..30a1727e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/CatalogPluginsView.kt @@ -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, + 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)) + } + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5872bb3..c6e776b5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -796,6 +796,21 @@ Orphaned Dashboard Plugins Show in sidebar Hide from sidebar + Installed + Catalog + Search catalog plugins... + No catalog plugins found + No plugins match your filter or search query + Installing %1$s… + Plugin \"%1$s\" installed successfully + Update + Installed + All + Official + Community + by %1$s + Official + Community Category @@ -819,6 +834,13 @@ Enabled Disabled Trust Level + Tier + Maintainer + Repository + Commit SHA + Requires Hermes + Documentation + Capabilities Transport: %1$s diff --git a/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt new file mode 100644 index 00000000..010da388 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt @@ -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, + ) + } +}