diff --git a/README.md b/README.md index bba7d0c..9469f7a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Kotlin Multiplatform Compose JSON viewer and editor component for Android, iOS, ## Features -- **JSON Viewer** — Read-only, syntax-highlighted, foldable JSON tree with virtualized rendering (virtually no size limit for valid JSON; invalid JSON truncated at 100 KB) +- **JSON Viewer** — Read-only, syntax-highlighted, foldable JSON tree with virtualized rendering (virtually no size limit) - **JSON Editor** — Editable JSON with real-time validation, formatting, and sorting (50 KB write limit) - **Search** — Highlight matching text across the JSON document - **Multiple Themes** — Dark, Light, Monokai, Dracula, Solarized Dark (+ custom themes) diff --git a/docs/api/jsoncmp.md b/docs/api/jsoncmp.md index b8b6c91..f7227d7 100644 --- a/docs/api/jsoncmp.md +++ b/docs/api/jsoncmp.md @@ -4,7 +4,7 @@ Two separate composable entry points for viewing and editing JSON. ## JsonViewerCMP -Read-only JSON viewer with virtualized rendering. Virtually no size limit for valid JSON; invalid JSON is truncated at 100 KB with a warning. +Read-only JSON viewer with virtualized rendering. Virtually no size limit. ```kotlin @ExperimentalJsonCmpApi diff --git a/docs/features/viewer.md b/docs/features/viewer.md index ff09d00..c8e3920 100644 --- a/docs/features/viewer.md +++ b/docs/features/viewer.md @@ -9,8 +9,7 @@ ## Size Limits -- **Valid JSON** — Virtually no size limit. The viewer uses virtualized rendering to handle large documents efficiently. -- **Invalid JSON** — Truncated at 100 KB. A warning is shown indicating the original size and that the preview is truncated. +- Virtually no size limit. The viewer uses virtualized rendering to handle large documents efficiently. ## Syntax Highlighting diff --git a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/line/JsonLine.kt b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/line/JsonLine.kt index 279753a..a2c5961 100644 --- a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/line/JsonLine.kt +++ b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/line/JsonLine.kt @@ -4,6 +4,7 @@ package dev.skymansandy.jsoncmp.domain.line +import androidx.compose.runtime.Immutable import dev.skymansandy.jsoncmp.domain.model.FoldType import dev.skymansandy.jsoncmp.domain.model.JsonPath @@ -24,6 +25,7 @@ import dev.skymansandy.jsoncmp.domain.model.JsonPath * straight to `allLines[childEndIndex]` instead of iterating through every hidden child. * Set to -1 for non-foldable lines (values, closing brackets). */ +@Immutable internal data class JsonLine( val lineNumber: Int, val depth: Int, @@ -35,4 +37,7 @@ internal data class JsonLine( val path: JsonPath = emptyList(), val isClosingBracket: Boolean = false, val childEndIndex: Int = -1, -) +) { + /** Cached concatenation of all parts' text — avoids repeated joinToString in hot paths. */ + val text: String = parts.joinToString("") { it.text } +} diff --git a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/line/JsonLineBuilder.kt b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/line/JsonLineBuilder.kt index c63623d..82c3ccf 100644 --- a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/line/JsonLineBuilder.kt +++ b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/line/JsonLineBuilder.kt @@ -23,6 +23,9 @@ internal class JsonLineBuilder { private var lineNum = 0 private var nextFoldId = 0 + /** Pre-computed indent parts to avoid repeated string allocation. */ + private val indentCache = ArrayList>() + /** * Tracks the index in [out] where each foldable header line was emitted. * After the full tree walk, a post-pass uses these to compute [JsonLine.childEndIndex] @@ -77,9 +80,8 @@ internal class JsonLineBuilder { parentFoldIds: List, path: JsonPath, ) { - // Common parts shared by all node types - val indent: List = - if (depth > 0) listOf(JsonPart.Indent(" ".repeat(depth))) else emptyList() + // Common parts shared by all node types — indent is cached to avoid repeated allocation + val indent: List = indentForDepth(depth) val keyParts: List = if (key != null) { listOf(JsonPart.Key("\"$key\""), JsonPart.Punct(": ")) } else emptyList() @@ -190,6 +192,15 @@ internal class JsonLineBuilder { ) } } + + private fun indentForDepth(depth: Int): List { + if (depth == 0) return emptyList() + // Grow cache on demand + while (indentCache.size < depth) { + indentCache.add(listOf(JsonPart.Indent(" ".repeat(indentCache.size + 1)))) + } + return indentCache[depth - 1] + } } /** Convenience entry point — creates a builder, walks the tree, and returns the flat line list. */ diff --git a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/parser/ParseJsonResult.kt b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/parser/ParseJsonResult.kt index 4321c35..3273bf0 100644 --- a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/parser/ParseJsonResult.kt +++ b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/parser/ParseJsonResult.kt @@ -34,40 +34,23 @@ internal suspend fun parseAndBuildLines( val trimmed = raw.trim() if (trimmed.isEmpty()) return@withContext ParseResult.Empty - val (node, err) = parseJsonResult(trimmed) - if (node != null) { - ParseResult.Success(node, buildDisplayLines(node)) - } else { - ParseResult.Failure(err) - } -} - -/** Parses [input] into a [JsonNode] tree, returning (node, null) on success or (null, error) on failure. */ -private suspend fun parseJsonResult( - input: String, -): Pair = withContext(Dispatchers.Default) { try { - val trimmed = input.trim() - if (trimmed.isEmpty()) { - null to JsonError("Empty input") - } else { - val element = json.parseToJsonElement(trimmed) - element.toJsonNode() to null - } + val element = json.parseToJsonElement(trimmed) + val node = element.toJsonNode() + ParseResult.Success(node, buildDisplayLines(node)) } catch (e: Exception) { - null to JsonError(e.message ?: "Invalid JSON") + ParseResult.Failure(JsonError(e.message ?: "Invalid JSON")) } } -private suspend fun JsonElement.toJsonNode(): JsonNode = withContext(Dispatchers.Default) { - when (this@toJsonNode) { - is JsonObject -> JsonNode.JObject(entries.map { (key, value) -> key to value.toJsonNode() }) - is JsonArray -> JsonNode.JArray(map { it.toJsonNode() }) - JsonNull -> JsonNode.JNull - is JsonPrimitive -> when { - isString -> JsonNode.JString(content) - booleanOrNull != null -> JsonNode.JBoolean(boolean) - else -> JsonNode.JNumber(content) - } +/** Converts a kotlinx.serialization [JsonElement] tree to our [JsonNode] tree — plain function, no dispatch overhead. */ +private fun JsonElement.toJsonNode(): JsonNode = when (this) { + is JsonObject -> JsonNode.JObject(entries.map { (key, value) -> key to value.toJsonNode() }) + is JsonArray -> JsonNode.JArray(map { it.toJsonNode() }) + JsonNull -> JsonNode.JNull + is JsonPrimitive -> when { + isString -> JsonNode.JString(content) + booleanOrNull != null -> JsonNode.JBoolean(boolean) + else -> JsonNode.JNumber(content) } } diff --git a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/serializer/JsonSerializer.kt b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/serializer/JsonSerializer.kt index ff53453..03a1010 100644 --- a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/serializer/JsonSerializer.kt +++ b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/serializer/JsonSerializer.kt @@ -14,7 +14,8 @@ import dev.skymansandy.jsoncmp.domain.model.JsonNode */ internal fun JsonNode.toJsonString(indent: Int = 2, compact: Boolean = false): String { val sb = StringBuilder() - writeNode(sb = sb, node = this, currentIndent = 0, step = indent, compact = compact) + val indentCache = HashMap() + writeNode(sb = sb, node = this, currentIndent = 0, step = indent, compact = compact, indentCache = indentCache) return sb.toString() } @@ -59,11 +60,12 @@ private fun writeNode( currentIndent: Int, step: Int, compact: Boolean, + indentCache: MutableMap, ) { val nl = if (compact) "" else "\n" val childIndent = currentIndent + step - val indentStr = if (compact) "" else " ".repeat(childIndent) - val closingIndentStr = if (compact) "" else " ".repeat(currentIndent) + val indentStr = if (compact) "" else indentCache.getOrPut(childIndent) { " ".repeat(childIndent) } + val closingIndentStr = if (compact) "" else indentCache.getOrPut(currentIndent) { " ".repeat(currentIndent) } val colonSep = if (compact) ":" else ": " when (node) { @@ -74,9 +76,11 @@ private fun writeNode( sb.append("{").append(nl) node.fields.forEachIndexed { i, (key, value) -> sb.append(indentStr) - sb.append('"').append(escapeJsonString(key)).append('"') + sb.append('"') + escapeJsonStringTo(sb, key) + sb.append('"') sb.append(colonSep) - writeNode(sb, value, childIndent, step, compact) + writeNode(sb, value, childIndent, step, compact, indentCache) if (i < node.fields.lastIndex) sb.append(",") sb.append(nl) } @@ -91,7 +95,7 @@ private fun writeNode( sb.append("[").append(nl) node.elements.forEachIndexed { i, element -> sb.append(indentStr) - writeNode(sb, element, childIndent, step, compact) + writeNode(sb, element, childIndent, step, compact, indentCache) if (i < node.elements.lastIndex) sb.append(",") sb.append(nl) } @@ -100,7 +104,9 @@ private fun writeNode( } is JsonNode.JString -> { - sb.append('"').append(escapeJsonString(node.value)).append('"') + sb.append('"') + escapeJsonStringTo(sb, node.value) + sb.append('"') } is JsonNode.JNumber -> sb.append(node.value) @@ -109,22 +115,22 @@ private fun writeNode( } } -/** Escapes special characters (quotes, backslashes, control chars) for safe JSON string output. */ -private fun escapeJsonString(s: String): String = buildString(s.length) { +/** Escapes special characters directly into [sb], avoiding intermediate string allocation. */ +private fun escapeJsonStringTo(sb: StringBuilder, s: String) { for (c in s) { when (c) { - '"' -> append("\\\"") - '\\' -> append("\\\\") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - '\b' -> append("\\b") - '\u000C' -> append("\\f") + '"' -> sb.append("\\\"") + '\\' -> sb.append("\\\\") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + '\b' -> sb.append("\\b") + '\u000C' -> sb.append("\\f") else -> { if (c.code < 0x20) { - append("\\u${c.code.toString(16).padStart(4, '0')}") + sb.append("\\u${c.code.toString(16).padStart(4, '0')}") } else { - append(c) + sb.append(c) } } } diff --git a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/store/JsonHolderImpl.kt b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/store/JsonHolderImpl.kt index 20752b9..18724e8 100644 --- a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/store/JsonHolderImpl.kt +++ b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/store/JsonHolderImpl.kt @@ -75,9 +75,12 @@ internal class JsonHolderImpl( } is JsonAction.CollapseAll -> { - _state.update { current -> - val allFoldIds = current.allLines.mapNotNull { it.foldId } - current.copy(foldState = allFoldIds.associateWith { true }) + scope.launch { + val current = _state.value + val collapsedFolds = current.allLines + .mapNotNull { it.foldId } + .associateWith { true } + _state.update { it.copy(foldState = collapsedFolds) } } } diff --git a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/store/JsonHolderState.kt b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/store/JsonHolderState.kt index 1a7f805..2dc04e2 100644 --- a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/store/JsonHolderState.kt +++ b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/store/JsonHolderState.kt @@ -47,22 +47,25 @@ internal class JsonHolderState( fun computeFoldedContent(line: JsonLine): String { if (line.foldId == null || line.childEndIndex < 0) return "" - val startIdx = allLines.indexOf(line) - if (startIdx < 0) return "" + val startIdx = line.lineNumber - 1 + if (startIdx < 0 || startIdx >= allLines.size) return "" val endIdx = line.childEndIndex.coerceAtMost(allLines.size) - return allLines.subList(startIdx + 1, endIdx) - .joinToString(" ") { l -> l.parts.joinToString("") { it.text }.trim() } + return buildString { + for (i in (startIdx + 1) until endIdx) { + if (isNotEmpty()) append(' ') + append(allLines[i].text.trim()) + } + } } fun hasFoldedMatch(line: JsonLine, searchQuery: String): Boolean { if (line.foldId == null || line.childEndIndex < 0 || searchQuery.isBlank()) return false - val startIdx = allLines.indexOf(line) - if (startIdx < 0) return false + val startIdx = line.lineNumber - 1 + if (startIdx < 0 || startIdx >= allLines.size) return false val endIdx = line.childEndIndex.coerceAtMost(allLines.size) val queryLower = searchQuery.lowercase() for (i in (startIdx + 1) until endIdx) { - val lineText = allLines[i].parts.joinToString("") { it.text } - if (lineText.lowercase().contains(queryLower)) return true + if (allLines[i].text.lowercase().contains(queryLower)) return true } return false } @@ -70,13 +73,13 @@ internal class JsonHolderState( fun countFoldedMatches(line: JsonLine, searchQuery: String): Int { if (line.foldId == null || line.childEndIndex < 0 || searchQuery.isBlank()) return 0 if (foldState[line.foldId] != true) return 0 - val startIdx = allLines.indexOf(line) - if (startIdx < 0) return 0 + val startIdx = line.lineNumber - 1 + if (startIdx < 0 || startIdx >= allLines.size) return 0 val endIdx = line.childEndIndex.coerceAtMost(allLines.size) val queryLower = searchQuery.lowercase() var count = 0 for (i in (startIdx + 1) until endIdx) { - val lineText = allLines[i].parts.joinToString("") { it.text }.lowercase() + val lineText = allLines[i].text.lowercase() var idx = lineText.indexOf(queryLower) while (idx >= 0) { count++ @@ -89,24 +92,25 @@ internal class JsonHolderState( override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is JsonHolderState) return false - return raw == other.raw && - parsedJson == other.parsedJson && - error == other.error && - isParsing == other.isParsing && + return isParsing == other.isParsing && isCompact == other.isCompact && isEditing == other.isEditing && + error == other.error && + foldState == other.foldState && + raw == other.raw && allLines == other.allLines && - foldState == other.foldState + parsedJson == other.parsedJson } override fun hashCode(): Int { - var result = raw.hashCode() + // Use raw.length instead of raw.hashCode() to avoid O(n) hash on large strings + var result = raw.length result = 31 * result + (parsedJson?.hashCode() ?: 0) result = 31 * result + (error?.hashCode() ?: 0) result = 31 * result + isParsing.hashCode() result = 31 * result + isCompact.hashCode() result = 31 * result + isEditing.hashCode() - result = 31 * result + allLines.hashCode() + result = 31 * result + allLines.size result = 31 * result + foldState.hashCode() return result } diff --git a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/common/JsonLineView.kt b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/common/JsonLineView.kt index d038213..5d6312d 100644 --- a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/common/JsonLineView.kt +++ b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/common/JsonLineView.kt @@ -158,13 +158,9 @@ internal fun JsonLineView( } } else { // Expanded: render all parts with syntax colors and optional search highlights - val lineText = remember(line) { - buildString { line.parts.forEach { append(it.text) } } - } - val styledText = remember(line, searchQuery, colors) { buildAnnotatedString { - append(lineText) + append(line.text) var cursor = 0 line.parts.forEach { part -> addStyle( @@ -175,7 +171,7 @@ internal fun JsonLineView( cursor += part.text.length } if (searchQuery.isNotBlank()) { - val lower = lineText.lowercase() + val lower = line.text.lowercase() val queryLower = searchQuery.lowercase() var idx = lower.indexOf(queryLower) while (idx >= 0) { diff --git a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/common/PlainText.kt b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/common/PlainText.kt index 3178e3f..296a482 100644 --- a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/common/PlainText.kt +++ b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/common/PlainText.kt @@ -8,9 +8,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -23,7 +24,7 @@ import androidx.compose.ui.unit.dp import dev.skymansandy.jsoncmp.theme.LocalJsonCmpColors import dev.skymansandy.jsoncmp.theme.monoStyle -/** Fallback text display for invalid/unparseable JSON with optional search highlighting. */ +/** Fallback text display for invalid/unparseable JSON with lazy line-based rendering and optional search highlighting. */ @Composable internal fun PlainText( modifier: Modifier = Modifier, @@ -31,41 +32,53 @@ internal fun PlainText( searchQuery: String, ) { val colors = LocalJsonCmpColors.current - - val annotated = remember(text, searchQuery, colors) { - buildAnnotatedString { - append(text) - addStyle(SpanStyle(color = colors.punctuation), 0, text.length) - if (searchQuery.isNotBlank()) { - val lowerText = text.lowercase() - val lowerQuery = searchQuery.lowercase() - var idx = lowerText.indexOf(lowerQuery) - while (idx >= 0) { - addStyle( - SpanStyle(background = colors.highlight, color = colors.highlightFg), - idx, - idx + lowerQuery.length, - ) - idx = lowerText.indexOf(lowerQuery, idx + lowerQuery.length) - } - } - } - } + val lines = remember(text) { text.split('\n') } + val horizontalScrollState = rememberScrollState() SelectionContainer( modifier = modifier .fillMaxWidth() .background(colors.background), ) { - Text( - text = annotated, - style = monoStyle, + LazyColumn( modifier = Modifier .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = 4.dp), - ) + .horizontalScroll(horizontalScrollState), + ) { + itemsIndexed( + items = lines, + key = { index, _ -> index }, + ) { _, line -> + val annotated = remember(line, searchQuery, colors) { + buildAnnotatedString { + append(line) + addStyle(SpanStyle(color = colors.punctuation), 0, line.length) + if (searchQuery.isNotBlank()) { + val lowerLine = line.lowercase() + val lowerQuery = searchQuery.lowercase() + var idx = lowerLine.indexOf(lowerQuery) + while (idx >= 0) { + addStyle( + SpanStyle( + background = colors.highlight, + color = colors.highlightFg, + ), + idx, + idx + lowerQuery.length, + ) + idx = lowerLine.indexOf(lowerQuery, idx + lowerQuery.length) + } + } + } + } + + Text( + text = annotated, + style = monoStyle, + modifier = Modifier.padding(horizontal = 12.dp), + ) + } + } } } diff --git a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/editor/JsonEditor.kt b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/editor/JsonEditor.kt index c09d3bf..4ee6882 100644 --- a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/editor/JsonEditor.kt +++ b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/editor/JsonEditor.kt @@ -49,6 +49,7 @@ import dev.skymansandy.jsoncmp.ui.editor.component.EditorToolbar import dev.skymansandy.jsoncmp.ui.editor.component.ErrorBanner import dev.skymansandy.jsoncmp.ui.editor.component.LineGutterEditMode import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext /** Internal JSON editor with syntax-highlighted BasicTextField and line gutter. */ @@ -79,11 +80,14 @@ internal fun JsonEditor( val focusRequester = remember { FocusRequester() } - // Syntax highlighting runs off the main thread to avoid jank on large inputs + // Syntax highlighting runs off the main thread to avoid jank on large inputs. + // Short debounce avoids redundant work during fast typing; stale results are + // discarded via the text-length guard below. val highlighted by produceState( initialValue = AnnotatedString(textFieldValue.text), textFieldValue.text, searchQuery, colors, ) { + delay(HIGHLIGHT_DEBOUNCE_MS) value = withContext(Dispatchers.Default) { highlightJson( text = textFieldValue.text, @@ -93,6 +97,14 @@ internal fun JsonEditor( } } + // Guard: if highlighting is stale (from previous text), fall back to plain text + // so BasicTextField never receives a mismatched AnnotatedString. + val safeHighlighted = if (highlighted.text == textFieldValue.text) { + highlighted + } else { + AnnotatedString(textFieldValue.text) + } + Column( modifier = modifier.background(colors.background), ) { @@ -137,7 +149,7 @@ internal fun JsonEditor( // JSON editor BasicTextField( - value = textFieldValue.copy(annotatedString = highlighted), + value = textFieldValue.copy(annotatedString = safeHighlighted), onValueChange = { newValue -> textFieldValue = newValue onAction(JsonAction.UpdateJson(newValue.text)) @@ -156,6 +168,8 @@ internal fun JsonEditor( } } +private const val HIGHLIGHT_DEBOUNCE_MS = 100L + private fun TextRange.constrain(maxLength: Int): TextRange { val newStart = start.coerceIn(0, maxLength) val newEnd = end.coerceIn(0, maxLength) diff --git a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/viewer/component/JsonViewerContent.kt b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/viewer/component/JsonViewerContent.kt index aabfa04..8e052d1 100644 --- a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/viewer/component/JsonViewerContent.kt +++ b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/viewer/component/JsonViewerContent.kt @@ -73,7 +73,7 @@ internal fun JsonViewerContent( buildList { visibleLines.forEachIndexed { lineIdx, line -> // Find all occurrences in the visible text of this line - val lineText = line.parts.joinToString("") { it.text }.lowercase() + val lineText = line.text.lowercase() var idx = lineText.indexOf(queryLower) while (idx >= 0) { add(SearchMatch(lineIdx = lineIdx, charOffset = idx)) diff --git a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/viewer/component/JsonViewerEmptyState.kt b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/viewer/component/JsonViewerEmptyState.kt index aa4bf6e..1e19819 100644 --- a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/viewer/component/JsonViewerEmptyState.kt +++ b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/viewer/component/JsonViewerEmptyState.kt @@ -6,24 +6,18 @@ package dev.skymansandy.jsoncmp.ui.viewer.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.skymansandy.jsoncmp.domain.store.JsonHolderState import dev.skymansandy.jsoncmp.theme.LocalJsonCmpColors -import dev.skymansandy.jsoncmp.theme.monoStyle import dev.skymansandy.jsoncmp.ui.common.PlainText -/** Shown when no parsed lines exist — loading spinner, plain text fallback, or truncated preview. */ +/** Shown when no parsed lines exist — loading spinner or lazy plain-text fallback. */ @Composable internal fun JsonViewerEmptyState( modifier: Modifier = Modifier, @@ -45,36 +39,11 @@ internal fun JsonViewerEmptyState( ) } - state.raw.length <= 100 * 1024 -> + else -> PlainText( modifier = modifier, text = state.raw, searchQuery = searchQuery, ) - - else -> { - val truncatedText = state.raw.take(100 * 1024) - - Column( - modifier = modifier.fillMaxHeight(), - ) { - Text( - text = "Invalid JSON. Showing truncated preview (first 100 KB of ${state.raw.length / 1024} KB)", - style = monoStyle, - color = colors.highlightFg, - modifier = Modifier - .fillMaxWidth() - .background(colors.highlight) - .padding(horizontal = 12.dp, vertical = 4.dp), - ) - - Box(modifier = Modifier.weight(1f)) { - PlainText( - text = truncatedText, - searchQuery = searchQuery, - ) - } - } - } } } diff --git a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/viewer/component/JsonViewerLineList.kt b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/viewer/component/JsonViewerLineList.kt index 1382152..fb98c33 100644 --- a/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/viewer/component/JsonViewerLineList.kt +++ b/json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/viewer/component/JsonViewerLineList.kt @@ -143,6 +143,10 @@ internal fun JsonViewerLineList( items( items = visibleLines, key = { it.lineNumber }, + contentType = { line -> + val isFolded = line.foldId != null && foldState[line.foldId] == true + if (isFolded) 1 else 0 + }, ) { line -> lineContent(line) } } }