diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputBuffer.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputBuffer.kt new file mode 100644 index 0000000000..793f83aa58 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputBuffer.kt @@ -0,0 +1,146 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.fragments.output + +import kotlinx.coroutines.channels.Channel + +/** + * Thread-safe pending build output with fixed memory and batch budgets. + * + * Inputs are indivisible: one input larger than [maxBatchChars] is emitted alone, while one larger + * than [maxPendingChars] is omitted. Other inputs are never split or reordered. + */ +internal class BuildOutputBuffer( + private val maxPendingChars: Int = DEFAULT_MAX_PENDING_CHARS, + private val maxBatchChars: Int = DEFAULT_MAX_BATCH_CHARS, +) { + data class Batch( + val text: String, + val sessionGeneration: Int, + ) + + private sealed interface Entry { + val sessionGeneration: Int + + data class Text( + val value: String, + override val sessionGeneration: Int, + ) : Entry + + data class Omission( + var lineCount: Long, + override val sessionGeneration: Int, + ) : Entry + } + + private val entries = ArrayDeque() + private val available = Channel(Channel.CONFLATED) + private val lock = Any() + private var retainedChars = 0 + private var omission: Entry.Omission? = null + + internal val pendingChars: Int + get() = synchronized(lock) { retainedChars } + + init { + require(maxPendingChars > 0) { "maxPendingChars must be positive" } + require(maxBatchChars > 0) { "maxBatchChars must be positive" } + } + + fun offer( + text: String, + sessionGeneration: Int, + ) { + if (text.isEmpty()) return + val needsNewline = !text.endsWith('\n') + val normalizedLength = text.length.toLong() + if (needsNewline) 1 else 0 + val lineCount = text.count { it == '\n' }.toLong() + if (needsNewline) 1 else 0 + synchronized(lock) { + if ( + normalizedLength > maxPendingChars.toLong() || + normalizedLength > (maxPendingChars - retainedChars).toLong() + ) { + val existingOmission = omission + if ( + existingOmission?.sessionGeneration == sessionGeneration && + entries.lastOrNull() === existingOmission + ) { + existingOmission.lineCount += lineCount + } else { + val marker = Entry.Omission(lineCount, sessionGeneration) + omission = marker + entries.addLast(marker) + } + } else { + val normalized = if (needsNewline) "$text\n" else text + entries.addLast(Entry.Text(normalized, sessionGeneration)) + retainedChars += normalizedLength.toInt() + } + available.trySend(Unit) + } + } + + suspend fun takeBatch(): Batch { + while (true) { + available.receive() + val batch = synchronized(lock) { takeAvailableBatch() } + if (batch != null) return batch + } + } + + fun clear() { + synchronized(lock) { + entries.clear() + retainedChars = 0 + omission = null + while (available.tryReceive().isSuccess) { + // Discard stale wakeups from the cleared build session. + } + } + } + + private fun takeAvailableBatch(): Batch? { + if (entries.isEmpty()) return null + val sessionGeneration = entries.first().sessionGeneration + val batch = StringBuilder(minOf(retainedChars, maxBatchChars)) + while (entries.isNotEmpty()) { + val entry = entries.first() + if (entry.sessionGeneration != sessionGeneration) break + val value = + when (entry) { + is Entry.Text -> entry.value + is Entry.Omission -> omissionMarker(entry.lineCount) + } + if (batch.isNotEmpty() && batch.length + value.length > maxBatchChars) break + + entries.removeFirst() + batch.append(value) + if (entry is Entry.Text) retainedChars -= entry.value.length + if (entry is Entry.Omission && omission === entry) omission = null + } + if (entries.isNotEmpty()) available.trySend(Unit) + return Batch(batch.toString(), sessionGeneration) + } + + private fun omissionMarker(lineCount: Long): String = "[$lineCount build output lines omitted]\n" + + companion object { + private const val DEFAULT_MAX_PENDING_CHARS = 256 * 1024 + private const val DEFAULT_MAX_BATCH_CHARS = 32 * 1024 + } +} diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index 6c5e4c42dc..497c42c91b 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -37,8 +37,6 @@ import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashInfo import com.itsaky.androidide.viewmodel.BuildOutputViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.drop @@ -60,7 +58,7 @@ class BuildOutputFragment : override val currentEditor: IDEEditor? get() = editor - private val logChannel = Channel(Channel.UNLIMITED) + private val outputBuffer = BuildOutputBuffer() private var searchLayout: EditorSearchLayout? = null private var filterBar: LogFilterBarController? = null @@ -78,6 +76,11 @@ class BuildOutputFragment : // in-flight batch flush drained before the replacement can detect it and drop itself. @Volatile private var editorContentGeneration = 0 + + @Volatile + private var visibleEditorChars = 0 + @Volatile + private var editorSourceChars = 0 private val noMatchTracker = FilterNoMatchTracker() // Reads view state (bar visibility), so evaluate it on the main thread. @@ -98,7 +101,7 @@ class BuildOutputFragment : launch { restoreWindowFromViewModel() } launch(Dispatchers.Default) { processLogs() } launch { - val content = buildOutputViewModel.getFullContent() + val content = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } buildOutputViewModel.setCachedSnapshot(content) } launch { @@ -126,10 +129,14 @@ class BuildOutputFragment : val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } val filtered = withContext(Dispatchers.Default) { - BuildOutputViewModel.filterLines(window, query, showTimestamps, showDeltas) + BuildOutputViewModel.fitEditorWindow( + BuildOutputViewModel.filterLines(window, query, showTimestamps, showDeltas), + ) } withContext(Dispatchers.Main) { editor?.setText(filtered) + visibleEditorChars = filtered.length + editorSourceChars = window.length val isSourceEmpty = window.isBlank() updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive) if (noMatchTracker.onRender(isSourceEmpty = isSourceEmpty, isFilteredEmpty = filtered.isBlank())) { @@ -287,57 +294,79 @@ class BuildOutputFragment : }.also { filterBar = it } } - private suspend fun restoreWindowFromViewModel() { - val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } - val content = - BuildOutputViewModel.filterLines( - window, - buildOutputViewModel.filterText.value, - buildOutputViewModel.showTimestamps.value, - buildOutputViewModel.showDeltas.value, - ) - val query = buildOutputViewModel.filterText.value - val isSourceEmpty = window.isBlank() - val isFilteredEmpty = content.isBlank() - - withContext(Dispatchers.Main) { - updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive) - noMatchTracker.prime(isFilteredEmpty) - if (!isSourceEmpty && isFilteredEmpty) { - editor?.setText("") - onContentReplaced() - } - } + private suspend fun restoreWindowFromViewModel() = + withContext(Dispatchers.Default) { + val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } + val content = + BuildOutputViewModel.fitEditorWindow( + BuildOutputViewModel.filterLines( + window, + buildOutputViewModel.filterText.value, + buildOutputViewModel.showTimestamps.value, + buildOutputViewModel.showDeltas.value, + ), + ) + val generationAtRestore = editorContentGeneration + val visibleCharsAtRestore = visibleEditorChars + val sourceCharsAtRestore = editorSourceChars + fun isRestoreCurrent() = + editorContentGeneration == generationAtRestore && + visibleEditorChars == visibleCharsAtRestore && + editorSourceChars == sourceCharsAtRestore + val isSourceEmpty = window.isBlank() + val isFilteredEmpty = content.isBlank() - if (content.isEmpty()) return - withContext(Dispatchers.Main) { - val editor = this@BuildOutputFragment.editor ?: return@withContext - val layoutCompleted = - withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { - editor.awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) }) + withContext(Dispatchers.Main) { + updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive) + noMatchTracker.prime(isFilteredEmpty) + if (!isSourceEmpty && isFilteredEmpty) { + editorContentMutex.withLock { + if (isRestoreCurrent()) { + editor?.setText("") + visibleEditorChars = 0 + editorSourceChars = window.length + onContentReplaced() + } + } } - if (layoutCompleted != null) { - editor.appendBatch(content) - updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) - } else { - // Timeout: defer append until layout is ready so content is not lost - val generationAtRestore = editorContentGeneration - val job = - viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { - editor.run { - awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) }) - editorContentMutex.withLock { - if (editorContentGeneration == generationAtRestore) { - appendBatch(content) - updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) + } + + if (content.isEmpty()) return@withContext + withContext(Dispatchers.Main) { + val editor = this@BuildOutputFragment.editor ?: return@withContext + val layoutCompleted = + withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + editor.awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) }) + } + if (layoutCompleted != null) { + editorContentMutex.withLock { + if (isRestoreCurrent()) { + editor.appendBatch(content) + visibleEditorChars += content.length + editorSourceChars = window.length + updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) + } + } + } else { + // Timeout: defer append until layout is ready so content is not lost + val job = + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { + editor.run { + awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) }) + editorContentMutex.withLock { + if (isRestoreCurrent()) { + appendBatch(content) + visibleEditorChars += content.length + editorSourceChars = window.length + updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) + } } } } - } - job.join() + job.join() + } } } - } override fun onDestroyView() { searchLayout = null @@ -351,13 +380,13 @@ class BuildOutputFragment : // Avoid forcing the activityViewModels lazy init (which calls requireActivity()) // when the fragment is detached, otherwise an IllegalStateException is thrown. if (!isAdded || activity == null) return - while (logChannel.tryReceive().isSuccess) { - // Discard: these lines belong to the session being cleared. - } // Invalidate in-flight flushes before deleting content, so a batch drained from the - // channel earlier cannot re-seed the cleared session. + // buffer earlier cannot re-seed the cleared session. sessionGeneration++ + outputBuffer.clear() editorContentGeneration++ + visibleEditorChars = 0 + editorSourceChars = 0 noMatchTracker.reset() buildOutputViewModel.clear() super.clearOutput() @@ -377,55 +406,22 @@ class BuildOutputFragment : fun appendOutput(output: String?) { 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.drainTo(buffer: StringBuilder) { - var result = tryReceive() - while (result.isSuccess) { - val line = result.getOrNull() - if (!line.isNullOrEmpty()) { - buffer.append(line.ensureNewline()) - } - result = tryReceive() + outputBuffer.offer(output, sessionGeneration) } } /** * 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. + * Suspends until bounded output is available, then sends one bounded batch to the UI. */ - private suspend fun processLogs() = - with(StringBuilder()) { - for (firstLine in logChannel) { - val sessionGenAtDrain = sessionGeneration - val editorGenAtDrain = editorContentGeneration - append(firstLine.ensureNewline()) - logChannel.drainTo(this) - - if (isNotEmpty()) { - val batchText = toString() - clear() - flushToEditor(batchText, sessionGenAtDrain, editorGenAtDrain) - } - } + private suspend fun processLogs() { + while (true) { + val batch = outputBuffer.takeBatch() + val editorGenAtDrain = editorContentGeneration + flushToEditor(batch.text, batch.sessionGeneration, editorGenAtDrain) } + } /** * Performs the safe UI update on the Main Thread. @@ -443,7 +439,10 @@ class BuildOutputFragment : // A clear (new build) after this batch was drained invalidates session append. if (sessionGen != sessionGeneration) return - buildOutputViewModel.append(text) + buildOutputViewModel.append(text) { sessionGen == sessionGeneration } + if (sessionGen != sessionGeneration) return + val refreshEditorWindow = + BuildOutputViewModel.wouldExceedEditorWindow(editorSourceChars, text.length) // The session file always gets the full text; the editor only shows matching lines val visibleText = @@ -453,13 +452,41 @@ class BuildOutputFragment : buildOutputViewModel.showTimestamps.value, buildOutputViewModel.showDeltas.value, ) - if (visibleText.isEmpty()) { + if (visibleText.isEmpty() && !refreshEditorWindow) { + withContext(Dispatchers.Main) { + if (sessionGen == sessionGeneration) editorSourceChars += text.length + } return } + val refreshedWindow = + if (refreshEditorWindow) { + val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } + withContext(Dispatchers.Default) { + Pair( + BuildOutputViewModel.fitEditorWindow( + BuildOutputViewModel.filterLines( + window, + buildOutputViewModel.filterText.value, + buildOutputViewModel.showTimestamps.value, + buildOutputViewModel.showDeltas.value, + ), + ), + window.length, + ) + } + } else { + null + } withContext(Dispatchers.Main) { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) - if (visibleText.isEmpty()) { + if (editorGen != editorContentGeneration) return@withContext + if (refreshedWindow != null) { + editorContentGeneration++ + editor?.setText(refreshedWindow.first) + visibleEditorChars = refreshedWindow.first.length + editorSourceChars = refreshedWindow.second + onContentReplaced() return@withContext } editor?.run { @@ -471,6 +498,8 @@ class BuildOutputFragment : // clearOutput() or renderFiltered() may have run since the file append. if (editorGen == editorContentGeneration) { appendBatch(visibleText) + visibleEditorChars += visibleText.length + editorSourceChars += text.length updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) } } else { @@ -481,6 +510,8 @@ class BuildOutputFragment : editorContentMutex.withLock { if (editorGen == editorContentGeneration) { appendBatch(visibleText) + visibleEditorChars += visibleText.length + editorSourceChars += text.length updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt index dc94377062..6065a05afd 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt @@ -90,10 +90,14 @@ class BuildOutputViewModel( * Appends text to the session file. File I/O is performed on a background dispatcher; call from * any thread. Prefer calling before switching to Main so disk write does not block the UI. */ - suspend fun append(text: String) { + suspend fun append( + text: String, + isCurrentSession: () -> Boolean = { true }, + ) { if (text.isEmpty()) return withContext(Dispatchers.IO) { lock.withLock { + if (!isCurrentSession()) return@withLock try { FileOutputStream(sessionFile, true).use { it.write(text.toByteArray(StandardCharsets.UTF_8)) @@ -108,12 +112,12 @@ class BuildOutputViewModel( } /** - * Returns the last [WINDOW_SIZE_CHARS] characters from the session file for the editor to + * Returns the last [EDITOR_WINDOW_MAX_CHARS] characters from the session file for the editor to * display (e.g. initial view or after rotation). Returns empty string if no content. */ fun getWindowForEditor(): String = lock.withLock { - readTailFromFile(sessionFile, WINDOW_SIZE_CHARS) + readTailFromFile(sessionFile, EDITOR_WINDOW_MAX_CHARS) } /** @@ -194,6 +198,20 @@ class BuildOutputViewModel( } companion object { + internal const val EDITOR_WINDOW_MAX_CHARS = 512 * 1024 + + internal fun wouldExceedEditorWindow( + currentChars: Int, + incomingChars: Int, + ): Boolean = currentChars > EDITOR_WINDOW_MAX_CHARS - incomingChars + + internal fun fitEditorWindow(content: String): String = + if (content.length <= EDITOR_WINDOW_MAX_CHARS) { + content + } else { + content.takeLast(EDITOR_WINDOW_MAX_CHARS) + } + // Must mirror formatLinePrefix exactly; the round-trip is covered by BuildOutputFilterTest. // Anchored to line start so timestamp-shaped text inside a message is never stripped. private val PREFIX_REGEX = @@ -261,10 +279,8 @@ class BuildOutputViewModel( } private const val SESSION_FILE_NAME = "build_output_session.txt" - private const val WINDOW_SIZE_CHARS = 512 * 1024 - /** Max length of [cachedContentSnapshot] to bound memory. */ - private const val CACHE_SNAPSHOT_MAX_CHARS = WINDOW_SIZE_CHARS + private const val CACHE_SNAPSHOT_MAX_CHARS = EDITOR_WINDOW_MAX_CHARS private val log = org.slf4j.LoggerFactory.getLogger(BuildOutputViewModel::class.java) } } diff --git a/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputBufferTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputBufferTest.kt new file mode 100644 index 0000000000..8139d44caa --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputBufferTest.kt @@ -0,0 +1,196 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.fragments.output + +import com.itsaky.androidide.viewmodel.BuildOutputViewModel +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BuildOutputBufferTest { + private fun BuildOutputBuffer.offer(text: String) { + offer(text, sessionGeneration = 0) + } + + @Test + fun `output below limits is emitted in order with one trailing newline`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 64, maxBatchChars = 64) + + buffer.offer("first") + buffer.offer("second\n") + + assertEquals("first\nsecond\n", buffer.takeBatch().text) + } + + @Test + fun `output is split into bounded batches without reordering`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 64, maxBatchChars = 6) + + buffer.offer("aa") + buffer.offer("bb") + buffer.offer("cc") + + val first = buffer.takeBatch().text + val second = buffer.takeBatch().text + assertEquals("aa\nbb\n", first) + assertEquals("cc\n", second) + assertTrue(first.length <= 6) + assertTrue(second.length <= 6) + } + + @Test + fun `one indivisible input may exceed the batch limit`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 64, maxBatchChars = 4) + + buffer.offer("oversized") + + assertEquals("oversized\n", buffer.takeBatch().text) + } + + @Test + fun `overflow is coalesced before retained output resumes`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 8, maxBatchChars = 128) + + buffer.offer("one") + buffer.offer("two") + buffer.offer("dropped one") + buffer.offer("dropped two\nand three") + + assertEquals( + "one\ntwo\n[3 build output lines omitted]\n", + buffer.takeBatch().text, + ) + assertTrue(buffer.pendingChars <= 8) + } + + @Test + fun `overflow after resumed output starts a new omission marker`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 8, maxBatchChars = 4) + + buffer.offer("one") + buffer.offer("two") + buffer.offer("first dropped") + assertEquals("one\n", buffer.takeBatch().text) + + buffer.offer("new") + buffer.offer("second dropped") + + assertEquals("two\n", buffer.takeBatch().text) + assertEquals("[1 build output lines omitted]\n", buffer.takeBatch().text) + assertEquals("new\n", buffer.takeBatch().text) + assertEquals("[1 build output lines omitted]\n", buffer.takeBatch().text) + } + + @Test + fun `clear resets pending output and overflow accounting`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 4, maxBatchChars = 64) + + buffer.offer("kept") + buffer.offer("dropped") + buffer.clear() + buffer.offer("new") + + assertEquals("new\n", buffer.takeBatch().text) + } + + @Test + fun `in-flight batch keeps the session generation from its producer`() = + runTest { + val buffer = BuildOutputBuffer(maxPendingChars = 64, maxBatchChars = 64) + + buffer.offer("old", sessionGeneration = 3) + val inFlight = buffer.takeBatch() + buffer.clear() + buffer.offer("new", sessionGeneration = 4) + + assertEquals(3, inFlight.sessionGeneration) + assertEquals(4, buffer.takeBatch().sessionGeneration) + } + + @Test + fun `repeated live batches refresh to the newest bounded editor tail`() { + val chunk = "x".repeat(200 * 1024) + val newest = "newest build output\n" + var session = "" + var visible = "" + var refreshCount = 0 + + for (batch in listOf(chunk, chunk, chunk + newest)) { + session += batch + visible = + if (BuildOutputViewModel.wouldExceedEditorWindow(visible.length, batch.length)) { + refreshCount++ + session.takeLast(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS) + } else { + visible + batch + } + } + + assertEquals(1, refreshCount) + assertTrue(visible.length <= BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS) + assertTrue(visible.endsWith(newest)) + } + + @Test + fun `filtered editor refreshes when hidden source output advances the window`() { + val oldMatch = "old match\n" + val hidden = "x".repeat(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS) + var session = oldMatch + var sourceChars = session.length + var visible = oldMatch + + session += hidden + if (BuildOutputViewModel.wouldExceedEditorWindow(sourceChars, hidden.length)) { + val window = session.takeLast(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS) + visible = + BuildOutputViewModel.filterLines( + window, + query = "match", + showTimestamps = true, + showDeltas = true, + ) + sourceChars = window.length + } + + assertEquals("", visible) + assertEquals(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS, sourceChars) + } + + @Test + fun `refreshed tail applies filtering and timing visibility`() { + val prefix = BuildOutputViewModel.formatLinePrefix(1_722_000_000_000L, 42L) + val tail = prefix + "ignored\n" + prefix + "newest output\n" + + val visible = + BuildOutputViewModel.filterLines( + tail, + query = "newest", + showTimestamps = false, + showDeltas = false, + ) + + assertEquals("newest output\n", visible) + } +}