Skip to content

Commit 124aff9

Browse files
authored
minor: Minor fixes + enhancements. Removes clipping even for invalid json (#7)
* Show virtualised json for invalid json also * Update documentation
1 parent 22a2344 commit 124aff9

15 files changed

Lines changed: 155 additions & 148 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Kotlin Multiplatform Compose JSON viewer and editor component for Android, iOS,
1616
1717
## Features
1818

19-
- **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)
19+
- **JSON Viewer** — Read-only, syntax-highlighted, foldable JSON tree with virtualized rendering (virtually no size limit)
2020
- **JSON Editor** — Editable JSON with real-time validation, formatting, and sorting (50 KB write limit)
2121
- **Search** — Highlight matching text across the JSON document
2222
- **Multiple Themes** — Dark, Light, Monokai, Dracula, Solarized Dark (+ custom themes)

docs/api/jsoncmp.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Two separate composable entry points for viewing and editing JSON.
44

55
## JsonViewerCMP
66

7-
Read-only JSON viewer with virtualized rendering. Virtually no size limit for valid JSON; invalid JSON is truncated at 100 KB with a warning.
7+
Read-only JSON viewer with virtualized rendering. Virtually no size limit.
88

99
```kotlin
1010
@ExperimentalJsonCmpApi

docs/features/viewer.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@
99

1010
## Size Limits
1111

12-
- **Valid JSON** — Virtually no size limit. The viewer uses virtualized rendering to handle large documents efficiently.
13-
- **Invalid JSON** — Truncated at 100 KB. A warning is shown indicating the original size and that the preview is truncated.
12+
- Virtually no size limit. The viewer uses virtualized rendering to handle large documents efficiently.
1413

1514
## Syntax Highlighting
1615

json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/line/JsonLine.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
package dev.skymansandy.jsoncmp.domain.line
66

7+
import androidx.compose.runtime.Immutable
78
import dev.skymansandy.jsoncmp.domain.model.FoldType
89
import dev.skymansandy.jsoncmp.domain.model.JsonPath
910

@@ -24,6 +25,7 @@ import dev.skymansandy.jsoncmp.domain.model.JsonPath
2425
* straight to `allLines[childEndIndex]` instead of iterating through every hidden child.
2526
* Set to -1 for non-foldable lines (values, closing brackets).
2627
*/
28+
@Immutable
2729
internal data class JsonLine(
2830
val lineNumber: Int,
2931
val depth: Int,
@@ -35,4 +37,7 @@ internal data class JsonLine(
3537
val path: JsonPath = emptyList(),
3638
val isClosingBracket: Boolean = false,
3739
val childEndIndex: Int = -1,
38-
)
40+
) {
41+
/** Cached concatenation of all parts' text — avoids repeated joinToString in hot paths. */
42+
val text: String = parts.joinToString("") { it.text }
43+
}

json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/line/JsonLineBuilder.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ internal class JsonLineBuilder {
2323
private var lineNum = 0
2424
private var nextFoldId = 0
2525

26+
/** Pre-computed indent parts to avoid repeated string allocation. */
27+
private val indentCache = ArrayList<List<JsonPart>>()
28+
2629
/**
2730
* Tracks the index in [out] where each foldable header line was emitted.
2831
* After the full tree walk, a post-pass uses these to compute [JsonLine.childEndIndex]
@@ -77,9 +80,8 @@ internal class JsonLineBuilder {
7780
parentFoldIds: List<Int>,
7881
path: JsonPath,
7982
) {
80-
// Common parts shared by all node types
81-
val indent: List<JsonPart> =
82-
if (depth > 0) listOf(JsonPart.Indent(" ".repeat(depth))) else emptyList()
83+
// Common parts shared by all node types — indent is cached to avoid repeated allocation
84+
val indent: List<JsonPart> = indentForDepth(depth)
8385
val keyParts: List<JsonPart> = if (key != null) {
8486
listOf(JsonPart.Key("\"$key\""), JsonPart.Punct(": "))
8587
} else emptyList()
@@ -190,6 +192,15 @@ internal class JsonLineBuilder {
190192
)
191193
}
192194
}
195+
196+
private fun indentForDepth(depth: Int): List<JsonPart> {
197+
if (depth == 0) return emptyList()
198+
// Grow cache on demand
199+
while (indentCache.size < depth) {
200+
indentCache.add(listOf(JsonPart.Indent(" ".repeat(indentCache.size + 1))))
201+
}
202+
return indentCache[depth - 1]
203+
}
193204
}
194205

195206
/** Convenience entry point — creates a builder, walks the tree, and returns the flat line list. */

json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/parser/ParseJsonResult.kt

Lines changed: 13 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -34,40 +34,23 @@ internal suspend fun parseAndBuildLines(
3434
val trimmed = raw.trim()
3535
if (trimmed.isEmpty()) return@withContext ParseResult.Empty
3636

37-
val (node, err) = parseJsonResult(trimmed)
38-
if (node != null) {
39-
ParseResult.Success(node, buildDisplayLines(node))
40-
} else {
41-
ParseResult.Failure(err)
42-
}
43-
}
44-
45-
/** Parses [input] into a [JsonNode] tree, returning (node, null) on success or (null, error) on failure. */
46-
private suspend fun parseJsonResult(
47-
input: String,
48-
): Pair<JsonNode?, JsonError?> = withContext(Dispatchers.Default) {
4937
try {
50-
val trimmed = input.trim()
51-
if (trimmed.isEmpty()) {
52-
null to JsonError("Empty input")
53-
} else {
54-
val element = json.parseToJsonElement(trimmed)
55-
element.toJsonNode() to null
56-
}
38+
val element = json.parseToJsonElement(trimmed)
39+
val node = element.toJsonNode()
40+
ParseResult.Success(node, buildDisplayLines(node))
5741
} catch (e: Exception) {
58-
null to JsonError(e.message ?: "Invalid JSON")
42+
ParseResult.Failure(JsonError(e.message ?: "Invalid JSON"))
5943
}
6044
}
6145

62-
private suspend fun JsonElement.toJsonNode(): JsonNode = withContext(Dispatchers.Default) {
63-
when (this@toJsonNode) {
64-
is JsonObject -> JsonNode.JObject(entries.map { (key, value) -> key to value.toJsonNode() })
65-
is JsonArray -> JsonNode.JArray(map { it.toJsonNode() })
66-
JsonNull -> JsonNode.JNull
67-
is JsonPrimitive -> when {
68-
isString -> JsonNode.JString(content)
69-
booleanOrNull != null -> JsonNode.JBoolean(boolean)
70-
else -> JsonNode.JNumber(content)
71-
}
46+
/** Converts a kotlinx.serialization [JsonElement] tree to our [JsonNode] tree — plain function, no dispatch overhead. */
47+
private fun JsonElement.toJsonNode(): JsonNode = when (this) {
48+
is JsonObject -> JsonNode.JObject(entries.map { (key, value) -> key to value.toJsonNode() })
49+
is JsonArray -> JsonNode.JArray(map { it.toJsonNode() })
50+
JsonNull -> JsonNode.JNull
51+
is JsonPrimitive -> when {
52+
isString -> JsonNode.JString(content)
53+
booleanOrNull != null -> JsonNode.JBoolean(boolean)
54+
else -> JsonNode.JNumber(content)
7255
}
7356
}

json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/serializer/JsonSerializer.kt

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import dev.skymansandy.jsoncmp.domain.model.JsonNode
1414
*/
1515
internal fun JsonNode.toJsonString(indent: Int = 2, compact: Boolean = false): String {
1616
val sb = StringBuilder()
17-
writeNode(sb = sb, node = this, currentIndent = 0, step = indent, compact = compact)
17+
val indentCache = HashMap<Int, String>()
18+
writeNode(sb = sb, node = this, currentIndent = 0, step = indent, compact = compact, indentCache = indentCache)
1819
return sb.toString()
1920
}
2021

@@ -59,11 +60,12 @@ private fun writeNode(
5960
currentIndent: Int,
6061
step: Int,
6162
compact: Boolean,
63+
indentCache: MutableMap<Int, String>,
6264
) {
6365
val nl = if (compact) "" else "\n"
6466
val childIndent = currentIndent + step
65-
val indentStr = if (compact) "" else " ".repeat(childIndent)
66-
val closingIndentStr = if (compact) "" else " ".repeat(currentIndent)
67+
val indentStr = if (compact) "" else indentCache.getOrPut(childIndent) { " ".repeat(childIndent) }
68+
val closingIndentStr = if (compact) "" else indentCache.getOrPut(currentIndent) { " ".repeat(currentIndent) }
6769
val colonSep = if (compact) ":" else ": "
6870

6971
when (node) {
@@ -74,9 +76,11 @@ private fun writeNode(
7476
sb.append("{").append(nl)
7577
node.fields.forEachIndexed { i, (key, value) ->
7678
sb.append(indentStr)
77-
sb.append('"').append(escapeJsonString(key)).append('"')
79+
sb.append('"')
80+
escapeJsonStringTo(sb, key)
81+
sb.append('"')
7882
sb.append(colonSep)
79-
writeNode(sb, value, childIndent, step, compact)
83+
writeNode(sb, value, childIndent, step, compact, indentCache)
8084
if (i < node.fields.lastIndex) sb.append(",")
8185
sb.append(nl)
8286
}
@@ -91,7 +95,7 @@ private fun writeNode(
9195
sb.append("[").append(nl)
9296
node.elements.forEachIndexed { i, element ->
9397
sb.append(indentStr)
94-
writeNode(sb, element, childIndent, step, compact)
98+
writeNode(sb, element, childIndent, step, compact, indentCache)
9599
if (i < node.elements.lastIndex) sb.append(",")
96100
sb.append(nl)
97101
}
@@ -100,7 +104,9 @@ private fun writeNode(
100104
}
101105

102106
is JsonNode.JString -> {
103-
sb.append('"').append(escapeJsonString(node.value)).append('"')
107+
sb.append('"')
108+
escapeJsonStringTo(sb, node.value)
109+
sb.append('"')
104110
}
105111

106112
is JsonNode.JNumber -> sb.append(node.value)
@@ -109,22 +115,22 @@ private fun writeNode(
109115
}
110116
}
111117

112-
/** Escapes special characters (quotes, backslashes, control chars) for safe JSON string output. */
113-
private fun escapeJsonString(s: String): String = buildString(s.length) {
118+
/** Escapes special characters directly into [sb], avoiding intermediate string allocation. */
119+
private fun escapeJsonStringTo(sb: StringBuilder, s: String) {
114120
for (c in s) {
115121
when (c) {
116-
'"' -> append("\\\"")
117-
'\\' -> append("\\\\")
118-
'\n' -> append("\\n")
119-
'\r' -> append("\\r")
120-
'\t' -> append("\\t")
121-
'\b' -> append("\\b")
122-
'\u000C' -> append("\\f")
122+
'"' -> sb.append("\\\"")
123+
'\\' -> sb.append("\\\\")
124+
'\n' -> sb.append("\\n")
125+
'\r' -> sb.append("\\r")
126+
'\t' -> sb.append("\\t")
127+
'\b' -> sb.append("\\b")
128+
'\u000C' -> sb.append("\\f")
123129
else -> {
124130
if (c.code < 0x20) {
125-
append("\\u${c.code.toString(16).padStart(4, '0')}")
131+
sb.append("\\u${c.code.toString(16).padStart(4, '0')}")
126132
} else {
127-
append(c)
133+
sb.append(c)
128134
}
129135
}
130136
}

json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/store/JsonHolderImpl.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,12 @@ internal class JsonHolderImpl(
7575
}
7676

7777
is JsonAction.CollapseAll -> {
78-
_state.update { current ->
79-
val allFoldIds = current.allLines.mapNotNull { it.foldId }
80-
current.copy(foldState = allFoldIds.associateWith { true })
78+
scope.launch {
79+
val current = _state.value
80+
val collapsedFolds = current.allLines
81+
.mapNotNull { it.foldId }
82+
.associateWith { true }
83+
_state.update { it.copy(foldState = collapsedFolds) }
8184
}
8285
}
8386

json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/domain/store/JsonHolderState.kt

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -47,36 +47,39 @@ internal class JsonHolderState(
4747

4848
fun computeFoldedContent(line: JsonLine): String {
4949
if (line.foldId == null || line.childEndIndex < 0) return ""
50-
val startIdx = allLines.indexOf(line)
51-
if (startIdx < 0) return ""
50+
val startIdx = line.lineNumber - 1
51+
if (startIdx < 0 || startIdx >= allLines.size) return ""
5252
val endIdx = line.childEndIndex.coerceAtMost(allLines.size)
53-
return allLines.subList(startIdx + 1, endIdx)
54-
.joinToString(" ") { l -> l.parts.joinToString("") { it.text }.trim() }
53+
return buildString {
54+
for (i in (startIdx + 1) until endIdx) {
55+
if (isNotEmpty()) append(' ')
56+
append(allLines[i].text.trim())
57+
}
58+
}
5559
}
5660

5761
fun hasFoldedMatch(line: JsonLine, searchQuery: String): Boolean {
5862
if (line.foldId == null || line.childEndIndex < 0 || searchQuery.isBlank()) return false
59-
val startIdx = allLines.indexOf(line)
60-
if (startIdx < 0) return false
63+
val startIdx = line.lineNumber - 1
64+
if (startIdx < 0 || startIdx >= allLines.size) return false
6165
val endIdx = line.childEndIndex.coerceAtMost(allLines.size)
6266
val queryLower = searchQuery.lowercase()
6367
for (i in (startIdx + 1) until endIdx) {
64-
val lineText = allLines[i].parts.joinToString("") { it.text }
65-
if (lineText.lowercase().contains(queryLower)) return true
68+
if (allLines[i].text.lowercase().contains(queryLower)) return true
6669
}
6770
return false
6871
}
6972

7073
fun countFoldedMatches(line: JsonLine, searchQuery: String): Int {
7174
if (line.foldId == null || line.childEndIndex < 0 || searchQuery.isBlank()) return 0
7275
if (foldState[line.foldId] != true) return 0
73-
val startIdx = allLines.indexOf(line)
74-
if (startIdx < 0) return 0
76+
val startIdx = line.lineNumber - 1
77+
if (startIdx < 0 || startIdx >= allLines.size) return 0
7578
val endIdx = line.childEndIndex.coerceAtMost(allLines.size)
7679
val queryLower = searchQuery.lowercase()
7780
var count = 0
7881
for (i in (startIdx + 1) until endIdx) {
79-
val lineText = allLines[i].parts.joinToString("") { it.text }.lowercase()
82+
val lineText = allLines[i].text.lowercase()
8083
var idx = lineText.indexOf(queryLower)
8184
while (idx >= 0) {
8285
count++
@@ -89,24 +92,25 @@ internal class JsonHolderState(
8992
override fun equals(other: Any?): Boolean {
9093
if (this === other) return true
9194
if (other !is JsonHolderState) return false
92-
return raw == other.raw &&
93-
parsedJson == other.parsedJson &&
94-
error == other.error &&
95-
isParsing == other.isParsing &&
95+
return isParsing == other.isParsing &&
9696
isCompact == other.isCompact &&
9797
isEditing == other.isEditing &&
98+
error == other.error &&
99+
foldState == other.foldState &&
100+
raw == other.raw &&
98101
allLines == other.allLines &&
99-
foldState == other.foldState
102+
parsedJson == other.parsedJson
100103
}
101104

102105
override fun hashCode(): Int {
103-
var result = raw.hashCode()
106+
// Use raw.length instead of raw.hashCode() to avoid O(n) hash on large strings
107+
var result = raw.length
104108
result = 31 * result + (parsedJson?.hashCode() ?: 0)
105109
result = 31 * result + (error?.hashCode() ?: 0)
106110
result = 31 * result + isParsing.hashCode()
107111
result = 31 * result + isCompact.hashCode()
108112
result = 31 * result + isEditing.hashCode()
109-
result = 31 * result + allLines.hashCode()
113+
result = 31 * result + allLines.size
110114
result = 31 * result + foldState.hashCode()
111115
return result
112116
}

json-cmp/src/commonMain/kotlin/dev/skymansandy/jsoncmp/ui/common/JsonLineView.kt

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,9 @@ internal fun JsonLineView(
158158
}
159159
} else {
160160
// Expanded: render all parts with syntax colors and optional search highlights
161-
val lineText = remember(line) {
162-
buildString { line.parts.forEach { append(it.text) } }
163-
}
164-
165161
val styledText = remember(line, searchQuery, colors) {
166162
buildAnnotatedString {
167-
append(lineText)
163+
append(line.text)
168164
var cursor = 0
169165
line.parts.forEach { part ->
170166
addStyle(
@@ -175,7 +171,7 @@ internal fun JsonLineView(
175171
cursor += part.text.length
176172
}
177173
if (searchQuery.isNotBlank()) {
178-
val lower = lineText.lowercase()
174+
val lower = line.text.lowercase()
179175
val queryLower = searchQuery.lowercase()
180176
var idx = lower.indexOf(queryLower)
181177
while (idx >= 0) {

0 commit comments

Comments
 (0)