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
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user