Outputs.kt
src/jsMain/kotlin/afield/app/client/Outputs.kt · 2088 lines · cited by How an output panel decides its size
package and imports — 391 lines, from line 1
package afield.app.client
import afield.app.editor.routeMonacoOutput
import afield.app.protocol.Execute
import afield.app.protocol.Output
import afield.app.protocol.handle
import afield.app.canvas.invoke
import afield.app.notifications.notify
import afield.app.protocol.OutputStackLocation
import afield.app.protocol.OutputSuggestion
import afield.app.protocol.payload
import afield.app.protocol.send
import afield.app.protocol.sendWithReply
import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.Element
import org.w3c.dom.HTMLElement
import org.w3c.dom.Node
import org.w3c.dom.Text
import kotlin.math.*
private val knownOutputs = mutableListOf<dynamic>()
private class EditorDecorations {
var collection: dynamic = null
var contents: MutableList<dynamic> = mutableListOf()
}
private val perEditorDecorations = mutableMapOf<Double, EditorDecorations>()
private fun getOrCreateEditorDecorations(editor: dynamic): EditorDecorations {
val key = weakHash(editor)
val existing = perEditorDecorations[key]
if (existing != null && existing.collection != null) return existing
existing?.collection?.clear()
val ed = EditorDecorations()
ed.collection = editor.createDecorationsCollection(emptyArray<Any>())
perEditorDecorations[key] = ed
return ed
}
private fun editorDecorationsFor(editor: dynamic): EditorDecorations? {
return perEditorDecorations[weakHash(editor)]
}
private var executionDecorations = mutableListOf<dynamic>()
private var executionsCollection: dynamic = null
private val stackTraceHintCollections = mutableMapOf<Double, dynamic>()
private val debounceTimers = mutableMapOf<String, Int>()
private var orderCounter = 0
private var addPromptBoxUid = 0
private val recentLocalLaunchOutputClears = mutableMapOf<String, Double>()
private const val executionMarkerGutterInsetPx = -12
private const val outputHeightFudgePx = 5.0
private const val remoteClearSuppressionWindowMs = 2_000.0
private var outputMaxHeightPx = 500.0
private var outputScrollTailExistingPx = 40.0
private var outputScrollTailNewPx = 46.0
private const val maxRenderedOutputRows = 80
private const val maxRenderedOutputChars = 40_000
private const val maxOutputMessageBreaks = 80
private val htmlBreakRegex = Regex("<br\\s*/?>", RegexOption.IGNORE_CASE)
private fun executionMarkerStateClass(mode: String): String {
return when (mode) {
"launching" -> "running-launching"
"continue" -> "running-continued"
"ending" -> "running-ending"
else -> "running"
}
}
private fun debounceTimeout(key: String, delay: Int, fn: () -> Unit) {
debounceTimers[key]?.let { window.clearTimeout(it) }
val id = window.setTimeout({
debounceTimers.remove(key)
fn()
}, delay)
debounceTimers[key] = id
}
private fun weakHash(d: dynamic): Double {
val existing = d.__weakheash__
return if (existing != null) {
existing as Double
} else {
val v = js("Math.random()") as Double
d.__weakheash__ = v
v
}
}
private fun focusedMonacoEditorOrNull(): dynamic {
val w = js("window")
val embeddedEditor = runCatching {
val registry = w.embeddedEditors
val entry = if (registry != null) registry.getFocused() else null
entry?.editor
}.getOrNull()
if (embeddedEditor != null) return embeddedEditor
val globalEditor = w.globalMonacoEditor ?: return null
val hasTextFocus = runCatching {
globalEditor.hasTextFocus() as Boolean
}.getOrDefault(false)
return if (hasTextFocus) globalEditor else null
}
private fun activeMonacoEditor(): dynamic {
val w = js("window")
return focusedMonacoEditorOrNull() ?: w.globalMonacoEditor
}
private fun outputEditorOrNull(output: dynamic): dynamic {
val outputEditor = output.editor
if (outputEditor != null) return outputEditor
return js("window").globalMonacoEditor
}
private fun knownOutputsForEditor(editor: dynamic): List<dynamic> {
return knownOutputs.filter { output ->
val outputEditor = outputEditorOrNull(output) ?: return@filter false
sameEditor(outputEditor, editor)
}
}
private fun isStructuralClosingLine(text: String): Boolean {
val trimmed = text.trim()
if (trimmed.isEmpty()) return true
return trimmed.all { it == '}' || it == ')' || it == ']' || it == ';' }
}
private fun normalizeInlineOutputTargetLine(editor: dynamic, lineNumber: Int): Int {
val model = editor.getModel() ?: return lineNumber
var line = lineNumber
val lineCount = ((model.getLineCount() as? Number)?.toInt() ?: line).coerceAtLeast(1)
if (line > lineCount) line = lineCount
if (line < 1) line = 1
while (line > 1) {
val lineText = (model.getLineContent(line) as? String) ?: break
if (!isStructuralClosingLine(lineText)) break
line--
}
return line
}
private fun sameEditor(a: dynamic, b: dynamic): Boolean {
return weakHash(a) == weakHash(b)
}
private fun findReusableOutput(editor: dynamic, belowLine: Int, extraClass: String, allowNearby: Boolean = true): dynamic {
val candidates = knownOutputs.filter { output ->
val outputEditor = output.editor ?: return@filter false
sameEditor(outputEditor, editor) && (output.kind as? String) == extraClass
}
val exact = candidates
.filter { (it.line as? Number)?.toInt() == belowLine }
.maxByOrNull { (it.insertionOrder as? Number)?.toInt() ?: -1 }
if (exact != null) return exact
if (!allowNearby) return null
return candidates
.filter {
val line = (it.line as? Number)?.toInt() ?: return@filter false
kotlin.math.abs(line - belowLine) <= 1
}
.sortedWith(
compareBy<dynamic> {
kotlin.math.abs(((it.line as? Number)?.toInt() ?: Int.MAX_VALUE) - belowLine)
}.thenByDescending {
(it.insertionOrder as? Number)?.toInt() ?: -1
}
)
.firstOrNull()
}
private fun isAppendableRichHtml(text: String): Boolean {
return text.contains("afield-appendable-output")
}
private fun findKnownOutputForElement(element: HTMLElement?): dynamic {
val outputRoot = element?.closest(".execution_output") as? HTMLElement ?: return null
return knownOutputs.firstOrNull { output ->
(output.domNode as? HTMLElement) == outputRoot
}
}
private fun scheduleKnownOutputRecompute(element: HTMLElement?) {
val output = findKnownOutputForElement(element) ?: return
val editor = output.editor ?: js("window").globalMonacoEditor ?: return
scheduleOutputHeightRecompute(editor, output)
}
private fun flash(node: dynamic) {
node.style.transition = "background 0s"
node.style.background = "rgba(255,255,255,0.4)"
debounceTimeout("blink" + weakHash(node), 20) {
node.style.transition = "background 0.3s"
node.style.background = "rgba(0,0,0,1)"
}
}
private fun pulseOutput(node: dynamic) {
val element = node as? HTMLElement
if (element != null) {
val inErrorOutput = element.parentElement?.classList?.contains("error") == true
if (inErrorOutput) {
element.classList.remove("error-pulse")
window.requestAnimationFrame {
element.classList.add("error-pulse")
window.setTimeout({
element.classList.remove("error-pulse")
}, 500)
}
}
element.classList.remove("flash-inverted")
window.requestAnimationFrame {
element.classList.add("flash-inverted")
}
}
flash(node)
}
private fun updateOutputFlashSignature(output: dynamic, nextSignature: String): Boolean {
val previous = output.flashSignature as? String
output.flashSignature = nextSignature
return previous != null && previous == nextSignature
}
private fun newRange(startLine: Int, startCol: Int, endLine: Int, endCol: Int): dynamic {
val w = js("window")
val ctor = w.globalMonaco.Range
return js("Reflect.construct")(ctor, arrayOf(startLine, startCol, endLine, endCol))
}
private fun getMonacoLineHeightPx(editor: dynamic): Double {
val fallback = 20.0
return try {
val w = js("window")
val monaco = w.globalMonaco
val lineHeightOption = monaco?.editor?.EditorOption?.lineHeight
val value = if (lineHeightOption != null) editor.getOption(lineHeightOption) else null
val lineHeight = (value as? Number)?.toDouble()
if (lineHeight != null && lineHeight > 0.0) lineHeight else fallback
} catch (_: dynamic) {
fallback
}
}
private fun preferredOutputLines(editor: dynamic, domNode: HTMLElement?, domNodeInner: HTMLElement?): Int {
val innerScrollHeight = (domNodeInner?.scrollHeight as? Number)?.toDouble() ?: 0.0
val outerScrollHeight = (domNode?.scrollHeight as? Number)?.toDouble() ?: 0.0
val renderedChildExtent = domNodeInner?.let { inner ->
val innerRect = inner.getBoundingClientRect()
(0 until inner.children.length).mapNotNull { index ->
val child = inner.children.item(index) ?: return@mapNotNull null
val rect = runCatching {
child.asDynamic().getBoundingClientRect?.call(child)
}.getOrNull() ?: return@mapNotNull null
val bottom = (rect.bottom as? Number)?.toDouble() ?: return@mapNotNull null
bottom - innerRect.top
}.maxOrNull() ?: 0.0
} ?: 0.0
// Prefer actual content height (inner). Using outer zone height can lock outputs
// into an oversized state after an initial over-estimate.
val measuredHeight = max(
0.0,
max(
renderedChildExtent,
if (innerScrollHeight > 0.0) innerScrollHeight else outerScrollHeight
)
)
if (measuredHeight <= 0.0) return 2
val lineHeight = getMonacoLineHeightPx(editor)
val lines = max(2.0, ceil((measuredHeight + outputHeightFudgePx) / lineHeight)).toInt()
val maxLines = max(2.0, floor(outputMaxHeightPx / lineHeight)).toInt()
return min(lines, maxLines)
}
private fun updateOutputControlVisibility(output: dynamic) {
val zoneControl = output.zone?.marginDomNode as? HTMLElement
val domNodeControl = (output.domNodeControl as? HTMLElement) ?: zoneControl ?: return
output.domNodeControl = domNodeControl
val id = output.id?.toString() ?: return
val small = (output.small as? Number)?.toInt() ?: 0
val currentHeightPx = (output.zone?.heightInPx as? Number)?.toInt() ?: (small * 19 + 18)
val isExpanded = currentHeightPx > small * 19 + 18
val domNode = output.domNode as? HTMLElement
val scrollHeight = (domNode?.scrollHeight as? Number)?.toDouble() ?: 0.0
val clientHeight = (domNode?.clientHeight as? Number)?.toDouble() ?: 0.0
val overflowPx = max(0.0, scrollHeight - clientHeight)
val hasMeaningfulOverflow = overflowPx > 2.0
val showToggle = isExpanded || hasMeaningfulOverflow
val toggle = if (showToggle) {
"<div class='toggleBox hoverable' onclick='toggleArea(\"$id\")'>↕</div>"
} else {
""
}
domNodeControl.innerHTML = "<button type='button' class='closeBox hoverable' title='Close' aria-label='Close' onclick='removeArea(\"$id\")'>×</button>$toggle"
}
private fun recomputeOutputHeight(editor: dynamic, output: dynamic) {
val zone = output.zone ?: return
val previousSmall = (output.small as? Number)?.toInt() ?: 2
val currentHeightPx = (zone.heightInPx as? Number)?.toInt() ?: (previousSmall * 19 + 18)
val userCollapsed = output.userCollapsed as? Boolean ?: false
val preferredLines = preferredOutputLines(
editor,
output.domNode as? HTMLElement,
output.domNodeInner as? HTMLElement
)
output.big = preferredLines
output.small = min(2, preferredLines)
val targetHeight = if (userCollapsed) output.small else output.big
val targetHeightInt = (targetHeight as? Number)?.toInt() ?: output.small
val targetHeightPx = (targetHeightInt as Number).toInt() * 19 + 18
if (targetHeightPx == currentHeightPx) {
updateOutputControlVisibility(output)
return
}
editor.changeViewZones { changeAccessor: dynamic ->
zone.heightInPx = targetHeightPx
changeAccessor.layoutZone(output.id)
}
window.requestAnimationFrame {
updateOutputControlVisibility(output)
}
}
private fun scheduleOutputHeightRecompute(editor: dynamic, output: dynamic) {
if (output.heightRecomputeQueued as? Boolean == true) return
output.heightRecomputeQueued = true
val firstTimeout = output.heightRecomputeFirstTimeout as? Int
val secondTimeout = output.heightRecomputeSecondTimeout as? Int
val thirdTimeout = output.heightRecomputeThirdTimeout as? Int
if (firstTimeout != null) window.clearTimeout(firstTimeout)
if (secondTimeout != null) window.clearTimeout(secondTimeout)
if (thirdTimeout != null) window.clearTimeout(thirdTimeout)
window.requestAnimationFrame {
output.heightRecomputeQueued = false
recomputeOutputHeight(editor, output)
output.heightRecomputeFirstTimeout = window.setTimeout({
recomputeOutputHeight(editor, output)
}, 35)
output.heightRecomputeSecondTimeout = window.setTimeout({
recomputeOutputHeight(editor, output)
}, 150)
output.heightRecomputeThirdTimeout = window.setTimeout({
recomputeOutputHeight(editor, output)
}, 350)
}
}
private fun scrollOutputToBottom(domNode: HTMLElement?, domNodeInner: HTMLElement?, tailPx: Double) {
if (domNode == null) return
val innerHeight = (domNodeInner?.scrollHeight as? Number)?.toDouble() ?: 0.0
val outerHeight = (domNode.scrollHeight as? Number)?.toDouble() ?: 0.0
val clientHeight = (domNode.clientHeight as? Number)?.toDouble() ?: 0.0
val contentHeight = max(innerHeight, outerHeight)
if (contentHeight <= 0.0 || clientHeight <= 0.0) return
val maxScrollTop = max(0.0, outerHeight - clientHeight)
val target = if (maxScrollTop <= 4.0) {
maxScrollTop
} else {
(contentHeight - tailPx).coerceIn(0.0, maxScrollTop)
}
domNode.scrollTo(0.0, target)
}
private fun applyScrollTailToKnownOutputs() {
knownOutputs.forEach { output ->
if (output.autoScroll as? Boolean == false) return@forEach
scrollOutputToBottom(
output.domNode as? HTMLElement,
output.domNodeInner as? HTMLElement,
outputScrollTailExistingPx
)
}
}
fun setOutputScrollTail(existingPx: Double, newPx: Double)
@OptIn(ExperimentalJsExport::class)
@JsExport
fun setOutputScrollTail(existingPx: Double, newPx: Double) {
val existingSafe = if (existingPx.isFinite()) existingPx else outputScrollTailExistingPx
val newSafe = if (newPx.isFinite()) newPx else outputScrollTailNewPx
outputScrollTailExistingPx = max(0.0, existingSafe)
outputScrollTailNewPx = max(0.0, newSafe)
applyScrollTailToKnownOutputs()
}
fun nudgeOutputScrollTail(deltaPx: Double)
@OptIn(ExperimentalJsExport::class)
@JsExport
fun nudgeOutputScrollTail(deltaPx: Double) {
if (!deltaPx.isFinite()) return
outputScrollTailExistingPx = max(0.0, outputScrollTailExistingPx + deltaPx)
outputScrollTailNewPx = max(0.0, outputScrollTailNewPx + deltaPx)
applyScrollTailToKnownOutputs()
}
fun getOutputScrollTail(): String
@OptIn(ExperimentalJsExport::class)
@JsExport
fun getOutputScrollTail(): String {
return "existing=$outputScrollTailExistingPx new=$outputScrollTailNewPx"
}
fun setOutputMaxHeight(px: Double)
@OptIn(ExperimentalJsExport::class)
@JsExport
fun setOutputMaxHeight(px: Double) {
if (!px.isFinite()) return
outputMaxHeightPx = max(120.0, px)
val w = js("window")
val globalEditor = w.globalMonacoEditor
knownOutputs.forEach { output ->
val editor = output.editor ?: globalEditor
if (editor != null) {
recomputeOutputHeight(editor, output)
}
}
}
fun getOutputMaxHeight(): Double
@OptIn(ExperimentalJsExport::class)
@JsExport
fun getOutputMaxHeight(): Double {
return outputMaxHeightPx
}
private fun clearOutputErrorMarkerDecoration(editor: dynamic, output: dynamic) {
output.errorMarkerOriginalText = null
val model = editor.getModel() ?: return
val id = output.errorMarkerDecorationId as? String ?: run {
output.errorMarkerDecorationId = null
return
}
model.deltaDecorations(arrayOf(id), emptyArray<Any>())
output.errorMarkerDecorationId = null
}
private fun resetOutputErrorMarkerState(editor: dynamic, output: dynamic) {
output.errorMarkerSuppressed = null
output.errorMarkerSuppressedColumn = null
output.errorMarkerBackendColumn = null
clearOutputErrorMarkerDecoration(editor, output)
}
private fun clearEditorDecorations(editor: dynamic) {
val decorations = perEditorDecorations.remove(weakHash(editor)) ?: return
decorations.contents.clear()
decorations.collection?.clear()
}
fun clearStackTraceHints(editor: dynamic)
fun clearStackTraceHints(editor: dynamic) {
val collection = stackTraceHintCollections.remove(weakHash(editor)) ?: return
collection.clear()
}
private fun clearAllStackTraceHints() {
stackTraceHintCollections.values.forEach { it.clear() }
stackTraceHintCollections.clear()
}
private fun clearOutputsForEditor(editor: dynamic, clearInlineDecorations: Boolean = false) {
val outputs = knownOutputsForEditor(editor)
if (outputs.isNotEmpty()) {
outputs.forEach { output ->
resetOutputErrorMarkerState(editor, output)
}
editor.changeViewZones { changeAccessor: dynamic ->
outputs.forEach { output ->
changeAccessor.removeZone(output.id)
}
}
outputs.forEach { output ->
val domNode = output.domNode as? HTMLElement
domNode?.parentElement?.removeChild(domNode)
}
knownOutputs.removeAll(outputs.toSet())
}
if (clearInlineDecorations) {
clearEditorDecorations(editor)
}
clearStackTraceHints(editor)
}
fun clearEditorOutputsForBox(uid: String)
@OptIn(ExperimentalJsExport::class)
@JsExport
fun clearEditorOutputsForBox(uid: String) {
if (uid.isBlank()) return
val w = js("window")
val seenEditors = mutableSetOf<Double>()
fun clearOnce(editor: dynamic) {
if (editor == null || editor == undefined) return
val key = weakHash(editor)
if (!seenEditors.add(key)) return
clearOutputsForEditor(editor, clearInlineDecorations = true)
}
val selected = w.currentSelectedCode
val selectedUid = if (selected != null && (selected.length as? Number)?.toInt() == 1) {
selected[0].uid as? String
} else {
null
}
if (selectedUid == uid) {
clearOnce(w.globalMonacoEditor ?: w.globalEditor)
}
val registry = w.embeddedEditors
if (registry != null && registry != undefined && registry.getAll != null && registry.getAll != undefined) {
val entries = registry.getAll()
val length = (entries.length as? Number)?.toInt() ?: 0
for (i in 0 until length) {
val entry = entries[i] ?: continue
if ((entry.uid as? String) == uid) {
clearOnce(entry.editor)
}
}
}
}
fun clearEditorOutputsForBoxBeforeLaunch(uid: String)
@OptIn(ExperimentalJsExport::class)
@JsExport
fun clearEditorOutputsForBoxBeforeLaunch(uid: String) {
if (uid.isBlank()) return
recentLocalLaunchOutputClears[uid] = window.performance.now()
clearEditorOutputsForBox(uid)
}
fun clearEditorOutputsForBoxFromServer(uid: String)
fun clearEditorOutputsForBoxFromServer(uid: String) {
if (uid.isBlank()) return
val now = window.performance.now()
val lastLocalClear = recentLocalLaunchOutputClears[uid]
if (lastLocalClear != null && now - lastLocalClear < remoteClearSuppressionWindowMs) {
return
}
recentLocalLaunchOutputClears.entries.removeAll { now - it.value > remoteClearSuppressionWindowMs }
clearEditorOutputsForBox(uid)
}
fun setStackTraceHints(editor: dynamic, locations: List<OutputStackLocation>)
body — 116 lines, from line 552
fun setStackTraceHints(editor: dynamic, locations: List<OutputStackLocation>) {
val model = editor.getModel() ?: return
clearStackTraceHints(editor)
if (locations.isEmpty()) return
val lineCount = ((model.getLineCount() as? Number)?.toInt() ?: 1).coerceAtLeast(1)
val decorations = locations
.filter { it.line > 0 }
.map { location ->
val line = location.line.coerceIn(1, lineCount)
val options = js("{}")
options.linesDecorationsClassName = "kotlin-stack-marker"
val hover = js("{}")
hover.value = "Stack frame: `${location.property}:${location.line}`" +
if (location.method.isNotBlank()) " in `${location.method}`" else ""
options.hoverMessage = hover
val entry = js("{}")
entry.range = newRange(line, 1, line, 1)
entry.options = options
entry
}
.toTypedArray()
if (decorations.isEmpty()) return
val collection = editor.createDecorationsCollection(decorations)
stackTraceHintCollections[weakHash(editor)] = collection
}
private fun setOutputErrorMarkerDecoration(
editor: dynamic,
output: dynamic,
startLineInput: Int,
startColumnInput: Int,
_endLineInput: Int,
_endColumnInput: Int
) {
val model = editor.getModel() ?: return
val lineCount = ((model.getLineCount() as? Number)?.toInt() ?: 1).coerceAtLeast(1)
val suppressed = output.errorMarkerSuppressed as? Boolean ?: false
val suppressedColumn = (output.errorMarkerSuppressedColumn as? Number)?.toInt()
if (suppressed && suppressedColumn != null && suppressedColumn == startColumnInput) {
clearOutputErrorMarkerDecoration(editor, output)
return
}
if (startLineInput < 1 || startColumnInput < 1) {
clearOutputErrorMarkerDecoration(editor, output)
return
}
val startLine = startLineInput.coerceIn(1, lineCount)
val startLineLength = (model.getLineLength(startLine) as? Number)?.toInt()?.coerceAtLeast(0) ?: 0
if (startLineLength == 0) {
clearOutputErrorMarkerDecoration(editor, output)
return
}
val startColumn = startColumnInput.coerceIn(1, startLineLength)
val endLine = startLine
val endColumn = (startColumn + 1).coerceAtMost(startLineLength + 1)
val options = js("{}")
options.inlineClassName = "kotlin-error-marker"
options.stickiness = 1
val entry = js("{}")
entry.range = newRange(startLine, startColumn, endLine, endColumn)
entry.options = options
val originalText = try {
model.getValueInRange(entry.range) as? String
} catch (_: dynamic) {
null
}
if (originalText == null || originalText.isEmpty()) {
clearOutputErrorMarkerDecoration(editor, output)
return
}
val existingId = output.errorMarkerDecorationId as? String
val oldIds = if (existingId != null) arrayOf(existingId) else emptyArray<String>()
val newIds = model.deltaDecorations(oldIds, arrayOf(entry)) as Array<String>
output.errorMarkerDecorationId = newIds.firstOrNull()
output.errorMarkerOriginalText = originalText
output.errorMarkerBackendColumn = startColumnInput
output.errorMarkerSuppressed = false
output.errorMarkerSuppressedColumn = null
}
private fun pruneFragileErrorMarkers(editor: dynamic) {
if (knownOutputs.isEmpty()) return
val model = editor.getModel() ?: return
knownOutputs.forEach { output ->
val id = output.errorMarkerDecorationId as? String ?: return@forEach
val original = output.errorMarkerOriginalText as? String ?: return@forEach
val range = model.getDecorationRange(id)
if (range == null) {
clearOutputErrorMarkerDecoration(editor, output)
return@forEach
}
val now = try {
model.getValueInRange(range) as? String
} catch (_: dynamic) {
null
}
if (now == null || now != original) {
output.errorMarkerSuppressed = true
output.errorMarkerSuppressedColumn = output.errorMarkerBackendColumn
clearOutputErrorMarkerDecoration(editor, output)
}
}
}
private var fragileErrorMarkerPruneInstalled = false
fun installFragileErrorMarkerPruning(editor: dynamic)
fun installFragileErrorMarkerPruning(editor: dynamic) {
if (fragileErrorMarkerPruneInstalled) return
fragileErrorMarkerPruneInstalled = true
editor.onDidChangeModelContent { _: dynamic ->
debounceTimeout("prune-error-markers", 25) {
pruneFragileErrorMarkers(editor)
}
}
}
fun clear(editor: dynamic)
@OptIn(ExperimentalJsExport::class)
@JsExport
fun clear(editor: dynamic) {
// Group outputs by owning editor so we remove zones from the correct editor
val byEditor = mutableMapOf<dynamic, MutableList<dynamic>>()
for (output in knownOutputs) {
val owningEditor = output.editor ?: editor
byEditor.getOrPut(owningEditor) { mutableListOf() }.add(output)
}
for ((ed, outputs) in byEditor) {
for (output in outputs) {
resetOutputErrorMarkerState(ed, output)
}
ed.changeViewZones { changeAccessor: dynamic ->
for (output in outputs) {
changeAccessor.removeZone(output.id)
}
}
for (output in outputs) {
val domNode = output.domNode as? HTMLElement
domNode?.parentElement?.removeChild(domNode)
}
}
knownOutputs.clear()
for (ed in perEditorDecorations.values) {
ed.collection?.clear()
}
perEditorDecorations.clear()
clearAllStackTraceHints()
if (executionsCollection != null) executionsCollection.clear()
}
fun clearErrors(editor: dynamic)
@OptIn(ExperimentalJsExport::class)
@JsExport
fun clearErrors(editor: dynamic) {
clearAllStackTraceHints()
val toRemove = mutableListOf<dynamic>()
for (output in knownOutputs) {
if ((output.kind as? String) == "error") {
resetOutputErrorMarkerState(editor, output)
editor.changeViewZones { changeAccessor: dynamic ->
changeAccessor.removeZone(output.id)
}
val domNode = output.domNode as? HTMLElement
domNode?.parentElement?.removeChild(domNode)
toRemove.add(output)
}
}
knownOutputs.removeAll(toRemove.toSet())
}
fun removeNewestOutput()
@OptIn(ExperimentalJsExport::class)
@JsExport
fun removeNewestOutput() {
val editor = activeMonacoEditor() ?: return
var bestOrder = -1
var best: dynamic = null
for (output in knownOutputsForEditor(editor)) {
val insertionOrder = (output.insertionOrder as? Number)?.toInt() ?: -1
if (insertionOrder > bestOrder) {
bestOrder = insertionOrder
best = output
}
}
if (best != null) {
resetOutputErrorMarkerState(editor, best)
editor.changeViewZones { changeAccessor: dynamic ->
changeAccessor.removeZone(best.id)
}
val domNode = best.domNode as? HTMLElement
domNode?.parentElement?.removeChild(domNode)
knownOutputs.remove(best)
}
}
fun clearFocusedEditorOutputs()
@OptIn(ExperimentalJsExport::class)
@JsExport
fun clearFocusedEditorOutputs() {
val editor = activeMonacoEditor() ?: return
clearOutputsForEditor(editor, clearInlineDecorations = true)
}
fun removeArea(id: String)
@OptIn(ExperimentalJsExport::class)
@JsExport
fun removeArea(id: String) {
val found = knownOutputs.firstOrNull { output ->
output.id?.toString() == id
} ?: return
val w = js("window")
val editor = found.editor ?: w.globalMonacoEditor
resetOutputErrorMarkerState(editor, found)
editor.changeViewZones { changeAccessor: dynamic ->
changeAccessor.removeZone(id)
}
knownOutputs.remove(found)
}
fun toggleArea(id: String)
@OptIn(ExperimentalJsExport::class)
@JsExport
fun toggleArea(id: String) {
val found = knownOutputs.firstOrNull { output ->
output.id?.toString() == id
} ?: return
val w = js("window")
val editor = found.editor ?: w.globalMonacoEditor
val zone = found.zone ?: return
val wasCollapsed = (zone.heightInPx as? Number)?.toInt() == ((found.small as? Number)?.toInt() ?: 2) * 19 + 18
val domNode = found.domNode as? HTMLElement
val domNodeInner = found.domNodeInner as? HTMLElement
val preferredLines = preferredOutputLines(editor, domNode, domNodeInner)
found.big = preferredLines
found.small = min(2, preferredLines)
found.userCollapsed = !wasCollapsed
editor.changeViewZones { changeAccessor: dynamic ->
zone.heightInPx = (if (wasCollapsed) (found.big as? Number)?.toInt() ?: 2 else (found.small as? Number)?.toInt() ?: 2) * 19 + 18
changeAccessor.layoutZone(id)
window.setTimeout({
val domNode = found.domNode as? HTMLElement ?: return@setTimeout
domNode.scrollTo(js("{ left: 0, top: 1000, behavior: 'smooth' }"))
}, 100)
}
window.requestAnimationFrame {
updateOutputControlVisibility(found)
}
}
fun afieldToggleLazyOutput(buttonLike: dynamic)
body — 57 lines, from line 808
@OptIn(ExperimentalJsExport::class)
@JsExport
fun afieldToggleLazyOutput(buttonLike: dynamic) {
val button = buttonLike as? HTMLElement ?: return
val wrapper = button.parentElement as? HTMLElement ?: return
val panel = wrapper.querySelector(".afield-lazy-output-panel") as? HTMLElement ?: return
val ds = button.dataset.asDynamic()
val originalLabel = (ds.afieldBrowseLabel as? String)?.ifBlank { null } ?: button.textContent ?: "…"
val isOpen = (ds.afieldBrowseOpen as? String) == "1"
if (isOpen) {
panel.style.display = "none"
panel.classList.remove("is-open")
ds.afieldBrowseOpen = "0"
button.textContent = originalLabel
scheduleKnownOutputRecompute(wrapper)
return
}
if ((ds.afieldBrowseLoaded as? String) != "1") {
val src = ds.afieldBrowseSrc as? String ?: return
val iframeHeight = (panel.dataset.asDynamic().afieldBrowseHeight as? String)?.toIntOrNull() ?: 264
val header = document.createElement("div") as HTMLElement
header.classList.add("afield-lazy-output-header")
val link = document.createElement("a") as HTMLElement
link.setAttribute("href", src)
link.setAttribute("target", "_blank")
link.setAttribute("rel", "noopener noreferrer")
link.textContent = "open inspector"
link.classList.add("afield-lazy-output-link")
header.appendChild(link)
val iframe = document.createElement("iframe") as HTMLElement
iframe.setAttribute("src", src)
iframe.setAttribute("loading", "lazy")
iframe.setAttribute("data-afield-autosize", "off")
iframe.classList.add("afield-lazy-output-frame")
iframe.style.height = "${iframeHeight}px"
iframe.addEventListener("load", { _: dynamic ->
scheduleKnownOutputRecompute(wrapper)
})
panel.classList.add("is-open")
panel.appendChild(header)
panel.appendChild(iframe)
ds.afieldBrowseLoaded = "1"
} else {
panel.classList.add("is-open")
panel.style.display = "block"
}
ds.afieldBrowseOpen = "1"
button.textContent = "hide"
scheduleKnownOutputRecompute(wrapper)
}
Output iframes have their own HTMLElement constructor. Check the node's realm so the same value inspector can handle links passed up from a same-origin iframe.
private fun lazyValueElement(value: dynamic): HTMLElement? {
if (value == null) return null
val constructor = value.ownerDocument?.defaultView?.HTMLElement ?: return null
if (!(js("value instanceof constructor") as Boolean)) return null
return value.unsafeCast<HTMLElement>()
}
fun afieldExpandLazyValue(linkLike: dynamic)
@OptIn(ExperimentalJsExport::class)
@JsExport
fun afieldExpandLazyValue(linkLike: dynamic) {
val link = lazyValueElement(linkLike) ?: return
val src = link.getAttribute("data-afield-lazy-src")?.takeIf { it.isNotBlank() } ?: return
val wrapper = lazyValueElement(link.parentElement) ?: return
val parent = lazyValueElement(wrapper.parentElement)
val ds = link.dataset.asDynamic()
if ((ds.afieldLazyLoading as? String) == "1") return
val originalHtml = wrapper.outerHTML
ds.afieldLazyLoading = "1"
link.textContent = "loading…"
window.fetch(src).then({ response: dynamic ->
val ok = response.ok as? Boolean ?: false
if (!ok) {
val status = response.status?.toString() ?: "?"
js("throw new Error('lazy value request failed: ' + status)")
}
response.text()
}).then({ html: dynamic ->
wrapper.outerHTML = html as String
scheduleKnownOutputRecompute(parent)
null
}).catch { err: dynamic ->
console.error("[lazy-value] expand failed", err)
wrapper.outerHTML = originalHtml
scheduleKnownOutputRecompute(parent)
null
}
}
How tall the output-area value viewer opens, in pixels.
A pixel count and not a percentage, for the reason b8aa72f3d and 84e436da6 each reached
independently: an output panel measures an iframe from its height attribute, its inline
style.height, or data-afield-intrinsic-height, and falls back to the bounding rect only when
all three are non-positive. This frame carries data-afield-autosize="off", so
iframe_autosize.js never reports a height, and its CSS
(.value-viz-output-viewer-frame { height: auto }) is invisible to that measurement. The
bounding rect was then circular — the frame sized to a container that sized to the frame — and
resolved to nothing, so the viewer opened as a one-line sliver with a scrollbar and no content.
Matching LazyValueVisualizer's own H2_IFRAME_HEIGHT_PX, so a value opened into the output area
and the same value shown in a .h2 panel start the same size. flex: 1 1 auto and
min-height: 0 stay in the CSS, so dragging the disclosure still resizes it.
Pre-existing: the CSS rule dates to 415d03fe3 and the sliver affected the inline .h expander's
"columns" link too. It only became visible when .h2's outliner and columns links stopped opening
in a browser tab.
private const val LAZY_VIEWER_HEIGHT_PX = 253
private fun upsertLazyValueViewer(boxUid: String, title: String, src: String) {
val box = findBoxByUid(boxUid) ?: run {
console.warn("[lazy-value] box not found for viewer: $boxUid")
return
}
val outputArea = box.getElementsByClassName("outputArea").item(0) as? HTMLElement ?: return
outputArea.style.visibility = "visible"
outputArea.style.height = "auto"
val contain = outputArea.querySelector(".disclosure-group") ?: return
val factory = js("window.disclosureInterface") ?: run {
console.info("[lazy-value] disclosureInterface not ready yet; skipping viewer")
return
}
val inter = factory(contain)
val normalized = title.lowercase()
.replace(Regex("[^a-z0-9]+"), "-")
.trim('-')
.ifBlank { "value" }
val channelId = "__lazy_view__$normalized"
val contentRoot = (inter.upsertDisclosure as (String, String, String) -> Element).invoke(channelId, title, "") as HTMLElement
contentRoot.innerHTML = """
<div class="value-viz-output-viewer">
<iframe data-afield-autosize="off" src="../${src}" loading="lazy"
class="value-viz-output-viewer-frame"
style="height:${LAZY_VIEWER_HEIGHT_PX}px"></iframe>
</div>
""".trimIndent()
(inter.open as (String) -> Unit).invoke(channelId)
}
fun afieldOpenLazyValueInOutputArea(linkLike: dynamic)
@OptIn(ExperimentalJsExport::class)
@JsExport
fun afieldOpenLazyValueInOutputArea(linkLike: dynamic) {
val link = lazyValueElement(linkLike) ?: return
val boxUid = link.getAttribute("data-afield-box-uid")?.takeIf { it.isNotBlank() } ?: return
val src = link.getAttribute("data-afield-view-src")?.takeIf { it.isNotBlank() } ?: return
val title = link.getAttribute("data-afield-view-title")?.takeIf { it.isNotBlank() } ?: "Columns"
upsertLazyValueViewer(boxUid, title, src)
}
@Suppress("unused")
private val installAfieldToggleLazyOutputOnWindow = run {
window.asDynamic().afieldToggleLazyOutput = ::afieldToggleLazyOutput
window.asDynamic().afieldExpandLazyValue = ::afieldExpandLazyValue
window.asDynamic().afieldOpenLazyValueInOutputArea = ::afieldOpenLazyValueInOutputArea
}
fun addInlineOutput
body — 57 lines, from line 979
@OptIn(ExperimentalJsExport::class)
@JsExport
fun addInlineOutput(
editor: dynamic,
lineNumber: Int,
text: String,
extraClass: String,
frameMarker: Int,
shouldFlash: Boolean = true
) {
val edDecor = getOrCreateEditorDecorations(editor)
var line = lineNumber
val model = editor.getModel()
val lineCount = (model.getLineCount() as Number).toInt()
if (line >= lineCount) line = lineCount
if (line == 0) line = 1
while (line > 1 && (model.getLineLength(line) as Number).toInt() == 0) {
line--
}
val lineLength = (model.getLineLength(line) as Number).toInt()
for (i in edDecor.contents.size - 1 downTo 0) {
val entry = edDecor.contents[i]
val entryFrame = (entry.frameMarker as? Number)?.toInt() ?: 0
if (entryFrame < frameMarker) {
edDecor.contents.removeAt(i)
continue
}
if ((entry._line as? Number)?.toInt() == line) {
edDecor.contents.removeAt(i)
}
}
val options = js("{}")
val after = js("{}")
val inlineOutputSpacer = "\u00A0\u00A0"
after.content = if (text.isEmpty()) text else inlineOutputSpacer + text
val flashClass = if (shouldFlash) " flash-inverted" else ""
after.inlineClassName =
"tick_text$flashClass $extraClass q${js("Math.random()") as Double}"
after.inlineClassNameAffectsLetterSpacing = true
after.cursorStops = 1
options.after = after
options.stickiness = 0
options.blockPadding = js("{ left: 100 }")
val entry = js("{}")
entry.range = newRange(line, lineLength, line, lineLength + 1)
entry.options = options
entry._line = line
entry._text = text
entry._extraClass = extraClass
entry.isTick = true
entry.frameMarker = frameMarker
edDecor.contents.add(entry)
edDecor.collection.set(edDecor.contents.toTypedArray())
}
fun addExecutionMarker(editor: dynamic, startLine: Int, endLine: Int): String
body — 45 lines, from line 1037
@OptIn(ExperimentalJsExport::class)
@JsExport
fun addExecutionMarker(editor: dynamic, startLine: Int, endLine: Int): String {
return addExecutionMarker(editor, startLine, endLine, markerKey = null)
}
private fun addExecutionMarker(editor: dynamic, startLine: Int, endLine: Int, markerKey: String?): String {
if (executionsCollection == null) {
executionsCollection = editor.createDecorationsCollection(emptyArray<Any>())
}
val n = (window.performance.now() as Number).toInt()
val className = "execution-marker-$n"
val sheet = document.styleSheets.asDynamic()[0]
val rule = ".$className { background: black; width: 5px !important; margin-left:${executionMarkerGutterInsetPx}px;}"
sheet.insertRule(rule, sheet.cssRules.length)
val range = newRange(startLine, 1, endLine, 1)
val found = mutableListOf<Int>()
executionDecorations.forEachIndexed { index, _ ->
val rangeNow = executionsCollection.getRange(index) ?: return@forEachIndexed
if (range.intersectRanges(rangeNow) as? Boolean == true) {
found.add(0, index)
}
}
for (idx in found) {
executionDecorations.removeAt(idx)
executionsCollection.set(executionDecorations.toTypedArray())
}
val options = js("{}")
options.isWholeLine = true
options.linesDecorationsClassName = "$className ${executionMarkerStateClass("launch")}"
val decoration = js("{}")
decoration.range = range
decoration.options = options
decoration._sourceClassname = markerKey ?: className
decoration._visualClassname = className
executionsCollection.append(arrayOf(decoration))
executionDecorations.add(decoration)
return className
}
fun ensureExecutionMarkerForLaunch(editor: dynamic, markerKey: String, startLine: Int, endLine: Int): Boolean
@OptIn(ExperimentalJsExport::class)
@JsExport
fun ensureExecutionMarkerForLaunch(editor: dynamic, markerKey: String, startLine: Int, endLine: Int): Boolean {
val key = markerKey.ifBlank { "default" }
if (executionsCollection != null) {
executionDecorations.forEachIndexed { index, decor ->
if (decor._sourceClassname == key && executionsCollection.getRange(index) != null) return true
}
}
addExecutionMarker(editor, startLine, endLine, markerKey = key)
return true
}
fun setStatusForExecutionMarker(className: String, running: Boolean, mode: String): Boolean
@OptIn(ExperimentalJsExport::class)
@JsExport
fun setStatusForExecutionMarker(className: String, running: Boolean, mode: String): Boolean {
val collection = executionsCollection ?: return false
var found = false
val stale = mutableListOf<Int>()
executionDecorations.forEachIndexed { index, decor ->
if (decor._sourceClassname == className) {
val range = collection.getRange(index)
if (range == null) {
stale.add(0, index)
return@forEachIndexed
}
val visualClassName = decor._visualClassname as? String ?: className
decor.options.linesDecorationsClassName =
if (running) "$visualClassName ${executionMarkerStateClass(mode)}" else visualClassName
decor.range = range
found = true
}
}
for (idx in stale) {
executionDecorations.removeAt(idx)
}
if (found || stale.isNotEmpty()) {
collection.set(executionDecorations.toTypedArray())
}
return found
}
fun clearAllDecorations()
@OptIn(ExperimentalJsExport::class)
@JsExport
fun clearAllDecorations() {
for (ed in perEditorDecorations.values) {
ed.collection?.set(emptyArray<Any>())
ed.contents.clear()
}
perEditorDecorations.clear()
}
An output message as the plain text a beside-the-line chip shows.
Output is HTML — Python escapes <, > and & so that its multi-line <pre> blocks render —
but the chip is a Monaco after decoration, which only takes text, so print(p) of a
<Pipeline ...> showed as <Pipeline ...>. Decoding through a textarea turns entities back
into characters and leaves already-plain text, such as Kotlin's, exactly as it was.
source — 112 lines, from line 1143
private fun inlineChipText(text: String): String {
if (!text.contains('&')) return text
val decoder = document.createElement("textarea") as org.w3c.dom.HTMLTextAreaElement
decoder.innerHTML = text
return decoder.value
}
private fun looksLikeHTML(text: String): Boolean {
val trimmed = text.trim()
val regex = Regex("^(?:<\\?xml\\b[^>]*\\?>|<!DOCTYPE\\b[^>]*>|<!--[\\s\\S]*?-->|<[A-Za-z][-\\w:]*\\b(?:\\s[^>]*)?>)")
return regex.containsMatchIn(trimmed)
}
private data class TypeParamsSegment(val text: String, val isTypeParams: Boolean)
private fun splitTypeParamsSegments(text: String): List<TypeParamsSegment>? {
if (!text.contains('<') || !text.contains('>')) return null
val segments = mutableListOf<TypeParamsSegment>()
var plainStart = 0
var groupStart = -1
var depth = 0
var i = 0
while (i < text.length) {
when (text[i]) {
'<' -> {
if (groupStart == -1) {
if (i > plainStart) {
segments.add(TypeParamsSegment(text.substring(plainStart, i), isTypeParams = false))
}
groupStart = i
depth = 1
} else {
depth++
}
}
'>' -> {
if (groupStart != -1) {
depth--
if (depth <= 0) {
val endExclusive = i + 1
segments.add(TypeParamsSegment(text.substring(groupStart, endExclusive), isTypeParams = true))
plainStart = endExclusive
groupStart = -1
depth = 0
}
}
}
}
i++
}
if (groupStart != -1) return null
if (plainStart < text.length) {
segments.add(TypeParamsSegment(text.substring(plainStart), isTypeParams = false))
}
if (segments.none { it.isTypeParams }) return null
return segments
}
private fun decorateTypeParamsInTextNode(textNode: Text) {
val text = textNode.data
val segments = splitTypeParamsSegments(text) ?: return
val frag = document.createDocumentFragment()
segments.forEach { seg ->
if (seg.text.isEmpty()) return@forEach
if (seg.isTypeParams) {
val span = document.createElement("span") as HTMLElement
span.classList.add("kotlin-type-params")
span.textContent = seg.text
frag.appendChild(span)
} else {
frag.appendChild(document.createTextNode(seg.text))
}
}
textNode.parentNode?.replaceChild(frag, textNode)
}
private fun decorateTypeParamsRec(node: Node) {
val children = node.childNodes
if (children.length == 0) return
val snapshot = mutableListOf<Node>()
for (i in 0 until children.length) {
val child = children.item(i) ?: continue
snapshot.add(child)
}
snapshot.forEach { child ->
when (child.nodeType.toInt()) {
3 -> decorateTypeParamsInTextNode(child.unsafeCast<Text>())
1 -> {
val tag = (child as? Element)?.tagName?.lowercase()
if (tag != "script" && tag != "style") {
decorateTypeParamsRec(child)
}
}
}
}
}
private fun decorateTypeParamsInHtml(html: String): String {
if (!html.contains('&') && !html.contains('<')) return html
val container = document.createElement("div") as HTMLElement
container.innerHTML = html
decorateTypeParamsRec(container)
return container.innerHTML
}
fun chatKeyboardCallback(event: dynamic, id: Int, input: dynamic): Boolean
@OptIn(ExperimentalJsExport::class)
@JsExport
fun chatKeyboardCallback(event: dynamic, id: Int, input: dynamic): Boolean {
if (event.isComposing as? Boolean == true) return true
val key = event.key as? String ?: ""
if (key == "Enter" && event.shiftKey as? Boolean != true) {
event.preventDefault()
for (output in knownOutputs) {
if ((output.addPromptBox_uid as? Number)?.toInt() == id) {
removeArea(output.id.toString())
val callback = output.acceptCallback
if (callback != null) {
callback(input.value as String)
}
}
}
return false
}
if (key == "Escape") {
event.preventDefault()
for (output in knownOutputs) {
if ((output.addPromptBox_uid as? Number)?.toInt() == id) {
removeArea(output.id.toString())
}
}
return false
}
return true
}
fun addPromptBox(editor: dynamic, belowLine: Int, acceptCallback: dynamic)
body — 49 lines, from line 1286
@OptIn(ExperimentalJsExport::class)
@JsExport
fun addPromptBox(editor: dynamic, belowLine: Int, acceptCallback: dynamic) {
editor.changeViewZones { changeAccessor: dynamic ->
val domNode = document.createElement("div") as HTMLElement
addPromptBoxUid += 1
domNode.innerHTML = """
<textarea
rows="1"
placeholder=">"
id="promptBox_$addPromptBoxUid"
class="afield-prompt-box"
oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px';"
onfocus="this.style.height='auto';this.style.height=this.scrollHeight+'px';"
onkeydown="return window.chatKeyboardCallback(event, $addPromptBoxUid, this);"
></textarea>
""".trimIndent()
domNode.style.zIndex = "100"
val zone = js("{}")
zone.afterLineNumber = belowLine
zone.heightInLines = 1
zone.domNode = domNode
val id = changeAccessor.addZone(zone)
window.setTimeout({
val steal = document.body?.querySelector("#promptBox_$addPromptBoxUid") as? HTMLElement
steal?.focus()
}, 100)
val output = js("{}")
output.id = id
output.zone = zone
output.big = 1
output.small = 1
output.line = belowLine
output.domNode = domNode
output.domNodeInner = null
output.domNodeAnnotation = null
output.insertionOrder = ++orderCounter
output.kind = ""
output.addPromptBox_uid = addPromptBoxUid
output.acceptCallback = acceptCallback
knownOutputs.add(output)
}
}
Actionable error suggestions ("Did you mean" / "add import" / numeric-wrap). Rendered as rows under an error output, each with an "Apply" button (direct Monaco edit) and an "Ask AI4" button (shaped ai-assist request). Structured data comes from the JVM via Output.suggestions (see suggestionsFor()).
Resolve the Monaco range of the identifier at (line, column); null if it doesn't match expectToken.
private fun fixWordRangeAt(editor: dynamic, line: Int, column: Int, expectToken: String): dynamic {
if (line < 1) return null
val model = editor.getModel() ?: return null
val lineCount = (model.getLineCount() as? Number)?.toInt() ?: return null
if (line > lineCount) return null
val col = if (column >= 1) column else 1
val word = model.getWordAtPosition(js("{ lineNumber: line, column: col }")) ?: return null
val w = (word.word as? String) ?: return null
if (w.isEmpty()) return null
if (expectToken.isNotEmpty() && w != expectToken) return null
return newRange(line, (word.startColumn as Number).toInt(), line, (word.endColumn as Number).toInt())
}
private fun notifyFixCantApply() {
notify("Couldn't apply automatically — try Ask AI4", "*field-fix")
}
private fun applyEdit(editor: dynamic, range: dynamic, text: String) {
val edit = js("{}")
edit.range = range
edit.text = text
edit.forceMoveMarkers = true
editor.executeEdits("field-fix", arrayOf(edit))
}
Insert import <fqn> after the last existing import (or package, or at the top).
Returns the 1-based line the import ends up on — the existing one if it was already there —
or -1 if there was nothing to do.
private fun insertImportLine(editor: dynamic, fqn: String): Int {
if (fqn.isBlank()) return -1
val model = editor.getModel() ?: return -1
val importLine = "import $fqn"
val lines = (model.getValue() as String).split("\n")
val already = lines.indexOfFirst { val t = it.trim(); t == importLine || t == "$importLine;" }
if (already >= 0) return already + 1
var lastImport = -1
var packageLine = -1
for (idx in lines.indices) {
val t = lines[idx].trim()
when {
t.startsWith("import ") -> lastImport = idx
t.startsWith("package ") -> packageLine = idx
}
}
val insertLine0 = when {
lastImport >= 0 -> lastImport + 1
packageLine >= 0 -> packageLine + 1
else -> 0
}
val ln = insertLine0 + 1 // 1-based
val lineCount = (model.getLineCount() as Number).toInt()
if (ln > lineCount) {
val lastLen = (model.getLineMaxColumn(lineCount) as Number).toInt()
applyEdit(editor, newRange(lineCount, lastLen, lineCount, lastLen), "\n$importLine")
return lineCount + 1
}
applyEdit(editor, newRange(ln, 1, ln, 1), "$importLine\n")
return ln
}
Execute the one import … line an "add import" fix just wrote, in the box's own
KotlinInterface2 — so the code that errored can be re-run as-is, instead of the user having to
jump to the top of the box, execute the import by hand, and come back down again.
private fun executeImportLine(uid: String, property: String, line: Int, code: String) {
if (uid.isBlank() || line < 1) return
val prop = property.ifBlank { "code" }
val payload = js("{}")
payload.uid = uid
payload.type = "fragment"
payload.line = line - 1
payload.code = code
payload.singleLine = true
payload.property = prop
val w = js("window")
val handler = w.handleOutput
val callback: (dynamic, dynamic) -> Boolean = { message, response ->
handler(response, message, uid)
true
}
if (w.LineHeatmap != null && w.LineHeatmap != undefined) {
w.LineHeatmap.recordExecutionRange(uid, prop, line, line)
}
sendWithReply("execute-fragment", payload, "exec_$uid", callback.asDynamic())
}
The numeric literal (with any type suffix) spanning line/column, as (range, text), or null.
private fun numericLiteralAt(model: dynamic, line: Int, column: Int): Pair<dynamic, String>? {
if (line < 1) return null
val lineCount = (model.getLineCount() as? Number)?.toInt() ?: return null
if (line > lineCount) return null
val text = model.getLineContent(line) as String
val col0 = (column - 1).coerceIn(0, text.length)
val regex = Regex("""(?<![\w.])(?:\d[\d_]*(?:\.\d[\d_]*)?|\.\d[\d_]*)(?:[eE][+-]?\d+)?[fFlL]?""")
for (m in regex.findAll(text)) {
val start = m.range.first
val end = m.range.last + 1
if (col0 in start..end) {
return newRange(line, start + 1, line, end + 1) to m.value
}
}
return null
}
Rewrite a numeric literal to a clean literal of the target type implied by helper
(.d->Double, .f->Float, .i->Int, .l->Long). Returns null when a clean rewrite would
silently lose precision (fractional literal -> Int/Long), so the caller can fall back to the
explicit .i/.l helper append instead.
source — 94 lines, from line 1460
private fun rewriteNumericLiteral(text: String, helper: String): String? {
val mantissa = if (text.isNotEmpty() && text.last() in "fFlL") text.dropLast(1) else text
val fractional = mantissa.contains('.') || mantissa.contains('e') || mantissa.contains('E')
return when (helper) {
".d" -> if (fractional) mantissa else "$mantissa.0"
".f" -> "${mantissa}f"
".i" -> if (fractional) null else mantissa
".l" -> if (fractional) null else "${mantissa}L"
else -> null
}
}
private fun applyFixSuggestion(editor: dynamic, s: OutputSuggestion, boxUid: String, boxProperty: String) {
when (s.kind) {
"rename" -> {
val range = fixWordRangeAt(editor, s.line, s.column, s.token)
if (range == null) { notifyFixCantApply(); return }
applyEdit(editor, range, s.replacement)
editor.focus()
}
"import" -> {
// Rename first (original coords) if the class name differs, then insert the import at the top.
if (s.replacement.isNotEmpty()) {
val range = fixWordRangeAt(editor, s.line, s.column, s.token)
if (range == null) { notifyFixCantApply(); return }
applyEdit(editor, range, s.replacement)
}
val importedAt = insertImportLine(editor, s.importFqn)
editor.focus()
executeImportLine(boxUid, boxProperty, importedAt, "import ${s.importFqn}")
}
"wrap" -> {
val model = editor.getModel() ?: run { notifyFixCantApply(); return }
// Numeric literals get rewritten to a clean literal of the target type
// (0.2f -> 0.2, 3 -> 3.0) rather than the funky `0.2f.d` helper append.
val lit = numericLiteralAt(model, s.line, s.column)
if (lit != null) {
val (litRange, litText) = lit
applyEdit(editor, litRange, rewriteNumericLiteral(litText, s.replacement) ?: (litText + s.replacement))
editor.focus()
return
}
// Non-literal expression/variable: fall back to appending the helper (`x` -> `x.d`).
val range = fixWordRangeAt(editor, s.line, s.column, "")
if (range == null) { notifyFixCantApply(); return }
val existing = model.getValueInRange(range) as String
applyEdit(editor, range, existing + s.replacement)
editor.focus()
}
else -> notifyFixCantApply()
}
}
private fun askAi4ForSuggestion(editor: dynamic, s: OutputSuggestion, boxUid: String, boxProperty: String) {
if (boxUid.isBlank()) { notifyFixCantApply(); return }
val w = js("window")
val model = editor.getModel() ?: return
val fullText = model.getValue() as String
val line = s.line
val instruction = s.aiInstruction
fun offsetAt(lineNumber: Int, col: Int): Int =
(model.getOffsetAt(js("{ lineNumber: lineNumber, column: col }")) as Number).toInt()
// Focus AI4 on the offending token when we can locate it, else the whole error line, else the file.
val range = if (s.token.isNotEmpty()) fixWordRangeAt(editor, s.line, s.column, s.token) else null
val wrapped: String = when {
range != null -> {
val startOff = offsetAt((range.startLineNumber as Number).toInt(), (range.startColumn as Number).toInt())
val endOff = offsetAt((range.endLineNumber as Number).toInt(), (range.endColumn as Number).toInt())
fullText.substring(0, startOff) + "<rewrite>" + fullText.substring(startOff, endOff) +
"</rewrite>" + fullText.substring(endOff)
}
line >= 1 && line <= (model.getLineCount() as Number).toInt() -> {
val startOff = offsetAt(line, 1)
val endOff = offsetAt(line, (model.getLineMaxColumn(line) as Number).toInt())
fullText.substring(0, startOff) + "<rewrite>" + fullText.substring(startOff, endOff) +
"</rewrite>" + fullText.substring(endOff)
}
else -> "<rewrite>$fullText</rewrite>"
}
val property = boxProperty.ifBlank { (w.currentSelectedProperty as? String) ?: "code" }
val opener = w.openAiChatForBox
if (opener != null) opener(boxUid, property)
val payload = js("{}")
payload.uid = boxUid
payload.prompt = wrapped
payload.extraPrompt = instruction
payload.property = property
payload.kind = "rewrite"
send("ai-assist", payload)
}
Append one "Apply / Ask AI4" row per structured suggestion to an error output's inner node.
source — 56 lines, from line 1556
private fun appendErrorSuggestions(
editor: dynamic,
container: HTMLElement,
suggestions: dynamic,
boxUid: String,
boxProperty: String
) {
if (suggestions == null || suggestions == undefined) return
// Always a genuine Kotlin List<OutputSuggestion> here (Output arrives typed via routeMonacoOutput).
val list = suggestions.unsafeCast<List<OutputSuggestion>>()
if (list.isEmpty()) return
val wrap = document.createElement("div") as HTMLElement
wrap.className = "field-fix-suggestions"
for (s in list) {
val row = document.createElement("div") as HTMLElement
row.className = "field-fix-row"
val label = document.createElement("span") as HTMLElement
label.className = "field-fix-label"
label.textContent = s.label
row.appendChild(label)
val detail = s.detail
if (detail.isNotBlank()) {
val det = document.createElement("span") as HTMLElement
det.className = "field-fix-detail"
det.textContent = detail
row.appendChild(det)
}
val apply = document.createElement("button") as HTMLElement
apply.className = "field-fix-apply"
apply.textContent = "Apply"
apply.setAttribute("title", "Apply this fix directly")
apply.addEventListener("click", { e: dynamic ->
e.stopPropagation(); e.preventDefault()
applyFixSuggestion(editor, s, boxUid, boxProperty)
})
row.appendChild(apply)
val ai = document.createElement("button") as HTMLElement
ai.className = "field-fix-ai"
ai.textContent = "Ask AI4"
ai.setAttribute("title", "Ask AI4 to make this fix")
ai.addEventListener("click", { e: dynamic ->
e.stopPropagation(); e.preventDefault()
askAi4ForSuggestion(editor, s, boxUid, boxProperty)
})
row.appendChild(ai)
wrap.appendChild(row)
}
container.appendChild(wrap)
}
fun addOutput
body — 475 lines, from line 1613
@OptIn(ExperimentalJsExport::class)
@JsExport
fun addOutput(
editor: dynamic,
text: String,
belowLineInput: Int,
extraClass: String,
annotation: String?,
frameMarker: Int,
markerStartColumn: Int,
markerEndLine: Int,
markerEndColumn: Int,
suggestions: dynamic = null,
boxUid: String = "",
boxProperty: String = "code"
) {
var textClean = text
val isHTML = looksLikeHTML(text)
val hasEmbeddedIframe = text.contains("<iframe", ignoreCase = true)
val richHtml = isHTML || hasEmbeddedIframe
val replaceRichHtml = richHtml && !isAppendableRichHtml(text)
if (!richHtml) {
textClean = textClean.replace("\n", "<br>")
}
if (extraClass == "error") {
textClean = decorateTypeParamsInHtml(textClean)
}
var belowLine = belowLineInput
if (belowLine == -1) {
belowLine = (editor.getModel().getValue() as String).split("\n").size
}
if (!richHtml && extraClass != "error") {
belowLine = normalizeInlineOutputTargetLine(editor, belowLine)
}
// A warning takes its own zone, as an error does: an inline tick would be replaced by the next print on its line.
val canInline = !textClean.contains("<br>") && extraClass != "error" && extraClass != "warning" && !richHtml
val edDecor = editorDecorationsFor(editor)
var repeatedInlineOutput = false
if (edDecor != null && edDecor.contents.isNotEmpty()) {
var changed = false
for (i in edDecor.contents.size - 1 downTo 0) {
val entry = edDecor.contents[i]
val entryFrame = (entry.frameMarker as? Number)?.toInt() ?: 0
if (entryFrame < frameMarker) {
edDecor.contents.removeAt(i)
changed = true
continue
}
if ((entry._line as? Number)?.toInt() == belowLine) {
if (entry.isTick as? Boolean == true) {
val previousText = entry._text as? String ?: ""
val previousClass = entry._extraClass as? String ?: ""
repeatedInlineOutput =
repeatedInlineOutput || (previousText == textClean && previousClass == extraClass)
}
edDecor.contents.removeAt(i)
changed = true
}
}
if (changed) {
edDecor.collection?.set(edDecor.contents.toTypedArray())
}
}
val found = if (canInline) null else findReusableOutput(
editor,
belowLine,
extraClass,
allowNearby = !richHtml && extraClass != "error"
)
if (found != null) {
found.autoScroll = !replaceRichHtml
val shouldPulse = if (replaceRichHtml) {
val repeatedContent = updateOutputFlashSignature(found, textClean)
found.domNodeInner.innerHTML = textClean
repeatedContent
} else {
found.flashSignature = null
appendWithBadge(found.domNodeInner, textClean)
}
scheduleOutputHeightRecompute(editor, found)
if (extraClass == "error") {
setOutputErrorMarkerDecoration(editor, found, belowLine, markerStartColumn, markerEndLine, markerEndColumn)
} else {
resetOutputErrorMarkerState(editor, found)
}
val annotationNode = found.domNodeAnnotation as? HTMLElement
if (annotationNode != null) {
annotationNode.innerHTML = annotation ?: ""
}
if (extraClass == "error") {
(found.domNodeInner as? HTMLElement)?.let {
appendErrorSuggestions(editor, it, suggestions, boxUid, boxProperty)
}
}
val domNode = found.domNode as HTMLElement
domNode.className = ""
domNode.classList.add("execution_output")
domNode.classList.add(extraClass)
(found.domNodeInner as? HTMLElement)?.classList?.toggle("copyable-error-output", extraClass == "error")
domNode.style.asDynamic().overflow = if (replaceRichHtml) "hidden" else "scroll"
domNode.style.zIndex = "11"
if (shouldPulse) {
pulseOutput(found.domNodeInner)
}
if (found.autoScroll as? Boolean != false) {
window.setTimeout({
scrollOutputToBottom(domNode, found.domNodeInner as? HTMLElement, outputScrollTailExistingPx)
}, 10)
}
debounceTimeout("align", 50) {
clearTabSpans()
var n = 0
while (n++ < 10 && alignSpansWithinXPixels(150.0)) {
}
}
return
}
var extraText = ""
var allowInline = true
val decorationMatch = edDecor?.contents?.firstOrNull { entry ->
(entry._line as? Number)?.toInt() == belowLine
}
if (decorationMatch != null) {
if (decorationMatch.isTick as? Boolean == true) {
edDecor.contents.remove(decorationMatch)
edDecor.collection?.set(edDecor.contents.toTypedArray())
allowInline = true
} else {
extraText += decorationMatch._text as? String ?: ""
edDecor.contents.remove(decorationMatch)
edDecor.collection?.set(edDecor.contents.toTypedArray())
allowInline = false
}
} else {
allowInline = true
}
if (allowInline && canInline) {
addInlineOutput(editor, belowLine, inlineChipText(textClean), extraClass, frameMarker, shouldFlash = repeatedInlineOutput)
} else {
editor.changeViewZones { changeAccessor: dynamic ->
val domNode = document.createElement("div") as HTMLElement
domNode.style.asDynamic().overflow = if (replaceRichHtml) "hidden" else "scroll"
domNode.classList.add(extraClass)
domNode.classList.add("execution_output")
val domNodeAnnotation = document.createElement("div") as HTMLElement
val domNodeInner = document.createElement("div") as HTMLElement
domNode.appendChild(domNodeInner)
if (replaceRichHtml) {
domNodeInner.innerHTML = textClean
} else {
appendWithBadge(domNodeInner, textClean)
}
domNode.appendChild(domNodeAnnotation)
domNodeAnnotation.innerHTML = annotation ?: ""
domNodeAnnotation.classList.add("output_annotation")
domNodeInner.onmousedown = { _: dynamic -> }
domNodeInner.classList.add("inner_output")
if (extraClass == "error") {
domNodeInner.classList.add("copyable-error-output")
appendErrorSuggestions(editor, domNodeInner, suggestions, boxUid, boxProperty)
}
domNodeInner.style.background = "rgba(0,0,0,1)"
domNodeInner.style.borderRadius = "8px"
domNodeInner.style.width = "calc( 100% - 8px )"
domNodeInner.style.padding = "6px 6px 4px 6px"
domNodeInner.style.marginLeft = "3px"
domNodeInner.style.fontSize = "10px"
domNodeInner.style.asDynamic().overflowX = if (replaceRichHtml) "hidden" else "scroll"
domNode.style.zIndex = "11"
val domNodeControl = document.createElement("div") as HTMLElement
domNodeControl.style.borderLeft = "4px solid rgba(255,255,255,0.23)"
domNodeControl.style.borderTop = "4px solid rgba(255,255,255,0.23)"
domNodeControl.style.borderBottom = "4px solid rgba(255,255,255,0.23)"
domNodeControl.style.marginLeft = "2px"
domNodeControl.style.opacity = "0.5"
domNodeControl.style.zIndex = "1000"
domNodeControl.style.borderRadius = "4px"
domNodeControl.style.paddingTop = "4px"
domNodeControl.style.paddingLeft = "2px"
domNodeControl.style.asDynamic().pointerEvents = "auto"
domNodeControl.classList.add("notselectable")
domNode.onwheel = { wheel: dynamic ->
domNode.scrollBy(js("{ top: wheel.deltaY }"))
wheel.preventDefault()
wheel.stopPropagation()
wheel.stopImmediatePropagation()
}
var lines = if (replaceRichHtml) {
2
} else {
min(5, textClean.split("\n").size) + 1
}
lines = max(2, lines)
if (!replaceRichHtml) {
window.setTimeout({
scrollOutputToBottom(domNode, domNodeInner, outputScrollTailNewPx)
}, 100)
}
val zone = js("{}")
zone.afterLineNumber = belowLine
zone.heightInPx = lines * 19 + 18
zone.domNode = domNode
zone.marginDomNode = domNodeControl
if (extraClass != "error") {
for (i in 0 until domNodeInner.children.length) {
val child = domNodeInner.children.item(i)
child?.addEventListener("mousemove", { e: dynamic ->
e.stopPropagation()
e.stopImmediatePropagation()
})
}
}
val id = changeAccessor.addZone(zone)
val output = js("{}")
output.id = id
output.zone = zone
output.big = lines
output.small = min(2, lines)
output.line = belowLine
output.domNode = domNode
output.domNodeInner = domNodeInner
output.domNodeAnnotation = domNodeAnnotation
output.domNodeControl = domNodeControl
output.insertionOrder = ++orderCounter
output.kind = extraClass
output.flashSignature = if (replaceRichHtml) textClean else null
output.autoScroll = !replaceRichHtml
output.userCollapsed = false
output.editor = editor
updateOutputControlVisibility(output)
knownOutputs.add(output)
if (extraClass == "error") {
setOutputErrorMarkerDecoration(editor, output, belowLine, markerStartColumn, markerEndLine, markerEndColumn)
}
scheduleOutputHeightRecompute(editor, output)
}
}
debounceTimeout("align", 50) {
clearTabSpans()
var n = 0
while (n++ < 10 && alignSpansWithinXPixels(150.0)) {
}
}
}
private fun getInsetClientRectByMargin(el: dynamic): dynamic {
val rect = el.getBoundingClientRect()
val style = window.getComputedStyle(el)
val top = style.marginTop.toDoubleOrNull() ?: 0.0
val right = style.marginRight.toDoubleOrNull() ?: 0.0
val bottom = style.marginBottom.toDoubleOrNull() ?: 0.0
val left = style.marginLeft.toDoubleOrNull() ?: 0.0
val inset = js("{}")
inset.top = rect.top + top
inset.left = rect.left + left
inset.right = rect.right - right
inset.bottom = rect.bottom - bottom
inset.width = inset.right - inset.left
inset.height = inset.bottom - inset.top
return inset
}
private fun clearTabSpans() {
val spans = document.querySelectorAll("span.tab")
for (i in 0 until spans.length) {
val span = spans.item(i) as? HTMLElement ?: continue
span.setAttribute("style", "")
}
}
private fun alignSpansWithinXPixels(x: Double): Boolean {
val spans = document.querySelectorAll("span.tab")
val spanData = mutableListOf<dynamic>()
for (i in 0 until spans.length) {
val span = spans.item(i) as? HTMLElement ?: continue
val rect = getInsetClientRectByMargin(span)
val entry = js("{}")
entry.span = span
entry.left = rect.left
entry.top = rect.top
entry.height = rect.height
entry.width = rect.width
spanData.add(entry)
}
fun closestTop(items: List<dynamic>, check: dynamic): Double {
if (items.isEmpty()) return 100.0
var minDist = Double.POSITIVE_INFINITY
for (item in items) {
val dist = abs((item.top as Number).toDouble() - (check.top as Number).toDouble())
if (dist < minDist) {
minDist = dist
}
}
return minDist
}
val groups = mutableListOf<dynamic>()
for (item in spanData) {
var closestGroup: dynamic = null
var minDist = Double.POSITIVE_INFINITY
for (group in groups) {
val dist = abs((group.avgLeft as Number).toDouble() - (item.left as Number).toDouble())
if (dist < minDist) {
minDist = dist
closestGroup = group
}
}
val group = if (closestGroup != null &&
minDist <= x &&
closestTop(closestGroup.items as List<dynamic>, item) > 3.0
) {
closestGroup
} else {
val newGroup = js("{}")
newGroup.items = mutableListOf<dynamic>()
newGroup.avgLeft = item.left
groups.add(newGroup)
newGroup
}
(group.items as MutableList<dynamic>).add(item)
var maxLeft = 0.0
for (entry in group.items as MutableList<dynamic>) {
maxLeft = max(maxLeft, (entry.left as Number).toDouble())
}
group.avgLeft = maxLeft
}
var stop = false
for (group in groups) {
if (stop) break
val items = group.items as MutableList<dynamic>
var maxLeft = 0.0
for (item in items) {
maxLeft = max(maxLeft, (item.left as Number).toDouble())
}
if (items.size > 1) {
for (item in items) {
val span = item.span as HTMLElement
val padding = maxLeft - (item.left as Number).toDouble()
if (span.style.marginLeft.isNullOrEmpty()) {
span.style.marginLeft = "${padding}px"
stop = true
} else if (span.style.left.endsWith("px")) {
val existing = span.style.marginLeft.replace("px", "").toDoubleOrNull() ?: 0.0
if (abs(existing - padding) > 2.0) {
span.style.marginLeft = "${padding}px"
stop = true
}
}
}
}
}
return stop
}
private fun appendWithBadge(div: dynamic, msg: String): Boolean {
val container = div as? HTMLElement ?: return false
val message = trimLargeLogMessageStart(msg)
val last = container.lastElementChild
val isLastLogLine = last?.classList?.contains("log-line") == true
if (isLastLogLine && (last as HTMLElement).dataset.asDynamic().msg == message) {
val badge = last.querySelector(".badge") as HTMLElement
val current = (badge.dataset.asDynamic().count as? String)?.toIntOrNull() ?: 1
val next = current + 1
badge.dataset.asDynamic().count = next.toString()
badge.textContent = "×$next"
badge.classList.add("show")
trimOutputContainerStart(container)
return true
}
val line = document.createElement("div") as HTMLElement
line.className = "log-line"
line.dataset.asDynamic().msg = message
val text = document.createElement("div") as HTMLElement
text.className = "log-msg"
text.innerHTML = message
val badge = document.createElement("span") as HTMLElement
badge.className = "badge"
badge.dataset.asDynamic().count = "1"
line.appendChild(text)
line.appendChild(badge)
container.appendChild(line)
trimOutputContainerStart(container)
container.scrollTop = container.scrollHeight.toDouble()
return false
}
private fun trimLargeLogMessageStart(message: String): String {
val parts = htmlBreakRegex.split(message)
if (parts.size <= maxOutputMessageBreaks) return message
val keep = parts.takeLast(maxOutputMessageBreaks)
val removed = parts.size - keep.size
return "<span class='trimmed-output'>[trimmed $removed earlier lines]</span><br>" + keep.joinToString("<br>")
}
private fun ensureTrimMarker(container: HTMLElement): HTMLElement {
val first = container.firstElementChild as? HTMLElement
if (first?.classList?.contains("output-trim-marker") == true) return first
val marker = document.createElement("div") as HTMLElement
marker.className = "log-line output-trim-marker"
val text = document.createElement("div") as HTMLElement
text.className = "log-msg"
marker.appendChild(text)
container.insertBefore(marker, container.firstChild)
return marker
}
private fun trimOutputContainerStart(container: HTMLElement) {
var removedRows = 0
while (container.children.length > maxRenderedOutputRows) {
val first = container.firstElementChild as? HTMLElement ?: break
if (first.classList.contains("output-trim-marker")) {
val next = first.nextElementSibling ?: break
container.removeChild(next)
} else {
container.removeChild(first)
}
removedRows++
}
var textLength = (container.textContent ?: "").length
while (textLength > maxRenderedOutputChars && container.children.length > 1) {
val first = container.firstElementChild as? HTMLElement ?: break
val victim = if (first.classList.contains("output-trim-marker")) {
first.nextElementSibling ?: break
} else {
first
}
textLength -= (victim.textContent ?: "").length
container.removeChild(victim)
removedRows++
}
if (removedRows > 0) {
val marker = ensureTrimMarker(container)
val previous = (marker.dataset.asDynamic().trimmedRows as? String)?.toIntOrNull() ?: 0
val next = previous + removedRows
marker.dataset.asDynamic().trimmedRows = next.toString()
val text = marker.querySelector(".log-msg") as? HTMLElement
text?.textContent = "[trimmed $next earlier output rows]"
}
}