refactor(mcp): decompose McpServersScreen into components
Extract McpSectionHeader, AddServerSection, ServerCard, CatalogSection, and McpDialogs into ui/mcp/components.
This commit is contained in:
@@ -87,6 +87,10 @@ import com.m57.hermescontrol.ui.common.ToastEffect
|
|||||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
import com.m57.hermescontrol.ui.common.toDetailRows
|
import com.m57.hermescontrol.ui.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
|
@Composable
|
||||||
fun McpServersScreen(
|
fun McpServersScreen(
|
||||||
@@ -247,842 +251,13 @@ fun McpServersScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showDetail?.let { server ->
|
McpDialogs(
|
||||||
DetailDialog(
|
state = state,
|
||||||
title = server.name,
|
viewModel = viewModel,
|
||||||
rows = server.toDetailRows(),
|
spacing = spacing,
|
||||||
onDismiss = { showDetail = null },
|
selectedServerDetail = showDetail,
|
||||||
)
|
onDismissDetail = { showDetail = null },
|
||||||
}
|
context = context,
|
||||||
|
clipboardManager = clipboardManager,
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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