This commit is contained in:
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user