From f4904653d1148d2a0d8f62dcb09584b8447fe3b7 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 19:53:16 +0300 Subject: [PATCH] refactor(mcp): decompose McpServersScreen into components Extract McpSectionHeader, AddServerSection, ServerCard, CatalogSection, and McpDialogs into ui/mcp/components. --- .../hermescontrol/ui/mcp/McpServersScreen.kt | 849 +----------------- .../ui/mcp/components/AddServerSection.kt | 198 ++++ .../ui/mcp/components/CatalogSection.kt | 235 +++++ .../ui/mcp/components/McpDialogs.kt | 202 +++++ .../ui/mcp/components/McpSectionHeader.kt | 59 ++ .../ui/mcp/components/ServerCard.kt | 365 ++++++++ 6 files changed, 1071 insertions(+), 837 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index 8548b5c7..df7e9748 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -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, -) { - 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) - }, - ) - } - } - } - } - } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt new file mode 100644 index 00000000..4bae7bf1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt @@ -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) + }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt new file mode 100644 index 00000000..533123d2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt @@ -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, + 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) + }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt new file mode 100644 index 00000000..039c5ac0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt @@ -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)) + } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt new file mode 100644 index 00000000..9dd01b89 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt @@ -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() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt new file mode 100644 index 00000000..155f6c32 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt @@ -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) +}