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
+923 -368

No files matched your search

@@ -26,6 +26,8 @@ data class SchemaField(
val description: String? = null,
val category: String? = null,
val options: List<String>? = null,
val searchable: Boolean = false,
val clearable: Boolean = false,
)
@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()
}
File diff suppressed because it is too large. Load diff
@@ -20,13 +20,17 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
data class ConfigUiState(
val isLoading: 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 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 yamlText: String? = null,
val modifiedKeys: Set<String> = emptySet(),
@@ -68,7 +72,7 @@ class ConfigViewModel :
val rawResult = rawDeferred.await()
if (configResult is NetworkResult.Success) {
val configData = configResult.data
val values = flattenConfig(configResult.data)
val schema = (schemaResult as? NetworkResult.Success)?.data
val defaults = (defaultsResult as? NetworkResult.Success)?.data
val path = (rawResult as? NetworkResult.Success)?.data?.path
@@ -76,9 +80,14 @@ class ConfigViewModel :
_uiState.update {
it.copy(
isLoading = false,
config = configData,
values = values,
schema = schema,
defaults = defaults,
uncoveredPaths =
collectUncoveredPaths(
values,
schema?.fields?.keys ?: emptySet(),
),
path = path,
activeCategory =
if (schema?.category_order?.isNotEmpty() == true) {
@@ -108,17 +117,34 @@ class ConfigViewModel :
value: JsonElement,
) {
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 {
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(),
)
}
@@ -193,7 +219,9 @@ class ConfigViewModel :
_uiState.update {
it.copy(
isSaving = false,
config = (configResult as? NetworkResult.Success)?.data ?: it.config,
values =
(configResult as? NetworkResult.Success)?.data?.let(::flattenConfig)
?: it.values,
modifiedKeys = emptySet(),
toastMessage = "Configuration saved successfully",
)
@@ -233,7 +261,9 @@ class ConfigViewModel :
_uiState.update {
it.copy(
yamlIsSaving = false,
config = (configResult as? NetworkResult.Success)?.data ?: it.config,
values =
(configResult as? NetworkResult.Success)?.data?.let(::flattenConfig)
?: it.values,
toastMessage = "YAML configuration saved",
)
}
@@ -262,15 +292,20 @@ class ConfigViewModel :
}
var count = 0
val updatedValues = state.values?.toMutableMap()
for ((key, _) in categoryFields) {
val defaultVal = defaults[key]
if (defaultVal != null) {
pendingChanges[key] = defaultVal
updatedValues?.set(key, defaultVal)
count++
}
}
_uiState.update {
it.copy(modifiedKeys = pendingChanges.keys.toSet())
it.copy(
values = updatedValues ?: it.values,
modifiedKeys = pendingChanges.keys.toSet(),
)
}
if (count > 0) {
@@ -284,34 +319,6 @@ class ConfigViewModel :
_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. */
private fun buildChangeset(changes: Map<String, JsonElement>): Map<String, JsonElement> {
val root = mutableMapOf<String, Any>()
@@ -333,18 +340,15 @@ class ConfigViewModel :
current[parts.last()] = value
}
fun toJsonObject(map: Map<String, Any>): JsonObject {
val content =
map.mapValues { (_, v) ->
if (v is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
toJsonObject(v as Map<String, Any>)
} else {
v as JsonElement
}
fun toJsonObject(map: Map<String, Any>): Map<String, JsonElement> =
map.mapValues { (_, v) ->
if (v is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
JsonObject(toJsonObject(v as Map<String, Any>))
} else {
v as JsonElement
}
return JsonObject(content)
}
}
return toJsonObject(root)
}