Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,33 @@ package com.itsaky.androidide.fragments.output

import android.os.Bundle
import android.view.View
import com.blankj.utilcode.util.ThreadUtils
import androidx.lifecycle.lifecycleScope
import com.itsaky.androidide.R
import com.itsaky.androidide.editor.ui.IDEEditor
import com.itsaky.androidide.idetooltips.TooltipTag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull

class BuildOutputFragment : NonEditableEditorFragment() {
companion object {
private const val LAYOUT_TIMEOUT_MS = 2000L
}

override val currentEditor: IDEEditor? get() = editor

private val unsavedLines: MutableList<String?> = ArrayList()
private val logChannel = Channel<String>(Channel.UNLIMITED)

override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
editor?.tag = TooltipTag.PROJECT_BUILD_OUTPUT
emptyStateViewModel.setEmptyMessage(getString(R.string.msg_emptyview_buildoutput))
if (unsavedLines.isNotEmpty()) {
for (line in unsavedLines) {
editor?.append("${line!!.trim()}\n")
}
unsavedLines.clear()

viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Default) {
processLogs()
}
}

Expand All @@ -49,20 +54,72 @@ class BuildOutputFragment : NonEditableEditorFragment() {
}

fun appendOutput(output: String?) {
if (editor == null) {
unsavedLines.add(output)
return
if (!output.isNullOrEmpty()) {
logChannel.trySend(output)
}
}

/**
* Ensures the string ends with a newline character (`\n`).
* Useful for maintaining correct formatting when concatenating log lines.
*/
private fun String.ensureNewline(): String =
if (endsWith('\n')) this else "$this\n"

/**
* Immediately drains (consumes) all available messages from the channel into the [buffer].
*
* This is a **non-blocking** operation that enables batching, grouping hundreds of pending lines
* into a single memory operation to avoid saturating the UI queue.
*/
private fun ReceiveChannel<String>.drainTo(buffer: StringBuilder) {
var result = tryReceive()
while (result.isSuccess) {
val line = result.getOrNull()
if (!line.isNullOrEmpty()) {
buffer.append(line.ensureNewline())
}
result = tryReceive()
}
ThreadUtils.runOnUiThread {
val message =
if (output == null || output.endsWith("\n")) {
output
} else {
"${output}\n"
}
editor?.append(message).also {
emptyStateViewModel.setEmpty(false)
}

/**
* Main log orchestrator: Consumes, Batches, and Dispatches.
*
* 1. Suspends (zero CPU usage) until the first log arrives.
* 2. Wakes up and drains the entire queue (Batching).
* 3. Sends the complete block to the UI in a single pass.
*/
private suspend fun processLogs() = with(StringBuilder()) {
for (firstLine in logChannel) {
append(firstLine.ensureNewline())
logChannel.drainTo(this)

if (isNotEmpty()) {
val batchText = toString()
clear()
flushToEditor(batchText)
}
}
}

/**
* Performs the safe UI update on the Main Thread.
*
* Uses [IDEEditor.awaitLayout] to guarantee the editor has physical dimensions (width > 0)
* before attempting to insert text, preventing the Sora library's `ArrayIndexOutOfBoundsException`.
*/
private suspend fun flushToEditor(text: String) = withContext(Dispatchers.Main) {
editor?.run {
withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
awaitLayout(onForceVisible = {
emptyStateViewModel.setEmpty(false)
})
}

appendBatch(text)

emptyStateViewModel.setEmpty(false)
}
}
}
59 changes: 59 additions & 0 deletions editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
Expand Down Expand Up @@ -97,12 +98,14 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.slf4j.LoggerFactory
import java.io.File
import kotlin.coroutines.resume

fun interface OnEditorLongPressListener {
fun onLongPress(event: MotionEvent)
Expand Down Expand Up @@ -206,6 +209,9 @@ constructor(
return _diagnosticWindow ?: DiagnosticWindow(this).also { _diagnosticWindow = it }
}

val isReadyToAppend: Boolean
get() = !isReleased && isAttachedToWindow && isLaidOut && width > 0

companion object {
private const val TAG = "TrackpadScrollDebug"
private const val SELECTION_CHANGE_DELAY = 500L
Expand Down Expand Up @@ -266,6 +272,59 @@ constructor(
}
}

/**
* Suspends the current coroutine until the editor has valid dimensions (`width > 0`).
*
* This is a **reactive** alternative to busy-waiting or `postDelayed`. It ensures that
* no text insertion is attempted before the editor's internal layout engine is ready,
* preventing the `ArrayIndexOutOfBoundsException`.
*
* @param onForceVisible A callback invoked immediately if the view is not ready.
* Used to set the view to `VISIBLE` and trigger the layout pass.
*/
suspend fun awaitLayout(onForceVisible: () -> Unit) {
if (isReadyToAppend) return

withContext(Dispatchers.Main) {
onForceVisible()
}

return suspendCancellableCoroutine { continuation ->
val listener = object : OnLayoutChangeListener {
override fun onLayoutChange(
v: View?, left: Int, top: Int, right: Int, bottom: Int,
oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int
) {
if ((v?.width ?: 0) > 0) {
v?.removeOnLayoutChangeListener(this)
if (continuation.isActive) {
continuation.resume(Unit)
}
}
}
}

addOnLayoutChangeListener(listener)

continuation.invokeOnCancellation {
removeOnLayoutChangeListener(listener)
}
}
}

/**
* Appends a block of text to the editor safely.
*
* It performs a final check on [isReadyToAppend] and wraps the underlying append operation
* in [runCatching]. This prevents the app from crashing if the editor's internal layout
* calculation fails during the insertion.
*/
fun appendBatch(text: String) {
if (isReadyToAppend) {
runCatching { append(text) }
}
}

override fun setLanguageServer(server: ILanguageServer?) {
if (isReleased) {
return
Expand Down