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 ef4b5ef38f..c14dda88ca 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,22 +18,31 @@ package com.itsaky.androidide.fragments.output import android.os.Bundle import android.view.View +import android.widget.LinearLayout import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.LayoutLogFilterBarBinding +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.utils.BasicBuildInfo 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.drop import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -class BuildOutputFragment : NonEditableEditorFragment() { - +class BuildOutputFragment : + NonEditableEditorFragment(), + SearchableOutputFragment { private val buildOutputViewModel: BuildOutputViewModel by activityViewModels() companion object { @@ -44,10 +53,23 @@ class BuildOutputFragment : NonEditableEditorFragment() { private val logChannel = Channel(Channel.UNLIMITED) - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + private var searchLayout: EditorSearchLayout? = null + private var filterBar: LogFilterBarController? = null + + // Serializes editor-content mutations (filtered re-renders vs live batch appends) + // so a re-render never misses or duplicates a concurrently flushed batch. + private val editorContentMutex = Mutex() + + private var editorContentGeneration = 0 + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { super.onViewCreated(view, savedInstanceState) editor?.tag = TooltipTag.PROJECT_BUILD_OUTPUT emptyStateViewModel.setEmptyMessage(getString(R.string.msg_emptyview_buildoutput)) + setupSearchLayout() viewLifecycleOwner.lifecycleScope.launch { restoreWindowFromViewModel() @@ -56,17 +78,94 @@ class BuildOutputFragment : NonEditableEditorFragment() { val content = buildOutputViewModel.getFullContent() buildOutputViewModel.setCachedSnapshot(content) } + launch { + buildOutputViewModel.filterText.drop(1).collectLatest { query -> + renderFiltered(query) + } + } + } + } + + /** Re-renders the editor window from the session file, filtered by [query]. */ + private suspend fun renderFiltered(query: String) { + editorContentMutex.withLock { + editorContentGeneration++ + val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } + val filtered = + withContext(Dispatchers.Default) { + BuildOutputViewModel.filterLines(window, query) + } + withContext(Dispatchers.Main) { + editor?.setText(filtered) + emptyStateViewModel.setEmpty(filtered.isBlank()) + onContentReplaced() + } } } + /** Called after the editor content has been replaced wholesale (e.g. on a filter change). */ + private fun onContentReplaced() { + val searchLayout = this.searchLayout ?: return + if (searchLayout.isSearchModeActive()) { + searchLayout.refreshSearch() + } else { + editor?.searcher?.stopSearch() + } + } + + override fun beginSearch() { + searchLayout?.beginSearchMode() + } + + override fun toggleFilterBar() { + val existing = filterBar + existing?.toggle() ?: createFilterBar() + } + + private fun setupSearchLayout() { + val editor = this.editor ?: return + val root = _binding?.root ?: return + val searchLayout = + EditorSearchLayout( + context = requireContext(), + editor = editor, + showReplaceAction = false, + applyCollapsedSheetMargin = false, + ) + root.addView( + searchLayout, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ), + ) + this.searchLayout = searchLayout + } + + private fun createFilterBar(): LogFilterBarController? { + val stub = _binding?.filterBarStub ?: return null + val barBinding = LayoutLogFilterBarBinding.bind(stub.inflate()) + return LogFilterBarController( + binding = barBinding, + coroutineScope = viewLifecycleOwner.lifecycleScope, + showLevelChips = false, + initialText = buildOutputViewModel.filterText.value, + initialLevels = LogFilter.ALL_LEVELS, + ) { _, text -> + buildOutputViewModel.filterText.value = text.trim() + }.also { filterBar = it } + } + private suspend fun restoreWindowFromViewModel() { - val content = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } + val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } + val content = BuildOutputViewModel.filterLines(window, buildOutputViewModel.filterText.value) if (content.isEmpty()) return withContext(Dispatchers.Main) { val editor = this@BuildOutputFragment.editor ?: return@withContext - val layoutCompleted = withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { - editor.awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) - } + val layoutCompleted = + withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + editor.awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) + } if (layoutCompleted != null) { editor.appendBatch(content) emptyStateViewModel.setEmpty(false) @@ -86,6 +185,8 @@ class BuildOutputFragment : NonEditableEditorFragment() { } override fun onDestroyView() { + searchLayout = null + filterBar = null editor?.release() super.onDestroyView() } @@ -118,8 +219,7 @@ class BuildOutputFragment : NonEditableEditorFragment() { * Ensures the string ends with a newline character (`\n`). * Useful for maintaining correct formatting when concatenating log lines. */ - private fun String.ensureNewline(): String = - if (endsWith('\n')) this else "$this\n" + private fun String.ensureNewline(): String = if (endsWith('\n')) this else "$this\n" /** * Immediately drains (consumes) all available messages from the channel into the [buffer]. @@ -145,18 +245,19 @@ class BuildOutputFragment : NonEditableEditorFragment() { * 2. Wakes up and drains the entire queue (Batching). * 3. Sends the complete block to the UI in a single pass. */ - private suspend fun processLogs() = with(StringBuilder()) { - for (firstLine in logChannel) { - append(firstLine.ensureNewline()) - logChannel.drainTo(this) - - if (isNotEmpty()) { - val batchText = toString() - clear() - flushToEditor(batchText) + private suspend fun processLogs() = + with(StringBuilder()) { + for (firstLine in logChannel) { + append(firstLine.ensureNewline()) + logChannel.drainTo(this) + + if (isNotEmpty()) { + val batchText = toString() + clear() + flushToEditor(batchText) + } } } - } /** * Performs the safe UI update on the Main Thread. @@ -166,22 +267,38 @@ class BuildOutputFragment : NonEditableEditorFragment() { * before attempting to insert text, preventing the Sora library's `ArrayIndexOutOfBoundsException`. */ private suspend fun flushToEditor(text: String) { - buildOutputViewModel.append(text) - withContext(Dispatchers.Main) { - editor?.run { - val layoutCompleted = withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { - awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) - } - if (layoutCompleted != null) { - appendBatch(text) - emptyStateViewModel.setEmpty(false) - } else { - // Timeout: defer append until layout is ready (same as restoreWindowFromViewModel) - viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { - editor?.run { + editorContentMutex.withLock { + 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) + if (visibleText.isEmpty()) { + return + } + + withContext(Dispatchers.Main) { + editor?.run { + val layoutCompleted = + withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) - appendBatch(text) - emptyStateViewModel.setEmpty(false) + } + if (layoutCompleted != null) { + 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) { + appendBatch(visibleText) + emptyStateViewModel.setEmpty(false) + } + } + } } } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/IDELogFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/IDELogFragment.kt index 4975928514..c21d4023fa 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/IDELogFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/IDELogFragment.kt @@ -20,20 +20,16 @@ package com.itsaky.androidide.fragments.output import android.os.Bundle import android.view.View import androidx.fragment.app.activityViewModels -import ch.qos.logback.classic.Level import com.itsaky.androidide.R import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.logging.GlobalBufferAppender -import com.itsaky.androidide.utils.FeatureFlags import com.itsaky.androidide.viewmodel.IDELogsViewModel /** - * Fragment to show IDE logs. + * Fragment to show IDE logs. Log consumption happens in [IDELogsViewModel]. + * * @author Akash Yadav */ -class IDELogFragment : - LogViewFragment(), - GlobalBufferAppender.Consumer { +class IDELogFragment : LogViewFragment() { override fun isSimpleFormattingEnabled() = true override fun getShareableFilename() = "ide_logs" @@ -42,24 +38,11 @@ class IDELogFragment : override val viewModel by activityViewModels() - override val logLevel: Level - get() = if (FeatureFlags.isDebugLoggingEnabled) Level.DEBUG else Level.INFO - override fun onViewCreated( view: View, savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) emptyStateViewModel.setEmptyMessage(getString(R.string.msg_emptyview_idelogs)) - - // Register with GlobalBufferAppender to receive all logs (including buffered ones) - GlobalBufferAppender.registerConsumer(this) - } - - override fun consume(message: String) = appendLine(message) - - override fun onDestroyView() { - GlobalBufferAppender.unregisterConsumer(this) - super.onDestroyView() } } 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 new file mode 100644 index 0000000000..6c27f22a34 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt @@ -0,0 +1,101 @@ +/* + * 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 androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import com.itsaky.androidide.databinding.LayoutLogFilterBarBinding +import com.itsaky.androidide.utils.ILogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.EnumSet + +/** + * Wires a [layout_log_filter_bar][LayoutLogFilterBarBinding] to a filter-change callback: + * level chips apply immediately, text input is debounced. + * + * @param showLevelChips Whether the level chip row is shown (outputs without level + * metadata, like the build output, only get the text filter). + * @param onFilterChanged Called with the enabled levels and the (untrimmed) filter text. + */ +class LogFilterBarController( + private val binding: LayoutLogFilterBarBinding, + coroutineScope: CoroutineScope, + showLevelChips: Boolean, + initialText: String, + initialLevels: Set, + private val onFilterChanged: (levels: Set, text: String) -> Unit, +) { + companion object { + private const val FILTER_TEXT_DEBOUNCE_MS = 250L + } + + private val chipsByLevel = + mapOf( + ILogger.Level.VERBOSE to binding.chipLevelVerbose, + ILogger.Level.DEBUG to binding.chipLevelDebug, + ILogger.Level.INFO to binding.chipLevelInfo, + ILogger.Level.WARNING to binding.chipLevelWarning, + ILogger.Level.ERROR to binding.chipLevelError, + ) + + private var textDebounceJob: Job? = null + + init { + binding.levelChipsScroll.isVisible = showLevelChips + binding.filterInput.setText(initialText) + chipsByLevel.forEach { (level, chip) -> + chip.isChecked = level in initialLevels + chip.setOnCheckedChangeListener { _, _ -> notifyFilterChanged() } + } + binding.filterInput.doAfterTextChanged { + textDebounceJob?.cancel() + textDebounceJob = + coroutineScope.launch { + delay(FILTER_TEXT_DEBOUNCE_MS) + notifyFilterChanged() + } + } + binding.closeFilterBar.setOnClickListener { hide() } + } + + fun toggle() { + binding.root.isVisible = !binding.root.isVisible + } + + fun hide() { + binding.root.isVisible = false + } + + private fun notifyFilterChanged() { + val levels = EnumSet.noneOf(ILogger.Level::class.java) + chipsByLevel.forEach { (level, chip) -> + if (chip.isChecked) { + levels.add(level) + } + } + onFilterChanged( + levels, + binding.filterInput.text + ?.toString() + .orEmpty(), + ) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt index 3e4803cdc3..796a096a42 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt @@ -19,17 +19,21 @@ package com.itsaky.androidide.fragments.output import android.os.Bundle import android.view.View +import android.widget.LinearLayout import androidx.annotation.UiThread import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle import com.itsaky.androidide.R import com.itsaky.androidide.databinding.FragmentLogBinding +import com.itsaky.androidide.databinding.LayoutLogFilterBarBinding import com.itsaky.androidide.editor.language.treesitter.LogLanguage import com.itsaky.androidide.editor.language.treesitter.TreeSitterLanguageProvider import com.itsaky.androidide.editor.schemes.IDEColorScheme import com.itsaky.androidide.editor.schemes.IDEColorSchemeProvider +import com.itsaky.androidide.editor.ui.EditorSearchLayout import com.itsaky.androidide.editor.ui.IDEEditor import com.itsaky.androidide.fragments.EmptyStateFragment +import com.itsaky.androidide.models.LogFilter import com.itsaky.androidide.models.LogLine import com.itsaky.androidide.utils.BasicBuildInfo import com.itsaky.androidide.utils.isTestMode @@ -47,7 +51,8 @@ import org.slf4j.LoggerFactory */ abstract class LogViewFragment : EmptyStateFragment(R.layout.fragment_log, FragmentLogBinding::bind), - ShareableOutputFragment { + ShareableOutputFragment, + SearchableOutputFragment { companion object { private val log = LoggerFactory.getLogger(LogViewFragment::class.java) } @@ -58,6 +63,9 @@ abstract class LogViewFragment : abstract val viewModel: V + private var searchLayout: EditorSearchLayout? = null + private var filterBar: LogFilterBarController? = null + /** * Append a log line to the log view. * @@ -75,20 +83,44 @@ abstract class LogViewFragment : abstract fun isSimpleFormattingEnabled(): Boolean override fun onDestroyView() { + searchLayout = null + filterBar = null _binding?.editor?.release() super.onDestroyView() } + override fun beginSearch() { + searchLayout?.beginSearchMode() + } + + override fun toggleFilterBar() { + val existing = filterBar + existing?.toggle() ?: createFilterBar() + } + + private fun createFilterBar(): LogFilterBarController? { + val stub = _binding?.filterBarStub ?: return null + val barBinding = LayoutLogFilterBarBinding.bind(stub.inflate()) + val currentFilter = viewModel.filter.value + return LogFilterBarController( + binding = barBinding, + coroutineScope = viewLifecycleScope, + showLevelChips = true, + initialText = currentFilter.text, + initialLevels = currentFilter.enabledLevels, + ) { levels, text -> + viewModel.setFilter(LogFilter(levels, text.trim())) + }.also { filterBar = it } + } + override fun getShareableContent(): String { - val editorText = - this._binding - ?.editor - ?.text - ?.toString() ?: "" - return "${BasicBuildInfo.shareableBuildInfo()}${System.lineSeparator()}$editorText" + // Share the full retained history, not the (possibly filtered) editor text + val logText = viewModel.snapshotUnfiltered() + return "${BasicBuildInfo.shareableBuildInfo()}${System.lineSeparator()}$logText" } override fun clearOutput() { + viewModel.clear() _binding?.editor?.setText("")?.also { emptyStateViewModel.setEmpty(true) } @@ -101,6 +133,7 @@ abstract class LogViewFragment : super.onViewCreated(view, savedInstanceState) setupEditor() + setupSearchLayout() viewLifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -111,7 +144,7 @@ abstract class LogViewFragment : } } - private suspend fun observeLogs(): Nothing { + private suspend fun observeLogs() { // Wait for the editor's first layout pass. The sora-editor's // LineBreakLayout populates its line-width tracker asynchronously after // layout; appending before that races BlockIntList.set on an empty list. @@ -121,6 +154,10 @@ abstract class LogViewFragment : viewModel.uiEvents.collect { event -> when (event) { + is LogViewModel.UiEvent.SetText -> { + setText(event.text) + } + is LogViewModel.UiEvent.Append -> { append(event.text) trimLinesAtStart() @@ -129,6 +166,34 @@ abstract class LogViewFragment : } } + /** Called after the editor content has been replaced wholesale (e.g. on a filter change). */ + private fun onContentReplaced() { + val searchLayout = this.searchLayout ?: return + if (searchLayout.isSearchModeActive()) { + searchLayout.refreshSearch() + } else { + _binding?.editor?.searcher?.stopSearch() + } + } + + private fun setupSearchLayout() { + val searchLayout = + EditorSearchLayout( + context = requireContext(), + editor = binding.editor, + showReplaceAction = false, + applyCollapsedSheetMargin = false, + ) + binding.root.addView( + searchLayout, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ), + ) + this.searchLayout = searchLayout + } + private fun setupEditor() { val editor = this.binding.editor editor.props.autoIndent = false @@ -162,6 +227,14 @@ abstract class LogViewFragment : } } + @UiThread + private fun setText(text: String) { + val editor = _binding?.editor ?: return + editor.setText(text) + emptyStateViewModel.setEmpty(text.isBlank()) + onContentReplaced() + } + @UiThread private fun append(chars: CharSequence?) { if (chars == null) { 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 b98018a67d..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 @@ -81,7 +81,7 @@ public String getShareableFilename() { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); getEmptyStateViewModel().setEmptyMessage(createEmptyStateMessage()); - final var editor = getBinding().getRoot(); + final var editor = getBinding().editor; editor.setEditable(false); editor.setDividerWidth(0); editor.setEditorLanguage(new EmptyLanguage()); diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/SearchableOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/SearchableOutputFragment.kt new file mode 100644 index 0000000000..a659d08d11 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/SearchableOutputFragment.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 + +/** + * An output fragment whose content can be searched and filtered from the + * editor bottom sheet's action buttons. + */ +interface SearchableOutputFragment { + /** Show the search bar and focus its input. */ + fun beginSearch() + + /** Show or hide the filter bar. */ + fun toggleFilterBar() +} diff --git a/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt b/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt new file mode 100644 index 0000000000..15312d4630 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt @@ -0,0 +1,105 @@ +/* + * 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.logs + +import com.itsaky.androidide.models.LogFilter +import com.itsaky.androidide.utils.ILogger + +/** + * A bounded, thread-safe history of log entries. Retains the level metadata that is + * lost once lines are flattened into the editor, so the view can be re-rendered when + * the user changes the active [LogFilter]. + * + * @param trimOnEntryCount Trim the buffer once it grows past this many entries. + * @param maxEntryCount The number of most-recent entries kept after a trim. + */ +class LogBuffer( + private val trimOnEntryCount: Int, + private val maxEntryCount: Int, +) { + /** + * A single submitted log line. + * + * @property seq Monotonically increasing sequence number, used to stitch a + * buffer snapshot together with the live entry stream without gaps or duplicates. + * @property text The rendered line, always terminated with a newline. + */ + data class Entry( + val seq: Long, + val level: ILogger.Level?, + val text: String, + ) + + init { + require(maxEntryCount in 1..trimOnEntryCount) { + "maxEntryCount must be in 1..trimOnEntryCount" + } + } + + private val entries = ArrayDeque() + private var nextSeq = 1L + + @Synchronized + fun append( + level: ILogger.Level?, + text: String, + ): Entry { + val entry = Entry(nextSeq++, level, text) + entries.addLast(entry) + if (entries.size > trimOnEntryCount) { + repeat(entries.size - maxEntryCount) { + entries.removeFirst() + } + } + return entry + } + + /** + * Render all entries matching [filter] into a single string. + * + * @return The rendered text and the sequence number of the newest entry in the + * buffer at snapshot time (0 if the buffer is empty), regardless of whether + * that entry matched the filter. + */ + @Synchronized + fun snapshotFiltered(filter: LogFilter): Pair { + val lastSeq = entries.lastOrNull()?.seq ?: 0L + val text = + buildString { + for (entry in entries) { + if (filter.matches(entry.level, entry.text)) { + append(entry.text) + } + } + } + return text to lastSeq + } + + @Synchronized + fun snapshotAll(): String = + buildString { + for (entry in entries) { + append(entry.text) + } + } + + @Synchronized + fun clear() { + entries.clear() + } +} diff --git a/app/src/main/java/com/itsaky/androidide/models/LogFilter.kt b/app/src/main/java/com/itsaky/androidide/models/LogFilter.kt new file mode 100644 index 0000000000..a104e16f16 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/models/LogFilter.kt @@ -0,0 +1,51 @@ +/* + * 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.models + +import com.itsaky.androidide.utils.ILogger +import java.util.EnumSet + +/** + * A filter for log lines shown in the output views. + * + * @property enabledLevels Log levels to show. Lines without a known level always pass. + * @property text Case-insensitive substring that a line must contain to be shown. + */ +data class LogFilter( + val enabledLevels: Set = ALL_LEVELS, + val text: String = "", +) { + companion object { + val ALL_LEVELS: Set = EnumSet.allOf(ILogger.Level::class.java) + + val NONE = LogFilter() + } + + val isActive: Boolean + get() = text.isNotEmpty() || enabledLevels.size < ALL_LEVELS.size + + fun matches( + level: ILogger.Level?, + line: String, + ): Boolean { + if (level != null && level !in enabledLevels) { + return false + } + return text.isEmpty() || line.contains(text, ignoreCase = true) + } +} 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 db33b703cf..8358a11aa8 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.adapters.DiagnosticsAdapter import com.itsaky.androidide.adapters.EditorBottomSheetTabAdapter import com.itsaky.androidide.adapters.SearchListAdapter import com.itsaky.androidide.databinding.LayoutEditorBottomSheetBinding +import com.itsaky.androidide.fragments.output.SearchableOutputFragment import com.itsaky.androidide.fragments.output.ShareableOutputFragment import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag @@ -203,7 +204,7 @@ class EditorBottomSheet }, ) - binding.shareOutputFab.setOnClickListener { + binding.shareOutputAction.setOnClickListener { val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) if (fragment !is ShareableOutputFragment) { log.error("Unknown fragment: {}", fragment) @@ -211,8 +212,8 @@ class EditorBottomSheet } if (shareJob?.isActive == true) return@setOnClickListener - binding.shareOutputFab.isEnabled = false - binding.clearFab.isEnabled = false + binding.shareOutputAction.isEnabled = false + binding.clearOutputAction.isEnabled = false shareJob = context.lifecycleScope.launch { @@ -231,15 +232,15 @@ class EditorBottomSheet } } finally { if (isAttachedToWindow) { - binding.shareOutputFab.isEnabled = true - binding.clearFab.isEnabled = true + binding.shareOutputAction.isEnabled = true + binding.clearOutputAction.isEnabled = true } } } } - binding.shareOutputFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SHARE_EXTERNAL)) + binding.shareOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SHARE_EXTERNAL)) - binding.clearFab.setOnClickListener { + binding.clearOutputAction.setOnClickListener { val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) if (fragment !is ShareableOutputFragment) { @@ -248,12 +249,35 @@ class EditorBottomSheet } (fragment as ShareableOutputFragment).clearOutput() } - binding.clearFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_CLEAR)) + binding.clearOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_CLEAR)) binding.copyDiagnosticsFab.setOnClickListener { copyDiagnosticsToClipboard() } + binding.searchOutputAction.setOnClickListener { + val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) + if (fragment !is SearchableOutputFragment) { + log.error("Unknown fragment: {}", fragment) + return@setOnClickListener + } + // Search happens inside the sheet, so it must be expanded to be usable + viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) + fragment.beginSearch() + } + binding.searchOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SEARCH)) + + binding.filterOutputAction.setOnClickListener { + val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) + if (fragment !is SearchableOutputFragment) { + log.error("Unknown fragment: {}", fragment) + return@setOnClickListener + } + viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) + fragment.toggleFilterBar() + } + binding.filterOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_FILTER)) + binding.headerContainer.setOnClickListener { viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) } @@ -275,10 +299,14 @@ class EditorBottomSheet } binding.tabs.clearOnTabSelectedListeners() - binding.shareOutputFab.setOnClickListener(null) - binding.shareOutputFab.setOnLongClickListener(null) - binding.clearFab.setOnClickListener(null) - binding.clearFab.setOnLongClickListener(null) + binding.shareOutputAction.setOnClickListener(null) + binding.shareOutputAction.setOnLongClickListener(null) + binding.clearOutputAction.setOnClickListener(null) + binding.clearOutputAction.setOnLongClickListener(null) + binding.searchOutputAction.setOnClickListener(null) + binding.searchOutputAction.setOnLongClickListener(null) + binding.filterOutputAction.setOnClickListener(null) + binding.filterOutputAction.setOnLongClickListener(null) binding.copyDiagnosticsFab.setOnClickListener(null) binding.headerContainer.setOnClickListener(null) removeOnLayoutChangeListener(fabLayoutChangeListener) @@ -607,24 +635,25 @@ class EditorBottomSheet state.sheetState == BottomSheetBehavior.STATE_HALF_EXPANDED val showShareAndClear = isExpanded && currentFragment is ShareableOutputFragment + val showSearchAndFilter = isExpanded && currentFragment is SearchableOutputFragment val showCopy = isExpanded && currentFragment != null && currentFragment === pagerAdapter.diagnosticsFragment - binding.clearFab.isVisible = showShareAndClear - binding.shareOutputFab.isVisible = showShareAndClear + binding.clearOutputAction.isVisible = showShareAndClear + binding.shareOutputAction.isVisible = showShareAndClear + binding.searchOutputAction.isVisible = showSearchAndFilter + binding.filterOutputAction.isVisible = showSearchAndFilter + binding.outputActions.isVisible = showShareAndClear || showSearchAndFilter binding.copyDiagnosticsFab.isVisible = showCopy } - // The bottom-anchored FABs go off-screen when the bottom sheet is collapsed. - // Shift them up by the offset from the expanded position (zero when fully expanded). + // The bottom-anchored FAB goes off-screen when the bottom sheet is collapsed. + // Shift it up by the offset from the expanded position (zero when fully expanded). + // The output actions row sits in normal layout flow, so it needs no translation. private fun updateFabTranslation() { val translationY = -(top - anchorOffset).coerceAtLeast(0).toFloat() - binding.apply { - clearFab.translationY = translationY - shareOutputFab.translationY = translationY - copyDiagnosticsFab.translationY = translationY - } + binding.copyDiagnosticsFab.translationY = translationY } } 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 96508977b7..505041cc8f 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,9 @@ package com.itsaky.androidide.viewmodel import android.app.Application import androidx.lifecycle.AndroidViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import java.io.RandomAccessFile @@ -25,8 +28,6 @@ import java.nio.charset.StandardCharsets import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock import kotlin.math.max -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext /** * File-backed build output with a moving window in memory. All output is appended to a session @@ -36,137 +37,172 @@ import kotlinx.coroutines.withContext * * Append/clear are intended to be called from the main thread (from [BuildOutputFragment]). */ -class BuildOutputViewModel(application: Application) : AndroidViewModel(application) { - - private val lock = ReentrantLock() - - /** - * Thread-safe snapshot of content for synchronous [getShareableContent] without blocking. - * Updated on [append] and [clear]; primed on restore via [setCachedSnapshot]. - * Capped at [CACHE_SNAPSHOT_MAX_CHARS] to bound memory. - */ - @Volatile - private var cachedContentSnapshot: String = "" - - /** Returns the current cached snapshot for share/copy (non-blocking). */ - fun getCachedContentSnapshot(): String = cachedContentSnapshot - - /** Updates the cached snapshot (e.g. after loading full content on restore). Capped to [CACHE_SNAPSHOT_MAX_CHARS]. */ - fun setCachedSnapshot(content: String) { - cachedContentSnapshot = - if (content.length <= CACHE_SNAPSHOT_MAX_CHARS) content - else content.takeLast(CACHE_SNAPSHOT_MAX_CHARS) - } - - private val sessionFile: File - get() = File(getApplication().cacheDir, SESSION_FILE_NAME) - - /** - * Appends text to the session file. File I/O is performed on a background dispatcher; call from - * any thread. Prefer calling before switching to Main so disk write does not block the UI. - */ - suspend fun append(text: String) { - if (text.isEmpty()) return - withContext(Dispatchers.IO) { - lock.withLock { - try { - FileOutputStream(sessionFile, true).use { - it.write(text.toByteArray(StandardCharsets.UTF_8)) - } - cachedContentSnapshot = - (cachedContentSnapshot + text).takeLast(CACHE_SNAPSHOT_MAX_CHARS) - } catch (e: Exception) { - log.error("Failed to append build output to session file", e) - } - } - } - } - - /** - * Returns the last [WINDOW_SIZE_CHARS] characters from the session file for the editor to - * display (e.g. initial view or after rotation). Returns empty string if no content. - */ - fun getWindowForEditor(): String = - lock.withLock { - readTailFromFile(sessionFile, WINDOW_SIZE_CHARS) - } - - /** - * Returns the full build output from the session file. Used for [BuildOutputProvider.getBuildOutputContent] - * and share/copy. Returns empty string if no content. File I/O is performed on [Dispatchers.IO]. - */ - suspend fun getFullContent(): String = - withContext(Dispatchers.IO) { - lock.withLock { - if (!sessionFile.exists()) return@withContext "" - try { - sessionFile.readText() - } catch (e: Exception) { - log.error("Failed to read full build output from session file", e) - "" - } - } - } - - /** - * Reads a range from the session file (for future scroll/windowed UI). [offset] and [length] are - * in characters; implementation reads the corresponding byte range and decodes. - */ - fun getRange(offset: Int, length: Int): String = - lock.withLock { - if (!sessionFile.exists()) return "" - try { - val content = sessionFile.readText() - val start = max(0, offset).coerceAtMost(content.length) - val end = (start + length).coerceAtMost(content.length) - content.substring(start, end) - } catch (e: Exception) { - log.error("Failed to read range from build output session file", e) - "" - } - } - - /** - * Clears the session: deletes the session file and resets state. Call when a new build starts. - */ - fun clear() { - lock.withLock { - cachedContentSnapshot = "" - try { - if (sessionFile.exists()) { - sessionFile.delete() - } - } catch (e: Exception) { - log.error("Failed to delete build output session file", e) - } - } - } - - private fun readTailFromFile(file: File, maxChars: Int): String { - if (!file.exists()) return "" - try { - RandomAccessFile(file, "r").use { raf -> - val len = raf.length() - if (len == 0L) return "" - // UTF-8: up to 4 bytes per char; read enough bytes for maxChars, then decode and take last maxChars - val maxBytes = minOf(len, maxChars * 4L) - raf.seek(max(0, len - maxBytes)) - val bytes = ByteArray(maxBytes.toInt()) - raf.readFully(bytes) - val decoded = String(bytes, Charsets.UTF_8) - return if (decoded.length <= maxChars) decoded else decoded.takeLast(maxChars) - } - } catch (e: Exception) { - log.error("Failed to read tail from build output session file", e) - return "" - } - } - - companion object { - private const val SESSION_FILE_NAME = "build_output_session.txt" - private const val WINDOW_SIZE_CHARS = 512 * 1024 - /** Max length of [cachedContentSnapshot] to bound memory. */ - private const val CACHE_SNAPSHOT_MAX_CHARS = WINDOW_SIZE_CHARS - private val log = org.slf4j.LoggerFactory.getLogger(BuildOutputViewModel::class.java) - } +class BuildOutputViewModel( + application: Application, +) : AndroidViewModel(application) { + private val lock = ReentrantLock() + + /** + * Case-insensitive line filter applied to the *editor view* of the build output. + * The session file always receives the unfiltered text. + */ + val filterText = MutableStateFlow("") + + /** + * Thread-safe snapshot of content for synchronous [getShareableContent] without blocking. + * Updated on [append] and [clear]; primed on restore via [setCachedSnapshot]. + * Capped at [CACHE_SNAPSHOT_MAX_CHARS] to bound memory. + */ + @Volatile + private var cachedContentSnapshot: String = "" + + /** Returns the current cached snapshot for share/copy (non-blocking). */ + fun getCachedContentSnapshot(): String = cachedContentSnapshot + + /** Updates the cached snapshot (e.g. after loading full content on restore). Capped to [CACHE_SNAPSHOT_MAX_CHARS]. */ + fun setCachedSnapshot(content: String) { + cachedContentSnapshot = + if (content.length <= CACHE_SNAPSHOT_MAX_CHARS) { + content + } else { + content.takeLast(CACHE_SNAPSHOT_MAX_CHARS) + } + } + + private val sessionFile: File + get() = File(getApplication().cacheDir, SESSION_FILE_NAME) + + /** + * Appends text to the session file. File I/O is performed on a background dispatcher; call from + * any thread. Prefer calling before switching to Main so disk write does not block the UI. + */ + suspend fun append(text: String) { + if (text.isEmpty()) return + withContext(Dispatchers.IO) { + lock.withLock { + try { + FileOutputStream(sessionFile, true).use { + it.write(text.toByteArray(StandardCharsets.UTF_8)) + } + cachedContentSnapshot = + (cachedContentSnapshot + text).takeLast(CACHE_SNAPSHOT_MAX_CHARS) + } catch (e: Exception) { + log.error("Failed to append build output to session file", e) + } + } + } + } + + /** + * Returns the last [WINDOW_SIZE_CHARS] characters from the session file for the editor to + * display (e.g. initial view or after rotation). Returns empty string if no content. + */ + fun getWindowForEditor(): String = + lock.withLock { + readTailFromFile(sessionFile, WINDOW_SIZE_CHARS) + } + + /** + * Returns the full build output from the session file. Used for [BuildOutputProvider.getBuildOutputContent] + * and share/copy. Returns empty string if no content. File I/O is performed on [Dispatchers.IO]. + */ + suspend fun getFullContent(): String = + withContext(Dispatchers.IO) { + lock.withLock { + if (!sessionFile.exists()) return@withContext "" + try { + sessionFile.readText() + } catch (e: Exception) { + log.error("Failed to read full build output from session file", e) + "" + } + } + } + + /** + * Reads a range from the session file (for future scroll/windowed UI). [offset] and [length] are + * in characters; implementation reads the corresponding byte range and decodes. + */ + fun getRange( + offset: Int, + length: Int, + ): String = + lock.withLock { + if (!sessionFile.exists()) return "" + try { + val content = sessionFile.readText() + val start = max(0, offset).coerceAtMost(content.length) + val end = (start + length).coerceAtMost(content.length) + content.substring(start, end) + } catch (e: Exception) { + log.error("Failed to read range from build output session file", e) + "" + } + } + + /** + * Clears the session: deletes the session file and resets state. Call when a new build starts. + */ + fun clear() { + lock.withLock { + cachedContentSnapshot = "" + try { + if (sessionFile.exists()) { + sessionFile.delete() + } + } catch (e: Exception) { + log.error("Failed to delete build output session file", e) + } + } + } + + private fun readTailFromFile( + file: File, + maxChars: Int, + ): String { + if (!file.exists()) return "" + try { + RandomAccessFile(file, "r").use { raf -> + val len = raf.length() + if (len == 0L) return "" + // UTF-8: up to 4 bytes per char; read enough bytes for maxChars, then decode and take last maxChars + val maxBytes = minOf(len, maxChars * 4L) + raf.seek(max(0, len - maxBytes)) + val bytes = ByteArray(maxBytes.toInt()) + raf.readFully(bytes) + val decoded = String(bytes, Charsets.UTF_8) + return if (decoded.length <= maxChars) decoded else decoded.takeLast(maxChars) + } + } catch (e: Exception) { + log.error("Failed to read tail from build output session file", e) + return "" + } + } + + companion object { + /** + * Returns only the lines of [content] containing [query] (case-insensitive), each terminated + * with a newline. Returns [content] unchanged when [query] is empty. + */ + fun filterLines( + content: String, + query: String, + ): String { + if (query.isEmpty() || content.isEmpty()) return content + return buildString { + for (line in content.lineSequence()) { + if (line.contains(query, ignoreCase = true)) { + append(line).append('\n') + } + } + } + } + + private const val SESSION_FILE_NAME = "build_output_session.txt" + private const val WINDOW_SIZE_CHARS = 512 * 1024 + + /** Max length of [cachedContentSnapshot] to bound memory. */ + private const val CACHE_SNAPSHOT_MAX_CHARS = WINDOW_SIZE_CHARS + private val log = org.slf4j.LoggerFactory.getLogger(BuildOutputViewModel::class.java) + } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/IDELogsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/IDELogsViewModel.kt index 8f31788fc7..0a6e0575e3 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/IDELogsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/IDELogsViewModel.kt @@ -1,6 +1,44 @@ package com.itsaky.androidide.viewmodel +import ch.qos.logback.classic.Level +import com.itsaky.androidide.logging.GlobalBufferAppender +import com.itsaky.androidide.utils.FeatureFlags +import com.itsaky.androidide.utils.ILogger + /** + * Consumes IDE logs from [GlobalBufferAppender]. The ViewModel (not the fragment) is the + * consumer so that the appender's buffer replay happens exactly once per ViewModel lifetime -- + * registering on every fragment view recreation would re-submit the replayed lines into the + * retained log history and duplicate them. + * * @author Akash Yadav */ -class IDELogsViewModel : LogViewModel() +class IDELogsViewModel : + LogViewModel(), + GlobalBufferAppender.Consumer { + override val logLevel: Level + get() = if (FeatureFlags.isDebugLoggingEnabled) Level.DEBUG else Level.INFO + + init { + GlobalBufferAppender.registerConsumer(this) + } + + override fun consume( + level: Level, + message: String, + ) = submit(level.toILoggerLevel(), message) + + override fun onCleared() { + GlobalBufferAppender.unregisterConsumer(this) + super.onCleared() + } +} + +private fun Level.toILoggerLevel(): ILogger.Level = + when { + levelInt <= Level.TRACE_INT -> ILogger.Level.VERBOSE + levelInt <= Level.DEBUG_INT -> ILogger.Level.DEBUG + levelInt <= Level.INFO_INT -> ILogger.Level.INFO + levelInt <= Level.WARN_INT -> ILogger.Level.WARNING + else -> ILogger.Level.ERROR + } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt index 53e7466101..b3a68857d4 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt @@ -1,23 +1,31 @@ package com.itsaky.androidide.viewmodel import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import com.itsaky.androidide.logs.LogBuffer +import com.itsaky.androidide.models.LogFilter import com.itsaky.androidide.models.LogLine +import com.itsaky.androidide.utils.ILogger import com.itsaky.androidide.viewmodel.LogViewModel.Companion.LOG_FREQUENCY import com.itsaky.androidide.viewmodel.LogViewModel.Companion.MAX_CHUNK_SIZE import com.itsaky.androidide.viewmodel.LogViewModel.Companion.MAX_LINE_COUNT import com.itsaky.androidide.viewmodel.LogViewModel.Companion.TRIM_ON_LINE_COUNT +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -54,35 +62,62 @@ abstract class LogViewModel : ViewModel() { const val MAX_LINE_COUNT = TRIM_ON_LINE_COUNT - 300 /** - * The number of log events that are replayed to consumers. + * The number of live log events that may be buffered for a slow collector. */ - const val EVENT_REPLAY_COUNT = TRIM_ON_LINE_COUNT + const val EVENT_BUFFER_COUNT = TRIM_ON_LINE_COUNT } sealed interface UiEvent { + /** Replace the entire log view content. */ + data class SetText( + val text: String, + ) : UiEvent + data class Append( val text: String, ) : UiEvent } - private val logs = - MutableSharedFlow( - replay = 0, - extraBufferCapacity = EVENT_REPLAY_COUNT, + private val buffer = LogBuffer(TRIM_ON_LINE_COUNT, MAX_LINE_COUNT) + + private val liveEntries = + MutableSharedFlow( + replay = EVENT_BUFFER_COUNT, + extraBufferCapacity = EVENT_BUFFER_COUNT, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) - @OptIn(FlowPreview::class) - val uiEvents: SharedFlow = - logs - .map { if (it.endsWith("\n")) it else "$it\n" } - .chunkedBySizeOrTime(MAX_CHUNK_SIZE, LOG_FREQUENCY) - .map { UiEvent.Append(it) } - .shareIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - replay = EVENT_REPLAY_COUNT, - ) + private val _filter = MutableStateFlow(LogFilter.NONE) + val filter: StateFlow = _filter.asStateFlow() + + // Bumped when the buffer is cleared, to force a re-render without a filter change + private val generation = MutableStateFlow(0) + + /** + * The log view content as UI events: on every (re)collection or filter change, a + * [UiEvent.SetText] snapshot of the retained history filtered by the current [filter], + * followed by [UiEvent.Append]s for matching live lines. The snapshot is taken after + * subscribing to the live stream and stitched by sequence number, so no line is + * missed or duplicated in between. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val uiEvents: Flow = + combine(_filter, generation) { filter, _ -> filter } + .flatMapLatest { filter -> + channelFlow { + var snapshotSeq = 0L + liveEntries + .onSubscription { + val (text, lastSeq) = buffer.snapshotFiltered(filter) + snapshotSeq = lastSeq + send(UiEvent.SetText(text)) + }.filter { entry -> + entry.seq > snapshotSeq && filter.matches(entry.level, entry.text) + }.map { entry -> entry.text } + .chunkedBySizeOrTime(MAX_CHUNK_SIZE, LOG_FREQUENCY) + .collect { chunk -> send(UiEvent.Append(chunk)) } + } + }.flowOn(Dispatchers.Default) /** * Submit a log line. @@ -94,6 +129,8 @@ abstract class LogViewModel : ViewModel() { line: LogLine, simpleFormattingEnabled: Boolean = false, ) { + // Copy what we need before recycling -- LogLine instances are pooled + val level = line.level val lineString = if (simpleFormattingEnabled) { line.toSimpleString() @@ -102,17 +139,52 @@ abstract class LogViewModel : ViewModel() { } line.recycle() - submit(lineString) + submit(level, lineString) } /** - * Submit a log line. + * Submit a log line without level metadata. * * @param line The log line to submit. */ fun submit(line: String) { - logs.tryEmit(line) + submit(level = null, line = line) } + + // Keeps buffer mutation and live emission atomic + private val eventLock = Any() + + /** + * Submit a log line. + * + * @param level The severity of the line, or `null` if unknown. + * @param line The log line to submit. + */ + fun submit( + level: ILogger.Level?, + line: String, + ) { + val text = if (line.endsWith("\n")) line else "$line\n" + synchronized(eventLock) { + val entry = buffer.append(level, text) + liveEntries.tryEmit(entry) + } + } + + fun setFilter(filter: LogFilter) { + _filter.value = filter + } + + /** Clear the retained log history and re-render the (now empty) view. */ + fun clear() { + synchronized(eventLock) { + buffer.clear() + generation.update { it + 1 } + } + } + + /** The full retained history, ignoring the active filter. */ + fun snapshotUnfiltered(): String = buffer.snapshotAll() } /** diff --git a/app/src/main/res/layout/fragment_log.xml b/app/src/main/res/layout/fragment_log.xml index f48ff2f8a3..5e92538eba 100644 --- a/app/src/main/res/layout/fragment_log.xml +++ b/app/src/main/res/layout/fragment_log.xml @@ -1,25 +1,30 @@ - + - + + + + + + diff --git a/app/src/main/res/layout/fragment_non_editable_editor.xml b/app/src/main/res/layout/fragment_non_editable_editor.xml index 90bcc26919..5e92538eba 100644 --- a/app/src/main/res/layout/fragment_non_editable_editor.xml +++ b/app/src/main/res/layout/fragment_non_editable_editor.xml @@ -1,25 +1,30 @@ - - - - - + + + + + + + + + + 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 58ddca1bfc..cb37bf19ba 100644 --- a/app/src/main/res/layout/layout_editor_bottom_sheet.xml +++ b/app/src/main/res/layout/layout_editor_bottom_sheet.xml @@ -1,119 +1,114 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_log_filter_bar.xml b/app/src/main/res/layout/layout_log_filter_bar.xml new file mode 100644 index 0000000000..ebe9378ef9 --- /dev/null +++ b/app/src/main/res/layout/layout_log_filter_bar.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt b/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt new file mode 100644 index 0000000000..3c18615aae --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt @@ -0,0 +1,96 @@ +/* + * 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.logs + +import com.itsaky.androidide.models.LogFilter +import com.itsaky.androidide.utils.ILogger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LogBufferTest { + @Test + fun `sequence numbers increase monotonically`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + val first = buffer.append(null, "a\n") + val second = buffer.append(null, "b\n") + assertTrue(second.seq > first.seq) + } + + @Test + fun `snapshot returns all entries and the last sequence`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + buffer.append(ILogger.Level.DEBUG, "debug\n") + val last = buffer.append(ILogger.Level.ERROR, "error\n") + + val (text, lastSeq) = buffer.snapshotFiltered(LogFilter.NONE) + assertEquals("debug\nerror\n", text) + assertEquals(last.seq, lastSeq) + } + + @Test + fun `snapshot applies the filter but reports the newest seq regardless`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + buffer.append(ILogger.Level.ERROR, "error\n") + val last = buffer.append(ILogger.Level.DEBUG, "debug\n") + + val (text, lastSeq) = + buffer.snapshotFiltered(LogFilter(enabledLevels = setOf(ILogger.Level.ERROR))) + assertEquals("error\n", text) + assertEquals(last.seq, lastSeq) + } + + @Test + fun `empty buffer snapshots to empty text and seq 0`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + val (text, lastSeq) = buffer.snapshotFiltered(LogFilter.NONE) + assertEquals("", text) + assertEquals(0L, lastSeq) + } + + @Test + fun `buffer trims to maxEntryCount once trimOnEntryCount is exceeded`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + repeat(11) { buffer.append(null, "line$it\n") } + + val (text, _) = buffer.snapshotFiltered(LogFilter.NONE) + val lines = text.trim().lines() + assertEquals(5, lines.size) + assertEquals("line6", lines.first()) + assertEquals("line10", lines.last()) + } + + @Test + fun `clear empties the buffer but keeps sequence increasing`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + val beforeClear = buffer.append(null, "a\n") + buffer.clear() + + assertEquals("", buffer.snapshotAll()) + val afterClear = buffer.append(null, "b\n") + assertTrue(afterClear.seq > beforeClear.seq) + } + + @Test + fun `snapshotAll ignores filters`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + buffer.append(ILogger.Level.DEBUG, "debug\n") + buffer.append(ILogger.Level.ERROR, "error\n") + assertEquals("debug\nerror\n", buffer.snapshotAll()) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/models/LogFilterTest.kt b/app/src/test/java/com/itsaky/androidide/models/LogFilterTest.kt new file mode 100644 index 0000000000..a7fa8fa3b2 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/models/LogFilterTest.kt @@ -0,0 +1,73 @@ +/* + * 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.models + +import com.itsaky.androidide.utils.ILogger +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LogFilterTest { + @Test + fun `default filter matches everything and is inactive`() { + val filter = LogFilter.NONE + assertFalse(filter.isActive) + assertTrue(filter.matches(ILogger.Level.VERBOSE, "anything")) + assertTrue(filter.matches(null, "anything")) + } + + @Test + fun `level filter hides disabled levels`() { + val filter = LogFilter(enabledLevels = setOf(ILogger.Level.ERROR)) + assertTrue(filter.isActive) + assertTrue(filter.matches(ILogger.Level.ERROR, "boom")) + assertFalse(filter.matches(ILogger.Level.DEBUG, "noise")) + assertFalse(filter.matches(ILogger.Level.WARNING, "warn")) + } + + @Test + fun `lines without level always pass the level check`() { + val filter = LogFilter(enabledLevels = setOf(ILogger.Level.ERROR)) + assertTrue(filter.matches(null, "gradle output line")) + } + + @Test + fun `text filter is case-insensitive contains`() { + val filter = LogFilter(text = "NullPointer") + assertTrue(filter.isActive) + assertTrue(filter.matches(null, "java.lang.nullpointerexception at ...")) + assertTrue(filter.matches(ILogger.Level.ERROR, "NULLPOINTER")) + assertFalse(filter.matches(null, "IllegalStateException")) + } + + @Test + fun `level and text filters combine`() { + val filter = LogFilter(enabledLevels = setOf(ILogger.Level.ERROR), text = "boom") + assertTrue(filter.matches(ILogger.Level.ERROR, "boom happened")) + assertFalse(filter.matches(ILogger.Level.ERROR, "other error")) + assertFalse(filter.matches(ILogger.Level.DEBUG, "boom happened")) + } + + @Test + fun `empty enabled levels hides all leveled lines but keeps unleveled ones`() { + val filter = LogFilter(enabledLevels = emptySet()) + assertTrue(filter.isActive) + assertFalse(filter.matches(ILogger.Level.INFO, "info")) + assertTrue(filter.matches(null, "plain")) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt new file mode 100644 index 0000000000..7d9d11c237 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt @@ -0,0 +1,63 @@ +/* + * 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.viewmodel + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class BuildOutputFilterTest { + @Test + fun `empty query returns content unchanged`() { + val content = "> Task :app:compileDebugKotlin\nBUILD SUCCESSFUL\n" + assertSame(content, BuildOutputViewModel.filterLines(content, "")) + } + + @Test + fun `only matching lines are kept, case-insensitively`() { + val content = "> Task :app:compileDebugKotlin\nwarning: deprecated API\nBUILD SUCCESSFUL\n" + assertEquals( + "> Task :app:compileDebugKotlin\n", + BuildOutputViewModel.filterLines(content, "task"), + ) + } + + @Test + fun `no matches yields empty text`() { + val content = "BUILD SUCCESSFUL in 1s\n" + assertEquals("", BuildOutputViewModel.filterLines(content, "error")) + } + + @Test + fun `multi-line batch keeps every matching line`() { + val content = "> Task :a\nnoise\n> Task :b\nnoise\n" + assertEquals( + "> Task :a\n> Task :b\n", + BuildOutputViewModel.filterLines(content, "> Task"), + ) + } + + @Test + fun `batch without trailing newline still terminates matched lines`() { + val content = "> Task :a\n> Task :b" + assertEquals( + "> Task :a\n> Task :b\n", + BuildOutputViewModel.filterLines(content, "> Task"), + ) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt new file mode 100644 index 0000000000..ba72d57c83 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt @@ -0,0 +1,198 @@ +/* + * 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.viewmodel + +import com.itsaky.androidide.models.LogFilter +import com.itsaky.androidide.models.LogLine +import com.itsaky.androidide.utils.ILogger +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for the snapshot-then-tail pipeline in [LogViewModel]. Uses [runBlocking] with real + * dispatchers because [LogViewModel.uiEvents] runs on [kotlinx.coroutines.Dispatchers.Default] + * and chunks output on a real 50ms cadence. + */ +class LogViewModelTest { + private class TestLogViewModel : LogViewModel() + + companion object { + private const val EVENT_TIMEOUT_MS = 5000L + } + + /** Collects [LogViewModel.uiEvents] into a channel inside [block]'s scope. */ + private fun withCollectedEvents( + viewModel: LogViewModel, + block: suspend (events: Channel) -> Unit, + ) = runBlocking { + coroutineScope { + val events = Channel(Channel.UNLIMITED) + launch { viewModel.uiEvents.collect { events.send(it) } } + try { + withTimeout(EVENT_TIMEOUT_MS) { + block(events) + } + } finally { + coroutineContext.cancelChildren() + } + } + } + + @Test + fun `collection starts with a snapshot of submitted lines`() { + val viewModel = TestLogViewModel() + viewModel.submit(ILogger.Level.ERROR, "first") + viewModel.submit(ILogger.Level.DEBUG, "second") + + withCollectedEvents(viewModel) { events -> + val snapshot = events.receive() + assertTrue(snapshot is LogViewModel.UiEvent.SetText) + assertEquals("first\nsecond\n", (snapshot as LogViewModel.UiEvent.SetText).text) + } + } + + @Test + fun `live lines arrive as appends after the snapshot with no gaps or duplicates`() { + val viewModel = TestLogViewModel() + viewModel.submit(null, "old") + + withCollectedEvents(viewModel) { events -> + assertEquals("old\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + + repeat(5) { viewModel.submit(null, "live$it") } + + val appended = StringBuilder() + while (!appended.endsWith("live4\n")) { + val event = events.receive() + assertTrue(event is LogViewModel.UiEvent.Append) + appended.append((event as LogViewModel.UiEvent.Append).text) + } + assertEquals("live0\nlive1\nlive2\nlive3\nlive4\n", appended.toString()) + } + } + + @Test + fun `changing the filter re-renders history with only matching lines`() { + val viewModel = TestLogViewModel() + viewModel.submit(ILogger.Level.ERROR, "error line") + viewModel.submit(ILogger.Level.DEBUG, "debug line") + viewModel.setFilter(LogFilter(enabledLevels = setOf(ILogger.Level.ERROR))) + + withCollectedEvents(viewModel) { events -> + assertEquals("error line\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + } + } + + @Test + fun `live lines failing the filter are not appended`() { + val viewModel = TestLogViewModel() + viewModel.setFilter(LogFilter(enabledLevels = setOf(ILogger.Level.ERROR))) + + withCollectedEvents(viewModel) { events -> + assertEquals("", (events.receive() as LogViewModel.UiEvent.SetText).text) + + viewModel.submit(ILogger.Level.DEBUG, "hidden") + viewModel.submit(ILogger.Level.ERROR, "visible") + + val append = events.receive() + assertTrue(append is LogViewModel.UiEvent.Append) + assertEquals("visible\n", (append as LogViewModel.UiEvent.Append).text) + } + } + + @Test + fun `filter change while collecting emits a new snapshot`() { + val viewModel = TestLogViewModel() + viewModel.submit(ILogger.Level.ERROR, "error line") + viewModel.submit(ILogger.Level.DEBUG, "debug line") + + withCollectedEvents(viewModel) { events -> + assertEquals( + "error line\ndebug line\n", + (events.receive() as LogViewModel.UiEvent.SetText).text, + ) + + viewModel.setFilter(LogFilter(text = "debug")) + + var event = events.receive() + // Skip any in-flight appends from the previous generation + while (event !is LogViewModel.UiEvent.SetText) { + event = events.receive() + } + assertEquals("debug line\n", event.text) + } + } + + @Test + fun `clear empties history and re-renders`() { + val viewModel = TestLogViewModel() + viewModel.submit(null, "stale") + + withCollectedEvents(viewModel) { events -> + assertEquals("stale\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + + viewModel.clear() + + var event = events.receive() + while (event !is LogViewModel.UiEvent.SetText) { + event = events.receive() + } + assertEquals("", event.text) + assertEquals("", viewModel.snapshotUnfiltered()) + } + } + + @Test + fun `re-collection replays history as a snapshot without duplicates`() { + val viewModel = TestLogViewModel() + viewModel.submit(null, "line") + + // First collection (e.g. before a configuration change) + withCollectedEvents(viewModel) { events -> + assertEquals("line\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + } + + // Second collection sees the same content once, via the snapshot + withCollectedEvents(viewModel) { events -> + assertEquals("line\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + } + } + + @Test + fun `LogLine level and text are captured before the instance is recycled`() { + val viewModel = TestLogViewModel() + val line = LogLine.obtain(ILogger.Level.ERROR, "MyTag", "kaboom") + val expected = line.toSimpleString() + + viewModel.submit(line, simpleFormattingEnabled = true) + // The pooled instance was recycled inside submit(); the retained entry must not change + viewModel.setFilter(LogFilter(enabledLevels = setOf(ILogger.Level.ERROR))) + + assertEquals("$expected\n", viewModel.snapshotUnfiltered()) + withCollectedEvents(viewModel) { events -> + assertEquals("$expected\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + } + } +} diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt index 80a2da92c5..59885284c9 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt @@ -42,6 +42,7 @@ import com.itsaky.androidide.utils.SingleTextWatcher import com.itsaky.androidide.utils.applyLongPressRecursively import io.github.rosemoe.sora.widget.EditorSearcher.SearchOptions import java.util.regex.Pattern +import java.util.regex.PatternSyntaxException /** * The search layout in [IDEEditor]. @@ -49,251 +50,302 @@ import java.util.regex.Pattern * @author Akash Yadav */ @SuppressLint("ViewConstructor") // Always created dynamically -class EditorSearchLayout(context: Context, val editor: IDEEditor) : FrameLayout(context) { - private val collapsedSheetMargin = - context.resources.getDimensionPixelSize(R.dimen.editor_sheet_peek_height) - - var onSearchModeChanged: ((isActive: Boolean) -> Unit)? = null - - private var searchInputTextWatcher: TextWatcher? = null - private var searchOptions = SearchOptions(true, false) - private val findInFileBinding: LayoutFindInFileBinding = LayoutFindInFileBinding.inflate(LayoutInflater.from(context)) - private val optionsMenu: PopupMenu - - init { - findInFileBinding.prev.setOnClickListener(::onSearchActionClick) - findInFileBinding.next.setOnClickListener(::onSearchActionClick) - findInFileBinding.replace.setOnClickListener(::onSearchActionClick) - findInFileBinding.close.setOnClickListener(::onSearchActionClick) - findInFileBinding.root.applyLongPressRecursively { - TooltipManager.showIdeCategoryTooltip( - context = this.context, - anchorView = this, - tag = TooltipTag.DIALOG_FIND_IN_FILE - ) - true - } - - optionsMenu = PopupMenu(context, findInFileBinding.moreOptions, Gravity.TOP) - optionsMenu.menu.add(0, 0, 0, R.string.msg_ignore_case).apply { - isCheckable = true - isChecked = true - } - - optionsMenu.menu.add(0, 1, 0, R.string.msg_use_regex).apply { - isCheckable = true - isChecked = false - } - - optionsMenu.setOnMenuItemClickListener { - return@setOnMenuItemClickListener if (it.isCheckable) { - it.isChecked = !it.isChecked - - val caseInsensitive = searchOptions.caseInsensitive - val regex = searchOptions.type == SearchOptions.TYPE_REGULAR_EXPRESSION - searchOptions = - when (it.itemId) { - 0 -> SearchOptions(it.isChecked, regex) - 1 -> SearchOptions(caseInsensitive, it.isChecked) - else -> searchOptions - } - editor.searcher.updateSearchOptions(searchOptions) - - true - } else false - } - - findInFileBinding.root.visibility = GONE - findInFileBinding.moreOptions.apply { - setOnClickListener { - showPopupMenu(findInFileBinding.moreOptions) - } - setOnLongClickListener { - TooltipManager.showIdeCategoryTooltip( - context = this@EditorSearchLayout.context, - anchorView = this, - tag = TooltipTag.DIALOG_FIND_IN_FILE_OPTIONS - ) - true - } - } - ViewCompat.setOnApplyWindowInsetsListener(findInFileBinding.root) { _, insets -> - updateActionsBottomMargin(insets.isVisible(WindowInsetsCompat.Type.ime())) - insets - } - - addView( - findInFileBinding.root, - LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - ) - } - - private fun showPopupMenu(anchorView: View) { - val binding = - SearchOptionsPopupMenuBinding.inflate(LayoutInflater.from(context), null, false) - - val popupWindow = PopupWindow( - binding.root, - LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT, - ).apply { - elevation = 2f - isOutsideTouchable = true - softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING - } - - val tooltipListener = OnLongClickListener { view -> - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = TooltipTag.DIALOG_FIND_IN_FILE_OPTIONS - ) - popupWindow.dismiss() - true - } - - binding.layoutSearchOptions.setOnLongClickListener(tooltipListener) - - val caseInsensitive = searchOptions.caseInsensitive - val regex = searchOptions.type == SearchOptions.TYPE_REGULAR_EXPRESSION - - fun onIgnoreCaseToggled() { - binding.checkboxIgnoreCase.isChecked = !binding.checkboxIgnoreCase.isChecked - searchOptions = SearchOptions(binding.checkboxIgnoreCase.isChecked, regex) - editor.searcher.updateSearchOptions(searchOptions) - popupWindow.dismiss() - } - - fun handleIgnoreCaseCheckedChange(isChecked: Boolean) { - searchOptions = SearchOptions(isChecked, regex) - editor.searcher.updateSearchOptions(searchOptions) - popupWindow.dismiss() - } - - binding.layoutIgnoreCase.apply { - setOnClickListener { onIgnoreCaseToggled() } - setOnLongClickListener(tooltipListener) - } - - binding.checkboxIgnoreCase.apply { - isChecked = caseInsensitive - setOnCheckedChangeListener { _, isChecked -> handleIgnoreCaseCheckedChange(isChecked) } - setOnLongClickListener(tooltipListener) - } - - fun onUseRegexToggled() { - binding.checkboxUseRegex.isChecked = !binding.checkboxUseRegex.isChecked - searchOptions = SearchOptions(caseInsensitive, binding.checkboxUseRegex.isChecked) - editor.searcher.updateSearchOptions(searchOptions) - popupWindow.dismiss() - } - - fun handleRegexCheckedChange(isChecked: Boolean) { - searchOptions = SearchOptions(caseInsensitive, isChecked) - editor.searcher.updateSearchOptions(searchOptions) - popupWindow.dismiss() - } - - binding.layoutUseRegex.apply { - setOnClickListener { onUseRegexToggled() } - setOnLongClickListener(tooltipListener) - } - - binding.checkboxUseRegex.apply { - isChecked = regex - setOnCheckedChangeListener { _, isChecked -> handleRegexCheckedChange(isChecked) } - setOnLongClickListener(tooltipListener) - } - - popupWindow.showAsDropDown(anchorView, 0, -anchorView.height) - } - - fun beginSearchMode() { - searchInputTextWatcher = SearchInputTextChangeListener(editor) - findInFileBinding.searchInput.addTextChangedListener(searchInputTextWatcher) - findInFileBinding.searchInput.setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_NEXT) { - onSearchActionClick(findInFileBinding.next) - } - false - } - findInFileBinding.root.visibility = VISIBLE - onSearchModeChanged?.invoke(true) - - findInFileBinding.searchInput.requestFocus() - findInFileBinding.searchInput.post { - ViewCompat.getWindowInsetsController(findInFileBinding.searchInput)?.show(WindowInsetsCompat.Type.ime()) - } - } - - private fun onSearchActionClick(v: View) { - val searcher = editor.searcher - if (v.id == findInFileBinding.close.id) { - if (this.searchInputTextWatcher == null) { - return - } - findInFileBinding.searchInput.removeTextChangedListener(this.searchInputTextWatcher) - findInFileBinding.root.visibility = GONE - this.searchInputTextWatcher = null - searcher.onClose() - onSearchModeChanged?.invoke(false) - } - if (!searcher.hasQuery()) { - return - } - if (v.id == findInFileBinding.prev.id) { - searcher.gotoPrevious() - return - } - if (v.id == findInFileBinding.next.id) { - searcher.gotoNext() - return - } - if (v.id == findInFileBinding.replace.id) { - doReplace(editor) - } - } - - inner class SearchInputTextChangeListener(val editor: IDEEditor?) : SingleTextWatcher() { - - override fun onTextChanged( - s: CharSequence, - start: Int, - before: Int, - count: Int, - ) { - if (editor == null) { - return - } - if (TextUtils.isEmpty(s)) { - editor.searcher.stopSearch() - return - } - - // Handle bad regexp - val query = - s.toString().let { - if (searchOptions.type == SearchOptions.TYPE_REGULAR_EXPRESSION) { - try { - Pattern.compile(it) - it - } catch (_: Throwable) { - "" - } - } else { - it - } - } - - if (query.isNotBlank()) { - editor.searcher.search(query, searchOptions) - } - } - } - - private fun updateActionsBottomMargin(isImeVisible: Boolean) { - findInFileBinding.actionsContainer.updateLayoutParams { - bottomMargin = if (isImeVisible) 0 else collapsedSheetMargin - } - } +class EditorSearchLayout( + context: Context, + val editor: IDEEditor, + showReplaceAction: Boolean = true, + applyCollapsedSheetMargin: Boolean = true, +) : FrameLayout(context) { + // Hosts above the collapsed bottom sheet need a margin so the action row stays visible; + // hosts inside the sheet itself don't. + private val collapsedSheetMargin = + if (applyCollapsedSheetMargin) { + context.resources.getDimensionPixelSize(R.dimen.editor_sheet_peek_height) + } else { + 0 + } + + var onSearchModeChanged: ((isActive: Boolean) -> Unit)? = null + + private var searchInputTextWatcher: TextWatcher? = null + private var searchOptions = SearchOptions(true, false) + private val findInFileBinding: LayoutFindInFileBinding = LayoutFindInFileBinding.inflate(LayoutInflater.from(context)) + private val optionsMenu: PopupMenu + + init { + findInFileBinding.prev.setOnClickListener(::onSearchActionClick) + findInFileBinding.next.setOnClickListener(::onSearchActionClick) + findInFileBinding.replace.setOnClickListener(::onSearchActionClick) + findInFileBinding.close.setOnClickListener(::onSearchActionClick) + if (!showReplaceAction) { + // Read-only hosts (log/output views) cannot replace text + findInFileBinding.replace.visibility = GONE + } + findInFileBinding.root.applyLongPressRecursively { + TooltipManager.showIdeCategoryTooltip( + context = this.context, + anchorView = this, + tag = TooltipTag.DIALOG_FIND_IN_FILE, + ) + true + } + + optionsMenu = PopupMenu(context, findInFileBinding.moreOptions, Gravity.TOP) + optionsMenu.menu.add(0, 0, 0, R.string.msg_ignore_case).apply { + isCheckable = true + isChecked = true + } + + optionsMenu.menu.add(0, 1, 0, R.string.msg_use_regex).apply { + isCheckable = true + isChecked = false + } + + optionsMenu.setOnMenuItemClickListener { + return@setOnMenuItemClickListener if (it.isCheckable) { + it.isChecked = !it.isChecked + + val caseInsensitive = searchOptions.caseInsensitive + val regex = searchOptions.type == SearchOptions.TYPE_REGULAR_EXPRESSION + searchOptions = + when (it.itemId) { + 0 -> SearchOptions(it.isChecked, regex) + 1 -> SearchOptions(caseInsensitive, it.isChecked) + else -> searchOptions + } + editor.searcher.updateSearchOptions(searchOptions) + + true + } else { + false + } + } + + findInFileBinding.root.visibility = GONE + findInFileBinding.moreOptions.apply { + setOnClickListener { + showPopupMenu(findInFileBinding.moreOptions) + } + setOnLongClickListener { + TooltipManager.showIdeCategoryTooltip( + context = this@EditorSearchLayout.context, + anchorView = this, + tag = TooltipTag.DIALOG_FIND_IN_FILE_OPTIONS, + ) + true + } + } + ViewCompat.setOnApplyWindowInsetsListener(findInFileBinding.root) { _, insets -> + updateActionsBottomMargin(insets.isVisible(WindowInsetsCompat.Type.ime())) + insets + } + + addView( + findInFileBinding.root, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT), + ) + } + + private fun showPopupMenu(anchorView: View) { + val binding = + SearchOptionsPopupMenuBinding.inflate(LayoutInflater.from(context), null, false) + + val popupWindow = + PopupWindow( + binding.root, + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT, + ).apply { + elevation = 2f + isOutsideTouchable = true + softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + } + + val tooltipListener = + OnLongClickListener { view -> + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = TooltipTag.DIALOG_FIND_IN_FILE_OPTIONS, + ) + popupWindow.dismiss() + true + } + + binding.layoutSearchOptions.setOnLongClickListener(tooltipListener) + + val caseInsensitive = searchOptions.caseInsensitive + val regex = searchOptions.type == SearchOptions.TYPE_REGULAR_EXPRESSION + + fun onIgnoreCaseToggled() { + binding.checkboxIgnoreCase.isChecked = !binding.checkboxIgnoreCase.isChecked + searchOptions = SearchOptions(binding.checkboxIgnoreCase.isChecked, regex) + editor.searcher.updateSearchOptions(searchOptions) + popupWindow.dismiss() + } + + fun handleIgnoreCaseCheckedChange(isChecked: Boolean) { + searchOptions = SearchOptions(isChecked, regex) + editor.searcher.updateSearchOptions(searchOptions) + popupWindow.dismiss() + } + + binding.layoutIgnoreCase.apply { + setOnClickListener { onIgnoreCaseToggled() } + setOnLongClickListener(tooltipListener) + } + + binding.checkboxIgnoreCase.apply { + isChecked = caseInsensitive + setOnCheckedChangeListener { _, isChecked -> handleIgnoreCaseCheckedChange(isChecked) } + setOnLongClickListener(tooltipListener) + } + + fun onUseRegexToggled() { + binding.checkboxUseRegex.isChecked = !binding.checkboxUseRegex.isChecked + searchOptions = SearchOptions(caseInsensitive, binding.checkboxUseRegex.isChecked) + editor.searcher.updateSearchOptions(searchOptions) + popupWindow.dismiss() + } + + fun handleRegexCheckedChange(isChecked: Boolean) { + searchOptions = SearchOptions(caseInsensitive, isChecked) + editor.searcher.updateSearchOptions(searchOptions) + popupWindow.dismiss() + } + + binding.layoutUseRegex.apply { + setOnClickListener { onUseRegexToggled() } + setOnLongClickListener(tooltipListener) + } + + binding.checkboxUseRegex.apply { + isChecked = regex + setOnCheckedChangeListener { _, isChecked -> handleRegexCheckedChange(isChecked) } + setOnLongClickListener(tooltipListener) + } + + popupWindow.showAsDropDown(anchorView, 0, -anchorView.height) + } + + fun beginSearchMode() { + searchInputTextWatcher = SearchInputTextChangeListener(editor) + findInFileBinding.searchInput.addTextChangedListener(searchInputTextWatcher) + findInFileBinding.searchInput.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_NEXT) { + onSearchActionClick(findInFileBinding.next) + } + false + } + findInFileBinding.root.visibility = VISIBLE + onSearchModeChanged?.invoke(true) + + findInFileBinding.searchInput.requestFocus() + findInFileBinding.searchInput.post { + ViewCompat.getWindowInsetsController(findInFileBinding.searchInput)?.show(WindowInsetsCompat.Type.ime()) + } + } + + fun isSearchModeActive(): Boolean = searchInputTextWatcher != null + + /** + * Re-run the current query against the editor's (possibly changed) content, + * or stop the search if there is no query. + */ + fun refreshSearch() { + if (!isSearchModeActive()) { + return + } + + val query = findInFileBinding.searchInput.text?.toString() ?: "" + val isValidQuery = + query.isNotBlank() && + ( + searchOptions.type != SearchOptions.TYPE_REGULAR_EXPRESSION || + try { + Pattern.compile(query) + true + } catch (_: PatternSyntaxException) { + false + } + ) + + if (isValidQuery) { + editor.searcher.search(query, searchOptions) + } else { + editor.searcher.stopSearch() + } + } + + private fun onSearchActionClick(v: View) { + val searcher = editor.searcher + if (v.id == findInFileBinding.close.id) { + if (this.searchInputTextWatcher == null) { + return + } + findInFileBinding.searchInput.removeTextChangedListener(this.searchInputTextWatcher) + findInFileBinding.root.visibility = GONE + this.searchInputTextWatcher = null + searcher.onClose() + onSearchModeChanged?.invoke(false) + } + if (!searcher.hasQuery()) { + return + } + if (v.id == findInFileBinding.prev.id) { + searcher.gotoPrevious() + return + } + if (v.id == findInFileBinding.next.id) { + searcher.gotoNext() + return + } + if (v.id == findInFileBinding.replace.id) { + doReplace(editor) + } + } + + inner class SearchInputTextChangeListener( + val editor: IDEEditor?, + ) : SingleTextWatcher() { + override fun onTextChanged( + s: CharSequence, + start: Int, + before: Int, + count: Int, + ) { + if (editor == null) { + return + } + if (TextUtils.isEmpty(s)) { + editor.searcher.stopSearch() + return + } + + // Handle bad regexp + val query = + s.toString().let { + if (searchOptions.type == SearchOptions.TYPE_REGULAR_EXPRESSION) { + try { + Pattern.compile(it) + it + } catch (_: PatternSyntaxException) { + "" + } + } else { + it + } + } + + if (query.isNotBlank()) { + editor.searcher.search(query, searchOptions) + } + } + } + + private fun updateActionsBottomMargin(isImeVisible: Boolean) { + findInFileBinding.actionsContainer.updateLayoutParams { + bottomMargin = if (isImeVisible) 0 else collapsedSheetMargin + } + } } 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 f9322288c3..3fd18b46be 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -26,6 +26,8 @@ object TooltipTag { const val PROJECT_AGENT = "project.agent" const val OUTPUT_CLEAR = "output.clear" const val OUTPUT_SHARE_EXTERNAL = "output.share.external" + const val OUTPUT_SEARCH = "output.search" + const val OUTPUT_FILTER = "output.filter" 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/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt b/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt index d672af33b5..fc107a47c8 100644 --- a/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt +++ b/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt @@ -17,6 +17,7 @@ package com.itsaky.androidide.logging +import android.util.Log import ch.qos.logback.classic.Level import ch.qos.logback.classic.spi.ILoggingEvent import ch.qos.logback.core.AppenderBase @@ -33,13 +34,19 @@ import java.util.concurrent.atomic.AtomicInteger * @author Akash Yadav */ class GlobalBufferAppender : AppenderBase() { - interface Consumer { val logLevel: Level - fun consume(message: String) + + fun consume( + level: Level, + message: String, + ) } - private data class LogEvent(val level: Level, val message: String) + private data class LogEvent( + val level: Level, + val message: String, + ) private val logLayout = IDELogFormatLayout(false) @@ -59,7 +66,7 @@ class GlobalBufferAppender : AppenderBase() { dispatchTo( consumer = consumer, level = message.level, - message = message.message + message = message.message, ) } } @@ -71,7 +78,10 @@ class GlobalBufferAppender : AppenderBase() { consumers.remove(consumer) } - private fun dispatch(level: Level, message: String) { + private fun dispatch( + level: Level, + message: String, + ) { consumers.forEach { consumer -> dispatchTo(consumer, level, message) } @@ -80,10 +90,14 @@ class GlobalBufferAppender : AppenderBase() { private fun dispatchTo( consumer: Consumer, level: Level, - message: String + message: String, ) { if (level.levelInt < consumer.logLevel.levelInt) return - runCatching { consumer.consume(message) } + try { + consumer.consume(level, message) + } catch (e: Exception) { + Log.e("GlobalBufferAppender", "Log consumer failed", e) + } } } diff --git a/resources/src/main/res/drawable/ic_keyboard_arrow_up.xml b/resources/src/main/res/drawable/ic_keyboard_arrow_up.xml new file mode 100644 index 0000000000..b405fb7262 --- /dev/null +++ b/resources/src/main/res/drawable/ic_keyboard_arrow_up.xml @@ -0,0 +1,11 @@ + + + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 059e605be8..a1d2aae07e 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -678,6 +678,19 @@ LogSender is disabled. You can enable it in preferences. Logs from the IDE are shown here. Open a file to show its diagnostic results + Filter lines + Filter + Search + Share + Clear + Hide filter bar + Verbose + Debug + Info + Warning + Error + Search in output + Filter output "Build the application or run a task to see its build output here. " Failed to perform code action Enable LogSender diff --git a/resources/src/main/res/values/styles.xml b/resources/src/main/res/values/styles.xml index a818961633..b54f0f1dc8 100755 --- a/resources/src/main/res/values/styles.xml +++ b/resources/src/main/res/values/styles.xml @@ -170,17 +170,23 @@ ?attr/colorOnSurface - - - - + + +