feat(config): redo config tab — lazy list, full-coverage search, per-field reset (#898)

- LazyColumn + precomputed flat dot-path value map: O(1) lookups, only
  visible fields compose (829 live leaves on a real config)
- Search now matches keys, labels, descriptions, categories, CURRENT
  values and select options; non-schema ('Other') paths are searchable too.
  Result count + category chip per hit while searching
- Per-field reset-to-default icon; clear-to-blank honoring schema
  'clearable'; searchable dropdown honoring schema 'searchable'
  (select-all-free fresh filter, exact-match commits on blur)
- Number/object/list editors keep local text state: intermediate typing
  ('0.') no longer drops keystrokes; objects/lists edit as multi-line JSON
  with validation
- Honor backend searchable/clearable schema flags (SchemaField model)
- Category reset now previews default values immediately

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-13 16:42:51 +03:00
1 parent 90c1e8dc5f
commit 9e454ce67b
5 files changed
+770 -215

No files matched your search

@@ -26,6 +26,8 @@ data class SchemaField(
val description: String? = null, val description: String? = null,
val category: String? = null, val category: String? = null,
val options: List<String>? = null, val options: List<String>? = null,
val searchable: Boolean = false,
val clearable: Boolean = false,
) )
@Serializable @Serializable
@@ -0,0 +1,97 @@
package com.m57.hermescontrol.ui.config
import com.m57.hermescontrol.data.model.SchemaField
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* One renderable row of the config form: a schema-driven field or an
* uncovered ("Other") config path. All rows are FLAT dot-paths — the nested
* config response is flattened once on load so the form does O(1) lookups.
*/
data class ConfigRow(
val key: String,
val field: SchemaField?,
val value: JsonElement?,
) {
/** Human label: last path segment, underscores → spaces. */
val label: String =
key.split(".").last().replace("_", " ").replaceFirstChar { it.uppercase() }
/** Search/display text of the current value. */
val valueText: String = value?.let(::jsonText) ?: ""
val category: String = if (field == null) "Other" else field.category ?: "general"
val isUncovered: Boolean = field == null
}
/** Flatten a nested config map into dot-path → leaf-value pairs. */
fun flattenConfig(nested: Map<String, JsonElement>): Map<String, JsonElement> {
val flat = mutableMapOf<String, JsonElement>()
fun walk(
prefix: String,
map: Map<String, JsonElement>,
) {
for ((key, value) in map) {
val dotPath = if (prefix.isEmpty()) key else "$prefix.$key"
if (value is JsonObject) {
walk(dotPath, value)
} else {
flat[dotPath] = value
}
}
}
walk("", nested)
return flat
}
/** True when [dotPath] is a schema key or lives under one (ancestor-or-self). */
fun isCoveredBySchema(
dotPath: String,
schemaKeys: Set<String>,
): Boolean = schemaKeys.any { dotPath == it || dotPath.startsWith("$it.") }
/** Flat dot-paths present in config but not covered by the schema (sorted). */
fun collectUncoveredPaths(
flatValues: Map<String, JsonElement>,
schemaKeys: Set<String>,
): List<String> =
flatValues.keys
.filterNot { isCoveredBySchema(it, schemaKeys) }
.sorted()
/**
* Match a row against a search query. Covers the key, human label, schema
* description, category, the CURRENT VALUE, and every select option — so
* searching "gpt-4o", "daytona" or "kittentts" finds the right field even
* though none of them appear in a key or description.
*/
fun rowMatchesQuery(
row: ConfigRow,
query: String,
): Boolean {
if (query.isBlank()) return true
val q = query.lowercase()
if (row.key.lowercase().contains(q)) return true
if (row.label.lowercase().contains(q)) return true
row.field?.let { field ->
if (field.description?.lowercase()?.contains(q) == true) return true
if (field.category?.lowercase()?.contains(q) == true) return true
field.options?.let { options ->
if (options.any { it.lowercase().contains(q) }) return true
}
}
return row.valueText.lowercase().contains(q)
}
/** Stable text form of a JSON value for display and search. */
fun jsonText(value: JsonElement): String =
when (value) {
is JsonPrimitive -> value.content
is JsonArray -> value.toString()
is JsonObject -> value.toString()
}
@@ -1,19 +1,25 @@
package com.m57.hermescontrol.ui.config package com.m57.hermescontrol.ui.config
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
@@ -23,13 +29,18 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SecondaryScrollableTabRow import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -37,10 +48,12 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -50,7 +63,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SchemaField import com.m57.hermescontrol.data.model.ConfigSchemaResponse
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.ExposedDropdownField import com.m57.hermescontrol.ui.common.ExposedDropdownField
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -58,9 +71,8 @@ import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.ToastEffect
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.booleanOrNull
@@ -96,11 +108,11 @@ fun ConfigScreen(
}, },
) { paddingValues -> ) { paddingValues ->
when { when {
state.isLoading && state.config == null -> { state.isLoading && state.values == null -> {
SkeletonListState(modifier = Modifier.padding(paddingValues)) SkeletonListState(modifier = Modifier.padding(paddingValues))
} }
state.errorMessage != null && state.config == null -> { state.errorMessage != null && state.values == null -> {
ErrorState( ErrorState(
message = state.errorMessage ?: "", message = state.errorMessage ?: "",
onRetry = { viewModel.loadAll() }, onRetry = { viewModel.loadAll() },
@@ -115,6 +127,8 @@ fun ConfigScreen(
onSearchQueryChange = viewModel::setSearchQuery, onSearchQueryChange = viewModel::setSearchQuery,
onCategoryChange = viewModel::setActiveCategory, onCategoryChange = viewModel::setActiveCategory,
onFieldChange = viewModel::updateField, onFieldChange = viewModel::updateField,
onResetField = viewModel::resetField,
onClearField = viewModel::clearField,
onSave = viewModel::saveConfig, onSave = viewModel::saveConfig,
onYamlTextChange = viewModel::setYamlText, onYamlTextChange = viewModel::setYamlText,
onYamlSave = viewModel::saveYamlConfig, onYamlSave = viewModel::saveYamlConfig,
@@ -133,6 +147,8 @@ private fun ConfigContent(
onSearchQueryChange: (String) -> Unit, onSearchQueryChange: (String) -> Unit,
onCategoryChange: (String) -> Unit, onCategoryChange: (String) -> Unit,
onFieldChange: (String, JsonElement) -> Unit, onFieldChange: (String, JsonElement) -> Unit,
onResetField: (String) -> Unit,
onClearField: (String) -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
onYamlTextChange: (String) -> Unit, onYamlTextChange: (String) -> Unit,
onYamlSave: () -> Unit, onYamlSave: () -> Unit,
@@ -232,13 +248,6 @@ private fun ConfigContent(
} }
// ── Scrollable content ── // ── Scrollable content ──
Column(
modifier =
Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
if (state.yamlMode) { if (state.yamlMode) {
YAMLEditor( YAMLEditor(
yamlText = state.yamlText ?: "", yamlText = state.yamlText ?: "",
@@ -249,22 +258,22 @@ private fun ConfigContent(
) )
} else { } else {
FormEditor( FormEditor(
values = state.values,
schema = state.schema, schema = state.schema,
config = state.config,
defaults = state.defaults, defaults = state.defaults,
uncoveredPaths = state.uncoveredPaths,
activeCategory = state.activeCategory, activeCategory = state.activeCategory,
searchQuery = state.searchQuery, searchQuery = state.searchQuery,
modifiedKeys = state.modifiedKeys, modifiedKeys = state.modifiedKeys,
isSaving = state.isSaving, isSaving = state.isSaving,
onCategoryChange = onCategoryChange, onCategoryChange = onCategoryChange,
onFieldChange = onFieldChange, onFieldChange = onFieldChange,
onResetField = onResetField,
onClearField = onClearField,
onSave = onSave, onSave = onSave,
onResetCategory = onResetCategory, onResetCategory = onResetCategory,
) )
} }
Spacer(modifier = Modifier.height(24.dp))
}
} }
} }
@@ -281,13 +290,19 @@ private fun YAMLEditor(
return return
} }
Column(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
) {
OutlinedTextField( OutlinedTextField(
value = yamlText, value = yamlText,
onValueChange = onYamlTextChange, onValueChange = onYamlTextChange,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(400.dp), .weight(1f),
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
maxLines = Int.MAX_VALUE, maxLines = Int.MAX_VALUE,
) )
@@ -315,23 +330,29 @@ private fun YAMLEditor(
) )
} }
} }
Spacer(modifier = Modifier.height(24.dp))
}
} }
@Composable @Composable
private fun FormEditor( private fun FormEditor(
schema: com.m57.hermescontrol.data.model.ConfigSchemaResponse?, values: Map<String, JsonElement>?,
config: Map<String, JsonElement>?, schema: ConfigSchemaResponse?,
defaults: Map<String, JsonElement>?, defaults: Map<String, JsonElement>?,
uncoveredPaths: List<String>,
activeCategory: String, activeCategory: String,
searchQuery: String, searchQuery: String,
modifiedKeys: Set<String>, modifiedKeys: Set<String>,
isSaving: Boolean, isSaving: Boolean,
onCategoryChange: (String) -> Unit, onCategoryChange: (String) -> Unit,
onFieldChange: (String, JsonElement) -> Unit, onFieldChange: (String, JsonElement) -> Unit,
onResetField: (String) -> Unit,
onClearField: (String) -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
onResetCategory: (String) -> Unit, onResetCategory: (String) -> Unit,
) { ) {
if (schema == null || config == null) return if (schema == null || values == null) return
val isSearching = searchQuery.isNotBlank() val isSearching = searchQuery.isNotBlank()
val categoryCounts = val categoryCounts =
@@ -341,37 +362,73 @@ private fun FormEditor(
.eachCount() .eachCount()
} }
// Non-schema config values — all dot-paths in the config that aren't in the schema // Every row is a flat dot-path with an O(1) value lookup — no nested walks.
val nonSchemaPaths = val rows: List<ConfigRow> =
remember(config, schema) { remember(values, schema, uncoveredPaths) {
collectUncoveredPaths(config, schema.fields.keys.toSet()) val covered =
schema.fields.map { (key, field) ->
ConfigRow(key = key, field = field, value = values[key])
}
val uncovered =
uncoveredPaths.map { dotPath ->
ConfigRow(key = dotPath, field = null, value = values[dotPath])
}
covered + uncovered
} }
val isOtherCategory = activeCategory == "Other" val visibleRows: List<ConfigRow> =
remember(rows, activeCategory, searchQuery) {
// Show tabs only when not searching if (isSearching) {
if (!isSearching) { rows.filter { rowMatchesQuery(it, searchQuery) }
val allCategories =
if (nonSchemaPaths.isNotEmpty()) {
schema.category_order.filter { it in categoryCounts } + "Other"
} else { } else {
schema.category_order.filter { it in categoryCounts } rows.filter { row ->
if (activeCategory == "Other") {
row.isUncovered
} else {
!row.isUncovered && row.category == activeCategory
} }
}
}
}
val allCategories =
remember(schema, uncoveredPaths, categoryCounts) {
val ordered =
schema.category_order.filter { it in categoryCounts } +
uncoveredPaths.takeIf { it.isNotEmpty() }?.let { listOf("Other") }.orEmpty()
ordered.ifEmpty { categoryCounts.keys.sorted() }
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
) {
// Category tabs — hidden while searching
if (!isSearching) {
item(key = "tabs") {
ConfigTabs( ConfigTabs(
categories = allCategories, categories = allCategories,
categoryCounts = categoryCounts, categoryCounts = categoryCounts,
selectedCategory = activeCategory, selectedCategory = activeCategory,
onCategorySelected = onCategoryChange, onCategorySelected = onCategoryChange,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
} else {
item(key = "search-meta") {
val count = visibleRows.size
Text(
text = if (count == 1) "1 result" else "$count results",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
}
}
// Fields list if (activeCategory == "Other" && !isSearching) {
if (isOtherCategory) { // Non-schema config values — read-only cards
// Render non-schema config dot-paths as read-only cards items(uncoveredPaths, key = { it }) { dotPath ->
nonSchemaPaths.forEach { dotPath ->
val jsonText = getJsonValue(config, dotPath)?.toString() ?: ""
Card( Card(
modifier = modifier =
Modifier Modifier
@@ -390,7 +447,7 @@ private fun FormEditor(
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = jsonText, text = values[dotPath]?.let(::jsonText) ?: "",
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -398,50 +455,38 @@ private fun FormEditor(
} }
} }
} else { } else {
val visibleFields: List<Pair<String, SchemaField>> = items(visibleRows, key = { it.key }) { row ->
remember(schema, activeCategory, searchQuery) { ConfigFieldCard(
if (isSearching) { row = row,
val query = searchQuery.lowercase() defaultValue = defaults?.get(row.key),
schema.fields isModified = row.key in modifiedKeys,
.filter { (key, field) -> showCategoryChip = isSearching,
val label = key.split(".").last().replace("_", " ") onChange = { onFieldChange(row.key, it) },
key.lowercase().contains(query) || onReset = { onResetField(row.key) },
label.contains(query) || onClear = { onClearField(row.key) },
(field.description?.lowercase()?.contains(query) == true) ||
(field.category?.lowercase()?.contains(query) == true)
}.entries
.map { it.key to it.value }
} else {
schema.fields
.filter { (_, field) ->
(field.category ?: "general") == activeCategory
}.entries
.map { it.key to it.value }
}
}
visibleFields.forEach { (key, field) ->
ConfigField(
key = key,
field = field,
currentValue = getJsonValue(config, key),
isModified = key in modifiedKeys,
onChange = { onFieldChange(key, it) },
) )
} }
if (visibleFields.isEmpty()) { if (visibleRows.isEmpty()) {
item(key = "empty") {
Text( Text(
text = if (isSearching) "No settings match your search." else "No fields in this category.", text =
if (isSearching) {
"No settings match your search."
} else {
"No fields in this category."
},
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp), modifier = Modifier.padding(vertical = 24.dp),
) )
} }
} }
}
Spacer(modifier = Modifier.height(12.dp))
// Save + Reset buttons // Save + Reset buttons
item(key = "actions") {
Spacer(modifier = Modifier.height(12.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -481,6 +526,8 @@ private fun FormEditor(
} }
} }
} }
}
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -518,22 +565,20 @@ private fun ConfigTabs(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ConfigField( private fun ConfigFieldCard(
key: String, row: ConfigRow,
field: SchemaField, defaultValue: JsonElement?,
currentValue: JsonElement?,
isModified: Boolean, isModified: Boolean,
showCategoryChip: Boolean,
onChange: (JsonElement) -> Unit, onChange: (JsonElement) -> Unit,
onReset: () -> Unit,
onClear: () -> Unit,
) { ) {
val label = val field = row.field
key val description =
.split(".") field?.description
.last() ?: row.key.replace(".", " → ").replace("_", " ").replaceFirstChar { it.uppercase() }
.replace("_", " ")
.replaceFirstChar { it.uppercase() }
val description = field.description ?: key.replace(".", " → ").replace("_", " ").replaceFirstChar { it.uppercase() }
Card( Card(
modifier = modifier =
@@ -551,12 +596,79 @@ private fun ConfigField(
), ),
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
// ── Header row: category chip (searching), label, actions ──
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (showCategoryChip) {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text( Text(
text = label, text = row.category.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Spacer(modifier = Modifier.width(6.dp))
}
Text(
text = row.label,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = if (isModified) FontWeight.Bold else FontWeight.Medium, fontWeight = if (isModified) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
) )
if (!field.type.isNullOrEmpty() && field.type != "string") {
if (isModified) {
Box(
modifier =
Modifier
.padding(start = 6.dp)
.size(8.dp),
) {
Surface(
modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.primary,
) {}
}
}
if (defaultValue != null && defaultValue != row.value) {
CompactIconButton(
icon = Icons.Filled.Refresh,
contentDescription = "Reset to default",
onClick = onReset,
)
}
if (field?.clearable == true && row.valueText.isNotEmpty()) {
CompactIconButton(
icon = Icons.Filled.Clear,
contentDescription = "Clear value",
onClick = onClear,
)
}
}
if (field == null) {
// Uncovered path reached via search — show its value read-only
Text(
text = row.valueText,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
return@Column
}
if (field.type.isNotEmpty() && field.type != "string") {
Text( Text(
text = field.type, text = field.type,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -580,20 +692,30 @@ private fun ConfigField(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Switch( Switch(
checked = (currentValue as? JsonPrimitive)?.booleanOrNull ?: false, checked = (row.value as? JsonPrimitive)?.booleanOrNull ?: false,
onCheckedChange = { onChange(JsonPrimitive(it)) }, onCheckedChange = { onChange(JsonPrimitive(it)) },
) )
} }
} }
"select" -> { "select" -> {
ExposedDropdownField( if (field.searchable) {
label = label, SearchableSelectField(
key = row.key,
label = row.label,
options = field.options ?: emptyList(), options = field.options ?: emptyList(),
selectedValue = (currentValue as? JsonPrimitive)?.content ?: "", selectedValue = (row.value as? JsonPrimitive)?.content ?: "",
onOptionSelected = { onChange(JsonPrimitive(it)) }, onOptionSelected = { onChange(JsonPrimitive(it)) },
) )
if (description != label) { } else {
ExposedDropdownField(
label = row.label,
options = field.options ?: emptyList(),
selectedValue = (row.value as? JsonPrimitive)?.content ?: "",
onOptionSelected = { onChange(JsonPrimitive(it)) },
)
}
if (description != row.label) {
Text( Text(
text = description, text = description,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -604,41 +726,27 @@ private fun ConfigField(
} }
"number" -> { "number" -> {
OutlinedTextField( NumberField(
value = key = row.key,
currentValue?.let { value = row.value,
if (it is JsonPrimitive) it.content else "" label = description,
} ?: "", onChange = onChange,
onValueChange = { text -> )
text.toDoubleOrNull()?.let { doubleVal ->
if (doubleVal == doubleVal.toLong().toDouble()) {
onChange(JsonPrimitive(doubleVal.toLong()))
} else {
onChange(JsonPrimitive(doubleVal))
} }
}
}, "object", "list" -> {
modifier = Modifier.fillMaxWidth(), JsonField(
label = { Text(description) }, key = row.key,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), value = row.value,
singleLine = true, label = description,
shape = RoundedCornerShape(8.dp), onChange = onChange,
) )
} }
else -> { else -> {
// String or other type // String
val textValue =
currentValue?.let {
when (it) {
is JsonPrimitive -> it.content
is JsonObject -> it.toString()
is JsonArray -> it.toString()
}
} ?: ""
OutlinedTextField( OutlinedTextField(
value = textValue, value = (row.value as? JsonPrimitive)?.content ?: "",
onValueChange = { onChange(JsonPrimitive(it)) }, onValueChange = { onChange(JsonPrimitive(it)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text(description) }, label = { Text(description) },
@@ -651,55 +759,218 @@ private fun ConfigField(
} }
} }
/** Recursively find all config dot-paths not covered by the schema. */ /** Compact header action icon (reset / clear). */
private fun collectUncoveredPaths( @Composable
config: Map<String, JsonElement>, private fun CompactIconButton(
schemaPaths: Set<String>, icon: androidx.compose.ui.graphics.vector.ImageVector,
prefix: String = "", contentDescription: String,
): List<String> { onClick: () -> Unit,
val result = mutableListOf<String>() ) {
for ((key, value) in config) { Icon(
val dotPath = if (prefix.isEmpty()) key else "$prefix.$key" imageVector = icon,
// If this exact path is in the schema, it's covered — skip entirely contentDescription = contentDescription,
if (dotPath in schemaPaths) continue tint = MaterialTheme.colorScheme.onSurfaceVariant,
if (value is JsonObject) { modifier =
// Before recursing deeper, check if this whole sub-tree has any schema coverage Modifier
val hasSchemaCoverage = schemaPaths.any { it == dotPath || it.startsWith("$dotPath.") } .padding(start = 4.dp)
if (hasSchemaCoverage) { .size(18.dp)
// Schema covers some sub-paths — recurse to find what's NOT covered .clickable(onClick = onClick),
result.addAll(collectUncoveredPaths(value, schemaPaths, dotPath)) )
} else {
// No schema coverage at all for this subtree — add the whole thing
result.add(dotPath)
}
} else if (value is JsonArray) {
// Arrays: check if the path is in schema; if not, add it
if (dotPath !in schemaPaths) {
result.add(dotPath)
}
} else {
// Leaf value (string, number, boolean, null) not in schema
result.add(dotPath)
}
}
return result.sorted()
} }
/** Get a nested value from a flat config Map using a dot-path key. */ /**
private fun getJsonValue( * Searchable dropdown (schema `searchable: true`, e.g. timezone's 590 options).
config: Map<String, JsonElement>, * Typing filters the menu; only a picked option (or an exact-match on blur)
dotPath: String, * commits — closed-world, matching the desktop SearchableSelect. The first
): JsonElement? { * keystroke after focus starts a FRESH filter (the existing value's prefix is
val parts = dotPath.split(".") * stripped) so typing "amman" over "Asia/Amman" doesn't append to it.
var current: JsonElement? = null */
for (i in parts.indices) { @OptIn(ExperimentalMaterial3Api::class)
val key = parts[i] @Composable
if (i == 0) { private fun SearchableSelectField(
current = config[key] key: String,
label: String,
options: List<String>,
selectedValue: String,
onOptionSelected: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
var query by remember(key) { mutableStateOf(selectedValue) }
var hasFocus by remember { mutableStateOf(false) }
var untouched by remember(key) { mutableStateOf(true) }
LaunchedEffect(selectedValue) {
if (!hasFocus) {
query = selectedValue
untouched = true
}
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
OutlinedTextField(
value = query,
onValueChange = { newText ->
if (untouched && query == selectedValue && newText.startsWith(selectedValue)) {
// Fresh filter over a pre-filled value: drop the old value
query = newText.removePrefix(selectedValue)
} else { } else {
current = (current as? JsonObject)?.get(key) query = newText
}
untouched = false
expanded = true
},
modifier =
Modifier
.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable)
.onFocusChanged {
hasFocus = it.isFocused
if (!it.isFocused) {
untouched = true
if (query in options && query != selectedValue) {
onOptionSelected(query)
} else {
query = selectedValue
}
}
},
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
singleLine = true,
shape = RoundedCornerShape(8.dp),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
val filtered = options.filter { it.contains(query, ignoreCase = true) }
if (filtered.isEmpty()) {
DropdownMenuItem(
text = {
Text(
text = "No matches",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = {},
)
} else {
filtered.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
query = option
onOptionSelected(option)
expanded = false
},
)
}
}
} }
if (current == null) return null
} }
return current }
/**
* Number editor with local text state. The value is committed only when the
* text parses, and the field's text never snaps while focused — so typing
* intermediate states like "0." or "42.0" works instead of dropping keys.
*/
@Composable
private fun NumberField(
key: String,
value: JsonElement?,
label: String,
onChange: (JsonElement) -> Unit,
) {
var text by remember(key) { mutableStateOf((value as? JsonPrimitive)?.content ?: "") }
var hasFocus by remember { mutableStateOf(false) }
LaunchedEffect(value) {
if (!hasFocus) text = (value as? JsonPrimitive)?.content ?: ""
}
OutlinedTextField(
value = text,
onValueChange = { newText ->
text = newText
newText.toDoubleOrNull()?.let { doubleVal ->
onChange(
if (doubleVal == doubleVal.toLong().toDouble()) {
JsonPrimitive(doubleVal.toLong())
} else {
JsonPrimitive(doubleVal)
},
)
}
},
modifier =
Modifier
.fillMaxWidth()
.onFocusChanged {
hasFocus = it.isFocused
if (!it.isFocused) text = (value as? JsonPrimitive)?.content ?: ""
},
isError = text.isNotEmpty() && text.toDoubleOrNull() == null,
label = { Text(label) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
shape = RoundedCornerShape(8.dp),
)
}
/**
* Object/list JSON editor with local text state. Commits only valid JSON;
* invalid input shows an error but is never sent. Multi-line + monospace.
*/
@Composable
private fun JsonField(
key: String,
value: JsonElement?,
label: String,
onChange: (JsonElement) -> Unit,
) {
var text by remember(key) { mutableStateOf(value?.let(::jsonText) ?: "") }
var hasFocus by remember { mutableStateOf(false) }
var invalid by remember { mutableStateOf(false) }
LaunchedEffect(value) {
if (!hasFocus) text = value?.let(::jsonText) ?: ""
}
OutlinedTextField(
value = text,
onValueChange = { newText ->
text = newText
val parsed = runCatching { Json.parseToJsonElement(newText) }.getOrNull()
invalid = newText.isNotBlank() && parsed == null
if (parsed != null && newText.isNotBlank()) {
onChange(parsed)
}
},
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 96.dp)
.onFocusChanged {
hasFocus = it.isFocused
if (!it.isFocused) {
invalid = false
text = value?.let(::jsonText) ?: ""
}
},
isError = invalid,
supportingText =
if (invalid) {
{ Text("Invalid JSON") }
} else {
null
},
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
label = { Text(label) },
maxLines = Int.MAX_VALUE,
)
} }
@@ -20,13 +20,17 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
data class ConfigUiState( data class ConfigUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isSaving: Boolean = false, val isSaving: Boolean = false,
val config: Map<String, JsonElement>? = null, /** Flattened dot-path → value map (see [flattenConfig]). */
val values: Map<String, JsonElement>? = null,
val schema: ConfigSchemaResponse? = null, val schema: ConfigSchemaResponse? = null,
val defaults: Map<String, JsonElement>? = null, val defaults: Map<String, JsonElement>? = null,
/** Config paths present but not covered by the schema (the "Other" tab). */
val uncoveredPaths: List<String> = emptyList(),
val path: String? = null, val path: String? = null,
val yamlText: String? = null, val yamlText: String? = null,
val modifiedKeys: Set<String> = emptySet(), val modifiedKeys: Set<String> = emptySet(),
@@ -68,7 +72,7 @@ class ConfigViewModel :
val rawResult = rawDeferred.await() val rawResult = rawDeferred.await()
if (configResult is NetworkResult.Success) { if (configResult is NetworkResult.Success) {
val configData = configResult.data val values = flattenConfig(configResult.data)
val schema = (schemaResult as? NetworkResult.Success)?.data val schema = (schemaResult as? NetworkResult.Success)?.data
val defaults = (defaultsResult as? NetworkResult.Success)?.data val defaults = (defaultsResult as? NetworkResult.Success)?.data
val path = (rawResult as? NetworkResult.Success)?.data?.path val path = (rawResult as? NetworkResult.Success)?.data?.path
@@ -76,9 +80,14 @@ class ConfigViewModel :
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
config = configData, values = values,
schema = schema, schema = schema,
defaults = defaults, defaults = defaults,
uncoveredPaths =
collectUncoveredPaths(
values,
schema?.fields?.keys ?: emptySet(),
),
path = path, path = path,
activeCategory = activeCategory =
if (schema?.category_order?.isNotEmpty() == true) { if (schema?.category_order?.isNotEmpty() == true) {
@@ -108,17 +117,34 @@ class ConfigViewModel :
value: JsonElement, value: JsonElement,
) { ) {
pendingChanges[key] = value pendingChanges[key] = value
val state = _uiState.value
// Apply the change locally so the UI reflects it immediately
val updatedConfig =
state.config?.let { config ->
applyNestedValue(config, key, value)
}
_uiState.update { _uiState.update {
it.copy( it.copy(
config = updatedConfig, values = it.values?.toMutableMap()?.apply { this[key] = value },
modifiedKeys = pendingChanges.keys.toSet(),
)
}
}
/** Reset ONE field to its default value (pending until Save). */
fun resetField(key: String) {
val state = _uiState.value
val defaultVal = state.defaults?.get(key) ?: return
pendingChanges[key] = defaultVal
_uiState.update {
it.copy(
values = it.values?.toMutableMap()?.apply { this[key] = defaultVal },
modifiedKeys = pendingChanges.keys.toSet(),
)
}
}
/** Clear a clearable field to blank (e.g. timezone → system default). */
fun clearField(key: String) {
val blank = JsonPrimitive("")
pendingChanges[key] = blank
_uiState.update {
it.copy(
values = it.values?.toMutableMap()?.apply { this[key] = blank },
modifiedKeys = pendingChanges.keys.toSet(), modifiedKeys = pendingChanges.keys.toSet(),
) )
} }
@@ -193,7 +219,9 @@ class ConfigViewModel :
_uiState.update { _uiState.update {
it.copy( it.copy(
isSaving = false, isSaving = false,
config = (configResult as? NetworkResult.Success)?.data ?: it.config, values =
(configResult as? NetworkResult.Success)?.data?.let(::flattenConfig)
?: it.values,
modifiedKeys = emptySet(), modifiedKeys = emptySet(),
toastMessage = "Configuration saved successfully", toastMessage = "Configuration saved successfully",
) )
@@ -233,7 +261,9 @@ class ConfigViewModel :
_uiState.update { _uiState.update {
it.copy( it.copy(
yamlIsSaving = false, yamlIsSaving = false,
config = (configResult as? NetworkResult.Success)?.data ?: it.config, values =
(configResult as? NetworkResult.Success)?.data?.let(::flattenConfig)
?: it.values,
toastMessage = "YAML configuration saved", toastMessage = "YAML configuration saved",
) )
} }
@@ -262,15 +292,20 @@ class ConfigViewModel :
} }
var count = 0 var count = 0
val updatedValues = state.values?.toMutableMap()
for ((key, _) in categoryFields) { for ((key, _) in categoryFields) {
val defaultVal = defaults[key] val defaultVal = defaults[key]
if (defaultVal != null) { if (defaultVal != null) {
pendingChanges[key] = defaultVal pendingChanges[key] = defaultVal
updatedValues?.set(key, defaultVal)
count++ count++
} }
} }
_uiState.update { _uiState.update {
it.copy(modifiedKeys = pendingChanges.keys.toSet()) it.copy(
values = updatedValues ?: it.values,
modifiedKeys = pendingChanges.keys.toSet(),
)
} }
if (count > 0) { if (count > 0) {
@@ -284,34 +319,6 @@ class ConfigViewModel :
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
/** Apply a value at a dot-path like "terminal.backend" into a flat config map. */
private fun applyNestedValue(
config: Map<String, JsonElement>,
dotPath: String,
value: JsonElement,
): Map<String, JsonElement> {
val parts = dotPath.split(".")
if (parts.size == 1) {
val mutable = config.toMutableMap()
mutable[parts[0]] = value
return mutable
}
val topKey = parts.first()
val rest = parts.drop(1).joinToString(".")
val topValue = config[topKey]
val nestedJson = (topValue as? JsonObject) ?: JsonObject(emptyMap())
val updatedNested =
applyNestedValue(
nestedJson,
rest,
value,
)
val nestedObj = JsonObject(updatedNested)
val mutable = config.toMutableMap()
mutable[topKey] = nestedObj
return mutable
}
/** Build a nested JSON changeset from dot-path pending changes. */ /** Build a nested JSON changeset from dot-path pending changes. */
private fun buildChangeset(changes: Map<String, JsonElement>): Map<String, JsonElement> { private fun buildChangeset(changes: Map<String, JsonElement>): Map<String, JsonElement> {
val root = mutableMapOf<String, Any>() val root = mutableMapOf<String, Any>()
@@ -333,18 +340,15 @@ class ConfigViewModel :
current[parts.last()] = value current[parts.last()] = value
} }
fun toJsonObject(map: Map<String, Any>): JsonObject { fun toJsonObject(map: Map<String, Any>): Map<String, JsonElement> =
val content =
map.mapValues { (_, v) -> map.mapValues { (_, v) ->
if (v is Map<*, *>) { if (v is Map<*, *>) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
toJsonObject(v as Map<String, Any>) JsonObject(toJsonObject(v as Map<String, Any>))
} else { } else {
v as JsonElement v as JsonElement
} }
} }
return JsonObject(content)
}
return toJsonObject(root) return toJsonObject(root)
} }
@@ -0,0 +1,181 @@
package com.m57.hermescontrol.ui.config
import com.m57.hermescontrol.data.model.SchemaField
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure-logic tests for the config form's data layer: flattening the nested
* config response into dot-paths, "Other" path discovery, and the search
* matcher (which must cover keys, labels, descriptions, categories, CURRENT
* VALUES and select options — the value/option coverage was missing before
* the redo).
*/
class ConfigFormDataTest {
// ── flattenConfig ──────────────────────────────────────────────────────
@Test
fun `flattenConfig walks nested objects into dot paths`() {
val nested =
JsonObject(
mapOf(
"model" to JsonPrimitive("gpt-4o"),
"terminal" to
JsonObject(
mapOf(
"backend" to JsonPrimitive("local"),
"timeout" to JsonPrimitive(30),
),
),
),
)
val flat = flattenConfig(nested)
assertEquals(
mapOf(
"model" to JsonPrimitive("gpt-4o"),
"terminal.backend" to JsonPrimitive("local"),
"terminal.timeout" to JsonPrimitive(30),
),
flat,
)
}
@Test
fun `flattenConfig keeps arrays and nulls as leaf values`() {
val nested =
JsonObject(
mapOf(
"toolsets" to JsonArray(listOf(JsonPrimitive("terminal"), JsonPrimitive("web"))),
"unset" to JsonNull,
),
)
val flat = flattenConfig(nested)
assertEquals(2, flat.size)
assertEquals(
JsonArray(listOf(JsonPrimitive("terminal"), JsonPrimitive("web"))),
flat["toolsets"],
)
assertEquals(JsonNull, flat["unset"])
}
@Test
fun `flattenConfig on already-flat map is identity`() {
val flat = mapOf("a.b" to JsonPrimitive(1), "c" to JsonPrimitive("x"))
assertEquals(flat, flattenConfig(flat))
}
// ── isCoveredBySchema ──────────────────────────────────────────────────
@Test
fun `isCoveredBySchema matches exact key and ancestors`() {
val schema = setOf("tts.provider", "model")
assertTrue(isCoveredBySchema("tts.provider", schema))
assertTrue(isCoveredBySchema("model", schema))
// Ancestor relationship: key below a schema key is covered
assertTrue(isCoveredBySchema("tts.provider.x", schema))
assertFalse(isCoveredBySchema("tts.other", schema))
// Prefix boundary: "tts.providers" must NOT match "tts.provider"
assertFalse(isCoveredBySchema("tts.providers.custom", schema))
}
// ── collectUncoveredPaths ──────────────────────────────────────────────
@Test
fun `collectUncoveredPaths finds only schema-orphaned leaves`() {
val flat =
mapOf(
"model" to JsonPrimitive("x"),
"tts.provider" to JsonPrimitive("edge"),
"tts.providers.custom.voice" to JsonPrimitive("amy"),
"dashboard.host" to JsonPrimitive("0.0.0.0"),
)
val schema = setOf("model", "tts.provider")
assertEquals(
listOf("dashboard.host", "tts.providers.custom.voice"),
collectUncoveredPaths(flat, schema),
)
}
// ── rowMatchesQuery ────────────────────────────────────────────────────
private fun row(
key: String,
field: SchemaField?,
value: kotlinx.serialization.json.JsonElement? = null,
) = ConfigRow(key = key, field = field, value = value)
@Test
fun `search matches dot key and human label`() {
val r = row("model_context_length", SchemaField(type = "number"), JsonPrimitive(8192))
assertTrue(rowMatchesQuery(r, "model_context"))
assertTrue(rowMatchesQuery(r, "context length"))
}
@Test
fun `search matches description and category`() {
val r =
row(
"stt.local.model",
SchemaField(type = "select", description = "Local faster-whisper model size", category = "stt"),
)
assertTrue(rowMatchesQuery(r, "whisper"))
assertTrue(rowMatchesQuery(r, "stt"))
}
@Test
fun `search matches current value`() {
val r = row("model", SchemaField(type = "string"), JsonPrimitive("anthropic/claude-sonnet-4.6"))
assertTrue(rowMatchesQuery(r, "claude-sonnet"))
assertFalse(rowMatchesQuery(r, "gpt-4o"))
}
@Test
fun `search matches select options`() {
val r =
row(
"terminal.backend",
SchemaField(type = "select", options = listOf("local", "docker", "ssh", "modal", "daytona")),
JsonPrimitive("local"),
)
assertTrue(rowMatchesQuery(r, "daytona"))
assertTrue(rowMatchesQuery(r, "docker"))
}
@Test
fun `search matches uncovered paths by key and value`() {
val r = row("plugins.myplugin.enabled", field = null, value = JsonPrimitive("true"))
assertTrue(rowMatchesQuery(r, "myplugin"))
assertTrue(rowMatchesQuery(r, "true"))
}
@Test
fun `search is case-insensitive and blank query matches all`() {
val r = row("Model", SchemaField(type = "string"), JsonPrimitive("OpenAI"))
assertTrue(rowMatchesQuery(r, "openai"))
assertTrue(rowMatchesQuery(r, "MODEL"))
assertTrue(rowMatchesQuery(r, ""))
assertTrue(rowMatchesQuery(r, " "))
}
@Test
fun `search does not match unrelated keys`() {
val r = row("memory.provider", SchemaField(type = "select"), JsonPrimitive("builtin"))
assertFalse(rowMatchesQuery(r, "terminal"))
}
// ── jsonText ───────────────────────────────────────────────────────────
@Test
fun `jsonText renders primitives, objects and arrays`() {
assertEquals("42", jsonText(JsonPrimitive(42)))
assertEquals("hello", jsonText(JsonPrimitive("hello")))
assertEquals("{\"a\":1}", jsonText(JsonObject(mapOf("a" to JsonPrimitive(1)))))
assertEquals("[1,2]", jsonText(JsonArray(listOf(JsonPrimitive(1), JsonPrimitive(2)))))
}
}