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 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.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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.filled.Add 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.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.Science
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.UploadFile 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.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager 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.data.model.McpServer
import com.m57.hermescontrol.theme.LocalSpacing 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.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.SkeletonListState 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.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.mcp.components.AddServerSection 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.McpDialogs
import com.m57.hermescontrol.ui.mcp.components.ServerCard import com.m57.hermescontrol.ui.mcp.components.ServerCard
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun McpServersScreen( fun McpServersScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -138,120 +99,158 @@ fun McpServersScreen(
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_mcp_servers)) }, title = { Text(stringResource(R.string.screen_mcp_servers)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = if (state.selectedTab == McpTab.INSTALLED) state.isLoading else state.catalogLoading,
onRefresh = { viewModel.loadServers(forceRefresh = true) }, onRefresh = {
actions = { if (state.selectedTab == McpTab.INSTALLED) {
IconButton( viewModel.loadServers(forceRefresh = true)
onClick = { viewModel.testAllServers() }, } else {
enabled = !state.isTestingAll && state.servers.any { it.enabled }, viewModel.loadCatalog()
) {
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),
)
} }
}, },
) { paddingValues -> actions = {
when { if (state.selectedTab == McpTab.INSTALLED) {
state.isLoading && state.servers.isEmpty() -> { IconButton(onClick = { viewModel.toggleAddForm() }) {
SkeletonListState(modifier = Modifier.padding(paddingValues)) Icon(
} imageVector = if (state.showAddForm) Icons.Filled.Close else Icons.Filled.Add,
contentDescription = stringResource(R.string.mcp_servers_add_server),
state.errorMessage != null -> { )
ErrorState( }
message = state.errorMessage ?: "", IconButton(onClick = { viewModel.toggleImportDialog() }) {
onRetry = { viewModel.loadServers() }, Icon(
modifier = Modifier.padding(paddingValues), imageVector = Icons.Filled.UploadFile,
) contentDescription = stringResource(R.string.mcp_servers_action_import_json),
} )
}
else -> { IconButton(
LazyColumn( onClick = { viewModel.testAllServers() },
modifier = Modifier.fillMaxSize(), enabled = !state.isTestingAll && state.servers.any { it.enabled },
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) { ) {
// ── 1. Add server ──────────────────────────────────── if (state.isTestingAll) {
item(key = "add-header") { CircularProgressIndicator(
AddServerSection(state, viewModel, spacing) modifier = Modifier.size(18.dp),
} strokeWidth = 2.dp,
)
// ── 2. Search ──────────────────────────────────────── } else {
item(key = "search") { Icon(
SearchBar( imageVector = Icons.Filled.Science,
query = query, contentDescription = stringResource(R.string.mcp_servers_action_test_all),
onQueryChange = { query = it },
placeholder = stringResource(R.string.mcp_search_placeholder),
) )
} }
}
}
},
) { _ ->
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 ─────────────────────────────────── when (state.selectedTab) {
if (filteredServers.isEmpty() && query.isEmpty()) { McpTab.INSTALLED -> {
item(key = "empty") { when {
EmptyState( state.isLoading && state.servers.isEmpty() -> {
title = stringResource(R.string.mcp_servers_empty_title), SkeletonListState()
subtitle = stringResource(R.string.mcp_servers_empty_desc), }
actionLabel = stringResource(R.string.empty_action_add_server),
onAction = { viewModel.toggleAddForm() }, 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 -> else -> {
ServerCard( LazyColumn(
server = server, modifier = Modifier.fillMaxSize(),
state = state, contentPadding = listContentPadding,
viewModel = viewModel, verticalArrangement = listItemSpacing,
spacing = spacing, ) {
onClick = { showDetail = server }, item(key = "add-form") {
onOpenBrowser = { url -> AddServerSection(state, viewModel, spacing)
try {
context.startActivity(
android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
),
)
} catch (_: Exception) {
} }
},
)
}
// ── 4. Catalog ────────────────────────────────────── if (state.servers.isNotEmpty()) {
item(key = "catalog-header") { item(key = "search") {
CatalogSection( SearchBar(
state = state, query = query,
viewModel = viewModel, onQueryChange = { query = it },
spacing = spacing, placeholder = stringResource(R.string.mcp_search_placeholder),
filteredCatalog = filteredCatalog, )
) }
}
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 AddServerMode { HTTP, Stdio }
enum class McpTab { INSTALLED, CATALOG }
data class McpServersUiState( data class McpServersUiState(
val selectedTab: McpTab = McpTab.INSTALLED,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val servers: List<McpServer> = emptyList(), val servers: List<McpServer> = emptyList(),
val errorMessage: String? = null, 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) { fun updateCatalogQuery(v: String) {
_uiState.update { it.copy(catalogQuery = v) } _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.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.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.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -50,37 +41,6 @@ fun AddServerSection(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(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) { AnimatedVisibility(visible = state.showAddForm) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -2,25 +2,21 @@ package com.m57.hermescontrol.ui.mcp.components
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -34,91 +30,66 @@ import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.McpCatalogEntry import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.theme.Spacing 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.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.McpServersUiState
import com.m57.hermescontrol.ui.mcp.McpServersViewModel import com.m57.hermescontrol.ui.mcp.McpServersViewModel
@Composable @Composable
fun CatalogSection( fun CatalogView(
state: McpServersUiState, state: McpServersUiState,
viewModel: McpServersViewModel, viewModel: McpServersViewModel,
spacing: Spacing, spacing: Spacing,
filteredCatalog: List<McpCatalogEntry>, filteredCatalog: List<McpCatalogEntry>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val catalogExpanded = remember { mutableStateOf(false) } Column(modifier = modifier.fillMaxSize()) {
SearchBar(
Column(modifier = modifier) { query = state.catalogQuery,
McpSectionHeader( onQueryChange = viewModel::updateCatalogQuery,
icon = Icons.Filled.Storage, placeholder = stringResource(R.string.mcp_servers_catalog_search),
title = stringResource(R.string.mcp_servers_section_catalog), modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
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")
}
},
) )
AnimatedVisibility(visible = catalogExpanded.value) { when {
Column(modifier = Modifier.fillMaxWidth()) { state.catalogLoading && state.catalogEntries.isEmpty() -> {
// Search SkeletonListState()
OutlinedTextField( }
value = state.catalogQuery,
onValueChange = viewModel::updateCatalogQuery, state.catalogError != null && state.catalogEntries.isEmpty() -> {
label = { Text(stringResource(R.string.mcp_servers_catalog_search)) }, ErrorState(
singleLine = true, message = state.catalogError,
modifier = Modifier.fillMaxWidth().padding(bottom = spacing.sm), onRetry = { viewModel.loadCatalog() },
) )
}
// Loading / error / content filteredCatalog.isEmpty() -> {
when { EmptyState(
state.catalogLoading -> { title = stringResource(R.string.mcp_servers_catalog_empty_title),
Spacer(modifier = Modifier.height(spacing.sm)) subtitle = stringResource(R.string.mcp_servers_catalog_empty),
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { actionLabel = stringResource(R.string.content_desc_refresh),
CircularProgressIndicator(modifier = Modifier.size(24.dp)) onAction = { viewModel.loadCatalog() },
} )
Spacer(modifier = Modifier.height(spacing.sm)) }
}
state.catalogError != null -> { else -> {
ErrorState( LazyColumn(
message = state.catalogError, modifier = Modifier.fillMaxSize(),
onRetry = { viewModel.loadCatalog() }, 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_reconnect">Reconnect</string>
<string name="account_connectors_no_tools">No tools available</string> <string name="account_connectors_no_tools">No tools available</string>
<string name="account_connectors_policy_locked">Read-only policy restrictions</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> </resources>