Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9c97579
feat(ADFA-934): Enable gutter line numbers in build output editor
dara-abijo-adfa Jul 24, 2026
1479dd1
feat(ADFA-934): Implement time stamps, deltas and line numbers
dara-abijo-adfa Jul 24, 2026
e056cdf
feat(ADFA-934): Handle formatted lines correctly
dara-abijo-adfa Jul 24, 2026
7b64fee
feat(ADFA-934): Add toggle for timestamps
dara-abijo-adfa Jul 27, 2026
3dc82ea
fix(ADFA-934): Code review fixes
dara-abijo-adfa Jul 27, 2026
c2f5d43
Merge branch 'stage' into ADFA-934-build-output-timestamp
dara-abijo-adfa Jul 28, 2026
24533b4
Merge branch 'stage' into ADFA-934-build-output-timestamp
dara-abijo-adfa Jul 28, 2026
895601e
fix(ADFA-934): Fix data loss during live edits
dara-abijo-adfa Jul 28, 2026
c31ea2d
Merge branch 'stage' into ADFA-934-build-output-timestamp
dara-abijo-adfa Jul 28, 2026
af1ee51
Merge branch 'stage' into ADFA-934-build-output-timestamp
dara-abijo-adfa Jul 29, 2026
760b745
feat(ADFA-934): Remove total deltas
dara-abijo-adfa Jul 31, 2026
17f3ad6
feat(ADFA-934): Left-align build output text
dara-abijo-adfa Jul 31, 2026
ef35dc6
Merge branch 'stage' into ADFA-934-build-output-timestamp
dara-abijo-adfa Jul 31, 2026
8e499ff
Merge branch 'stage' into ADFA-934-build-output-timestamp
dara-abijo-adfa Aug 3, 2026
55d7787
feat(ADFA-934): Set up output view options
dara-abijo-adfa Aug 3, 2026
99eeac7
feat(ADFA-934): Add view options action
dara-abijo-adfa Aug 3, 2026
71af3bd
feat(ADFA-934): Remove former view options chips
dara-abijo-adfa Aug 3, 2026
23f20e4
feat(ADFA-934): Implement view options popup
dara-abijo-adfa Aug 3, 2026
48f4aca
Merge branch 'stage' into ADFA-934-build-output-timestamp
dara-abijo-adfa Aug 4, 2026
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,7 +18,11 @@ package com.itsaky.androidide.fragments.output

import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.CheckedTextView
import android.widget.LinearLayout
import androidx.appcompat.widget.ListPopupWindow
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.itsaky.androidide.R
Expand All @@ -28,12 +32,14 @@ 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.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
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
Expand All @@ -43,7 +49,8 @@ import kotlinx.coroutines.withTimeoutOrNull

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

companion object {
Expand All @@ -61,6 +68,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
private val noMatchTracker = FilterNoMatchTracker()

Expand All @@ -75,6 +90,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 @@ -85,21 +101,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 Down Expand Up @@ -127,6 +153,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) requireContext().dpToPx(2f) else 0).toFloat())
}

override fun toggleFilterBar() {
val existing = filterBar
existing?.toggle() ?: createFilterBar()
Expand All @@ -152,6 +185,73 @@ class BuildOutputFragment :
this.searchLayout = searchLayout
}

private data class ViewOptionItem(
val title: String,
var isChecked: Boolean,
val onToggle: (Boolean) -> Unit,
)

override fun showViewOptions(anchorView: View) {
val context = anchorView.context
val options =
listOf(
ViewOptionItem(
title = context.getString(R.string.log_filter_line_numbers),
isChecked = buildOutputViewModel.showLineNumbers.value,
onToggle = { enabled ->
buildOutputViewModel.showLineNumbers.value = enabled
setLineNumbersEnabled(enabled)
},
),
ViewOptionItem(
title = context.getString(R.string.log_filter_timestamps),
isChecked = buildOutputViewModel.showTimestamps.value,
onToggle = { enabled ->
buildOutputViewModel.showTimestamps.value = enabled
},
),
ViewOptionItem(
title = context.getString(R.string.log_filter_deltas),
isChecked = buildOutputViewModel.showDeltas.value,
onToggle = { enabled ->
buildOutputViewModel.showDeltas.value = enabled
},
),
)

val adapter =
object : ArrayAdapter<String>(
context,
android.R.layout.simple_list_item_multiple_choice,
options.map { it.title },
) {
override fun getView(
position: Int,
convertView: View?,
parent: ViewGroup,
): View {
val view = super.getView(position, convertView, parent)
if (view is CheckedTextView) {
view.isChecked = options[position].isChecked
}
return view
}
}

val popup = ListPopupWindow(context)
popup.anchorView = anchorView
popup.setAdapter(adapter)
popup.width = context.dpToPx(200f)
popup.isModal = true
popup.setOnItemClickListener { _, _, position, _ ->
val item = options[position]
item.isChecked = !item.isChecked
item.onToggle(item.isChecked)
adapter.notifyDataSetChanged()
}
popup.show()
}

private fun createFilterBar(): LogFilterBarController? {
val stub = _binding?.filterBarStub ?: return null
val barBinding = LayoutLogFilterBarBinding.bind(stub.inflate())
Expand All @@ -162,7 +262,6 @@ class BuildOutputFragment :
initialText = buildOutputViewModel.filterText.value,
initialLevels = LogFilter.ALL_LEVELS,
onVisibilityChanged = {
// The cached snapshot is an O(1) stand-in for the session file's emptiness.
updateEmptyState(
isSourceEmpty = buildOutputViewModel.getCachedContentSnapshot().isEmpty(),
isFilterActive = isFilterActive,
Expand All @@ -175,8 +274,14 @@ class BuildOutputFragment :

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 content = BuildOutputViewModel.filterLines(window, query)
val isSourceEmpty = window.isBlank()
val isFilteredEmpty = content.isBlank()

Expand Down Expand Up @@ -231,6 +336,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++
noMatchTracker.reset()
buildOutputViewModel.clear()
super.clearOutput()
Expand Down Expand Up @@ -287,13 +399,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 @@ -305,13 +419,29 @@ 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
}
BuildOutputViewModel.filterLines(text, buildOutputViewModel.filterText.value)

withContext(Dispatchers.Main) {
updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
Expand All @@ -324,16 +454,20 @@ class BuildOutputFragment :
awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) })
}
if (layoutCompleted != null) {
// clearOutput() or renderFiltered() may have run since the file append.
if (editorGen == editorContentGeneration) {
appendBatch(visibleText)
emptyStateViewModel.setEmpty(false)
}
appendBatch(visibleText)
updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
} else {
// Timeout: defer append until layout is ready (same as restoreWindowFromViewModel)
val generationAtFlush = editorContentGeneration
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
editor?.run {
awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) })
editorContentMutex.withLock {
if (editorContentGeneration == generationAtFlush) {
if (editorGen == editorContentGeneration) {
appendBatch(visibleText)
updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ class LogFilterBarController(

init {
binding.levelChipsScroll.isVisible = showLevelChips

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

binding.filterInput.setText(initialText)
chipsByLevel.forEach { (level, chip) ->
chip.isChecked = level in initialLevels
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/

package com.itsaky.androidide.fragments.output

import android.view.View

/**
* Interface for output fragments that support toggling display view options.
*/
interface ViewOptionsOutputFragment {
/**
* Shows the view options popup menu anchored to [anchorView].
*/
fun showViewOptions(anchorView: View)
}
Loading
Loading