This commit is contained in:
8 files changed
+840
-71
No files matched your search
@@ -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<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
|
||||
|
||||
@@ -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<PluginsHubResponse>
|
||||
|
||||
@GET("api/dashboard/plugins/catalog")
|
||||
suspend fun getPluginCatalog(): Response<PluginCatalogResponse>
|
||||
|
||||
@POST("api/dashboard/agent-plugins/install")
|
||||
suspend fun installPlugin(
|
||||
@Body body: AgentPluginInstallBody,
|
||||
|
||||
@@ -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> =
|
||||
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
|
||||
fun Toolset.toDetailRows(): List<DetailRow> =
|
||||
listOf(
|
||||
|
||||
@@ -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<PluginInfo?>(null) }
|
||||
var showCatalogDetail by remember { mutableStateOf<PluginCatalogEntry?>(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)
|
||||
|
||||
@@ -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<PluginInfo> = emptyList(),
|
||||
val orphanPlugins: List<PluginInfo> = emptyList(),
|
||||
@@ -41,6 +50,13 @@ data class PluginsUiState(
|
||||
val removeConfirmPlugin: String? = null,
|
||||
// Rescan
|
||||
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" */
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -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_show_in_sidebar">Show in 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) -->
|
||||
<string name="detail_dialog_category">Category</string>
|
||||
@@ -819,6 +834,13 @@
|
||||
<string name="detail_dialog_status_enabled">Enabled</string>
|
||||
<string name="detail_dialog_status_disabled">Disabled</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 -->
|
||||
<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