refactor(mcp): decompose McpServersScreen into components
Extract McpSectionHeader, AddServerSection, ServerCard, CatalogSection, and McpDialogs into ui/mcp/components.
This commit is contained in:
1 parent
d2e5e8e1aa
commit
f4904653d1
6 files changed
+1071
-837
No files matched your search
@@ -87,6 +87,10 @@ 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.McpDialogs
|
||||
import com.m57.hermescontrol.ui.mcp.components.ServerCard
|
||||
|
||||
@Composable
|
||||
fun McpServersScreen(
|
||||
@@ -247,842 +251,13 @@ fun McpServersScreen(
|
||||
}
|
||||
}
|
||||
|
||||
showDetail?.let { server ->
|
||||
DetailDialog(
|
||||
title = server.name,
|
||||
rows = server.toDetailRows(),
|
||||
onDismiss = { showDetail = null },
|
||||
)
|
||||
}
|
||||
|
||||
state.activeOAuthFlow?.let { flow ->
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { viewModel.dismissOAuthFlow() },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
flow.authorizationUrl?.let { url ->
|
||||
try {
|
||||
context.startActivity(
|
||||
android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.mcp_servers_oauth_dialog_open_browser))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(onClick = { viewModel.dismissOAuthFlow() }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.mcp_servers_oauth_dialog_title))
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(stringResource(R.string.mcp_servers_oauth_dialog_desc))
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_oauth_dialog_status, flow.status),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
flow.error?.let { err ->
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_oauth_dialog_error, err),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (state.showImportDialog) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { viewModel.toggleImportDialog() },
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.UploadFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
Text(stringResource(R.string.mcp_servers_import_dialog_title))
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_import_dialog_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val clip = clipboardManager.getText()?.text
|
||||
if (!clip.isNullOrBlank()) {
|
||||
viewModel.updateImportJsonInput(clip)
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_import_dialog_paste),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
OutlinedTextField(
|
||||
value = state.importJsonInput,
|
||||
onValueChange = viewModel::updateImportJsonInput,
|
||||
placeholder = {
|
||||
Text(
|
||||
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp),
|
||||
maxLines = 10,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { viewModel.submitImportJson() },
|
||||
enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(),
|
||||
) {
|
||||
if (state.isImportingJson) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_import_dialog_importing))
|
||||
} else {
|
||||
Text(stringResource(R.string.mcp_servers_action_submit))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(onClick = { viewModel.toggleImportDialog() }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section header (MCP-specific: distinct icon + neutral title so it reads
|
||||
// as a header, not a button) ──────────────────────────────────────────────
|
||||
@Composable
|
||||
private fun McpSectionHeader(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sections ──────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun AddServerSection(
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
||||
) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
},
|
||||
McpDialogs(
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
spacing = spacing,
|
||||
selectedServerDetail = showDetail,
|
||||
onDismissDetail = { showDetail = null },
|
||||
context = context,
|
||||
clipboardManager = clipboardManager,
|
||||
)
|
||||
|
||||
AnimatedVisibility(visible = state.showAddForm) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md)) {
|
||||
SecondaryTabRow(selectedTabIndex = if (state.addMode == AddServerMode.HTTP) 0 else 1) {
|
||||
Tab(
|
||||
selected = state.addMode == AddServerMode.HTTP,
|
||||
onClick = { viewModel.setAddMode(AddServerMode.HTTP) },
|
||||
text = { Text(stringResource(R.string.mcp_servers_add_http)) },
|
||||
)
|
||||
Tab(
|
||||
selected = state.addMode == AddServerMode.Stdio,
|
||||
onClick = { viewModel.setAddMode(AddServerMode.Stdio) },
|
||||
text = { Text(stringResource(R.string.mcp_servers_add_stdio)) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.addServerName,
|
||||
onValueChange = viewModel::updateAddServerName,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
if (state.addMode == AddServerMode.HTTP) {
|
||||
OutlinedTextField(
|
||||
value = state.addServerUrl,
|
||||
onValueChange = viewModel::updateAddServerUrl,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_url)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_auth_mode),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = state.addServerAuth == "none",
|
||||
onClick = { viewModel.updateAddServerAuth("none") },
|
||||
label = { Text(stringResource(R.string.mcp_servers_auth_none)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.addServerAuth == "header",
|
||||
onClick = { viewModel.updateAddServerAuth("header") },
|
||||
label = { Text(stringResource(R.string.mcp_servers_auth_header)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.addServerAuth == "oauth",
|
||||
onClick = { viewModel.updateAddServerAuth("oauth") },
|
||||
label = { Text(stringResource(R.string.mcp_servers_auth_oauth)) },
|
||||
)
|
||||
}
|
||||
if (state.addServerAuth == "header") {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
OutlinedTextField(
|
||||
value = state.addServerBearerToken,
|
||||
onValueChange = viewModel::updateAddServerBearerToken,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_bearer_token)) },
|
||||
singleLine = true,
|
||||
visualTransformation = remember { PasswordVisualTransformation() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = state.addServerCommand,
|
||||
onValueChange = viewModel::updateAddServerCommand,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_command)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
OutlinedTextField(
|
||||
value = state.addServerArgs,
|
||||
onValueChange = viewModel::updateAddServerArgs,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_args)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.md))
|
||||
Button(
|
||||
onClick = { viewModel.submitAddServer() },
|
||||
enabled = !state.addingServer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
if (state.addingServer) {
|
||||
stringResource(R.string.mcp_servers_action_adding)
|
||||
} else {
|
||||
stringResource(R.string.mcp_servers_action_submit)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerCard(
|
||||
server: McpServer,
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
||||
onClick: () -> Unit,
|
||||
onOpenBrowser: (String) -> Unit,
|
||||
) {
|
||||
var showEnv by remember { mutableStateOf(false) }
|
||||
val isTesting = server.name in state.testingServers
|
||||
val testResult = state.serverTestResults[server.name]
|
||||
val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (server.enabled) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(spacing.md)) {
|
||||
// Header row: name + status + toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = server.name.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
when (healthStatus) {
|
||||
McpHealthStatus.HEALTHY -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_healthy),
|
||||
status = StatusBadgeType.SUCCESS,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.AUTH_REQUIRED -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_needs_auth),
|
||||
status = StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.ERROR -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_error),
|
||||
status = StatusBadgeType.ERROR,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.TESTING -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_testing),
|
||||
status = StatusBadgeType.INFO,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.UNKNOWN -> {
|
||||
ServerStatusBadge(server.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// Tool count & schema token overhead badge (issue #1029)
|
||||
val toolInfos = testResult?.tools
|
||||
val toolCount = toolInfos?.size ?: server.tools?.size
|
||||
if (toolCount != null && toolCount > 0) {
|
||||
val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) }
|
||||
val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Build,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = server.enabled,
|
||||
onCheckedChange = { viewModel.toggleServer(server) },
|
||||
)
|
||||
}
|
||||
|
||||
// URL or Command
|
||||
if (server.url != null) {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Text(
|
||||
text = server.url,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
} else if (server.command != null) {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_label_command),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "${server.command} ${server.args.orEmpty().joinToString(" ")}",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
}
|
||||
|
||||
// Error diagnostics
|
||||
server.error?.let { error ->
|
||||
if (error.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
StatusBadge(
|
||||
text = error,
|
||||
status = StatusBadgeType.ERROR,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
if (server.auth == "oauth") {
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) },
|
||||
modifier = Modifier.weight(1.2f),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Key, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_action_authorize), maxLines = 1)
|
||||
}
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.testServer(server.name) },
|
||||
enabled = !isTesting,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
if (isTesting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1)
|
||||
} else {
|
||||
Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { viewModel.deleteServer(server.name) }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||
}
|
||||
}
|
||||
|
||||
// Env vars toggle
|
||||
TextButton(
|
||||
onClick = { showEnv = !showEnv },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Storage, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_env_vars))
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = showEnv) {
|
||||
EnvVarSection(server = server, state = state, viewModel = viewModel, spacing = spacing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnvVarSection(
|
||||
server: McpServer,
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
||||
) {
|
||||
val isEditing = state.editingEnvFor == server.name
|
||||
val env = server.env ?: emptyMap()
|
||||
|
||||
if (env.isEmpty() && !isEditing) {
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_env_no_vars),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = spacing.xs),
|
||||
)
|
||||
} else {
|
||||
env.forEach { (key, value) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = key, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = if (value.length > 30) "${value.take(15)}…${value.takeLast(10)}" else value,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.envKeyInput,
|
||||
onValueChange = viewModel::updateEnvKey,
|
||||
label = { Text(stringResource(R.string.mcp_servers_env_key)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.envValueInput,
|
||||
onValueChange = viewModel::updateEnvValue,
|
||||
label = { Text(stringResource(R.string.mcp_servers_env_value)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Button(onClick = { viewModel.addEnvVar(server.name) }) {
|
||||
Text(stringResource(R.string.mcp_servers_env_add))
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.stopEditingEnv() }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TextButton(onClick = { viewModel.startEditingEnv(server) }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_env_add))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerStatusBadge(status: String?) {
|
||||
if (status == null) return
|
||||
val badgeType =
|
||||
when (status.lowercase()) {
|
||||
"running", "ok", "connected" -> StatusBadgeType.SUCCESS
|
||||
"error", "failed" -> StatusBadgeType.ERROR
|
||||
"stopped" -> StatusBadgeType.NEUTRAL
|
||||
else -> StatusBadgeType.NEUTRAL
|
||||
}
|
||||
StatusBadge(text = status, status = badgeType)
|
||||
}
|
||||
|
||||
// ── Catalog ─────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun CatalogSection(
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
||||
filteredCatalog: List<McpCatalogEntry>,
|
||||
) {
|
||||
val catalogExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
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")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
state.catalogError != null -> {
|
||||
ErrorState(
|
||||
message = state.catalogError,
|
||||
onRetry = { viewModel.loadCatalog() },
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogEntryCard(
|
||||
entry: McpCatalogEntry,
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
||||
) {
|
||||
var showInstallForm by remember { mutableStateOf(false) }
|
||||
val isInstalling = state.installingCatalogEntry == entry.name
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = entry.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
entry.source?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_catalog_source, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (entry.env?.isNotEmpty() == true) {
|
||||
showInstallForm = !showInstallForm
|
||||
} else {
|
||||
viewModel.installCatalogEntry(entry)
|
||||
}
|
||||
},
|
||||
enabled = !isInstalling,
|
||||
) {
|
||||
Text(
|
||||
if (isInstalling) {
|
||||
stringResource(R.string.mcp_servers_catalog_installing)
|
||||
} else {
|
||||
stringResource(R.string.mcp_servers_catalog_install)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
entry.description?.let {
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Install form with env vars
|
||||
AnimatedVisibility(visible = showInstallForm) {
|
||||
Column(modifier = Modifier.padding(top = spacing.sm)) {
|
||||
entry.env?.let { envVars ->
|
||||
if (envVars.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_catalog_required_env),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
envVars.forEach { envVar ->
|
||||
val label = envVar.label ?: envVar.key
|
||||
val currentValue = state.catalogInstallEnv[envVar.key] ?: ""
|
||||
OutlinedTextField(
|
||||
value = currentValue,
|
||||
onValueChange = { viewModel.updateCatalogEnvVar(envVar.key, it) },
|
||||
label = { Text(label) },
|
||||
placeholder = envVar.description?.let { { Text(it) } },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { viewModel.installCatalogEntry(entry) },
|
||||
enabled = !isInstalling,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
if (isInstalling) {
|
||||
stringResource(R.string.mcp_servers_catalog_installing)
|
||||
} else {
|
||||
stringResource(R.string.mcp_servers_catalog_install)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.m57.hermescontrol.ui.mcp.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import com.m57.hermescontrol.ui.mcp.AddServerMode
|
||||
import com.m57.hermescontrol.ui.mcp.McpServersUiState
|
||||
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun AddServerSection(
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: Spacing,
|
||||
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(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md)) {
|
||||
SecondaryTabRow(selectedTabIndex = if (state.addMode == AddServerMode.HTTP) 0 else 1) {
|
||||
Tab(
|
||||
selected = state.addMode == AddServerMode.HTTP,
|
||||
onClick = { viewModel.setAddMode(AddServerMode.HTTP) },
|
||||
text = { Text(stringResource(R.string.mcp_servers_add_http)) },
|
||||
)
|
||||
Tab(
|
||||
selected = state.addMode == AddServerMode.Stdio,
|
||||
onClick = { viewModel.setAddMode(AddServerMode.Stdio) },
|
||||
text = { Text(stringResource(R.string.mcp_servers_add_stdio)) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.addServerName,
|
||||
onValueChange = viewModel::updateAddServerName,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
if (state.addMode == AddServerMode.HTTP) {
|
||||
OutlinedTextField(
|
||||
value = state.addServerUrl,
|
||||
onValueChange = viewModel::updateAddServerUrl,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_url)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_auth_mode),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = state.addServerAuth == "none",
|
||||
onClick = { viewModel.updateAddServerAuth("none") },
|
||||
label = { Text(stringResource(R.string.mcp_servers_auth_none)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.addServerAuth == "header",
|
||||
onClick = { viewModel.updateAddServerAuth("header") },
|
||||
label = { Text(stringResource(R.string.mcp_servers_auth_header)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.addServerAuth == "oauth",
|
||||
onClick = { viewModel.updateAddServerAuth("oauth") },
|
||||
label = { Text(stringResource(R.string.mcp_servers_auth_oauth)) },
|
||||
)
|
||||
}
|
||||
if (state.addServerAuth == "header") {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
OutlinedTextField(
|
||||
value = state.addServerBearerToken,
|
||||
onValueChange = viewModel::updateAddServerBearerToken,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_bearer_token)) },
|
||||
singleLine = true,
|
||||
visualTransformation = remember { PasswordVisualTransformation() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = state.addServerCommand,
|
||||
onValueChange = viewModel::updateAddServerCommand,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_command)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
OutlinedTextField(
|
||||
value = state.addServerArgs,
|
||||
onValueChange = viewModel::updateAddServerArgs,
|
||||
label = { Text(stringResource(R.string.mcp_servers_field_args)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.md))
|
||||
Button(
|
||||
onClick = { viewModel.submitAddServer() },
|
||||
enabled = !state.addingServer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
if (state.addingServer) {
|
||||
stringResource(R.string.mcp_servers_action_adding)
|
||||
} else {
|
||||
stringResource(R.string.mcp_servers_action_submit)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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.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.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
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.ErrorState
|
||||
import com.m57.hermescontrol.ui.mcp.McpServersUiState
|
||||
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
|
||||
|
||||
@Composable
|
||||
fun CatalogSection(
|
||||
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")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
state.catalogError != null -> {
|
||||
ErrorState(
|
||||
message = state.catalogError,
|
||||
onRetry = { viewModel.loadCatalog() },
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CatalogEntryCard(
|
||||
entry: McpCatalogEntry,
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: Spacing,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showInstallForm by remember { mutableStateOf(false) }
|
||||
val isInstalling = state.installingCatalogEntry == entry.name
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = entry.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
entry.source?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_catalog_source, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (entry.env?.isNotEmpty() == true) {
|
||||
showInstallForm = !showInstallForm
|
||||
} else {
|
||||
viewModel.installCatalogEntry(entry)
|
||||
}
|
||||
},
|
||||
enabled = !isInstalling,
|
||||
) {
|
||||
Text(
|
||||
if (isInstalling) {
|
||||
stringResource(R.string.mcp_servers_catalog_installing)
|
||||
} else {
|
||||
stringResource(R.string.mcp_servers_catalog_install)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
entry.description?.let {
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Install form with env vars
|
||||
AnimatedVisibility(visible = showInstallForm) {
|
||||
Column(modifier = Modifier.padding(top = spacing.sm)) {
|
||||
entry.env?.let { envVars ->
|
||||
if (envVars.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_catalog_required_env),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
envVars.forEach { envVar ->
|
||||
val label = envVar.label ?: envVar.key
|
||||
val currentValue = state.catalogInstallEnv[envVar.key] ?: ""
|
||||
OutlinedTextField(
|
||||
value = currentValue,
|
||||
onValueChange = { viewModel.updateCatalogEnvVar(envVar.key, it) },
|
||||
label = { Text(label) },
|
||||
placeholder = envVar.description?.let { { Text(it) } },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { viewModel.installCatalogEntry(entry) },
|
||||
enabled = !isInstalling,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
if (isInstalling) {
|
||||
stringResource(R.string.mcp_servers_catalog_installing)
|
||||
} else {
|
||||
stringResource(R.string.mcp_servers_catalog_install)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.m57.hermescontrol.ui.mcp.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.UploadFile
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import com.m57.hermescontrol.ui.common.DetailDialog
|
||||
import com.m57.hermescontrol.ui.common.toDetailRows
|
||||
import com.m57.hermescontrol.ui.mcp.McpServersUiState
|
||||
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
|
||||
|
||||
@Composable
|
||||
fun McpDialogs(
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: Spacing,
|
||||
selectedServerDetail: McpServer?,
|
||||
onDismissDetail: () -> Unit,
|
||||
context: Context = LocalContext.current,
|
||||
clipboardManager: ClipboardManager = LocalClipboardManager.current,
|
||||
) {
|
||||
selectedServerDetail?.let { server ->
|
||||
DetailDialog(
|
||||
title = server.name,
|
||||
rows = server.toDetailRows(),
|
||||
onDismiss = onDismissDetail,
|
||||
)
|
||||
}
|
||||
|
||||
state.activeOAuthFlow?.let { flow ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.dismissOAuthFlow() },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
flow.authorizationUrl?.let { url ->
|
||||
try {
|
||||
context.startActivity(
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse(url),
|
||||
),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.mcp_servers_oauth_dialog_open_browser))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(onClick = { viewModel.dismissOAuthFlow() }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.mcp_servers_oauth_dialog_title))
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(stringResource(R.string.mcp_servers_oauth_dialog_desc))
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_oauth_dialog_status, flow.status),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
flow.error?.let { err ->
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_oauth_dialog_error, err),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (state.showImportDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.toggleImportDialog() },
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.UploadFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
Text(stringResource(R.string.mcp_servers_import_dialog_title))
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_import_dialog_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val clip = clipboardManager.getText()?.text
|
||||
if (!clip.isNullOrBlank()) {
|
||||
viewModel.updateImportJsonInput(clip)
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_import_dialog_paste),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
OutlinedTextField(
|
||||
value = state.importJsonInput,
|
||||
onValueChange = viewModel::updateImportJsonInput,
|
||||
placeholder = {
|
||||
Text(
|
||||
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp),
|
||||
maxLines = 10,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { viewModel.submitImportJson() },
|
||||
enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(),
|
||||
) {
|
||||
if (state.isImportingJson) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_import_dialog_importing))
|
||||
} else {
|
||||
Text(stringResource(R.string.mcp_servers_action_submit))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(onClick = { viewModel.toggleImportDialog() }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package com.m57.hermescontrol.ui.mcp.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.shape.RoundedCornerShape
|
||||
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.Delete
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Science
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
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
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.theme.Spacing
|
||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.mcp.McpHealthStatus
|
||||
import com.m57.hermescontrol.ui.mcp.McpServersUiState
|
||||
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
|
||||
import com.m57.hermescontrol.ui.mcp.McpTokenEstimator
|
||||
|
||||
@Composable
|
||||
fun ServerCard(
|
||||
server: McpServer,
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: Spacing,
|
||||
onClick: () -> Unit,
|
||||
onOpenBrowser: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showEnv by remember { mutableStateOf(false) }
|
||||
val isTesting = server.name in state.testingServers
|
||||
val testResult = state.serverTestResults[server.name]
|
||||
val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error)
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (server.enabled) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(spacing.md)) {
|
||||
// Header row: name + status + toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = server.name.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
when (healthStatus) {
|
||||
McpHealthStatus.HEALTHY -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_healthy),
|
||||
status = StatusBadgeType.SUCCESS,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.AUTH_REQUIRED -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_needs_auth),
|
||||
status = StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.ERROR -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_error),
|
||||
status = StatusBadgeType.ERROR,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.TESTING -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_testing),
|
||||
status = StatusBadgeType.INFO,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.UNKNOWN -> {
|
||||
ServerStatusBadge(server.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// Tool count & schema token overhead badge (issue #1029)
|
||||
val toolInfos = testResult?.tools
|
||||
val toolCount = toolInfos?.size ?: server.tools?.size
|
||||
if (toolCount != null && toolCount > 0) {
|
||||
val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) }
|
||||
val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Build,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = server.enabled,
|
||||
onCheckedChange = { viewModel.toggleServer(server) },
|
||||
)
|
||||
}
|
||||
|
||||
// URL or Command
|
||||
if (server.url != null) {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Text(
|
||||
text = server.url,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
} else if (server.command != null) {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_label_command),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "${server.command} ${server.args.orEmpty().joinToString(" ")}",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
}
|
||||
|
||||
// Error diagnostics
|
||||
server.error?.let { error ->
|
||||
if (error.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
StatusBadge(
|
||||
text = error,
|
||||
status = StatusBadgeType.ERROR,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
if (server.auth == "oauth") {
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) },
|
||||
modifier = Modifier.weight(1.2f),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Key, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_action_authorize), maxLines = 1)
|
||||
}
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.testServer(server.name) },
|
||||
enabled = !isTesting,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
if (isTesting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1)
|
||||
} else {
|
||||
Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { viewModel.deleteServer(server.name) }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||
}
|
||||
}
|
||||
|
||||
// Env vars toggle
|
||||
TextButton(
|
||||
onClick = { showEnv = !showEnv },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Storage, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_env_vars))
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = showEnv) {
|
||||
EnvVarSection(server = server, state = state, viewModel = viewModel, spacing = spacing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EnvVarSection(
|
||||
server: McpServer,
|
||||
state: McpServersUiState,
|
||||
viewModel: McpServersViewModel,
|
||||
spacing: Spacing,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isEditing = state.editingEnvFor == server.name
|
||||
val env = server.env ?: emptyMap()
|
||||
|
||||
Column(modifier = modifier) {
|
||||
if (env.isEmpty() && !isEditing) {
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_env_no_vars),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = spacing.xs),
|
||||
)
|
||||
} else {
|
||||
env.forEach { (key, value) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = key, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = if (value.length > 30) "${value.take(15)}…${value.takeLast(10)}" else value,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.envKeyInput,
|
||||
onValueChange = viewModel::updateEnvKey,
|
||||
label = { Text(stringResource(R.string.mcp_servers_env_key)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.envValueInput,
|
||||
onValueChange = viewModel::updateEnvValue,
|
||||
label = { Text(stringResource(R.string.mcp_servers_env_value)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Button(onClick = { viewModel.addEnvVar(server.name) }) {
|
||||
Text(stringResource(R.string.mcp_servers_env_add))
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.stopEditingEnv() }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TextButton(onClick = { viewModel.startEditingEnv(server) }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_env_add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerStatusBadge(status: String?) {
|
||||
if (status == null) return
|
||||
val badgeType =
|
||||
when (status.lowercase()) {
|
||||
"running", "ok", "connected" -> StatusBadgeType.SUCCESS
|
||||
"error", "failed" -> StatusBadgeType.ERROR
|
||||
"stopped" -> StatusBadgeType.NEUTRAL
|
||||
else -> StatusBadgeType.NEUTRAL
|
||||
}
|
||||
StatusBadge(text = status, status = badgeType)
|
||||
}
|
||||
Reference in new issue
Block a user