feat(mcp): Installed/Catalog tabs with search, matching Plugins and Skills (#1404)
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user