diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index feba15bf2a..58d56d268f 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -83,8 +83,8 @@ import com.google.android.material.tabs.TabLayout.Tab import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R import com.itsaky.androidide.R.string -import com.itsaky.androidide.activities.MainActivity import com.itsaky.androidide.actions.build.DebugAction +import com.itsaky.androidide.activities.MainActivity import com.itsaky.androidide.adapters.DiagnosticsAdapter import com.itsaky.androidide.adapters.SearchListAdapter import com.itsaky.androidide.api.BuildOutputProvider @@ -216,8 +216,9 @@ abstract class BaseEditorActivity : @Suppress("ktlint:standard:backing-property-naming") internal var _binding: ActivityEditorBinding? = null val binding: ActivityEditorBinding - get() = _binding - ?: throw IllegalStateException("Activity destroyed; binding not accessible") + get() = + _binding + ?: throw IllegalStateException("Activity destroyed; binding not accessible") val content: ContentEditorBinding get() = binding.content @@ -261,12 +262,14 @@ abstract class BaseEditorActivity : memoryUsage.forEachValue { proc -> _binding?.memUsageView?.chart?.apply { val dataset = - (data.getDataSetByIndex( - pidToDatasetIdxMap.getOrDefault( - proc.pid, - -1 - ) - ) as LineDataSet?) + ( + data.getDataSetByIndex( + pidToDatasetIdxMap.getOrDefault( + proc.pid, + -1, + ), + ) as LineDataSet? + ) ?: run { log.error( "No dataset found for process: {}: {}", @@ -376,12 +379,13 @@ abstract class BaseEditorActivity : isDebuggerStarting = true - val intent = Intent(this, DebuggerService::class.java) - .apply { - val currentOrDefaultDisplay = - ContextCompat.getDisplayOrDefault(this@BaseEditorActivity) - putExtra(DebuggerService.EXTRA_DISPLAY_ID, currentOrDefaultDisplay.displayId) - } + val intent = + Intent(this, DebuggerService::class.java) + .apply { + val currentOrDefaultDisplay = + ContextCompat.getDisplayOrDefault(this@BaseEditorActivity) + putExtra(DebuggerService.EXTRA_DISPLAY_ID, currentOrDefaultDisplay.displayId) + } if (bindService(intent, debuggerServiceConnection, BIND_AUTO_CREATE)) { postStopDebuggerServiceIfNotConnected() @@ -410,7 +414,6 @@ abstract class BaseEditorActivity : private var editorAppBarInsetTop: Int = 0 companion object { - const val DEBUGGER_SERVICE_STOP_DELAY_MS: Long = 60 * 1000 @JvmStatic @@ -536,7 +539,10 @@ abstract class BaseEditorActivity : _binding?.content?.applyImmersiveModeInsets(systemBars) } - private fun handleKeyboardInsets(imeInsets: Insets, systemBars: Insets) { + private fun handleKeyboardInsets( + imeInsets: Insets, + systemBars: Insets, + ) { val isImeVisible = imeInsets.bottom > 0 _binding?.content?.bottomSheet?.setImeVisible(isImeVisible) @@ -564,9 +570,9 @@ abstract class BaseEditorActivity : this.isImeVisible = isImeVisible if (editorViewModel.isFullscreen) { - // Hide the bottom sheet if in fullscreen mode, but only collapse it if keyboard - // is open so the symbol input view is visible - val targetState = if (isImeVisible) STATE_COLLAPSED else STATE_HIDDEN + // Hide the bottom sheet if in fullscreen mode, but only collapse it if keyboard + // is open so the symbol input view is visible + val targetState = if (isImeVisible) STATE_COLLAPSED else STATE_HIDDEN editorBottomSheet?.state = targetState } @@ -701,19 +707,20 @@ abstract class BaseEditorActivity : setupBottomSheetObserver() setupViews() - fullscreenManager = FullscreenManager( - contentBinding = content, - bottomSheetBehavior = editorBottomSheet!!, - closeDrawerAction = { - binding.editorDrawerLayout.closeDrawer(GravityCompat.START) - }, - onFullscreenToggleRequested = { - editorViewModel.toggleFullscreen() - }, - ).also { - it.bind() - it.render(editorViewModel.isFullscreen, animate = false) - } + fullscreenManager = + FullscreenManager( + contentBinding = content, + bottomSheetBehavior = editorBottomSheet!!, + closeDrawerAction = { + binding.editorDrawerLayout.closeDrawer(GravityCompat.START) + }, + onFullscreenToggleRequested = { + editorViewModel.toggleFullscreen() + }, + ).also { + it.bind() + it.render(editorViewModel.isFullscreen, animate = false) + } setupContainers() setupDiagnosticInfo() @@ -766,7 +773,6 @@ abstract class BaseEditorActivity : applyImmersiveModeInsets(systemBars) } - private fun setupToolbar() { // Set the project name in the title TextView content.root.findViewById(R.id.title_text)?.apply { @@ -1041,26 +1047,27 @@ abstract class BaseEditorActivity : return } - val pluginMenuItems = if (this is EditorHandlerActivity) { - val self = this - val fileIndex = getFileIndexForTabPosition(position) - if (fileIndex >= 0) { - val file = editorViewModel.getOpenedFile(fileIndex) - val pluginItems = - IDEApplication.getPluginManager()?.getFileTabMenuItems(file) ?: emptyList() - listOf( - FileTabMenuItem( - id = "ide.floating.undock", - title = getString(R.string.undock), - tooltipTag = TooltipTag.WINDOW_UNDOCK, - ) { self.undockFileTab(fileIndex) }, - ) + pluginItems + val pluginMenuItems = + if (this is EditorHandlerActivity) { + val self = this + val fileIndex = getFileIndexForTabPosition(position) + if (fileIndex >= 0) { + val file = editorViewModel.getOpenedFile(fileIndex) + val pluginItems = + IDEApplication.getPluginManager()?.getFileTabMenuItems(file) ?: emptyList() + listOf( + FileTabMenuItem( + id = "ide.floating.undock", + title = getString(R.string.undock), + tooltipTag = TooltipTag.WINDOW_UNDOCK, + ) { self.undockFileTab(fileIndex) }, + ) + pluginItems + } else { + emptyList() + } } else { emptyList() } - } else { - emptyList() - } showPopupWindow( context = this, @@ -1084,7 +1091,11 @@ abstract class BaseEditorActivity : hideBottomSheet() } - open fun handleSearchResults(map: Map>?) { + @JvmOverloads + open fun handleSearchResults( + map: Map>?, + dismissProgress: Boolean = true, + ) { val results = map ?: emptyMap() editorViewModel.onSearchResultsReady(results) @@ -1092,7 +1103,11 @@ abstract class BaseEditorActivity : sheetState = BottomSheetBehavior.STATE_HALF_EXPANDED, currentTab = BottomSheetViewModel.TAB_SEARCH_RESULT, ) - doDismissSearchProgress() + // A pending plugin-search fan-out keeps the progress indicator up until it resolves, + // so a query the built-in text search misses does not flash a terminal empty state. + if (dismissProgress) { + doDismissSearchProgress() + } } open fun setSearchResultAdapter(adapter: SearchListAdapter) { @@ -1180,7 +1195,7 @@ abstract class BaseEditorActivity : log.warn( "UI Designer returned invalid result: resultCode={}, data={}", result.resultCode, - result.data + result.data, ) return } @@ -1220,10 +1235,11 @@ abstract class BaseEditorActivity : result.onFailure { error -> log.error("String injection failed", error) withContext(Dispatchers.Main) { - val message = when (error) { - is StringsInjectionException -> getString(error.messageRes) - else -> getString(string.msg_strings_injection_failed) - } + val message = + when (error) { + is StringsInjectionException -> getString(error.messageRes) + else -> getString(string.msg_strings_injection_failed) + } flashError(message) } } @@ -1233,10 +1249,11 @@ abstract class BaseEditorActivity : private fun applyGeneratedXmlToEditor(generatedXml: String) { val view = provideCurrentEditor() - val text = view?.editor?.text ?: run { - log.warn("No file opened to append UI designer result") - return - } + val text = + view?.editor?.text ?: run { + log.warn("No file opened to append UI designer result") + return + } val endLine = text.lineCount - 1 text.replace(0, 0, endLine, text.getColumnCount(endLine), generatedXml) @@ -1392,7 +1409,8 @@ abstract class BaseEditorActivity : private fun updateSwipeRevealDragState() { val isFullscreen = editorViewModel.isFullscreen - val isBottomSheetOpen = bottomSheetViewModel.sheetBehaviorState != STATE_COLLAPSED && + val isBottomSheetOpen = + bottomSheetViewModel.sheetBehaviorState != STATE_COLLAPSED && bottomSheetViewModel.sheetBehaviorState != STATE_HIDDEN binding.swipeReveal.setVerticalDragEnabled(!isFullscreen && !isBottomSheetOpen) } @@ -1547,9 +1565,9 @@ abstract class BaseEditorActivity : it.realContainer.pivotX = it.realContainer.width.toFloat() / 2f it.realContainer.pivotY = (it.realContainer.height.toFloat() / 2f) + ( - systemBarInsets?.run { bottom - top } - ?: 0 - ) + systemBarInsets?.run { bottom - top } + ?: 0 + ) it.viewContainer.viewTreeObserver.removeOnGlobalLayoutListener(this) } } @@ -1636,19 +1654,23 @@ abstract class BaseEditorActivity : val startedNearTopEdge = e1.y < topEdgeThreshold val startedNearBottomEdge = e1.y > bottomEdgeThreshold - val isTopEdgeDismissFling = isVerticalSwipe && + val isTopEdgeDismissFling = + isVerticalSwipe && hasVerticalVelocity && startedNearTopEdge && hasDownFlingDistance - val isBottomEdgeDismissFling = isVerticalSwipe && + val isBottomEdgeDismissFling = + isVerticalSwipe && hasVerticalVelocity && startedNearBottomEdge && hasUpFlingDistance - val isDrawerOpenFling = hasRightFlingDistance && + val isDrawerOpenFling = + hasRightFlingDistance && hasHorizontalVelocity && isHorizontalSwipe - val isBottomSheetOpen = bottomSheetViewModel.sheetBehaviorState != STATE_COLLAPSED && + val isBottomSheetOpen = + bottomSheetViewModel.sheetBehaviorState != STATE_COLLAPSED && bottomSheetViewModel.sheetBehaviorState != STATE_HIDDEN if (isBottomSheetOpen) { diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 3d4cd3f1b6..5e7feba52c 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -43,6 +43,7 @@ import com.itsaky.androidide.actions.etc.FindInFileAction import com.itsaky.androidide.actions.etc.FindInProjectAction import com.itsaky.androidide.actions.internal.DefaultActionsRegistry import com.itsaky.androidide.activities.MainActivity +import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.databinding.LayoutSearchProjectBinding import com.itsaky.androidide.flashbar.Flashbar import com.itsaky.androidide.fragments.FindActionDialog @@ -58,6 +59,13 @@ import com.itsaky.androidide.lookup.Lookup import com.itsaky.androidide.lsp.IDELanguageClientImpl import com.itsaky.androidide.lsp.debug.DebugClientConnectionResult import com.itsaky.androidide.lsp.java.utils.CancelChecker +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.models.Range +import com.itsaky.androidide.models.SearchResult +import com.itsaky.androidide.plugins.extensions.ProjectSearchExtension +import com.itsaky.androidide.plugins.extensions.ProjectSearchRequest +import com.itsaky.androidide.plugins.extensions.ProjectSearchResult +import com.itsaky.androidide.plugins.extensions.ProjectSearchSection import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.projects.models.projectDir @@ -96,13 +104,16 @@ import com.itsaky.androidide.viewmodel.BottomSheetViewModel import com.itsaky.androidide.viewmodel.BuildState import com.itsaky.androidide.viewmodel.BuildVariantsViewModel import com.itsaky.androidide.viewmodel.BuildViewModel +import com.itsaky.androidide.viewmodel.EditorViewModel.SearchResultSection import io.github.rosemoe.sora.text.ICUUtils import io.github.rosemoe.sora.util.IntPair import io.sentry.Sentry import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.future.await import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.adfa.constants.CONTENT_KEY import org.koin.android.ext.android.inject import org.slf4j.LoggerFactory @@ -111,6 +122,7 @@ import java.io.FileNotFoundException import java.net.SocketException import java.nio.file.NoSuchFileException import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit import java.util.regex.Pattern import java.util.stream.Collectors @@ -181,6 +193,8 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { const val STATE_KEY_FROM_SAVED_INSTANACE = "ide.editor.isFromSavedInstance" const val STATE_KEY_SHOULD_INITIALIZE = "ide.editor.isInitializing" + + private const val PLUGIN_SEARCH_TIMEOUT_SECONDS = 10L } abstract fun doCloseAll() @@ -871,10 +885,13 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { .dropLastWhile { it.isEmpty() } .toTypedArray() ) { - if (str.trim().isEmpty()) { + val token = str.trim() + if (token.isEmpty()) { continue } - extensionList.add(str) + // Trim so a pipe-split token keeps no surrounding space; the file-name + // suffix test is endsWith(), which a stray " kt" would never satisfy. + extensionList.add(token) } } else { extensionList.add(extensions) @@ -895,7 +912,13 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { extensionList, searchDirs, ) { results -> - handleSearchResults(results) + val plugins = projectSearchPlugins() + // When the built-in search finds nothing but a plugin may still match, keep + // the progress indicator up until the plugin phase resolves. + handleSearchResults(results, dismissProgress = plugins.isEmpty() || results.isNotEmpty()) + if (plugins.isNotEmpty()) { + requestPluginSearchSections(plugins, text, extensionList, searchDirs, results) + } } } } @@ -930,6 +953,112 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { return mFindInProjectDialog } + private fun projectSearchPlugins(): List = + IDEApplication + .getPluginManager() + ?.getAllPluginInstances() + ?.filterIsInstance() + ?: emptyList() + + private fun requestPluginSearchSections( + plugins: List, + query: String, + extensions: List, + searchDirs: List, + exactResults: Map>, + ) { + val pluginManager = IDEApplication.getPluginManager() ?: return + + // handleSearchResults() just published the built-in results and bumped the generation; + // capture it so a superseding search invalidates this fan-out's late results. + val generation = editorViewModel.currentSearchGeneration + val request = + ProjectSearchRequest( + query = query, + roots = searchDirs, + extensions = extensions, + ) + // searchProject() is contractually called on the UI thread; launch the futures here. + val futures = + plugins.mapNotNull { plugin -> + val pluginId = + pluginManager.getPluginIdForInstance(plugin as com.itsaky.androidide.plugins.IPlugin) + ?: plugin.javaClass.name + try { + plugin + .searchProject(request) + .exceptionally { error -> + logger.warn("Project search plugin '{}' failed", pluginId, error) + // Runs on whatever thread completed the future; recordPluginCrash may + // force-disable the plugin, which mutates UI (tabs, sidebar). + runOnUiThread { pluginManager.recordPluginCrash(pluginId) } + emptyList() + } + } catch (error: Exception) { + logger.warn("Project search plugin '{}' failed", pluginId, error) + pluginManager.recordPluginCrash(pluginId) + null + } + } + if (futures.isEmpty()) { + doDismissSearchProgress() + return + } + + // lifecycleScope cancels on destroy, so a slow plugin cannot pin this Activity/ViewModel + // or post to a dead Activity; await() suspends instead of blocking a commonPool worker. + lifecycleScope.launch { + val pluginSections = + withContext(Dispatchers.Default) { + try { + withTimeoutOrNull(TimeUnit.SECONDS.toMillis(PLUGIN_SEARCH_TIMEOUT_SECONDS)) { + CompletableFuture.allOf(*futures.toTypedArray()).await() + } + futures + // getNow() skips futures still pending after the timeout; orEmpty() + // guards a Java plugin completing with a null list. + .flatMap { future -> future.getNow(emptyList()).orEmpty() } + .mapNotNull { section -> section?.toSearchResultSection() } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + logger.warn("Failed to collect project search plugin results", error) + emptyList() + } + } + val sections = + buildList { + if (exactResults.isNotEmpty()) { + add(SearchResultSection(title = null, results = exactResults)) + } + addAll(pluginSections) + } + editorViewModel.onSearchResultSectionsReady(generation, sections) + doDismissSearchProgress() + } + } + + private fun ProjectSearchSection.toSearchResultSection(): SearchResultSection? { + val grouped = + results + // mapNotNull guards a Java plugin returning null elements; a bare map() would + // NPE in toSearchResult() and take down the whole fan-out. + .mapNotNull { it?.toSearchResult() } + .groupBy { it.file } + return grouped + .takeIf { it.isNotEmpty() } + ?.let { SearchResultSection(title = title, results = it) } + } + + private fun ProjectSearchResult.toSearchResult(): SearchResult { + val range = + Range( + Position(startLine, startColumn), + Position(endLine, endColumn), + ) + return SearchResult(range, file, linePreview, matchText) + } + fun EditText.selectCurrentWord() { val content = text ?: return if (content.isEmpty()) return @@ -991,7 +1120,7 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { val results = try { connectDebugClient(debuggerViewModel.debugClient).values - } catch (e: Throwable) { + } catch (e: Exception) { if (e is CancellationException) { throw e } diff --git a/app/src/main/java/com/itsaky/androidide/adapters/SearchListAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/SearchListAdapter.kt index 25934112f9..d47a5913b1 100755 --- a/app/src/main/java/com/itsaky/androidide/adapters/SearchListAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/SearchListAdapter.kt @@ -17,82 +17,195 @@ package com.itsaky.androidide.adapters import android.graphics.PorterDuff.Mode.SRC_ATOP import android.view.LayoutInflater import android.view.ViewGroup -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView.Adapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.blankj.utilcode.util.ThreadUtils import com.itsaky.androidide.R -import com.itsaky.androidide.adapters.SearchListAdapter.VH import com.itsaky.androidide.databinding.LayoutSearchResultGroupBinding import com.itsaky.androidide.databinding.LayoutSearchResultItemBinding +import com.itsaky.androidide.databinding.LayoutSearchResultSectionBinding import com.itsaky.androidide.models.FileExtension import com.itsaky.androidide.models.SearchResult import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE import com.itsaky.androidide.syntax.highlighters.JavaHighlighter import com.itsaky.androidide.utils.resolveAttr +import com.itsaky.androidide.viewmodel.EditorViewModel.SearchResultSection +import org.slf4j.LoggerFactory import java.io.File import java.util.concurrent.CompletableFuture +// Sections, files and matches are flattened into a single row list so the hosting +// RecyclerView can recycle match rows; a nested RecyclerView would inflate every +// match of a file at once. Backed by ListAdapter so re-publishing (e.g. built-in results +// then built-in + plugin results) diffs against the current rows instead of rebinding +// everything and resetting the scroll position. class SearchListAdapter( - private val results: Map?>, - private val onFileClick: (File) -> Unit, - private val onMatchClick: (SearchResult) -> Unit, - private val keys: List -) : Adapter() { - - constructor( - results: Map?>, - onFileClick: (File) -> Unit, - onMatchClick: (SearchResult) -> Unit - ) : this(results, onFileClick, onMatchClick, results.keys.toList()) - - override fun onCreateViewHolder(p1: ViewGroup, p2: Int): VH { - return VH(LayoutSearchResultGroupBinding.inflate(LayoutInflater.from(p1.context))) - } - - override fun onBindViewHolder(p1: VH, p2: Int) { - val binding = p1.binding - val file = keys[p2] - val matches = results[file] ?: listOf() - val color = binding.icon.context.resolveAttr(R.attr.colorPrimary) - binding.title.text = file.name - binding.icon.setImageResource(FileExtension.Factory.forFile(file, false).icon) - binding.icon.setColorFilter(color, SRC_ATOP) - binding.items.layoutManager = LinearLayoutManager(binding.items.context) - binding.items.adapter = ChildAdapter(matches) - binding.root.setOnClickListener { onFileClick(file) } - } - - override fun getItemCount(): Int { - return results.size - } - - inner class ChildAdapter(val matches: List) : Adapter() { - - override fun onCreateViewHolder(p1: ViewGroup, p2: Int): ChildVH { - return ChildVH(LayoutSearchResultItemBinding.inflate(LayoutInflater.from(p1.context))) - } - - override fun onBindViewHolder(p1: ChildVH, p2: Int) { - val match = matches[p2] - val binding = p1.binding - CompletableFuture.runAsync { - try { - val scheme = SchemeAndroidIDE.newInstance(binding.text.context) - val sb = JavaHighlighter().highlight(scheme, match.line, match.match) - ThreadUtils.runOnUiThread { binding.text.text = sb } - } catch (e: Exception) { - ThreadUtils.runOnUiThread { binding.text.text = match.match } - } - } - binding.root.setOnClickListener { onMatchClick(match) } - } - - override fun getItemCount(): Int { - return matches.size - } - } - - class VH(val binding: LayoutSearchResultGroupBinding) : ViewHolder(binding.root) - class ChildVH(val binding: LayoutSearchResultItemBinding) : ViewHolder(binding.root) + private val onFileClick: (File) -> Unit, + private val onMatchClick: (SearchResult) -> Unit, +) : ListAdapter(DIFF) { + /** Convenience for a flat map of matches with no section header. */ + constructor( + results: Map>, + onFileClick: (File) -> Unit, + onMatchClick: (SearchResult) -> Unit, + ) : this(onFileClick, onMatchClick) { + submit(results) + } + + /** Publishes [sections], invoking [onCommitted] once the new rows are applied. */ + fun submit( + sections: List, + onCommitted: (() -> Unit)? = null, + ) { + submitList(buildRows(sections), onCommitted?.let { Runnable(it) }) + } + + fun submit(results: Map>) { + submit(listOf(SearchResultSection(null, results))) + } + + override fun getItemViewType(position: Int): Int = + when (getItem(position)) { + is Row.Header -> VIEW_TYPE_HEADER + is Row.Group -> VIEW_TYPE_GROUP + is Row.Match -> VIEW_TYPE_MATCH + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + VIEW_TYPE_HEADER -> HeaderVH(LayoutSearchResultSectionBinding.inflate(inflater, parent, false)) + VIEW_TYPE_GROUP -> VH(LayoutSearchResultGroupBinding.inflate(inflater, parent, false)) + else -> ChildVH(LayoutSearchResultItemBinding.inflate(inflater, parent, false)) + } + } + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + when (val row = getItem(position)) { + is Row.Header -> (holder as HeaderVH).binding.title.text = row.title + is Row.Group -> bindGroup(holder as VH, row) + is Row.Match -> bindMatch(holder as ChildVH, row.match) + } + } + + private fun bindGroup( + holder: VH, + row: Row.Group, + ) { + val binding = holder.binding + val file = row.file + val color = binding.icon.context.resolveAttr(R.attr.colorPrimary) + binding.title.text = file.name + binding.icon.setImageResource(FileExtension.Factory.forFile(file, false).icon) + binding.icon.setColorFilter(color, SRC_ATOP) + binding.root.setOnClickListener { onFileClick(file) } + } + + private fun bindMatch( + holder: ChildVH, + match: SearchResult, + ) { + val text = holder.binding.text + // Show the plain preview immediately; the tag guards the async highlight below + // against landing on a recycled row. + text.text = match.line + text.tag = match + CompletableFuture.runAsync { + try { + val scheme = SchemeAndroidIDE.newInstance(text.context) + val sb = JavaHighlighter().highlight(scheme, match.line, match.match) + ThreadUtils.runOnUiThread { + if (text.tag === match) { + text.text = sb + } + } + } catch (e: Exception) { + // The plain preview set above stays in place; only the highlight is lost. + logger.warn("Failed to highlight search result preview", e) + } + } + holder.binding.root.setOnClickListener { onMatchClick(match) } + } + + class VH( + val binding: LayoutSearchResultGroupBinding, + ) : ViewHolder(binding.root) + + class ChildVH( + val binding: LayoutSearchResultItemBinding, + ) : ViewHolder(binding.root) + + class HeaderVH( + val binding: LayoutSearchResultSectionBinding, + ) : ViewHolder(binding.root) + + sealed class Row { + data class Header( + val title: String, + ) : Row() + + data class Group( + val file: File, + ) : Row() + + data class Match( + val match: SearchResult, + ) : Row() + } + + private companion object { + val logger = LoggerFactory.getLogger(SearchListAdapter::class.java) + + const val VIEW_TYPE_HEADER = 0 + const val VIEW_TYPE_GROUP = 1 + const val VIEW_TYPE_MATCH = 2 + + fun buildRows(sections: List): List = + buildList { + sections.forEach { section -> + // Buffer the section's groups/matches so an empty section (no keys, or all + // values empty) never emits an orphan header, and skip blank titles. + val groupRows = + buildList { + section.results.forEach { (file, matches) -> + if (matches.isNotEmpty()) { + add(Row.Group(file)) + matches.forEach { match -> add(Row.Match(match)) } + } + } + } + if (groupRows.isEmpty()) return@forEach + section.title?.takeIf { it.isNotBlank() }?.let { add(Row.Header(it)) } + addAll(groupRows) + } + } + + val DIFF = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: Row, + newItem: Row, + ): Boolean = + when { + oldItem is Row.Header && newItem is Row.Header -> oldItem.title == newItem.title + oldItem is Row.Group && newItem is Row.Group -> oldItem.file == newItem.file + // SearchResult has no value equality; identity keeps rows from a re-publish + // (same instances) stable so their async highlight is not re-run. + oldItem is Row.Match && newItem is Row.Match -> oldItem.match === newItem.match + else -> false + } + + override fun areContentsTheSame( + oldItem: Row, + newItem: Row, + ): Boolean = oldItem == newItem + } + } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/SearchResultFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/SearchResultFragment.kt index 72320c5776..6795ad019f 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/SearchResultFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/SearchResultFragment.kt @@ -1,71 +1,76 @@ -/* - * 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 - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.RecyclerView -import com.itsaky.androidide.activities.editor.BaseEditorActivity -import com.itsaky.androidide.adapters.SearchListAdapter -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.viewmodel.EditorViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class SearchResultFragment : RecyclerViewFragment() { - override val fragmentTooltipTag: String? = TooltipTag.PROJECT_SEARCH_RESULTS - - private val editorViewModel: EditorViewModel by activityViewModels() - - private val editorActivity: BaseEditorActivity? - get() = activity as? BaseEditorActivity - - override fun onCreateAdapter(): RecyclerView.Adapter<*> { - val noOp: (Any) -> Unit = {} - return SearchListAdapter(emptyMap(), noOp, noOp) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - editorViewModel.searchResults.collectLatest { results -> - if (isAdded && _binding != null) { - binding.root.adapter = SearchListAdapter( - results, - onFileClick = { file -> - editorActivity?.doOpenFile(file, null) - editorActivity?.hideBottomSheet() - }, - onMatchClick = { match -> - editorActivity?.doOpenFile(match.file, match) - editorActivity?.hideBottomSheet() - } - ) - val itemCount = binding.root.adapter?.itemCount ?: 0 - isEmpty = itemCount == 0 - } - } - } - } - } -} \ No newline at end of file +/* + * 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 + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.RecyclerView +import com.itsaky.androidide.activities.editor.BaseEditorActivity +import com.itsaky.androidide.adapters.SearchListAdapter +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.models.SearchResult +import com.itsaky.androidide.viewmodel.EditorViewModel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.io.File + +class SearchResultFragment : RecyclerViewFragment() { + override val fragmentTooltipTag: String? = TooltipTag.PROJECT_SEARCH_RESULTS + + private val editorViewModel: EditorViewModel by activityViewModels() + + private val editorActivity: BaseEditorActivity? + get() = activity as? BaseEditorActivity + + private val onFileClick: (File) -> Unit = { file -> + editorActivity?.doOpenFile(file, null) + editorActivity?.hideBottomSheet() + } + + private val onMatchClick: (SearchResult) -> Unit = { match -> + editorActivity?.doOpenFile(match.file, match) + editorActivity?.hideBottomSheet() + } + + override fun onCreateAdapter(): RecyclerView.Adapter<*> = SearchListAdapter(onFileClick, onMatchClick) + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + editorViewModel.searchResultSections.collectLatest { sections -> + if (isAdded && _binding != null) { + // Reuse the attached adapter so re-publishes diff instead of resetting + // scroll and re-running highlights; only create one if none is present. + val adapter = + binding.root.adapter as? SearchListAdapter + ?: SearchListAdapter(onFileClick, onMatchClick).also { binding.root.adapter = it } + adapter.submit(sections) { isEmpty = adapter.itemCount == 0 } + } + } + } + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt index 62cb4f5e25..bb945b1261 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt @@ -1,359 +1,398 @@ -/* - * 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 android.view.Gravity.CENTER -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.blankj.utilcode.util.FileUtils -import com.google.gson.GsonBuilder -import com.itsaky.androidide.models.OpenedFilesCache -import com.itsaky.androidide.models.SearchResult -import com.itsaky.androidide.projects.IProjectManager -import com.itsaky.androidide.projects.ProjectManagerImpl -import com.itsaky.androidide.utils.Environment -import com.itsaky.androidide.utils.ILogger -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException -import java.util.Collections - -/** ViewModel for data used in [com.itsaky.androidide.activities.editor.EditorActivityKt] */ -@Suppress("PropertyName") -class EditorViewModel : ViewModel() { - - data class UiState( - val isFullscreen: Boolean = false, - ) - - internal val _isBuildInProgress = MutableLiveData(false) - internal val _isInitializing = MutableLiveData(false) - internal val _statusText = MutableLiveData>("" to CENTER) - internal val _displayedFile = MutableLiveData(-1) - internal val _startDrawerOpened = MutableLiveData(false) - internal val _isSyncNeeded = MutableLiveData(false) - - internal val _filesModified = MutableLiveData(false) - internal val _filesSaving = MutableLiveData(false) - - private val _openedFiles = MutableLiveData() - private val _isBoundToBuildService = MutableLiveData(false) - private val _files = MutableLiveData>(ArrayList()) - private val _uiState = MutableStateFlow(UiState()) - - private val _searchResults = MutableStateFlow>>(emptyMap()) - val searchResults: StateFlow>> = _searchResults.asStateFlow() - val uiState: StateFlow = _uiState.asStateFlow() - - fun onSearchResultsReady(results: Map>) { - _searchResults.value = results - } - - /** - * Holds information about the currently selected editor fragment. First value in the pair is the - * index of the editor opened. Second value is the file that is opened. - */ - private val mCurrentFile = MutableLiveData?>(null) - - var areFilesModified: Boolean - get() = _filesModified.value ?: false - set(value) { - _filesModified.value = value - } - - var areFilesSaving: Boolean - get() = _filesSaving.value ?: false - set(value) { - _filesSaving.value = value - } - - var openedFilesCache: OpenedFilesCache? - get() = _openedFiles.value - set(value) { - this._openedFiles.value = value - } - - var isBoundToBuildSerice: Boolean - get() = _isBoundToBuildService.value ?: false - set(value) { - _isBoundToBuildService.value = value - } - - var isBuildInProgress: Boolean - get() = _isBuildInProgress.value ?: false - set(value) { - _isBuildInProgress.value = value - } - - var isInitializing: Boolean - get() = _isInitializing.value ?: false - set(value) { - _isInitializing.value = value - } - - var statusText: CharSequence - get() = this._statusText.value?.first ?: "" - set(value) { - _statusText.value = value to (_statusText.value?.second ?: 0) - } - - var statusGravity: Int - get() = this._statusText.value?.second ?: CENTER - set(value) { - _statusText.value = (_statusText.value?.first ?: "") to value - } - - var displayedFileIndex: Int - get() = _displayedFile.value!! - set(value) { - _displayedFile.value = value - } - - var startDrawerOpened: Boolean - get() = _startDrawerOpened.value ?: false - set(value) { - _startDrawerOpened.value = value - } - - var isSyncNeeded: Boolean - get() = _isSyncNeeded.value ?: false - set(value) { - _isSyncNeeded.value = value - } - - var isFullscreen: Boolean - get() = uiState.value.isFullscreen - set(value) { - updateFullscreen(value) - } - - fun updateFullscreen(value: Boolean) { - _uiState.update { current -> - if (current.isFullscreen == value) { - current - } else { - current.copy(isFullscreen = value) - } - } - } - - fun toggleFullscreen() { - updateFullscreen(!uiState.value.isFullscreen) - } - - fun exitFullscreen() { - updateFullscreen(false) - } - - internal var files: MutableList - get() = this._files.value ?: Collections.emptyList() - set(value) { - this._files.value = value - } - - private inline fun updateFiles(crossinline action: (files: MutableList) -> Unit) { - val files = this.files - action(files) - this.files = files - } - - /** - * Add the given file to the list of opened files. - * - * @param file The file that has been opened. - */ - fun addFile(file: File) = updateFiles { files -> - files.add(file) - } - - /** - * Remove the file at the given index from the list of opened files. - * - * @param index The index of the closed file. - */ - fun removeFile(index: Int) = updateFiles { files -> - files.removeAt(index) - - if (files.isEmpty()) { - mCurrentFile.value = null - } - } - - fun removeAllFiles() = updateFiles { files -> - files.clear() - setCurrentFile(-1, null) - } - - fun setCurrentFile(index: Int, file: File?) { - displayedFileIndex = index - mCurrentFile.value = index to file - } - - fun updateFile(index: Int, newFile: File) = updateFiles { files -> - files[index] = newFile - } - - /** - * Get the opened file at the given index. - * - * @param index The index of the file. - * @return The file at the given index. - */ - fun getOpenedFile(index: Int): File { - return files[index] - } - - /** - * Get the number of files opened. - * - * @return The number of files opened. - */ - fun getOpenedFileCount(): Int { - return files.size - } - - /** - * Get the list of currently opened files. - * - * @return The list of opened files. - */ - fun getOpenedFiles(): List { - return Collections.unmodifiableList(files) - } - - /** - * Add an observer to the list of opened files. - * - * @param lifecycleOwner The lifecycle owner. - * @param observer The observer. - */ - fun observeFiles(lifecycleOwner: LifecycleOwner?, observer: Observer?>?) { - _files.observe(lifecycleOwner!!, observer!!) - } - - fun getCurrentFileIndex(): Int { - return mCurrentFile.value?.first ?: -1 - } - - fun getCurrentFile(): File? { - return mCurrentFile.value?.second - } - - /** - * Get the [OpenedFilesCache] if it is already loaded, otherwise read the cache from the file system - * and invoke the given callback. - * - * If the cache is already loaded, [result] is called on the same thread. Otherwise, it is - * always called on the main/UI thread. - */ - inline fun getOrReadOpenedFilesCache(crossinline result: (OpenedFilesCache?) -> Unit) { - return openedFilesCache?.let(result) ?: run { - viewModelScope.launch(Dispatchers.IO) { - val cache = try { - val cacheFile = getOpenedFilesCache(false) - if (cacheFile.exists() && cacheFile.length() > 0L) { - cacheFile.bufferedReader().use(OpenedFilesCache::parse) - } else null - } catch (err: IOException) { - // ignore exception - null - } - - withContext(Dispatchers.Main) { - result(cache) - } - }.also { job -> - handleOpenedFilesCacheJobCompletion(job, "read") - } - Unit - } - } - - fun handleOpenedFilesCacheJobCompletion(it: Job, operation: String) { - it.invokeOnCompletion { err -> - if (err != null) { - ILogger.ROOT.error( - "[EditorViewModel] Failed to {} opened files cache", - operation, - err - ) - } - } - } - - fun writeOpenedFiles(cache: OpenedFilesCache?) { - viewModelScope.launch(Dispatchers.IO) { - val file = getOpenedFilesCache(true) - - if (cache == null) { - file.delete() - return@launch - } - - try { - file.parentFile?.mkdirs() - - // Convert data to JSON and write to the file. - // `writeText` will create the file if it doesn't exist. - val gson = GsonBuilder().setPrettyPrinting().create() - val string = gson.toJson(cache) - file.writeText(string) - } catch (e: IOException) { - ILogger.ROOT.error( - "[EditorViewModel] Failed to write opened files cache", - e - ) - } - }.also { job -> - handleOpenedFilesCacheJobCompletion(job, "write") - } - } - - @PublishedApi - internal fun getOpenedFilesCache(forWrite: Boolean = false): File { - var file = Environment.getProjectCacheDir(IProjectManager.getInstance().projectDir) - file = File(file, "editor/openedFiles.json") - if (file.exists() && forWrite) { - FileUtils.rename(file, "${file.name}.bak") - } - - return file - } - - /** - * Returns the open project's directory name, or an empty string when no project path is - * available (the process-death recreation state where the project path is uninitialized). - */ - fun getProjectName(): String { - val manager = ProjectManagerImpl.getInstance() - val path = manager.projectDirPath - if (path.isBlank()) { - return "" - } - return manager.projectDir.name - } -} +/* + * 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 android.view.Gravity.CENTER +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.blankj.utilcode.util.FileUtils +import com.google.gson.GsonBuilder +import com.itsaky.androidide.models.OpenedFilesCache +import com.itsaky.androidide.models.SearchResult +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.utils.ILogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.util.Collections +import java.util.concurrent.atomic.AtomicInteger + +/** ViewModel for data used in [com.itsaky.androidide.activities.editor.EditorActivityKt] */ +@Suppress("PropertyName") +class EditorViewModel : ViewModel() { + data class SearchResultSection( + val title: String?, + val results: Map>, + ) + + data class UiState( + val isFullscreen: Boolean = false, + ) + + internal val _isBuildInProgress = MutableLiveData(false) + internal val _isInitializing = MutableLiveData(false) + internal val _statusText = MutableLiveData>("" to CENTER) + internal val _displayedFile = MutableLiveData(-1) + internal val _startDrawerOpened = MutableLiveData(false) + internal val _isSyncNeeded = MutableLiveData(false) + + internal val _filesModified = MutableLiveData(false) + internal val _filesSaving = MutableLiveData(false) + + private val _openedFiles = MutableLiveData() + private val _isBoundToBuildService = MutableLiveData(false) + private val _files = MutableLiveData>(ArrayList()) + private val _uiState = MutableStateFlow(UiState()) + + private val _searchResultSections = MutableStateFlow>(emptyList()) + val searchResultSections: StateFlow> = _searchResultSections.asStateFlow() + val uiState: StateFlow = _uiState.asStateFlow() + + private val searchGeneration = AtomicInteger() + + /** + * Identifies the search whose results are currently displayed. Capture it when starting + * an async fan-out and pass it to [onSearchResultSectionsReady] so late results of a + * superseded search cannot overwrite a newer one. + */ + val currentSearchGeneration: Int + get() = searchGeneration.get() + + fun onSearchResultsReady(results: Map>) { + searchGeneration.incrementAndGet() + _searchResultSections.value = + listOfNotNull( + results.takeIf { it.isNotEmpty() }?.let { SearchResultSection(title = null, results = it) }, + ) + } + + /** Publishes [sections] unless [generation] no longer matches [currentSearchGeneration]. */ + fun onSearchResultSectionsReady( + generation: Int, + sections: List, + ) { + if (generation != searchGeneration.get()) { + return + } + _searchResultSections.value = sections + } + + /** + * Holds information about the currently selected editor fragment. First value in the pair is the + * index of the editor opened. Second value is the file that is opened. + */ + private val mCurrentFile = MutableLiveData?>(null) + + var areFilesModified: Boolean + get() = _filesModified.value ?: false + set(value) { + _filesModified.value = value + } + + var areFilesSaving: Boolean + get() = _filesSaving.value ?: false + set(value) { + _filesSaving.value = value + } + + var openedFilesCache: OpenedFilesCache? + get() = _openedFiles.value + set(value) { + this._openedFiles.value = value + } + + var isBoundToBuildSerice: Boolean + get() = _isBoundToBuildService.value ?: false + set(value) { + _isBoundToBuildService.value = value + } + + var isBuildInProgress: Boolean + get() = _isBuildInProgress.value ?: false + set(value) { + _isBuildInProgress.value = value + } + + var isInitializing: Boolean + get() = _isInitializing.value ?: false + set(value) { + _isInitializing.value = value + } + + var statusText: CharSequence + get() = this._statusText.value?.first ?: "" + set(value) { + _statusText.value = value to (_statusText.value?.second ?: 0) + } + + var statusGravity: Int + get() = this._statusText.value?.second ?: CENTER + set(value) { + _statusText.value = (_statusText.value?.first ?: "") to value + } + + var displayedFileIndex: Int + get() = _displayedFile.value!! + set(value) { + _displayedFile.value = value + } + + var startDrawerOpened: Boolean + get() = _startDrawerOpened.value ?: false + set(value) { + _startDrawerOpened.value = value + } + + var isSyncNeeded: Boolean + get() = _isSyncNeeded.value ?: false + set(value) { + _isSyncNeeded.value = value + } + + var isFullscreen: Boolean + get() = uiState.value.isFullscreen + set(value) { + updateFullscreen(value) + } + + fun updateFullscreen(value: Boolean) { + _uiState.update { current -> + if (current.isFullscreen == value) { + current + } else { + current.copy(isFullscreen = value) + } + } + } + + fun toggleFullscreen() { + updateFullscreen(!uiState.value.isFullscreen) + } + + fun exitFullscreen() { + updateFullscreen(false) + } + + internal var files: MutableList + get() = this._files.value ?: Collections.emptyList() + set(value) { + this._files.value = value + } + + private inline fun updateFiles(crossinline action: (files: MutableList) -> Unit) { + val files = this.files + action(files) + this.files = files + } + + /** + * Add the given file to the list of opened files. + * + * @param file The file that has been opened. + */ + fun addFile(file: File) = + updateFiles { files -> + files.add(file) + } + + /** + * Remove the file at the given index from the list of opened files. + * + * @param index The index of the closed file. + */ + fun removeFile(index: Int) = + updateFiles { files -> + files.removeAt(index) + + if (files.isEmpty()) { + mCurrentFile.value = null + } + } + + fun removeAllFiles() = + updateFiles { files -> + files.clear() + setCurrentFile(-1, null) + } + + fun setCurrentFile( + index: Int, + file: File?, + ) { + displayedFileIndex = index + mCurrentFile.value = index to file + } + + fun updateFile( + index: Int, + newFile: File, + ) = updateFiles { files -> + files[index] = newFile + } + + /** + * Get the opened file at the given index. + * + * @param index The index of the file. + * @return The file at the given index. + */ + fun getOpenedFile(index: Int): File = files[index] + + /** + * Get the number of files opened. + * + * @return The number of files opened. + */ + fun getOpenedFileCount(): Int = files.size + + /** + * Get the list of currently opened files. + * + * @return The list of opened files. + */ + fun getOpenedFiles(): List = Collections.unmodifiableList(files) + + /** + * Add an observer to the list of opened files. + * + * @param lifecycleOwner The lifecycle owner. + * @param observer The observer. + */ + fun observeFiles( + lifecycleOwner: LifecycleOwner?, + observer: Observer?>?, + ) { + _files.observe(lifecycleOwner!!, observer!!) + } + + fun getCurrentFileIndex(): Int = mCurrentFile.value?.first ?: -1 + + fun getCurrentFile(): File? = mCurrentFile.value?.second + + /** + * Get the [OpenedFilesCache] if it is already loaded, otherwise read the cache from the file system + * and invoke the given callback. + * + * If the cache is already loaded, [result] is called on the same thread. Otherwise, it is + * always called on the main/UI thread. + */ + inline fun getOrReadOpenedFilesCache(crossinline result: (OpenedFilesCache?) -> Unit) = + openedFilesCache?.let(result) ?: run { + viewModelScope + .launch(Dispatchers.IO) { + val cache = + try { + val cacheFile = getOpenedFilesCache(false) + if (cacheFile.exists() && cacheFile.length() > 0L) { + cacheFile.bufferedReader().use(OpenedFilesCache::parse) + } else { + null + } + } catch (err: IOException) { + // ignore exception + null + } + + withContext(Dispatchers.Main) { + result(cache) + } + }.also { job -> + handleOpenedFilesCacheJobCompletion(job, "read") + } + Unit + } + + fun handleOpenedFilesCacheJobCompletion( + it: Job, + operation: String, + ) { + it.invokeOnCompletion { err -> + if (err != null) { + ILogger.ROOT.error( + "[EditorViewModel] Failed to {} opened files cache", + operation, + err, + ) + } + } + } + + fun writeOpenedFiles(cache: OpenedFilesCache?) { + viewModelScope + .launch(Dispatchers.IO) { + val file = getOpenedFilesCache(true) + + if (cache == null) { + file.delete() + return@launch + } + + try { + file.parentFile?.mkdirs() + + // Convert data to JSON and write to the file. + // `writeText` will create the file if it doesn't exist. + val gson = GsonBuilder().setPrettyPrinting().create() + val string = gson.toJson(cache) + file.writeText(string) + } catch (e: IOException) { + ILogger.ROOT.error( + "[EditorViewModel] Failed to write opened files cache", + e, + ) + } + }.also { job -> + handleOpenedFilesCacheJobCompletion(job, "write") + } + } + + @PublishedApi + internal fun getOpenedFilesCache(forWrite: Boolean = false): File { + var file = Environment.getProjectCacheDir(IProjectManager.getInstance().projectDir) + file = File(file, "editor/openedFiles.json") + if (file.exists() && forWrite) { + FileUtils.rename(file, "${file.name}.bak") + } + + return file + } + + /** + * Returns the open project's directory name, or an empty string when no project path is + * available (the process-death recreation state where the project path is uninitialized). + */ + fun getProjectName(): String { + val manager = ProjectManagerImpl.getInstance() + val path = manager.projectDirPath + if (path.isBlank()) { + return "" + } + return manager.projectDir.name + } +} diff --git a/app/src/main/res/layout/layout_search_result_group.xml b/app/src/main/res/layout/layout_search_result_group.xml index 2ce9b0755e..28bf186f5a 100755 --- a/app/src/main/res/layout/layout_search_result_group.xml +++ b/app/src/main/res/layout/layout_search_result_group.xml @@ -1,61 +1,34 @@ - - - - - - - - - - - - - - - - - + + + + + + + diff --git a/app/src/main/res/layout/layout_search_result_item.xml b/app/src/main/res/layout/layout_search_result_item.xml index 49c0d21aca..9bddcece92 100755 --- a/app/src/main/res/layout/layout_search_result_item.xml +++ b/app/src/main/res/layout/layout_search_result_item.xml @@ -1,26 +1,26 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?attr/selectableItemBackground" + android:clickable="true" + android:descendantFocusability="blocksDescendants" + android:focusable="true" + android:gravity="top|start" + android:paddingStart="40dp" + android:paddingTop="8dp" + android:paddingEnd="16dp" + android:paddingBottom="8dp" + tools:ignore="UnusedAttribute"> - + diff --git a/app/src/main/res/layout/layout_search_result_section.xml b/app/src/main/res/layout/layout_search_result_section.xml new file mode 100644 index 0000000000..5efa467d78 --- /dev/null +++ b/app/src/main/res/layout/layout_search_result_section.xml @@ -0,0 +1,14 @@ + + diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index 8375657a53..e646caffa1 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -910,6 +910,65 @@ public final class com/itsaky/androidide/plugins/extensions/ProjectLanguage : ja public static fun values ()[Lcom/itsaky/androidide/plugins/extensions/ProjectLanguage; } +public abstract interface class com/itsaky/androidide/plugins/extensions/ProjectSearchExtension : com/itsaky/androidide/plugins/IPlugin { + public abstract fun searchProject (Lcom/itsaky/androidide/plugins/extensions/ProjectSearchRequest;)Ljava/util/concurrent/CompletableFuture; +} + +public final class com/itsaky/androidide/plugins/extensions/ProjectSearchRequest { + public fun (Ljava/lang/String;Ljava/util/List;Ljava/util/List;)V + public synthetic fun (Ljava/lang/String;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Ljava/util/List; + public final fun copy (Ljava/lang/String;Ljava/util/List;Ljava/util/List;)Lcom/itsaky/androidide/plugins/extensions/ProjectSearchRequest; + public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/extensions/ProjectSearchRequest;Ljava/lang/String;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/extensions/ProjectSearchRequest; + public fun equals (Ljava/lang/Object;)Z + public final fun getExtensions ()Ljava/util/List; + public final fun getQuery ()Ljava/lang/String; + public final fun getRoots ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/itsaky/androidide/plugins/extensions/ProjectSearchResult { + public fun (Ljava/io/File;Ljava/lang/String;Ljava/lang/String;IIIILjava/lang/Float;)V + public synthetic fun (Ljava/io/File;Ljava/lang/String;Ljava/lang/String;IIIILjava/lang/Float;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/io/File; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()I + public final fun component5 ()I + public final fun component6 ()I + public final fun component7 ()I + public final fun component8 ()Ljava/lang/Float; + public final fun copy (Ljava/io/File;Ljava/lang/String;Ljava/lang/String;IIIILjava/lang/Float;)Lcom/itsaky/androidide/plugins/extensions/ProjectSearchResult; + public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/extensions/ProjectSearchResult;Ljava/io/File;Ljava/lang/String;Ljava/lang/String;IIIILjava/lang/Float;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/extensions/ProjectSearchResult; + public fun equals (Ljava/lang/Object;)Z + public final fun getEndColumn ()I + public final fun getEndLine ()I + public final fun getFile ()Ljava/io/File; + public final fun getLinePreview ()Ljava/lang/String; + public final fun getMatchText ()Ljava/lang/String; + public final fun getScore ()Ljava/lang/Float; + public final fun getStartColumn ()I + public final fun getStartLine ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/itsaky/androidide/plugins/extensions/ProjectSearchSection { + public fun (Ljava/lang/String;Ljava/util/List;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/List; + public final fun copy (Ljava/lang/String;Ljava/util/List;)Lcom/itsaky/androidide/plugins/extensions/ProjectSearchSection; + public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/extensions/ProjectSearchSection;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/extensions/ProjectSearchSection; + public fun equals (Ljava/lang/Object;)Z + public final fun getResults ()Ljava/util/List; + public final fun getTitle ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/itsaky/androidide/plugins/extensions/ProjectTemplate { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Lcom/itsaky/androidide/plugins/extensions/ProjectLanguage;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Lcom/itsaky/androidide/plugins/extensions/ProjectLanguage;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/ProjectSearchExtension.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/ProjectSearchExtension.kt new file mode 100644 index 0000000000..cb10d351f2 --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/ProjectSearchExtension.kt @@ -0,0 +1,82 @@ +package com.itsaky.androidide.plugins.extensions + +import com.itsaky.androidide.plugins.IPlugin +import java.io.File +import java.util.concurrent.CompletableFuture + +/** + * Optional extension for plugins that contribute additional sections to project search. + * + * The IDE runs the built-in text search first, then asks enabled [ProjectSearchExtension] + * plugins for extra sections to append in the existing Search Results tab. + */ +interface ProjectSearchExtension : IPlugin { + /** + * Searches the project for [ProjectSearchRequest.query] and returns the sections to + * append to the Search Results tab. + * + * Called on the UI thread - implementations must not block. Do the work asynchronously + * and complete the returned future from any thread. The IDE waits a bounded time for + * the future; sections from futures that complete after the timeout are dropped. + * + * Complete with an empty list (never null) when there are no results. A future that + * completes exceptionally, or a method that throws, is logged and recorded as a plugin + * crash, and contributes no sections. + */ + fun searchProject(request: ProjectSearchRequest): CompletableFuture> +} + +/** + * Query parameters passed to [ProjectSearchExtension.searchProject]. + * + * @property query The literal (non-regex), non-empty search text entered by the user. + * @property roots Source directories the user selected for this search; implementations + * must not report matches outside them. + * @property extensions File-name suffixes to restrict the search to (e.g. "java", ".kt"); + * empty means all files match. The IDE splits the search dialog's filter field on "|" and + * trims each token, dropping blanks, so "java | kt" arrives as ["java", "kt"]. A match is a + * plain suffix test (file name ends with the token). + */ +data class ProjectSearchRequest( + val query: String, + val roots: List, + val extensions: List = emptyList(), +) + +/** + * A titled group of results rendered as its own section in the Search Results list. + * + * @property title Section header shown above the results, e.g. the contributing plugin's + * display name. Sections with no results are not rendered. + * @property results Matches in this section; the IDE groups them by [ProjectSearchResult.file]. + */ +data class ProjectSearchSection( + val title: String, + val results: List, +) + +/** + * A single match within a [ProjectSearchSection]. + * + * All line and column values are 0-based, matching the IDE's editor coordinates: [startLine] + * and [startColumn] address the first character of the match, [endLine] and [endColumn] + * address the position just past its last character (exclusive end). Columns are character + * offsets within the line. The IDE feeds these values directly into editor navigation when + * the user opens the result, so 1-based values will misplace the cursor by one. + * + * @property file The file containing the match. + * @property linePreview Short single-line snippet shown in the results list. + * @property matchText The exact text that matched. + * @property score Optional relevance score, higher is more relevant. The IDE currently groups + * a section's results by [file] and does not order them by score. + */ +data class ProjectSearchResult( + val file: File, + val linePreview: String, + val matchText: String, + val startLine: Int, + val startColumn: Int, + val endLine: Int, + val endColumn: Int, + val score: Float? = null, +)