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 @@ -21,6 +21,7 @@ import android.view.View
import android.widget.LinearLayout
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.blankj.utilcode.util.SizeUtils
import com.itsaky.androidide.R
import com.itsaky.androidide.databinding.LayoutLogFilterBarBinding
import com.itsaky.androidide.editor.ui.EditorSearchLayout
Expand All @@ -33,6 +34,7 @@ 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
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
Expand Down Expand Up @@ -60,6 +62,14 @@ class BuildOutputFragment :
// so a re-render never misses or duplicates a concurrently flushed batch.
private val editorContentMutex = Mutex()

// Bumped only when a build session is cleared (new build) so live streaming logs
// are never dropped from the disk session file during filter re-renders.
@Volatile
private var sessionGeneration = 0

// Bumped on every wholesale content replacement (filtered re-render or clear) so an
// in-flight batch flush drained before the replacement can detect it and drop itself.
@Volatile
private var editorContentGeneration = 0

override fun onViewCreated(
Expand All @@ -69,6 +79,7 @@ class BuildOutputFragment :
super.onViewCreated(view, savedInstanceState)
editor?.tag = TooltipTag.PROJECT_BUILD_OUTPUT
emptyStateViewModel.setEmptyMessage(getString(R.string.msg_emptyview_buildoutput))
setLineNumbersEnabled(buildOutputViewModel.showLineNumbers.value)
setupSearchLayout()

viewLifecycleOwner.lifecycleScope.launch {
Expand All @@ -79,21 +90,31 @@ class BuildOutputFragment :
buildOutputViewModel.setCachedSnapshot(content)
}
launch {
buildOutputViewModel.filterText.drop(1).collectLatest { query ->
renderFiltered(query)
combine(
buildOutputViewModel.filterText,
buildOutputViewModel.showTimestamps,
buildOutputViewModel.showDeltas,
) { query, ts, deltas ->
Triple(query, ts, deltas)
}.drop(1).collectLatest { (query, ts, deltas) ->
renderFiltered(query, ts, deltas)
}
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Re-renders the editor window from the session file, filtered by [query]. */
private suspend fun renderFiltered(query: String) {
/** Re-renders the editor window from the session file, filtered by [query] and visibility options. */
private suspend fun renderFiltered(
query: String = buildOutputViewModel.filterText.value,
showTimestamps: Boolean = buildOutputViewModel.showTimestamps.value,
showDeltas: Boolean = buildOutputViewModel.showDeltas.value,
) {
editorContentMutex.withLock {
editorContentGeneration++
val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() }
val filtered =
withContext(Dispatchers.Default) {
BuildOutputViewModel.filterLines(window, query)
BuildOutputViewModel.filterLines(window, query, showTimestamps, showDeltas)
}
withContext(Dispatchers.Main) {
editor?.setText(filtered)
Expand All @@ -117,6 +138,13 @@ class BuildOutputFragment :
searchLayout?.beginSearchMode()
}

fun setLineNumbersEnabled(enabled: Boolean) {
val ed = editor ?: return
ed.setLineNumberEnabled(enabled)
// Zero the divider with the gutter, otherwise a stray 2dp rule remains.
ed.setDividerWidth((if (enabled) SizeUtils.dp2px(2f) else 0).toFloat())
}

override fun toggleFilterBar() {
val existing = filterBar
existing?.toggle() ?: createFilterBar()
Expand Down Expand Up @@ -149,16 +177,36 @@ class BuildOutputFragment :
binding = barBinding,
coroutineScope = viewLifecycleOwner.lifecycleScope,
showLevelChips = false,
showOptionChips = true,
initialText = buildOutputViewModel.filterText.value,
initialLevels = LogFilter.ALL_LEVELS,
initialLineNumbersEnabled = buildOutputViewModel.showLineNumbers.value,
initialTimestampsEnabled = buildOutputViewModel.showTimestamps.value,
initialDeltasEnabled = buildOutputViewModel.showDeltas.value,
onLineNumbersToggled = { enabled ->
buildOutputViewModel.showLineNumbers.value = enabled
setLineNumbersEnabled(enabled)
},
onTimestampsToggled = { enabled ->
buildOutputViewModel.showTimestamps.value = enabled
},
onDeltasToggled = { enabled ->
buildOutputViewModel.showDeltas.value = enabled
},
) { _, text ->
buildOutputViewModel.filterText.value = text.trim()
}.also { filterBar = it }
}

private suspend fun restoreWindowFromViewModel() {
val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() }
val content = BuildOutputViewModel.filterLines(window, buildOutputViewModel.filterText.value)
val content =
BuildOutputViewModel.filterLines(
window,
buildOutputViewModel.filterText.value,
buildOutputViewModel.showTimestamps.value,
buildOutputViewModel.showDeltas.value,
)
if (content.isEmpty()) return
withContext(Dispatchers.Main) {
val editor = this@BuildOutputFragment.editor ?: return@withContext
Expand Down Expand Up @@ -196,6 +244,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.
sessionGeneration++
editorContentGeneration++
buildOutputViewModel.clear()
super.clearOutput()
}
Expand Down Expand Up @@ -248,13 +303,15 @@ class BuildOutputFragment :
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)
flushToEditor(batchText, sessionGenAtDrain, editorGenAtDrain)
}
}
}
Expand All @@ -266,13 +323,25 @@ class BuildOutputFragment :
* 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) {
private suspend fun flushToEditor(
text: String,
sessionGen: Int,
editorGen: Int,
) {
editorContentMutex.withLock {
// A clear (new build) after this batch was drained invalidates session append.
if (sessionGen != sessionGeneration) return

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)
BuildOutputViewModel.filterLines(
text,
buildOutputViewModel.filterText.value,
buildOutputViewModel.showTimestamps.value,
buildOutputViewModel.showDeltas.value,
)
if (visibleText.isEmpty()) {
return
}
Expand All @@ -284,16 +353,18 @@ class BuildOutputFragment :
awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) })
}
if (layoutCompleted != null) {
appendBatch(visibleText)
emptyStateViewModel.setEmpty(false)
// clearOutput() or renderFiltered() may have run since the file append.
if (editorGen == editorContentGeneration) {
appendBatch(visibleText)
emptyStateViewModel.setEmpty(false)
}
} else {
// Timeout: defer append until layout is ready (same as restoreWindowFromViewModel)
val generationAtFlush = editorContentGeneration
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
editor?.run {
awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) })
editorContentMutex.withLock {
if (editorContentGeneration == generationAtFlush) {
if (editorGen == editorContentGeneration) {
appendBatch(visibleText)
emptyStateViewModel.setEmpty(false)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,15 @@ class LogFilterBarController(
private val binding: LayoutLogFilterBarBinding,
coroutineScope: CoroutineScope,
showLevelChips: Boolean,
showOptionChips: Boolean = false,
initialText: String,
initialLevels: Set<ILogger.Level>,
initialLineNumbersEnabled: Boolean = true,
initialTimestampsEnabled: Boolean = true,
initialDeltasEnabled: Boolean = true,
private val onLineNumbersToggled: ((Boolean) -> Unit)? = null,
private val onTimestampsToggled: ((Boolean) -> Unit)? = null,
private val onDeltasToggled: ((Boolean) -> Unit)? = null,
private val onFilterChanged: (levels: Set<ILogger.Level>, text: String) -> Unit,
) {
companion object {
Expand All @@ -59,7 +66,30 @@ class LogFilterBarController(
private var textDebounceJob: Job? = null

init {
binding.levelChipsScroll.isVisible = showLevelChips
binding.levelChipsScroll.isVisible = showLevelChips || showOptionChips

chipsByLevel.values.forEach { chip ->
chip.isVisible = showLevelChips
}

binding.chipLineNumbers.isVisible = showOptionChips
binding.chipLineNumbers.isChecked = initialLineNumbersEnabled
binding.chipLineNumbers.setOnCheckedChangeListener { _, isChecked ->
onLineNumbersToggled?.invoke(isChecked)
}

binding.chipTimestamps.isVisible = showOptionChips
binding.chipTimestamps.isChecked = initialTimestampsEnabled
binding.chipTimestamps.setOnCheckedChangeListener { _, isChecked ->
onTimestampsToggled?.invoke(isChecked)
}

binding.chipDeltas.isVisible = showOptionChips
binding.chipDeltas.isChecked = initialDeltasEnabled
binding.chipDeltas.setOnCheckedChangeListener { _, isChecked ->
onDeltasToggled?.invoke(isChecked)
}

binding.filterInput.setText(initialText)
chipsByLevel.forEach { (level, chip) ->
chip.isChecked = level in initialLevels
Expand Down
Loading
Loading