feat(mcp): Installed/Catalog tabs with search, matching Plugins and Skills (#1404)

This commit is contained in:
m57 authored and GitHub committed 2026-09-30 13:15:30 +03:00
1 parent fc2ce3f26b
commit 81b5e6d84b
6 files changed
+204 -319

No files matched your search

@@ -1,98 +1,59 @@
package com.m57.hermescontrol.ui.mcp
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Science
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
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.mcp.components.AddServerSection
import com.m57.hermescontrol.ui.mcp.components.CatalogSection
import com.m57.hermescontrol.ui.mcp.components.CatalogView
import com.m57.hermescontrol.ui.mcp.components.McpDialogs
import com.m57.hermescontrol.ui.mcp.components.ServerCard
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun McpServersScreen(
modifier: Modifier = Modifier,
@@ -138,120 +99,158 @@ fun McpServersScreen(
HermesScaffold(
title = { Text(stringResource(R.string.screen_mcp_servers)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadServers(forceRefresh = true) },
actions = {
IconButton(
onClick = { viewModel.testAllServers() },
enabled = !state.isTestingAll && state.servers.any { it.enabled },
) {
if (state.isTestingAll) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = Icons.Filled.Science,
contentDescription = stringResource(R.string.mcp_servers_action_test_all),
)
}
}
IconButton(
onClick = { viewModel.toggleImportDialog() },
) {
Icon(
imageVector = Icons.Filled.UploadFile,
contentDescription = stringResource(R.string.mcp_servers_action_import_json),
)
isRefreshing = if (state.selectedTab == McpTab.INSTALLED) state.isLoading else state.catalogLoading,
onRefresh = {
if (state.selectedTab == McpTab.INSTALLED) {
viewModel.loadServers(forceRefresh = true)
} else {
viewModel.loadCatalog()
}
},
) { paddingValues ->
when {
state.isLoading && state.servers.isEmpty() -> {
SkeletonListState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadServers() },
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
actions = {
if (state.selectedTab == McpTab.INSTALLED) {
IconButton(onClick = { viewModel.toggleAddForm() }) {
Icon(
imageVector = if (state.showAddForm) Icons.Filled.Close else Icons.Filled.Add,
contentDescription = stringResource(R.string.mcp_servers_add_server),
)
}
IconButton(onClick = { viewModel.toggleImportDialog() }) {
Icon(
imageVector = Icons.Filled.UploadFile,
contentDescription = stringResource(R.string.mcp_servers_action_import_json),
)
}
IconButton(
onClick = { viewModel.testAllServers() },
enabled = !state.isTestingAll && state.servers.any { it.enabled },
) {
// ── 1. Add server ────────────────────────────────────
item(key = "add-header") {
AddServerSection(state, viewModel, spacing)
}
// ── 2. Search ────────────────────────────────────────
item(key = "search") {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.mcp_search_placeholder),
if (state.isTestingAll) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = Icons.Filled.Science,
contentDescription = stringResource(R.string.mcp_servers_action_test_all),
)
}
}
}
},
) { _ ->
Column(modifier = Modifier.fillMaxSize()) {
SingleChoiceSegmentedButtonRow(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
SegmentedButton(
selected = state.selectedTab == McpTab.INSTALLED,
onClick = { viewModel.setTab(McpTab.INSTALLED) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
) {
Text(stringResource(R.string.mcp_tab_installed))
}
SegmentedButton(
selected = state.selectedTab == McpTab.CATALOG,
onClick = { viewModel.setTab(McpTab.CATALOG) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
) {
Text(stringResource(R.string.mcp_tab_catalog))
}
}
// ── 3. Server list ───────────────────────────────────
if (filteredServers.isEmpty() && query.isEmpty()) {
item(key = "empty") {
EmptyState(
title = stringResource(R.string.mcp_servers_empty_title),
subtitle = stringResource(R.string.mcp_servers_empty_desc),
actionLabel = stringResource(R.string.empty_action_add_server),
onAction = { viewModel.toggleAddForm() },
when (state.selectedTab) {
McpTab.INSTALLED -> {
when {
state.isLoading && state.servers.isEmpty() -> {
SkeletonListState()
}
state.errorMessage != null && state.servers.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadServers() },
)
}
} else if (filteredServers.isEmpty()) {
item(key = "no-match") {
Text(
text = stringResource(R.string.mcp_no_match, query),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(spacing.md),
)
}
}
items(filteredServers, key = { "server:${it.name}" }) { server ->
ServerCard(
server = server,
state = state,
viewModel = viewModel,
spacing = spacing,
onClick = { showDetail = server },
onOpenBrowser = { url ->
try {
context.startActivity(
android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
),
)
} catch (_: Exception) {
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
item(key = "add-form") {
AddServerSection(state, viewModel, spacing)
}
},
)
}
// ── 4. Catalog ──────────────────────────────────────
item(key = "catalog-header") {
CatalogSection(
state = state,
viewModel = viewModel,
spacing = spacing,
filteredCatalog = filteredCatalog,
)
if (state.servers.isNotEmpty()) {
item(key = "search") {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.mcp_search_placeholder),
)
}
}
if (state.servers.isEmpty()) {
item(key = "empty") {
EmptyState(
title = stringResource(R.string.mcp_servers_empty_title),
subtitle = stringResource(R.string.mcp_servers_empty_desc),
actionLabel = stringResource(R.string.mcp_tab_catalog),
onAction = { viewModel.setTab(McpTab.CATALOG) },
modifier = Modifier.height(320.dp),
)
}
} else if (filteredServers.isEmpty()) {
item(key = "no-match") {
Text(
text = stringResource(R.string.mcp_no_match, query),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(spacing.md),
)
}
}
items(filteredServers, key = { "server:${it.name}" }) { server ->
ServerCard(
server = server,
state = state,
viewModel = viewModel,
spacing = spacing,
onClick = { showDetail = server },
onOpenBrowser = { url ->
try {
context.startActivity(
android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
),
)
} catch (_: Exception) {
}
},
)
}
}
}
}
}
McpTab.CATALOG -> {
CatalogView(
state = state,
viewModel = viewModel,
spacing = spacing,
filteredCatalog = filteredCatalog,
)
}
}
}
}
@@ -32,7 +32,10 @@ import kotlinx.coroutines.withContext
enum class AddServerMode { HTTP, Stdio }
enum class McpTab { INSTALLED, CATALOG }
data class McpServersUiState(
val selectedTab: McpTab = McpTab.INSTALLED,
val isLoading: Boolean = false,
val servers: List<McpServer> = emptyList(),
val errorMessage: String? = null,
@@ -547,6 +550,14 @@ class McpServersViewModel(
}
}
fun setTab(tab: McpTab) {
_uiState.update { it.copy(selectedTab = tab) }
val s = _uiState.value
if (tab == McpTab.CATALOG && s.catalogEntries.isEmpty() && !s.catalogLoading) {
loadCatalog()
}
}
fun updateCatalogQuery(v: String) {
_uiState.update { it.copy(catalogQuery = v) }
}
@@ -5,29 +5,20 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -50,37 +41,6 @@ fun AddServerSection(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
McpSectionHeader(
icon = Icons.Filled.Add,
title = stringResource(R.string.mcp_servers_add_server),
trailing = {
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
TextButton(
onClick = { viewModel.toggleImportDialog() },
colors =
ButtonDefaults.textButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_action_import_json))
}
TextButton(
onClick = { viewModel.toggleAddForm() },
colors =
ButtonDefaults.textButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text(if (state.showAddForm) "Hide" else "New")
}
}
},
)
AnimatedVisibility(visible = state.showAddForm) {
Card(
modifier = Modifier.fillMaxWidth(),
@@ -2,25 +2,21 @@ package com.m57.hermescontrol.ui.mcp.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.material.icons.Icons
import androidx.compose.material.icons.filled.Storage
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -34,91 +30,66 @@ import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
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.mcp.McpServersUiState
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
@Composable
fun CatalogSection(
fun CatalogView(
state: McpServersUiState,
viewModel: McpServersViewModel,
spacing: Spacing,
filteredCatalog: List<McpCatalogEntry>,
modifier: Modifier = Modifier,
) {
val catalogExpanded = remember { mutableStateOf(false) }
Column(modifier = modifier) {
McpSectionHeader(
icon = Icons.Filled.Storage,
title = stringResource(R.string.mcp_servers_section_catalog),
trailing = {
TextButton(
onClick = {
catalogExpanded.value = !catalogExpanded.value
if (catalogExpanded.value && state.catalogEntries.isEmpty() && !state.catalogLoading) {
viewModel.loadCatalog()
}
},
colors =
ButtonDefaults.textButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text(if (catalogExpanded.value) "Hide" else "Browse")
}
},
Column(modifier = modifier.fillMaxSize()) {
SearchBar(
query = state.catalogQuery,
onQueryChange = viewModel::updateCatalogQuery,
placeholder = stringResource(R.string.mcp_servers_catalog_search),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
AnimatedVisibility(visible = catalogExpanded.value) {
Column(modifier = Modifier.fillMaxWidth()) {
// Search
OutlinedTextField(
value = state.catalogQuery,
onValueChange = viewModel::updateCatalogQuery,
label = { Text(stringResource(R.string.mcp_servers_catalog_search)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(bottom = spacing.sm),
when {
state.catalogLoading && state.catalogEntries.isEmpty() -> {
SkeletonListState()
}
state.catalogError != null && state.catalogEntries.isEmpty() -> {
ErrorState(
message = state.catalogError,
onRetry = { viewModel.loadCatalog() },
)
}
// Loading / error / content
when {
state.catalogLoading -> {
Spacer(modifier = Modifier.height(spacing.sm))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.height(spacing.sm))
}
filteredCatalog.isEmpty() -> {
EmptyState(
title = stringResource(R.string.mcp_servers_catalog_empty_title),
subtitle = stringResource(R.string.mcp_servers_catalog_empty),
actionLabel = stringResource(R.string.content_desc_refresh),
onAction = { viewModel.loadCatalog() },
)
}
state.catalogError != null -> {
ErrorState(
message = state.catalogError,
onRetry = { viewModel.loadCatalog() },
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(filteredCatalog, key = { "catalog:${it.name}" }) { entry ->
CatalogEntryCard(
entry = entry,
state = state,
viewModel = viewModel,
spacing = spacing,
)
}
filteredCatalog.isEmpty() -> {
Text(
text = stringResource(R.string.mcp_servers_catalog_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = spacing.md),
)
}
else -> {
filteredCatalog.forEach { entry ->
CatalogEntryCard(
entry = entry,
state = state,
viewModel = viewModel,
spacing = spacing,
)
Spacer(modifier = Modifier.height(spacing.sm))
}
}
}
}
}
@@ -1,59 +0,0 @@
package com.m57.hermescontrol.ui.mcp.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.theme.LocalSpacing
@Composable
fun McpSectionHeader(
icon: ImageVector,
title: String,
modifier: Modifier = Modifier,
trailing: (@Composable () -> Unit)? = null,
) {
val spacing = LocalSpacing.current
Row(
modifier =
modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(12.dp),
).padding(horizontal = spacing.md, vertical = spacing.sm),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(spacing.sm))
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
)
}
trailing?.invoke()
}
}
+3
View File
@@ -1840,4 +1840,7 @@
<string name="account_connectors_reconnect">Reconnect</string>
<string name="account_connectors_no_tools">No tools available</string>
<string name="account_connectors_policy_locked">Read-only policy restrictions</string>
<string name="mcp_tab_installed">Installed</string>
<string name="mcp_tab_catalog">Catalog</string>
<string name="mcp_servers_catalog_empty_title">No catalog entries</string>
</resources>