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..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 @@ -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 @@ -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( @@ -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 { @@ -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) } } } } - /** 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 +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() @@ -149,8 +177,22 @@ 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 } @@ -158,7 +200,13 @@ 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,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() } @@ -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) } } } @@ -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 } @@ -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) } 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..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 @@ -39,8 +39,15 @@ class LogFilterBarController( 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 8f1077e070..c546299fa3 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,219 @@ -/* - * 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.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 com.itsaky.androidide.viewmodel.BuildOutputViewModel +import org.slf4j.LoggerFactory +import java.lang.ref.WeakReference +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 buildStartTimeMs: Long = System.currentTimeMillis() + private var lastOutputTimeMs: Long = buildStartTimeMs + + 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 activityOrNull: 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 + } + + 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 + } + } + } + + /** + * 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 + } + + private fun analyzeCurrentFile() { + checkActivity("analyzeCurrentFile") ?: return + + val editorView = activityOrNull?.getCurrentEditor() + if (editorView != null) { + val editor = editorView.editor + editor?.analyze() + } + } + + private fun checkActivity(action: String): EditorHandlerActivity? { + if (!enabled) return null + + 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 505041cc8f..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 @@ -48,6 +53,15 @@ 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) + + /** 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]. @@ -180,19 +194,74 @@ class BuildOutputViewModel( } companion object { + // 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 { + 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) + } + } + /** - * 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] 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, query: String, + showTimestamps: Boolean = true, + showDeltas: Boolean = true, ): String { - if (query.isEmpty() || 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 (line in content.lineSequence()) { - if (line.contains(query, ignoreCase = true)) { - append(line).append('\n') + 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/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"> + + + + + + 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) } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index c56ca9ac40..92da13760b 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -696,6 +696,9 @@ 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. "