From 9c975793f06dd947bc955e340a1907f61c9ef112 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Fri, 24 Jul 2026 17:14:26 +0100 Subject: [PATCH 1/6] feat(ADFA-934): Enable gutter line numbers in build output editor --- .../fragments/output/NonEditableEditorFragment.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/NonEditableEditorFragment.java b/app/src/main/java/com/itsaky/androidide/fragments/output/NonEditableEditorFragment.java index e33f8aeb8e..f5b81c576d 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/NonEditableEditorFragment.java +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/NonEditableEditorFragment.java @@ -21,6 +21,7 @@ import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.blankj.utilcode.util.SizeUtils; import com.itsaky.androidide.R; import com.itsaky.androidide.databinding.FragmentNonEditableEditorBinding; import com.itsaky.androidide.editor.ui.IDEEditor; @@ -83,7 +84,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat getEmptyStateViewModel().setEmptyMessage(createEmptyStateMessage()); final var editor = getBinding().editor; editor.setEditable(false); - editor.setDividerWidth(0); + editor.setLineNumberEnabled(true); + editor.setDividerWidth(SizeUtils.dp2px(2f)); editor.setEditorLanguage(new EmptyLanguage()); editor.setWordwrap(true); editor.setUndoEnabled(false); From 1479dd18a236bc50e00a5831f460269f432b2b7a Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Fri, 24 Jul 2026 17:15:45 +0100 Subject: [PATCH 2/6] feat(ADFA-934): Implement time stamps, deltas and line numbers --- .../handlers/EditorBuildEventListener.kt | 440 ++++++++++-------- 1 file changed, 252 insertions(+), 188 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt index 8f1077e070..c05cdaecf2 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -1,188 +1,252 @@ -/* - * 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.handlers - -import com.itsaky.androidide.R -import com.itsaky.androidide.activities.editor.EditorHandlerActivity -import com.itsaky.androidide.plugins.manager.services.IdeBuildServiceImpl as IdeBuildService -import com.itsaky.androidide.preferences.internal.GeneralPreferences -import com.itsaky.androidide.projects.builder.BuildResult -import com.itsaky.androidide.projects.builder.LaunchResult -import com.itsaky.androidide.resources.R.string -import com.itsaky.androidide.services.builder.GradleBuildService -import com.itsaky.androidide.tooling.api.messages.result.BuildInfo -import com.itsaky.androidide.tooling.events.ProgressEvent -import com.itsaky.androidide.tooling.events.configuration.ProjectConfigurationStartEvent -import com.itsaky.androidide.tooling.events.task.TaskStartEvent -import com.itsaky.androidide.utils.flashError -import com.itsaky.androidide.utils.flashSuccess -import org.slf4j.LoggerFactory -import java.lang.ref.WeakReference - -/** - * Handles events received from [GradleBuildService] updates [EditorHandlerActivity]. - * @author Akash Yadav - */ -class EditorBuildEventListener : GradleBuildService.EventListener { - - private var lastStatusLine: String = "" - - private var enabled = true - private var activityReference: WeakReference = WeakReference(null) - - private val pluginBuildService by lazy { - try { - IdeBuildService.getInstance() - } catch (e: Exception) { - log.warn("Failed to get IdeBuildServiceImpl instance", e) - null - } - } - - companion object { - - private val log = LoggerFactory.getLogger(EditorBuildEventListener::class.java) - } - - private val _activity: EditorHandlerActivity? - get() = activityReference.get() - private val activity: EditorHandlerActivity - get() = checkNotNull(activityReference.get()) { "Activity reference has been destroyed!" } - - fun setActivity(activity: EditorHandlerActivity) { - this.activityReference = WeakReference(activity) - this.enabled = true - } - - fun release() { - activityReference.clear() - this.enabled = false - } - - override fun prepareBuild(buildInfo: BuildInfo) { - checkActivity("prepareBuild") ?: return - - pluginBuildService?.setBuildInProgress(true) - - val isFirstBuild = GeneralPreferences.isFirstBuild - activity - .setStatus( - activity.getString(if (isFirstBuild) string.preparing_first else string.preparing) - ) - - if (isFirstBuild) { - activity.showFirstBuildNotice() - } - - activity.editorViewModel.isBuildInProgress = true - activity.content.bottomSheet.clearBuildOutput() - - if (buildInfo.tasks.isNotEmpty()) { - activity.content.bottomSheet.appendBuildOut( - activity.getString(R.string.title_run_tasks) + " : " + buildInfo.tasks) - } - } - - override fun onBuildSuccessful(tasks: List) { - val act = checkActivity("onBuildSuccessful") ?: return - - pluginBuildService?.notifyBuildFinished() - - analyzeCurrentFile() - - GeneralPreferences.isFirstBuild = false - act.editorViewModel.isBuildInProgress = false - act.flashSuccess(R.string.build_status_sucess) - - val message = - if (lastStatusLine.contains("BUILD SUCCESSFUL")) lastStatusLine else "Build completed successfully." - - // Create a simulated LaunchResult because the build succeeded. - // We assume the action that triggered this was a "build and run". - val launchResult = LaunchResult(isSuccess = true, message = "Launch command issued.") - - // Pass the new launchResult to the BuildResult constructor - act.notifyBuildResult( - BuildResult( - isSuccess = true, - message = message, - launchResult = launchResult - ) - ) - - lastStatusLine = "" - } - - override fun onProgressEvent(event: ProgressEvent) { - checkActivity("onProgressEvent") ?: return - - if (event is ProjectConfigurationStartEvent || event is TaskStartEvent) { - activity.setStatus(event.descriptor.displayName) - } - } - - override fun onBuildFailed(tasks: List) { - val act = checkActivity("onBuildFailed") ?: return - - analyzeCurrentFile() - GeneralPreferences.isFirstBuild = false - act.editorViewModel.isBuildInProgress = false - act.flashError(R.string.build_status_failed) - - val message = - if (lastStatusLine.contains("BUILD FAILED")) lastStatusLine else "Build failed. Check build output for details." - - pluginBuildService?.notifyBuildFailed(message) - - act.notifyBuildResult(BuildResult(isSuccess = false, message = message, launchResult = null)) - - lastStatusLine = "" - } - - override fun onOutput(line: String?) { - val act = checkActivity("onOutput") ?: return - line?.let { - act.appendBuildOutput(it) - if (it.contains("BUILD SUCCESSFUL") || it.contains("BUILD FAILED")) { - act.setStatus(it) - lastStatusLine = it - } - } - } - - private fun analyzeCurrentFile() { - checkActivity("analyzeCurrentFile") ?: return - - val editorView = _activity?.getCurrentEditor() - if (editorView != null) { - val editor = editorView.editor - editor?.analyze() - } - } - - private fun checkActivity(action: String): EditorHandlerActivity? { - if (!enabled) return null - - return _activity.also { - if (it == null) { - log.warn("[{}] Activity reference has been destroyed!", action) - enabled = false - } - } - } -} +/* + * 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.handlers + +import com.itsaky.androidide.R +import com.itsaky.androidide.activities.editor.EditorHandlerActivity +import com.itsaky.androidide.plugins.manager.services.IdeBuildServiceImpl as IdeBuildService +import com.itsaky.androidide.preferences.internal.GeneralPreferences +import com.itsaky.androidide.projects.builder.BuildResult +import com.itsaky.androidide.projects.builder.LaunchResult +import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.services.builder.GradleBuildService +import com.itsaky.androidide.tooling.api.messages.result.BuildInfo +import com.itsaky.androidide.tooling.events.ProgressEvent +import com.itsaky.androidide.tooling.events.configuration.ProjectConfigurationStartEvent +import com.itsaky.androidide.tooling.events.task.TaskStartEvent +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashSuccess +import org.slf4j.LoggerFactory +import java.lang.ref.WeakReference +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit + +/** + * Handles events received from [GradleBuildService] updates [EditorHandlerActivity]. + * @author Akash Yadav + */ +class EditorBuildEventListener : GradleBuildService.EventListener { + +private var lastStatusLine: String = "" + +private var buildStartTimeMs: Long = 0L +private var lastOutputTimeMs: Long = 0L +private var lineCounter: Int = 1 + +private val timestampFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + +private var enabled = true +private var activityReference: WeakReference = WeakReference(null) + +private val pluginBuildService by lazy { + try { + IdeBuildService.getInstance() + } catch (e: Exception) { + log.warn("Failed to get IdeBuildServiceImpl instance", e) + null + } +} + +companion object { + + private val log = LoggerFactory.getLogger(EditorBuildEventListener::class.java) +} + +private val _activity: EditorHandlerActivity? + get() = activityReference.get() +private val activity: EditorHandlerActivity + get() = checkNotNull(activityReference.get()) { "Activity reference has been destroyed!" } + +fun setActivity(activity: EditorHandlerActivity) { + this.activityReference = WeakReference(activity) + this.enabled = true +} + +fun release() { + activityReference.clear() + this.enabled = false +} + +override fun prepareBuild(buildInfo: BuildInfo) { + checkActivity("prepareBuild") ?: return + + pluginBuildService?.setBuildInProgress(true) + + val isFirstBuild = GeneralPreferences.isFirstBuild + activity + .setStatus( + activity.getString(if (isFirstBuild) string.preparing_first else string.preparing) + ) + + if (isFirstBuild) { + activity.showFirstBuildNotice() + } + + resetBuildTimers() + + activity.editorViewModel.isBuildInProgress = true + activity.content.bottomSheet.clearBuildOutput() + + if (buildInfo.tasks.isNotEmpty()) { + onOutput( + activity.getString(R.string.title_run_tasks) + " : " + buildInfo.tasks + ) + } +} + +private fun resetBuildTimers() { + buildStartTimeMs = System.currentTimeMillis() + lastOutputTimeMs = buildStartTimeMs + lineCounter = 1 +} + +override fun onBuildSuccessful(tasks: List) { + val act = checkActivity("onBuildSuccessful") ?: return + + pluginBuildService?.notifyBuildFinished() + + analyzeCurrentFile() + + GeneralPreferences.isFirstBuild = false + act.editorViewModel.isBuildInProgress = false + act.flashSuccess(R.string.build_status_sucess) + + val message = + if (lastStatusLine.contains("BUILD SUCCESSFUL")) lastStatusLine else "Build completed successfully." + + // Create a simulated LaunchResult because the build succeeded. + // We assume the action that triggered this was a "build and run". + val launchResult = LaunchResult(isSuccess = true, message = "Launch command issued.") + + // Pass the new launchResult to the BuildResult constructor + act.notifyBuildResult( + BuildResult( + isSuccess = true, + message = message, + launchResult = launchResult + ) + ) + + lastStatusLine = "" +} + +override fun onProgressEvent(event: ProgressEvent) { + checkActivity("onProgressEvent") ?: return + + if (event is ProjectConfigurationStartEvent || event is TaskStartEvent) { + activity.setStatus(event.descriptor.displayName) + } +} + +override fun onBuildFailed(tasks: List) { + val act = checkActivity("onBuildFailed") ?: return + + analyzeCurrentFile() + GeneralPreferences.isFirstBuild = false + act.editorViewModel.isBuildInProgress = false + act.flashError(R.string.build_status_failed) + + val message = + if (lastStatusLine.contains("BUILD FAILED")) lastStatusLine else "Build failed. Check build output for details." + + pluginBuildService?.notifyBuildFailed(message) + + act.notifyBuildResult(BuildResult(isSuccess = false, message = message, launchResult = null)) + + lastStatusLine = "" +} + +override fun onOutput(line: String?) { + val act = checkActivity("onOutput") ?: return + line?.let { raw -> + val formattedOutput = formatOutput(raw) + act.appendBuildOutput(formattedOutput) + if (raw.contains("BUILD SUCCESSFUL") || raw.contains("BUILD FAILED")) { + act.setStatus(raw) + lastStatusLine = raw + } + } +} + +private fun formatOutput(raw: String): String { + val now = System.currentTimeMillis() + if (buildStartTimeMs == 0L) { + buildStartTimeMs = now + lastOutputTimeMs = now + } + + val totalDeltaMs = now - buildStartTimeMs + val stepDeltaMs = now - lastOutputTimeMs + lastOutputTimeMs = now + + val timeStr = timestampFormat.format(Date(now)) + val totalMins = TimeUnit.MILLISECONDS.toMinutes(totalDeltaMs) + val totalSecs = TimeUnit.MILLISECONDS.toSeconds(totalDeltaMs) % 60 + val totalMillis = totalDeltaMs % 1000 + val totalDeltaStr = String.format(Locale.US, "+%02d:%02d.%03d", totalMins, totalSecs, totalMillis) + val stepDeltaStr = String.format(Locale.US, "Δ%dms", stepDeltaMs) + + val lines = raw.split("\n") + val builder = StringBuilder() + for (i in lines.indices) { + val l = lines[i] + if (i == lines.lastIndex && l.isEmpty() && lines.size > 1) { + continue + } + val lineNo = lineCounter++ + builder.append( + String.format( + Locale.US, + "%5d | [%s] [%s] (%s) %s", + lineNo, + timeStr, + totalDeltaStr, + stepDeltaStr, + l + ) + ) + if (i < lines.size - 1 || raw.endsWith("\n")) { + builder.append("\n") + } + } + return builder.toString() +} + +private fun analyzeCurrentFile() { + checkActivity("analyzeCurrentFile") ?: return + + val editorView = _activity?.getCurrentEditor() + if (editorView != null) { + val editor = editorView.editor + editor?.analyze() + } +} + +private fun checkActivity(action: String): EditorHandlerActivity? { + if (!enabled) return null + + return _activity.also { + if (it == null) { + log.warn("[{}] Activity reference has been destroyed!", action) + enabled = false + } + } +} +} From e056cdfddc018563dbd6e8712f4ad80baa11fea2 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Fri, 24 Jul 2026 18:01:53 +0100 Subject: [PATCH 3/6] feat(ADFA-934): Handle formatted lines correctly --- .../itsaky/androidide/fragments/output/BuildOutputFragment.kt | 3 +++ .../com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt | 1 + 2 files changed, 4 insertions(+) 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 c14dda88ca..ce4b7121f9 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 @@ -196,6 +196,9 @@ 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) { + // Drain and discard any pending log lines queued prior to clear + } buildOutputViewModel.clear() super.clearOutput() } 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 505041cc8f..f53fceebb3 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt @@ -145,6 +145,7 @@ class BuildOutputViewModel( */ fun clear() { lock.withLock { + filterText.value = "" cachedContentSnapshot = "" try { if (sessionFile.exists()) { From 7b64feec912d7947df42a265cc1395297d925537 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Mon, 27 Jul 2026 23:33:41 +0100 Subject: [PATCH 4/6] feat(ADFA-934): Add toggle for timestamps --- .../fragments/output/BuildOutputFragment.kt | 63 ++++++++++++++++--- .../output/LogFilterBarController.kt | 34 +++++++++- .../handlers/EditorBuildEventListener.kt | 26 ++++---- .../viewmodel/BuildOutputViewModel.kt | 39 ++++++++++-- .../main/res/layout/layout_log_filter_bar.xml | 30 +++++++++ resources/src/main/res/values/strings.xml | 3 + 6 files changed, 164 insertions(+), 31 deletions(-) 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 ce4b7121f9..c7477d0abc 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 @@ -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 @@ -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 @@ -79,21 +81,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) } } } } - /** 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) @@ -117,6 +129,15 @@ class BuildOutputFragment : searchLayout?.beginSearchMode() } + /** + * Dynamically toggles gutter line numbers in the Build Output editor. + */ + fun setLineNumbersEnabled(enabled: Boolean) { + val ed = editor ?: return + ed.setLineNumberEnabled(enabled) + ed.setDividerWidth((if (enabled) SizeUtils.dp2px(2f) else 0).toFloat()) + } + override fun toggleFilterBar() { val existing = filterBar existing?.toggle() ?: createFilterBar() @@ -149,8 +170,21 @@ class BuildOutputFragment : binding = barBinding, coroutineScope = viewLifecycleOwner.lifecycleScope, showLevelChips = false, + showOptionChips = true, initialText = buildOutputViewModel.filterText.value, initialLevels = LogFilter.ALL_LEVELS, + initialLineNumbersEnabled = editor?.isLineNumberEnabled ?: true, + initialTimestampsEnabled = buildOutputViewModel.showTimestamps.value, + initialDeltasEnabled = buildOutputViewModel.showDeltas.value, + onLineNumbersToggled = { enabled -> + setLineNumbersEnabled(enabled) + }, + onTimestampsToggled = { enabled -> + buildOutputViewModel.showTimestamps.value = enabled + }, + onDeltasToggled = { enabled -> + buildOutputViewModel.showDeltas.value = enabled + }, ) { _, text -> buildOutputViewModel.filterText.value = text.trim() }.also { filterBar = it } @@ -158,7 +192,12 @@ class BuildOutputFragment : 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 @@ -196,8 +235,9 @@ 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) { - // Drain and discard any pending log lines queued prior to clear + while (true) { + val result = logChannel.tryReceive() + if (!result.isSuccess) break } buildOutputViewModel.clear() super.clearOutput() @@ -275,7 +315,12 @@ class BuildOutputFragment : // 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 } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt index 6c27f22a34..8bc3de45a8 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt @@ -35,12 +35,19 @@ import java.util.EnumSet * metadata, like the build output, only get the text filter). * @param onFilterChanged Called with the enabled levels and the (untrimmed) filter text. */ -class LogFilterBarController( +class LogFilterBarController @JvmOverloads constructor( private val binding: LayoutLogFilterBarBinding, coroutineScope: CoroutineScope, showLevelChips: Boolean, + showOptionChips: Boolean = false, initialText: String, initialLevels: Set, + 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, text: String) -> Unit, ) { companion object { @@ -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 diff --git a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt index c05cdaecf2..42822bc919 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -46,9 +46,11 @@ class EditorBuildEventListener : GradleBuildService.EventListener { private var lastStatusLine: String = "" +var showTimestamps: Boolean = true +var showDeltas: Boolean = true + private var buildStartTimeMs: Long = 0L private var lastOutputTimeMs: Long = 0L -private var lineCounter: Int = 1 private val timestampFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) @@ -114,7 +116,6 @@ override fun prepareBuild(buildInfo: BuildInfo) { private fun resetBuildTimers() { buildStartTimeMs = System.currentTimeMillis() lastOutputTimeMs = buildStartTimeMs - lineCounter = 1 } override fun onBuildSuccessful(tasks: List) { @@ -210,18 +211,15 @@ private fun formatOutput(raw: String): String { if (i == lines.lastIndex && l.isEmpty() && lines.size > 1) { continue } - val lineNo = lineCounter++ - builder.append( - String.format( - Locale.US, - "%5d | [%s] [%s] (%s) %s", - lineNo, - timeStr, - totalDeltaStr, - stepDeltaStr, - l - ) - ) + val prefix = buildString { + if (showTimestamps) { + append("[").append(timeStr).append("] ") + } + if (showDeltas) { + append("[").append(totalDeltaStr).append("] (").append(stepDeltaStr).append(") ") + } + } + builder.append(prefix).append(l) if (i < lines.size - 1 || raw.endsWith("\n")) { builder.append("\n") } 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 f53fceebb3..0236a71e89 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt @@ -48,6 +48,12 @@ class BuildOutputViewModel( */ val filterText = MutableStateFlow("") + /** Toggle for showing wall-clock timestamps `[HH:mm:ss.SSS]` in editor view. */ + val showTimestamps = MutableStateFlow(true) + + /** Toggle for showing total and step time deltas `[+mm:ss.SSS] (ΔXms)` in editor view. */ + val showDeltas = MutableStateFlow(true) + /** * Thread-safe snapshot of content for synchronous [getShareableContent] without blocking. * Updated on [append] and [clear]; primed on restore via [setCachedSnapshot]. @@ -181,19 +187,40 @@ class BuildOutputViewModel( } companion object { + private val TIMESTAMP_REGEX = Regex("""\[\d{2}:\d{2}:\d{2}\.\d{3}\]\s*""") + private val DELTA_REGEX = Regex("""\[\+\d{2}:\d{2}\.\d{3}\]\s*\([\Delta\u0394]\d+ms\)\s*""") + + fun formatLineForDisplay( + line: String, + showTimestamps: Boolean, + showDeltas: Boolean, + ): String { + var result = line + if (!showTimestamps) { + result = result.replace(TIMESTAMP_REGEX, "") + } + if (!showDeltas) { + result = result.replace(DELTA_REGEX, "") + } + return result + } + /** - * Returns only the lines of [content] containing [query] (case-insensitive), each terminated - * with a newline. Returns [content] unchanged when [query] is empty. + * Returns only the lines of [content] containing [query] (case-insensitive), formatted according + * to [showTimestamps] and [showDeltas], each terminated with a newline. */ fun filterLines( content: String, query: String, + showTimestamps: Boolean = true, + showDeltas: Boolean = true, ): String { - if (query.isEmpty() || content.isEmpty()) return content + if (content.isEmpty()) return content return buildString { - for (line in content.lineSequence()) { - if (line.contains(query, ignoreCase = true)) { - append(line).append('\n') + for (rawLine in content.lineSequence()) { + if (query.isEmpty() || rawLine.contains(query, ignoreCase = true)) { + val displayLine = formatLineForDisplay(rawLine, showTimestamps, showDeltas) + append(displayLine).append('\n') } } } diff --git a/app/src/main/res/layout/layout_log_filter_bar.xml b/app/src/main/res/layout/layout_log_filter_bar.xml index ebe9378ef9..581ab66a84 100644 --- a/app/src/main/res/layout/layout_log_filter_bar.xml +++ b/app/src/main/res/layout/layout_log_filter_bar.xml @@ -63,6 +63,36 @@ app:chipSpacingHorizontal="6dp" app:singleLine="true"> + + + + + + Info Warning Error + Line numbers + Timestamps + Time deltas Search in output Filter output "Build the application or run a task to see its build output here. " From 3dc82ea44820d25850330218815b664c8d5e136f Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Tue, 28 Jul 2026 00:10:30 +0100 Subject: [PATCH 5/6] fix(ADFA-934): Code review fixes --- .../fragments/output/BuildOutputFragment.kt | 53 ++-- .../output/LogFilterBarController.kt | 2 +- .../output/NonEditableEditorFragment.java | 4 +- .../handlers/EditorBuildEventListener.kt | 295 ++++++++---------- .../viewmodel/BuildOutputViewModel.kt | 73 ++++- .../viewmodel/BuildOutputFilterTest.kt | 85 +++++ 6 files changed, 310 insertions(+), 202 deletions(-) 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 c7477d0abc..5cf5750005 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 @@ -62,6 +62,9 @@ class BuildOutputFragment : // so a re-render never misses or duplicates a concurrently flushed batch. private val editorContentMutex = Mutex() + // 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( @@ -71,6 +74,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 { @@ -129,12 +133,10 @@ class BuildOutputFragment : searchLayout?.beginSearchMode() } - /** - * Dynamically toggles gutter line numbers in the Build Output editor. - */ 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()) } @@ -173,10 +175,11 @@ class BuildOutputFragment : showOptionChips = true, initialText = buildOutputViewModel.filterText.value, initialLevels = LogFilter.ALL_LEVELS, - initialLineNumbersEnabled = editor?.isLineNumberEnabled ?: true, + initialLineNumbersEnabled = buildOutputViewModel.showLineNumbers.value, initialTimestampsEnabled = buildOutputViewModel.showTimestamps.value, initialDeltasEnabled = buildOutputViewModel.showDeltas.value, onLineNumbersToggled = { enabled -> + buildOutputViewModel.showLineNumbers.value = enabled setLineNumbersEnabled(enabled) }, onTimestampsToggled = { enabled -> @@ -192,12 +195,13 @@ 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 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 @@ -235,10 +239,12 @@ 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 (true) { - val result = logChannel.tryReceive() - if (!result.isSuccess) break + 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. + editorContentGeneration++ buildOutputViewModel.clear() super.clearOutput() } @@ -291,13 +297,14 @@ class BuildOutputFragment : private suspend fun processLogs() = with(StringBuilder()) { for (firstLine in logChannel) { + val generationAtDrain = editorContentGeneration append(firstLine.ensureNewline()) logChannel.drainTo(this) if (isNotEmpty()) { val batchText = toString() clear() - flushToEditor(batchText) + flushToEditor(batchText, generationAtDrain) } } } @@ -309,8 +316,14 @@ 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, + generation: Int, + ) { editorContentMutex.withLock { + // A clear (new build) after this batch was drained invalidates it. + if (generation != editorContentGeneration) return + buildOutputViewModel.append(text) // The session file always gets the full text; the editor only shows matching lines @@ -332,16 +345,18 @@ class BuildOutputFragment : awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) } if (layoutCompleted != null) { - appendBatch(visibleText) - emptyStateViewModel.setEmpty(false) + // clearOutput() (main thread, mutex-free) may have run since the file append. + if (generation == 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 (editorContentGeneration == generation) { appendBatch(visibleText) emptyStateViewModel.setEmpty(false) } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt index 8bc3de45a8..73bf2b0aeb 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt @@ -35,7 +35,7 @@ import java.util.EnumSet * metadata, like the build output, only get the text filter). * @param onFilterChanged Called with the enabled levels and the (untrimmed) filter text. */ -class LogFilterBarController @JvmOverloads constructor( +class LogFilterBarController( private val binding: LayoutLogFilterBarBinding, coroutineScope: CoroutineScope, showLevelChips: Boolean, diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/NonEditableEditorFragment.java b/app/src/main/java/com/itsaky/androidide/fragments/output/NonEditableEditorFragment.java index f5b81c576d..e33f8aeb8e 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/NonEditableEditorFragment.java +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/NonEditableEditorFragment.java @@ -21,7 +21,6 @@ import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.blankj.utilcode.util.SizeUtils; import com.itsaky.androidide.R; import com.itsaky.androidide.databinding.FragmentNonEditableEditorBinding; import com.itsaky.androidide.editor.ui.IDEEditor; @@ -84,8 +83,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat getEmptyStateViewModel().setEmptyMessage(createEmptyStateMessage()); final var editor = getBinding().editor; editor.setEditable(false); - editor.setLineNumberEnabled(true); - editor.setDividerWidth(SizeUtils.dp2px(2f)); + editor.setDividerWidth(0); editor.setEditorLanguage(new EmptyLanguage()); editor.setWordwrap(true); editor.setUndoEnabled(false); diff --git a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt index 42822bc919..c546299fa3 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -19,7 +19,6 @@ package com.itsaky.androidide.handlers import com.itsaky.androidide.R import com.itsaky.androidide.activities.editor.EditorHandlerActivity -import com.itsaky.androidide.plugins.manager.services.IdeBuildServiceImpl as IdeBuildService import com.itsaky.androidide.preferences.internal.GeneralPreferences import com.itsaky.androidide.projects.builder.BuildResult import com.itsaky.androidide.projects.builder.LaunchResult @@ -31,220 +30,190 @@ import com.itsaky.androidide.tooling.events.configuration.ProjectConfigurationSt import com.itsaky.androidide.tooling.events.task.TaskStartEvent import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.viewmodel.BuildOutputViewModel import org.slf4j.LoggerFactory import java.lang.ref.WeakReference -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.concurrent.TimeUnit +import com.itsaky.androidide.plugins.manager.services.IdeBuildServiceImpl as IdeBuildService /** * Handles events received from [GradleBuildService] updates [EditorHandlerActivity]. * @author Akash Yadav */ class EditorBuildEventListener : GradleBuildService.EventListener { + private var lastStatusLine: String = "" -private var lastStatusLine: String = "" + private var buildStartTimeMs: Long = System.currentTimeMillis() + private var lastOutputTimeMs: Long = buildStartTimeMs -var showTimestamps: Boolean = true -var showDeltas: Boolean = true + private var enabled = true + private var activityReference: WeakReference = WeakReference(null) -private var buildStartTimeMs: Long = 0L -private var lastOutputTimeMs: Long = 0L + private val pluginBuildService by lazy { + try { + IdeBuildService.getInstance() + } catch (e: Exception) { + log.warn("Failed to get IdeBuildServiceImpl instance", e) + null + } + } -private val timestampFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + companion object { + private val log = LoggerFactory.getLogger(EditorBuildEventListener::class.java) + } -private var enabled = true -private var activityReference: WeakReference = WeakReference(null) + private val activityOrNull: EditorHandlerActivity? + get() = activityReference.get() + private val activity: EditorHandlerActivity + get() = checkNotNull(activityReference.get()) { "Activity reference has been destroyed!" } -private val pluginBuildService by lazy { - try { - IdeBuildService.getInstance() - } catch (e: Exception) { - log.warn("Failed to get IdeBuildServiceImpl instance", e) - null + fun setActivity(activity: EditorHandlerActivity) { + this.activityReference = WeakReference(activity) + this.enabled = true } -} -companion object { + fun release() { + activityReference.clear() + this.enabled = false + } - private val log = LoggerFactory.getLogger(EditorBuildEventListener::class.java) -} + override fun prepareBuild(buildInfo: BuildInfo) { + checkActivity("prepareBuild") ?: return -private val _activity: EditorHandlerActivity? - get() = activityReference.get() -private val activity: EditorHandlerActivity - get() = checkNotNull(activityReference.get()) { "Activity reference has been destroyed!" } + pluginBuildService?.setBuildInProgress(true) -fun setActivity(activity: EditorHandlerActivity) { - this.activityReference = WeakReference(activity) - this.enabled = true -} + val isFirstBuild = GeneralPreferences.isFirstBuild + activity + .setStatus( + activity.getString(if (isFirstBuild) string.preparing_first else string.preparing), + ) -fun release() { - activityReference.clear() - this.enabled = false -} - -override fun prepareBuild(buildInfo: BuildInfo) { - checkActivity("prepareBuild") ?: return + if (isFirstBuild) { + activity.showFirstBuildNotice() + } - pluginBuildService?.setBuildInProgress(true) + resetBuildTimers() - val isFirstBuild = GeneralPreferences.isFirstBuild - activity - .setStatus( - activity.getString(if (isFirstBuild) string.preparing_first else string.preparing) - ) + activity.editorViewModel.isBuildInProgress = true + activity.content.bottomSheet.clearBuildOutput() - if (isFirstBuild) { - activity.showFirstBuildNotice() + if (buildInfo.tasks.isNotEmpty()) { + onOutput( + activity.getString(R.string.title_run_tasks) + " : " + buildInfo.tasks, + ) + } } - resetBuildTimers() - - activity.editorViewModel.isBuildInProgress = true - activity.content.bottomSheet.clearBuildOutput() - - if (buildInfo.tasks.isNotEmpty()) { - onOutput( - activity.getString(R.string.title_run_tasks) + " : " + buildInfo.tasks - ) + private fun resetBuildTimers() { + buildStartTimeMs = System.currentTimeMillis() + lastOutputTimeMs = buildStartTimeMs } -} - -private fun resetBuildTimers() { - buildStartTimeMs = System.currentTimeMillis() - lastOutputTimeMs = buildStartTimeMs -} - -override fun onBuildSuccessful(tasks: List) { - val act = checkActivity("onBuildSuccessful") ?: return - pluginBuildService?.notifyBuildFinished() + override fun onBuildSuccessful(tasks: List) { + val act = checkActivity("onBuildSuccessful") ?: return - analyzeCurrentFile() + pluginBuildService?.notifyBuildFinished() - GeneralPreferences.isFirstBuild = false - act.editorViewModel.isBuildInProgress = false - act.flashSuccess(R.string.build_status_sucess) + analyzeCurrentFile() - val message = - if (lastStatusLine.contains("BUILD SUCCESSFUL")) lastStatusLine else "Build completed successfully." + GeneralPreferences.isFirstBuild = false + act.editorViewModel.isBuildInProgress = false + act.flashSuccess(R.string.build_status_sucess) - // Create a simulated LaunchResult because the build succeeded. - // We assume the action that triggered this was a "build and run". - val launchResult = LaunchResult(isSuccess = true, message = "Launch command issued.") + val message = + if (lastStatusLine.contains("BUILD SUCCESSFUL")) lastStatusLine else "Build completed successfully." - // Pass the new launchResult to the BuildResult constructor - act.notifyBuildResult( - BuildResult( - isSuccess = true, - message = message, - launchResult = launchResult - ) - ) + // Create a simulated LaunchResult because the build succeeded. + // We assume the action that triggered this was a "build and run". + val launchResult = LaunchResult(isSuccess = true, message = "Launch command issued.") - lastStatusLine = "" -} - -override fun onProgressEvent(event: ProgressEvent) { - checkActivity("onProgressEvent") ?: return + // Pass the new launchResult to the BuildResult constructor + act.notifyBuildResult( + BuildResult( + isSuccess = true, + message = message, + launchResult = launchResult, + ), + ) - if (event is ProjectConfigurationStartEvent || event is TaskStartEvent) { - activity.setStatus(event.descriptor.displayName) + lastStatusLine = "" } -} -override fun onBuildFailed(tasks: List) { - val act = checkActivity("onBuildFailed") ?: return + override fun onProgressEvent(event: ProgressEvent) { + checkActivity("onProgressEvent") ?: return - analyzeCurrentFile() - GeneralPreferences.isFirstBuild = false - act.editorViewModel.isBuildInProgress = false - act.flashError(R.string.build_status_failed) + if (event is ProjectConfigurationStartEvent || event is TaskStartEvent) { + activity.setStatus(event.descriptor.displayName) + } + } - val message = - if (lastStatusLine.contains("BUILD FAILED")) lastStatusLine else "Build failed. Check build output for details." + override fun onBuildFailed(tasks: List) { + val act = checkActivity("onBuildFailed") ?: return - pluginBuildService?.notifyBuildFailed(message) + analyzeCurrentFile() + GeneralPreferences.isFirstBuild = false + act.editorViewModel.isBuildInProgress = false + act.flashError(R.string.build_status_failed) - act.notifyBuildResult(BuildResult(isSuccess = false, message = message, launchResult = null)) + val message = + if (lastStatusLine.contains("BUILD FAILED")) lastStatusLine else "Build failed. Check build output for details." - lastStatusLine = "" -} + pluginBuildService?.notifyBuildFailed(message) -override fun onOutput(line: String?) { - val act = checkActivity("onOutput") ?: return - line?.let { raw -> - val formattedOutput = formatOutput(raw) - act.appendBuildOutput(formattedOutput) - if (raw.contains("BUILD SUCCESSFUL") || raw.contains("BUILD FAILED")) { - act.setStatus(raw) - lastStatusLine = raw - } - } -} + act.notifyBuildResult(BuildResult(isSuccess = false, message = message, launchResult = null)) -private fun formatOutput(raw: String): String { - val now = System.currentTimeMillis() - if (buildStartTimeMs == 0L) { - buildStartTimeMs = now - lastOutputTimeMs = now + lastStatusLine = "" } - val totalDeltaMs = now - buildStartTimeMs - val stepDeltaMs = now - lastOutputTimeMs - lastOutputTimeMs = now - - val timeStr = timestampFormat.format(Date(now)) - val totalMins = TimeUnit.MILLISECONDS.toMinutes(totalDeltaMs) - val totalSecs = TimeUnit.MILLISECONDS.toSeconds(totalDeltaMs) % 60 - val totalMillis = totalDeltaMs % 1000 - val totalDeltaStr = String.format(Locale.US, "+%02d:%02d.%03d", totalMins, totalSecs, totalMillis) - val stepDeltaStr = String.format(Locale.US, "Δ%dms", stepDeltaMs) - - val lines = raw.split("\n") - val builder = StringBuilder() - for (i in lines.indices) { - val l = lines[i] - if (i == lines.lastIndex && l.isEmpty() && lines.size > 1) { - continue - } - val prefix = buildString { - if (showTimestamps) { - append("[").append(timeStr).append("] ") + override fun onOutput(line: String?) { + val act = checkActivity("onOutput") ?: return + line?.let { raw -> + val formattedOutput = formatOutput(raw) + act.appendBuildOutput(formattedOutput) + if (raw.contains("BUILD SUCCESSFUL") || raw.contains("BUILD FAILED")) { + act.setStatus(raw) + lastStatusLine = raw + } } - if (showDeltas) { - append("[").append(totalDeltaStr).append("] (").append(stepDeltaStr).append(") ") - } - } - builder.append(prefix).append(l) - if (i < lines.size - 1 || raw.endsWith("\n")) { - builder.append("\n") } + + /** + * Prefixes every non-blank line of [raw] with the timing prefix. Blank lines are kept + * unprefixed so separator lines stay blank, and the trailing newline is preserved as-is. + */ + private fun formatOutput(raw: String): String { + val now = System.currentTimeMillis() + val totalDeltaMs = now - buildStartTimeMs + val stepDeltaMs = now - lastOutputTimeMs + lastOutputTimeMs = now + + val prefix = BuildOutputViewModel.formatLinePrefix(now, totalDeltaMs, stepDeltaMs) + val hadTrailingNewline = raw.endsWith("\n") + val body = if (hadTrailingNewline) raw.dropLast(1) else raw + val prefixed = + body.lineSequence().joinToString("\n") { line -> + if (line.isEmpty()) line else prefix + line + } + return if (hadTrailingNewline) prefixed + "\n" else prefixed } - return builder.toString() -} -private fun analyzeCurrentFile() { - checkActivity("analyzeCurrentFile") ?: return + private fun analyzeCurrentFile() { + checkActivity("analyzeCurrentFile") ?: return - val editorView = _activity?.getCurrentEditor() - if (editorView != null) { - val editor = editorView.editor - editor?.analyze() + val editorView = activityOrNull?.getCurrentEditor() + if (editorView != null) { + val editor = editorView.editor + editor?.analyze() + } } -} -private fun checkActivity(action: String): EditorHandlerActivity? { - if (!enabled) return null + private fun checkActivity(action: String): EditorHandlerActivity? { + if (!enabled) return null - return _activity.also { - if (it == null) { - log.warn("[{}] Activity reference has been destroyed!", action) - enabled = false - } + return activityOrNull.also { + if (it == null) { + log.warn("[{}] Activity reference has been destroyed!", action) + enabled = false + } + } } } -} 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 0236a71e89..e6aa967aa1 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt @@ -25,6 +25,11 @@ import java.io.File import java.io.FileOutputStream import java.io.RandomAccessFile import java.nio.charset.StandardCharsets +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import java.util.concurrent.TimeUnit import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock import kotlin.math.max @@ -54,6 +59,9 @@ class BuildOutputViewModel( /** Toggle for showing total and step time deltas `[+mm:ss.SSS] (ΔXms)` in editor view. */ val showDeltas = MutableStateFlow(true) + /** Toggle for showing gutter line numbers in editor view. */ + val showLineNumbers = MutableStateFlow(true) + /** * Thread-safe snapshot of content for synchronous [getShareableContent] without blocking. * Updated on [append] and [clear]; primed on restore via [setCachedSnapshot]. @@ -151,7 +159,6 @@ class BuildOutputViewModel( */ fun clear() { lock.withLock { - filterText.value = "" cachedContentSnapshot = "" try { if (sessionFile.exists()) { @@ -187,27 +194,58 @@ class BuildOutputViewModel( } companion object { - private val TIMESTAMP_REGEX = Regex("""\[\d{2}:\d{2}:\d{2}\.\d{3}\]\s*""") - private val DELTA_REGEX = Regex("""\[\+\d{2}:\d{2}\.\d{3}\]\s*\([\Delta\u0394]\d+ms\)\s*""") + // 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 = + Regex("""^(\[\d{2}:\d{2}:\d{2}\.\d{3}\] )(\[\+\d{2,}:\d{2}\.\d{3}\] \(\u0394\d+ms\) )""") + + private val PREFIX_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss.SSS") + /** + * Formats the timing prefix written before every build output line: + * `[HH:mm:ss.SSS] [+mm:ss.SSS] (\u0394Nms) `. + */ + fun formatLinePrefix( + nowMs: Long, + totalDeltaMs: Long, + stepDeltaMs: Long, + ): String { + val time = + PREFIX_TIME_FORMAT.format(Instant.ofEpochMilli(nowMs).atZone(ZoneId.systemDefault())) + val totalMins = TimeUnit.MILLISECONDS.toMinutes(totalDeltaMs) + val totalSecs = TimeUnit.MILLISECONDS.toSeconds(totalDeltaMs) % 60 + val totalMillis = totalDeltaMs % 1000 + return String.format( + Locale.US, + "[%s] [+%02d:%02d.%03d] (\u0394%dms) ", + time, + totalMins, + totalSecs, + totalMillis, + stepDeltaMs, + ) + } + + /** Rebuilds [line] with the timestamp and/or delta part of its prefix hidden. */ fun formatLineForDisplay( line: String, showTimestamps: Boolean, showDeltas: Boolean, ): String { - var result = line - if (!showTimestamps) { - result = result.replace(TIMESTAMP_REGEX, "") - } - if (!showDeltas) { - result = result.replace(DELTA_REGEX, "") + if (showTimestamps && showDeltas) return line + val match = PREFIX_REGEX.find(line) ?: return line + val (timestamp, delta) = match.destructured + return buildString { + if (showTimestamps) append(timestamp) + if (showDeltas) append(delta) + append(line, match.value.length, line.length) } - return result } /** - * Returns only the lines of [content] containing [query] (case-insensitive), formatted according - * to [showTimestamps] and [showDeltas], each terminated with a newline. + * Returns only the lines of [content] whose *displayed* form (per [showTimestamps] and + * [showDeltas]) contains [query] (case-insensitive), each terminated with a newline. + * Returns [content] unchanged when there is nothing to filter or strip. */ fun filterLines( content: String, @@ -215,11 +253,14 @@ class BuildOutputViewModel( showTimestamps: Boolean = true, showDeltas: Boolean = true, ): String { - if (content.isEmpty()) return content + if (content.isEmpty() || (query.isEmpty() && showTimestamps && showDeltas)) return content + // Drop the trailing empty element lineSequence() yields for newline-terminated input, + // otherwise every render would gain a blank line. + val body = if (content.endsWith('\n')) content.substring(0, content.length - 1) else content return buildString { - for (rawLine in content.lineSequence()) { - if (query.isEmpty() || rawLine.contains(query, ignoreCase = true)) { - val displayLine = formatLineForDisplay(rawLine, showTimestamps, showDeltas) + for (rawLine in body.lineSequence()) { + val displayLine = formatLineForDisplay(rawLine, showTimestamps, showDeltas) + if (query.isEmpty() || displayLine.contains(query, ignoreCase = true)) { append(displayLine).append('\n') } } diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt index 7d9d11c237..b053488885 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt @@ -60,4 +60,89 @@ class BuildOutputFilterTest { BuildOutputViewModel.filterLines(content, "> Task"), ) } + + @Test + fun `empty query with default toggles returns prefixed content unchanged`() { + val content = prefix() + "> Task :a\n" + prefix() + "BUILD SUCCESSFUL\n" + assertSame(content, BuildOutputViewModel.filterLines(content, "")) + } + + @Test + fun `filtering does not add blank lines to newline-terminated content`() { + val content = "> Task :a\nnoise\n" + assertEquals( + "> Task :a\nnoise\n", + BuildOutputViewModel.filterLines(content, "", showTimestamps = false, showDeltas = true), + ) + } + + @Test + fun `prefix round-trips through display stripping`() { + val line = prefix() + "> Task :app:build" + assertEquals( + "> Task :app:build", + BuildOutputViewModel.formatLineForDisplay(line, showTimestamps = false, showDeltas = false), + ) + } + + @Test + fun `hiding only timestamps keeps the delta part`() { + val line = prefix(totalDeltaMs = 65_123, stepDeltaMs = 42) + "task output" + val displayed = + BuildOutputViewModel.formatLineForDisplay(line, showTimestamps = false, showDeltas = true) + assertEquals("[+01:05.123] (Δ42ms) task output", displayed) + } + + @Test + fun `hiding only deltas keeps the timestamp part`() { + val line = prefix() + "task output" + val displayed = + BuildOutputViewModel.formatLineForDisplay(line, showTimestamps = true, showDeltas = false) + assertEquals(line.substringBefore("] ") + "] task output", displayed) + } + + @Test + fun `prefix of builds longer than 99 minutes still strips`() { + val line = prefix(totalDeltaMs = 100 * 60_000L + 7_412) + "> Task :app:lint" + assertEquals( + "> Task :app:lint", + BuildOutputViewModel.formatLineForDisplay(line, showTimestamps = false, showDeltas = false), + ) + } + + @Test + fun `timestamp-shaped text inside a message body is preserved`() { + val line = prefix() + "Test run started [10:22:31.004] on device" + val displayed = + BuildOutputViewModel.formatLineForDisplay(line, showTimestamps = false, showDeltas = false) + assertEquals("Test run started [10:22:31.004] on device", displayed) + } + + @Test + fun `unprefixed lines are returned unchanged when toggles are off`() { + val line = "some output containing [10:22:31.004] and (x42ms)" + assertSame( + line, + BuildOutputViewModel.formatLineForDisplay(line, showTimestamps = false, showDeltas = false), + ) + } + + @Test + fun `query matches the displayed text, not the hidden prefix`() { + val content = prefix(stepDeltaMs = 42) + "compileKotlin\n" + assertEquals( + "", + BuildOutputViewModel.filterLines(content, "42ms", showTimestamps = false, showDeltas = false), + ) + assertEquals( + "compileKotlin\n", + BuildOutputViewModel.filterLines(content, "compile", showTimestamps = false, showDeltas = false), + ) + } + + private fun prefix( + nowMs: Long = 1_722_000_000_000L, + totalDeltaMs: Long = 65_123L, + stepDeltaMs: Long = 42L, + ): String = BuildOutputViewModel.formatLinePrefix(nowMs, totalDeltaMs, stepDeltaMs) } From 895601e55bb3bc4f7dfec7a1ed0c5fca1ab64b97 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Tue, 28 Jul 2026 14:05:06 +0100 Subject: [PATCH 6/6] fix(ADFA-934): Fix data loss during live edits --- .../fragments/output/BuildOutputFragment.kt | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) 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 5cf5750005..8d174d2342 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 @@ -62,6 +62,11 @@ 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 @@ -244,6 +249,7 @@ class BuildOutputFragment : } // 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() @@ -297,14 +303,15 @@ class BuildOutputFragment : private suspend fun processLogs() = with(StringBuilder()) { for (firstLine in logChannel) { - val generationAtDrain = editorContentGeneration + val sessionGenAtDrain = sessionGeneration + val editorGenAtDrain = editorContentGeneration append(firstLine.ensureNewline()) logChannel.drainTo(this) if (isNotEmpty()) { val batchText = toString() clear() - flushToEditor(batchText, generationAtDrain) + flushToEditor(batchText, sessionGenAtDrain, editorGenAtDrain) } } } @@ -318,11 +325,12 @@ class BuildOutputFragment : */ private suspend fun flushToEditor( text: String, - generation: Int, + sessionGen: Int, + editorGen: Int, ) { editorContentMutex.withLock { - // A clear (new build) after this batch was drained invalidates it. - if (generation != editorContentGeneration) return + // A clear (new build) after this batch was drained invalidates session append. + if (sessionGen != sessionGeneration) return buildOutputViewModel.append(text) @@ -345,8 +353,8 @@ class BuildOutputFragment : awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) } if (layoutCompleted != null) { - // clearOutput() (main thread, mutex-free) may have run since the file append. - if (generation == editorContentGeneration) { + // clearOutput() or renderFiltered() may have run since the file append. + if (editorGen == editorContentGeneration) { appendBatch(visibleText) emptyStateViewModel.setEmpty(false) } @@ -356,7 +364,7 @@ class BuildOutputFragment : editor?.run { awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) editorContentMutex.withLock { - if (editorContentGeneration == generation) { + if (editorGen == editorContentGeneration) { appendBatch(visibleText) emptyStateViewModel.setEmpty(false) }