Skip to content
Open
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,22 +18,31 @@ package com.itsaky.androidide.fragments.output

import android.os.Bundle
import android.view.View
import android.widget.LinearLayout
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.itsaky.androidide.R
import com.itsaky.androidide.databinding.LayoutLogFilterBarBinding
import com.itsaky.androidide.editor.ui.EditorSearchLayout
import com.itsaky.androidide.editor.ui.IDEEditor
import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.models.LogFilter
import com.itsaky.androidide.utils.BasicBuildInfo
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.drop
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull

class BuildOutputFragment : NonEditableEditorFragment() {

class BuildOutputFragment :
NonEditableEditorFragment(),
SearchableOutputFragment {
private val buildOutputViewModel: BuildOutputViewModel by activityViewModels()

companion object {
Expand All @@ -44,10 +53,21 @@ class BuildOutputFragment : NonEditableEditorFragment() {

private val logChannel = Channel<String>(Channel.UNLIMITED)

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
private var searchLayout: EditorSearchLayout? = null
private var filterBar: LogFilterBarController? = null

// Serializes editor-content mutations (filtered re-renders vs live batch appends)
// so a re-render never misses or duplicates a concurrently flushed batch.
private val editorContentMutex = Mutex()
Comment on lines +59 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep deferred appends inside the serialization boundary.

The timeout branch launches the append and releases editorContentMutex. A filter re-render can then replace the editor before stale visibleText is appended, or later batches can overtake it. Await layout and append directly while retaining the mutex.

Proposed fix
 					} else {
-						viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
-							editor?.run {
-								awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) })
-								appendBatch(visibleText)
-								emptyStateViewModel.setEmpty(false)
-							}
-						}
+						awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) })
+						appendBatch(visibleText)
+						emptyStateViewModel.setEmpty(false)
 					}

