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 b5316bb8e1..a003eb57b0 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 @@ -18,7 +18,11 @@ package com.itsaky.androidide.fragments.output import android.os.Bundle import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.CheckedTextView import android.widget.LinearLayout +import androidx.appcompat.widget.ListPopupWindow import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.R @@ -27,13 +31,16 @@ import com.itsaky.androidide.editor.ui.EditorSearchLayout import com.itsaky.androidide.editor.ui.IDEEditor import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.models.LogFilter +import com.itsaky.androidide.preferences.internal.EditorPreferences import com.itsaky.androidide.utils.BasicBuildInfo +import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashInfo import com.itsaky.androidide.viewmodel.BuildOutputViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -43,7 +50,8 @@ import kotlinx.coroutines.withTimeoutOrNull class BuildOutputFragment : NonEditableEditorFragment(), - SearchableOutputFragment { + SearchableOutputFragment, + ViewOptionsOutputFragment { private val buildOutputViewModel: BuildOutputViewModel by activityViewModels() companion object { @@ -75,6 +83,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 { @@ -85,21 +94,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) @@ -127,6 +146,17 @@ class BuildOutputFragment : searchLayout?.beginSearchMode() } + /** + * Enables or disables editor line numbers and updates the gutter divider width accordingly. + * + * @param enabled `true` to display line numbers and gutter divider, `false` to hide them. + */ + fun setLineNumbersEnabled(enabled: Boolean) { + val ed = editor ?: return + ed.setLineNumberEnabled(enabled) + ed.setDividerWidth((if (enabled) requireContext().dpToPx(2f) else 0).toFloat()) + } + override fun toggleFilterBar() { val existing = filterBar existing?.toggle() ?: createFilterBar() @@ -152,6 +182,76 @@ class BuildOutputFragment : this.searchLayout = searchLayout } + private data class ViewOptionItem( + val title: String, + var isChecked: Boolean, + val onToggle: (Boolean) -> Unit, + ) + + override fun showViewOptions(anchorView: View) { + val context = anchorView.context + val options = + listOf( + ViewOptionItem( + title = context.getString(R.string.log_filter_line_numbers), + isChecked = buildOutputViewModel.showLineNumbers.value, + onToggle = { enabled -> + EditorPreferences.outputLineNumbers = enabled + buildOutputViewModel.showLineNumbers.value = enabled + setLineNumbersEnabled(enabled) + }, + ), + ViewOptionItem( + title = context.getString(R.string.log_filter_timestamps), + isChecked = buildOutputViewModel.showTimestamps.value, + onToggle = { enabled -> + EditorPreferences.outputTimestamps = enabled + buildOutputViewModel.showTimestamps.value = enabled + }, + ), + ViewOptionItem( + title = context.getString(R.string.log_filter_deltas), + isChecked = buildOutputViewModel.showDeltas.value, + onToggle = { enabled -> + EditorPreferences.outputDeltas = enabled + buildOutputViewModel.showDeltas.value = enabled + }, + ), + ) + + val adapter = + object : ArrayAdapter( + context, + android.R.layout.simple_list_item_multiple_choice, + options.map { it.title }, + ) { + override fun getView( + position: Int, + convertView: View?, + parent: ViewGroup, + ): View { + val view = super.getView(position, convertView, parent) + if (view is CheckedTextView) { + view.isChecked = options[position].isChecked + } + return view + } + } + + val popup = ListPopupWindow(context) + popup.anchorView = anchorView + popup.setAdapter(adapter) + popup.width = context.dpToPx(200f) + popup.isModal = true + popup.setOnItemClickListener { _, _, position, _ -> + val item = options[position] + item.isChecked = !item.isChecked + item.onToggle(item.isChecked) + adapter.notifyDataSetChanged() + } + popup.show() + } + private fun createFilterBar(): LogFilterBarController? { val stub = _binding?.filterBarStub ?: return null val barBinding = LayoutLogFilterBarBinding.bind(stub.inflate()) @@ -176,7 +276,13 @@ class BuildOutputFragment : private suspend fun restoreWindowFromViewModel() { val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } val query = buildOutputViewModel.filterText.value - val content = BuildOutputViewModel.filterLines(window, query) + val content = + BuildOutputViewModel.filterLines( + window, + query, + buildOutputViewModel.showTimestamps.value, + buildOutputViewModel.showDeltas.value, + ) val isSourceEmpty = window.isBlank() val isFilteredEmpty = content.isBlank() @@ -311,7 +417,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, + ) withContext(Dispatchers.Main) { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) 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 895d1da671..4b0945758d 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 @@ -63,6 +63,11 @@ class LogFilterBarController( init { binding.levelChipsScroll.isVisible = showLevelChips + + chipsByLevel.values.forEach { chip -> + chip.isVisible = showLevelChips + } + binding.filterInput.setText(initialText) chipsByLevel.forEach { (level, chip) -> chip.isChecked = level in initialLevels diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/ViewOptionsOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/ViewOptionsOutputFragment.kt new file mode 100644 index 0000000000..196424bd07 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/ViewOptionsOutputFragment.kt @@ -0,0 +1,30 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.fragments.output + +import android.view.View + +/** + * Interface for output fragments that support toggling display view options. + */ +interface ViewOptionsOutputFragment { + /** + * Shows the view options popup menu anchored to [anchorView]. + */ + fun showViewOptions(anchorView: View) +} 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..ba7a9975b1 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,220 @@ -/* - * 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 android.os.SystemClock +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 = SystemClock.elapsedRealtime() + + 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 = SystemClock.elapsedRealtime() + } + + 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 nowWallClock = System.currentTimeMillis() + val nowMonotonic = SystemClock.elapsedRealtime() + val stepDeltaMs = maxOf(0L, nowMonotonic - lastOutputTimeMs) + lastOutputTimeMs = nowMonotonic + + val prefix = BuildOutputViewModel.formatLinePrefix(nowWallClock, 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/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 89b094a02e..7bcce2b991 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -55,6 +55,7 @@ import com.itsaky.androidide.databinding.LayoutEditorBottomSheetBinding import com.itsaky.androidide.fragments.EmptyStateFragment import com.itsaky.androidide.fragments.output.SearchableOutputFragment import com.itsaky.androidide.fragments.output.ShareableOutputFragment +import com.itsaky.androidide.fragments.output.ViewOptionsOutputFragment import com.itsaky.androidide.fragments.output.WrappableOutputFragment import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag @@ -298,6 +299,14 @@ class EditorBottomSheet } binding.wordWrapOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_WORD_WRAP)) + binding.viewOptionsOutputAction.setOnClickListener { + val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) + if (fragment is ViewOptionsOutputFragment) { + fragment.showViewOptions(it) + } + } + binding.viewOptionsOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_VIEW_OPTIONS)) + binding.headerContainer.setOnClickListener { viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) } @@ -332,6 +341,8 @@ class EditorBottomSheet binding.filterOutputAction.setOnLongClickListener(null) binding.wordWrapOutputAction.setOnClickListener(null) binding.wordWrapOutputAction.setOnLongClickListener(null) + binding.viewOptionsOutputAction.setOnClickListener(null) + binding.viewOptionsOutputAction.setOnLongClickListener(null) binding.copyDiagnosticsFab.setOnClickListener(null) binding.headerContainer.setOnClickListener(null) removeOnLayoutChangeListener(fabLayoutChangeListener) @@ -670,6 +681,7 @@ class EditorBottomSheet val showShareAndClear = isExpanded && currentFragment is ShareableOutputFragment val showSearchAndFilter = isExpanded && currentFragment is SearchableOutputFragment val showWordWrap = isExpanded && currentFragment is WrappableOutputFragment + val showViewOptions = isExpanded && currentFragment is ViewOptionsOutputFragment val showCopy = isExpanded && currentFragment != null && @@ -680,7 +692,8 @@ class EditorBottomSheet binding.searchOutputAction.isVisible = showSearchAndFilter binding.filterOutputAction.isVisible = showSearchAndFilter binding.wordWrapOutputAction.isVisible = showWordWrap - binding.outputActions.isVisible = showShareAndClear || showSearchAndFilter || showWordWrap + binding.viewOptionsOutputAction.isVisible = showViewOptions + binding.outputActions.isVisible = showShareAndClear || showSearchAndFilter || showWordWrap || showViewOptions binding.copyDiagnosticsFab.isVisible = showCopy if (showWordWrap) { 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..dc94377062 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.viewmodel import android.app.Application import androidx.lifecycle.AndroidViewModel +import com.itsaky.androidide.preferences.internal.EditorPreferences import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext @@ -25,6 +26,10 @@ 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.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(EditorPreferences.outputTimestamps) + + /** Toggle for showing step time deltas `ΔXms` in editor view. */ + val showDeltas = MutableStateFlow(EditorPreferences.outputDeltas) + + /** Toggle for showing gutter line numbers in editor view. */ + val showLineNumbers = MutableStateFlow(EditorPreferences.outputLineNumbers) + /** * Thread-safe snapshot of content for synchronous [getShareableContent] without blocking. * Updated on [append] and [clear]; primed on restore via [setCachedSnapshot]. @@ -180,19 +194,67 @@ 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}\] )(\u0394\d+ms\s+)""") + + 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] \u0394Nms `. + */ + fun formatLinePrefix( + nowMs: Long, + stepDeltaMs: Long, + ): String { + val time = + PREFIX_TIME_FORMAT.format(Instant.ofEpochMilli(nowMs).atZone(ZoneId.systemDefault())) + return String.format( + Locale.US, + "[%s] %-8s ", + time, + "\u0394${stepDeltaMs}ms", + ) + } + + /** 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_editor_bottom_sheet.xml b/app/src/main/res/layout/layout_editor_bottom_sheet.xml index 9acf90ebcb..9e0d29d866 100644 --- a/app/src/main/res/layout/layout_editor_bottom_sheet.xml +++ b/app/src/main/res/layout/layout_editor_bottom_sheet.xml @@ -71,46 +71,53 @@ android:layout_below="@id/tabs" android:layout_margin="8dp"> - - - + app:layout_constraintTop_toTopOf="parent"> + + + + + + + + + + + + - - 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(stepDeltaMs = 42) + "task output" + val displayed = + BuildOutputViewModel.formatLineForDisplay(line, showTimestamps = false, showDeltas = true) + assertEquals("Δ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() + "> 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), + ) + } + + @Test + fun `filtering with toggles disabled strips prefixes even with empty query`() { + val content = prefix(stepDeltaMs = 42) + "compileKotlin\n" + assertEquals( + "compileKotlin\n", + BuildOutputViewModel.filterLines(content, "", showTimestamps = false, showDeltas = false), + ) + } + + private fun prefix( + nowMs: Long = 1_722_000_000_000L, + stepDeltaMs: Long = 42L, + ): String = BuildOutputViewModel.formatLinePrefix(nowMs, stepDeltaMs) } diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index c4865af7ab..d8e71fd5dd 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -29,6 +29,7 @@ object TooltipTag { const val OUTPUT_SEARCH = "output.search" const val OUTPUT_FILTER = "output.filter" const val OUTPUT_WORD_WRAP = "output.wordwrap" + const val OUTPUT_VIEW_OPTIONS = "output.viewoptions" const val PROJECT_BUILD_OUTPUT = "project.buildoutput" const val PROJECT_GRADLE_TASKS = "project.gradle.tasks" const val PROJECT_RUN_GRADLE_TASKS = "project.run.gradle.tasks" diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/EditorPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/EditorPreferences.kt index ddd90a9684..ff31485cf7 100644 --- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/EditorPreferences.kt +++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/EditorPreferences.kt @@ -38,6 +38,9 @@ object EditorPreferences { const val FLAG_PASSWORD = "idepref_editor_flagPassword" const val WORD_WRAP = "idepref_editor_word_wrap" const val OUTPUT_WORD_WRAP = "idepref_output_word_wrap" + const val OUTPUT_LINE_NUMBERS = "idepref_output_line_numbers" + const val OUTPUT_TIMESTAMPS = "idepref_output_timestamps" + const val OUTPUT_DELTAS = "idepref_output_deltas" const val USE_MAGNIFER = "idepref_editor_use_magnifier" const val USE_ICU = "idepref_editor_useIcu" const val USE_SOFT_TAB = "idepref_editor_useSoftTab" @@ -135,6 +138,24 @@ object EditorPreferences { prefManager.putBoolean(OUTPUT_WORD_WRAP, value) } + var outputLineNumbers: Boolean + get() = prefManager.getBoolean(OUTPUT_LINE_NUMBERS, true) + set(value) { + prefManager.putBoolean(OUTPUT_LINE_NUMBERS, value) + } + + var outputTimestamps: Boolean + get() = prefManager.getBoolean(OUTPUT_TIMESTAMPS, true) + set(value) { + prefManager.putBoolean(OUTPUT_TIMESTAMPS, value) + } + + var outputDeltas: Boolean + get() = prefManager.getBoolean(OUTPUT_DELTAS, true) + set(value) { + prefManager.putBoolean(OUTPUT_DELTAS, value) + } + var useMagnifier: Boolean get() = prefManager.getBoolean(USE_MAGNIFER, true) set(value) { diff --git a/resources/src/main/res/drawable/ic_tune.xml b/resources/src/main/res/drawable/ic_tune.xml new file mode 100644 index 0000000000..dae61d9330 --- /dev/null +++ b/resources/src/main/res/drawable/ic_tune.xml @@ -0,0 +1,11 @@ + + + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 82e2b0cf07..cee797c34d 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -698,6 +698,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. " @@ -1032,6 +1035,7 @@ Enable word wrap Disable word wrap + View options "An unknown error occurred." A build is already in progress. Ignoring new request.