Also applies to: 267-295

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt`
around lines 59 - 61, Update the timeout/deferred-append path in the editor
content serialization flow around editorContentMutex so it awaits layout and
performs the append while still holding the mutex, rather than launching a
separate coroutine that outlives the lock. Preserve ordering with later batches
and prevent filter re-renders from replacing the editor before the stale
visibleText append completes.


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))
setupSearchLayout()

viewLifecycleOwner.lifecycleScope.launch {
restoreWindowFromViewModel()
Expand All @@ -56,17 +76,93 @@ class BuildOutputFragment : NonEditableEditorFragment() {
val content = buildOutputViewModel.getFullContent()
buildOutputViewModel.setCachedSnapshot(content)
}
launch {
buildOutputViewModel.filterText.drop(1).collectLatest { query ->
renderFiltered(query)
}
}
}
}

/** Re-renders the editor window from the session file, filtered by [query]. */
private suspend fun renderFiltered(query: String) {
editorContentMutex.withLock {
val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() }
val filtered =
withContext(Dispatchers.Default) {
BuildOutputViewModel.filterLines(window, query)
}
withContext(Dispatchers.Main) {
editor?.setText(filtered)
emptyStateViewModel.setEmpty(filtered.isBlank())
onContentReplaced()
}
}
}

/** Called after the editor content has been replaced wholesale (e.g. on a filter change). */
private fun onContentReplaced() {
val searchLayout = this.searchLayout ?: return
if (searchLayout.isSearchModeActive()) {
searchLayout.refreshSearch()
} else {
editor?.searcher?.stopSearch()
}
}

override fun beginSearch() {
searchLayout?.beginSearchMode()
}

override fun toggleFilterBar() {
val existing = filterBar
existing?.toggle() ?: createFilterBar()
}

private fun setupSearchLayout() {
val editor = this.editor ?: return
val root = _binding?.root ?: return
val searchLayout =
EditorSearchLayout(
context = requireContext(),
editor = editor,
showReplaceAction = false,
applyCollapsedSheetMargin = false,
)
root.addView(
searchLayout,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
),
)
this.searchLayout = searchLayout
}

private fun createFilterBar(): LogFilterBarController? {
val stub = _binding?.filterBarStub ?: return null
val barBinding = LayoutLogFilterBarBinding.bind(stub.inflate())
return LogFilterBarController(
binding = barBinding,
coroutineScope = viewLifecycleOwner.lifecycleScope,
showLevelChips = false,
initialText = buildOutputViewModel.filterText.value,
initialLevels = LogFilter.ALL_LEVELS,
) { _, text ->
buildOutputViewModel.filterText.value = text.trim()
}.also { filterBar = it }
}

private suspend fun restoreWindowFromViewModel() {
val content = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() }
val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() }
val content = BuildOutputViewModel.filterLines(window, buildOutputViewModel.filterText.value)
if (content.isEmpty()) return
withContext(Dispatchers.Main) {
val editor = this@BuildOutputFragment.editor ?: return@withContext
val layoutCompleted = withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
editor.awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) })
}
val layoutCompleted =
withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
editor.awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) })
}
if (layoutCompleted != null) {
editor.appendBatch(content)
emptyStateViewModel.setEmpty(false)
Expand All @@ -86,6 +182,8 @@ class BuildOutputFragment : NonEditableEditorFragment() {
}

override fun onDestroyView() {
searchLayout = null
filterBar = null
editor?.release()
super.onDestroyView()
}
Expand Down Expand Up @@ -118,8 +216,7 @@ class BuildOutputFragment : NonEditableEditorFragment() {
* 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"
private fun String.ensureNewline(): String = if (endsWith('\n')) this else "$this\n"

/**
* Immediately drains (consumes) all available messages from the channel into the [buffer].
Expand All @@ -145,18 +242,19 @@ class BuildOutputFragment : NonEditableEditorFragment() {
* 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)
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.
Expand All @@ -166,22 +264,33 @@ class BuildOutputFragment : NonEditableEditorFragment() {
* before attempting to insert text, preventing the Sora library's `ArrayIndexOutOfBoundsException`.
*/
private suspend fun flushToEditor(text: String) {
buildOutputViewModel.append(text)
withContext(Dispatchers.Main) {
editor?.run {
val layoutCompleted = withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) })
}
if (layoutCompleted != null) {
appendBatch(text)
emptyStateViewModel.setEmpty(false)
} else {
// Timeout: defer append until layout is ready (same as restoreWindowFromViewModel)
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
editor?.run {
editorContentMutex.withLock {
buildOutputViewModel.append(text)

// The session file always gets the full text; the editor only shows matching lines
val visibleText =
BuildOutputViewModel.filterLines(text, buildOutputViewModel.filterText.value)
if (visibleText.isEmpty()) {
return
}

withContext(Dispatchers.Main) {
editor?.run {
val layoutCompleted =
withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) })
appendBatch(text)
emptyStateViewModel.setEmpty(false)
}
if (layoutCompleted != null) {
appendBatch(visibleText)
emptyStateViewModel.setEmpty(false)
} else {
// Timeout: defer append until layout is ready (same as restoreWindowFromViewModel)
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
editor?.run {
awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) })
appendBatch(visibleText)
emptyStateViewModel.setEmpty(false)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,16 @@ package com.itsaky.androidide.fragments.output
import android.os.Bundle
import android.view.View
import androidx.fragment.app.activityViewModels
import ch.qos.logback.classic.Level
import com.itsaky.androidide.R
import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.logging.GlobalBufferAppender
import com.itsaky.androidide.utils.FeatureFlags
import com.itsaky.androidide.viewmodel.IDELogsViewModel

/**
* Fragment to show IDE logs.
* Fragment to show IDE logs. Log consumption happens in [IDELogsViewModel].
*
* @author Akash Yadav
*/
class IDELogFragment :
LogViewFragment<IDELogsViewModel>(),
GlobalBufferAppender.Consumer {
class IDELogFragment : LogViewFragment<IDELogsViewModel>() {
override fun isSimpleFormattingEnabled() = true

override fun getShareableFilename() = "ide_logs"
Expand All @@ -42,24 +38,11 @@ class IDELogFragment :

override val viewModel by activityViewModels<IDELogsViewModel>()

override val logLevel: Level
get() = if (FeatureFlags.isDebugLoggingEnabled) Level.DEBUG else Level.INFO

override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)
emptyStateViewModel.setEmptyMessage(getString(R.string.msg_emptyview_idelogs))

// Register with GlobalBufferAppender to receive all logs (including buffered ones)
GlobalBufferAppender.registerConsumer(this)
}

override fun consume(message: String) = appendLine(message)

override fun onDestroyView() {
GlobalBufferAppender.unregisterConsumer(this)
super.onDestroyView()
}
}
Loading
Loading