diff --git a/src/main/kotlin/com/mituuz/fuzzier/actions/FuzzyAction.kt b/src/main/kotlin/com/mituuz/fuzzier/actions/FuzzyAction.kt index 482da7ad..ac4d0b73 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/actions/FuzzyAction.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/actions/FuzzyAction.kt @@ -53,18 +53,15 @@ import kotlinx.coroutines.* import java.awt.Component import java.awt.Font import java.awt.event.ActionEvent -import java.util.* -import java.util.Timer import java.util.concurrent.ConcurrentHashMap import javax.swing.* -import kotlin.concurrent.schedule abstract class FuzzyAction : AnAction() { lateinit var component: FuzzyComponent lateinit var popup: JBPopup - private lateinit var originalDownHandler: EditorActionHandler - private lateinit var originalUpHandler: EditorActionHandler - private var debounceTimer: TimerTask? = null + private var originalDownHandler: EditorActionHandler? = null + private var originalUpHandler: EditorActionHandler? = null + private var debounceJob: Job? = null protected lateinit var projectState: FuzzierSettingsService.State protected val globalState = service().state protected var defaultDoc: Document? = null @@ -138,9 +135,10 @@ abstract class FuzzyAction : AnAction() { val document = component.searchField.document val listener: DocumentListener = object : DocumentListener { override fun documentChanged(event: DocumentEvent) { - debounceTimer?.cancel() + debounceJob?.cancel() val debouncePeriod = globalState.debouncePeriod - debounceTimer = Timer().schedule(debouncePeriod.toLong()) { + debounceJob = actionScope?.launch { + delay(debouncePeriod.toLong()) updateListContents(project, component.searchField.text) } } @@ -161,6 +159,9 @@ abstract class FuzzyAction : AnAction() { fun cleanupPopup() { resetOriginalHandlers() + debounceJob?.cancel() + debounceJob = null + currentUpdateListContentJob?.cancel() currentUpdateListContentJob = null @@ -180,8 +181,12 @@ abstract class FuzzyAction : AnAction() { fun resetOriginalHandlers() { val actionManager = EditorActionManager.getInstance() - actionManager.setActionHandler(IdeActions.ACTION_EDITOR_MOVE_CARET_DOWN, originalDownHandler) - actionManager.setActionHandler(IdeActions.ACTION_EDITOR_MOVE_CARET_UP, originalUpHandler) + originalDownHandler?.let { + actionManager.setActionHandler(IdeActions.ACTION_EDITOR_MOVE_CARET_DOWN, it) + } + originalUpHandler?.let { + actionManager.setActionHandler(IdeActions.ACTION_EDITOR_MOVE_CARET_UP, it) + } } class FuzzyListActionHandler(private val fuzzyAction: FuzzyAction, private val isUp: Boolean) : diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt index a9376ca1..593355ff 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt @@ -85,16 +85,26 @@ class FuzzierGlobalSettingsComponent( JBCheckBox(), "Fuzzy Grep: Preview entire file", """ Toggles showing the full file in the preview.

- + If set, preview will use full syntax highlighting.
Otherwise, preview will only use limited syntax highlighting and show a slice around the match.

- - Disabling this option may improve performance on very large files, + + Disabling this option may improve performance on very large files, for small-to-medium files the performance impact is negligible. """.trimIndent(), false ) + val grepBackendSelector = SettingsComponent( + ComboBox(), "Grep backend", + """ + Select which backend to use for Fuzzy Grep.

+ Dynamic: Uses ripgrep (rg) if available, otherwise falls back to Fuzzier.
+ Fuzzier: Uses the built-in Fuzzier backend. + """.trimIndent(), + false + ) + val globalExclusionTextArea: JBTextArea = JBTextArea().apply { rows = 5 lineWrap = true @@ -328,6 +338,7 @@ class FuzzierGlobalSettingsComponent( .addComponent(debounceTimerValue) .addComponent(fileListLimit) .addComponent(fuzzyGrepShowFullFile) + .addComponent(grepBackendSelector) .addComponent(globalExclusionSet) .addSeparator() @@ -430,6 +441,25 @@ class FuzzierGlobalSettingsComponent( popupSizingSelector.getPopupSizingComboBox().addItem(s) } + grepBackendSelector.getGrepBackendComboBox().renderer = object : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + val renderer = + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus) as JLabel + val backend = value as GrepBackend + renderer.text = backend.text + return renderer + } + } + for (backend in GrepBackend.entries) { + grepBackendSelector.getGrepBackendComboBox().addItem(backend) + } + filenameTypeSelector.getFilenameTypeComboBox().renderer = object : DefaultListCellRenderer() { override fun getListCellRendererComponent( list: JList<*>?, diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/SettingsComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/SettingsComponent.kt index 594e4367..b7065487 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/components/SettingsComponent.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/components/SettingsComponent.kt @@ -104,6 +104,11 @@ class SettingsComponent { return component as ComboBox } + fun getGrepBackendComboBox(): ComboBox { + @Suppress("UNCHECKED_CAST") + return component as ComboBox + } + fun getIntSpinner(index: Int): JBIntSpinner { return (component as JPanel).getComponent(index) as JBIntSpinner } diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt index c98798bc..9103ea8f 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt @@ -54,14 +54,13 @@ import java.util.concurrent.Future import javax.swing.DefaultListModel import javax.swing.JPanel import javax.swing.table.DefaultTableModel -import kotlin.concurrent.schedule class TestBenchComponent : JPanel(), Disposable { private val columnNames = arrayOf("Filename", "Filepath", "Streak", "MultiMatch", "PartialPath", "Filename", "Total") private val table = JBTable() private var searchField = EditorTextField() - private var debounceTimer: TimerTask? = null + private var debounceJob: Job? = null @Volatile var currentTask: Future<*>? = null @@ -122,9 +121,10 @@ class TestBenchComponent : JPanel(), Disposable { val document = searchField.document val listener: DocumentListener = object : DocumentListener { override fun documentChanged(event: DocumentEvent) { - debounceTimer?.cancel() + debounceJob?.cancel() val debouncePeriod = liveSettingsComponent.debounceTimerValue.getIntSpinner().value as Int - debounceTimer = Timer().schedule(debouncePeriod.toLong()) { + debounceJob = actionScope.launch { + delay(debouncePeriod.toLong()) updateListContents(project, searchField.text) } } @@ -199,14 +199,16 @@ class TestBenchComponent : JPanel(), Disposable { } override fun dispose() { - debounceTimer?.cancel() - debounceTimer = null + debounceJob?.cancel() + debounceJob = null currentTask?.let { task -> if (!task.isDone) task.cancel(true) } currentTask = null + actionScope.cancel() + ApplicationManager.getApplication().invokeLater { try { table.setPaintBusy(false) diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyContainer.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyContainer.kt index 527ab0c8..34ed42a6 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyContainer.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyContainer.kt @@ -1,31 +1,37 @@ /* -MIT License - -Copyright (c) 2025 Mitja Leino - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ + * MIT License + * + * Copyright (c) 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ package com.mituuz.fuzzier.entities +import com.intellij.openapi.vfs.VirtualFile import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService -abstract class FuzzyContainer(val filePath: String, val basePath: String, val filename: String) { +abstract class FuzzyContainer( + val filePath: String, + val basePath: String, + val filename: String, + val virtualFile: VirtualFile? = null, +) { /** * Get display string for the popup */ @@ -34,7 +40,7 @@ abstract class FuzzyContainer(val filePath: String, val basePath: String, val fi /** * Get the complete URI for the file */ - fun getFileUri() : String { + fun getFileUri(): String { return "$basePath$filePath" } diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/GrepConfig.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/GrepConfig.kt index 481910d5..d320632b 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/entities/GrepConfig.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/GrepConfig.kt @@ -24,13 +24,15 @@ package com.mituuz.fuzzier.entities +import com.intellij.openapi.vfs.VirtualFile + enum class CaseMode { SENSITIVE, INSENSITIVE, } class GrepConfig( - val targets: List, + val targets: Set?, val caseMode: CaseMode, val title: String = "", val supportsSecondaryField: Boolean = true, diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/RowContainer.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/RowContainer.kt index c3f7cd7e..df7d67e8 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/entities/RowContainer.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/RowContainer.kt @@ -23,6 +23,7 @@ */ package com.mituuz.fuzzier.entities +import com.intellij.openapi.vfs.VirtualFile import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService import java.io.File @@ -32,8 +33,9 @@ class RowContainer( filename: String, val rowNumber: Int, val trimmedRow: String, - val columnNumber: Int = 0 -) : FuzzyContainer(filePath, basePath, filename) { + val columnNumber: Int = 0, + virtualFile: VirtualFile? = null +) : FuzzyContainer(filePath, basePath, filename, virtualFile) { companion object { private val FILE_SEPARATOR: String = File.separator private val RG_PATTERN: Regex = Regex("""^.+:\d+:\d+:\s*.+$""") diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrep.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrep.kt index 88a7ea37..ecd0c566 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrep.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrep.kt @@ -35,6 +35,8 @@ import com.intellij.openapi.editor.LogicalPosition import com.intellij.openapi.editor.ScrollType import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.changes.ChangeListManager +import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.util.SingleAlarm import com.mituuz.fuzzier.actions.FuzzyAction @@ -72,7 +74,7 @@ open class FuzzyGrep : FuzzyAction() { open fun getGrepConfig(project: Project): GrepConfig { return GrepConfig( - targets = listOf("."), + targets = null, caseMode = CaseMode.SENSITIVE, title = "Fuzzy Grep", ) @@ -102,8 +104,7 @@ open class FuzzyGrep : FuzzyAction() { defaultDoc = EditorFactory.getInstance().createDocument("") val showSecondaryField = backend!!.supportsSecondaryField() && grepConfig.supportsSecondaryField component = FuzzyFinderComponent( - project = project, - showSecondaryField = showSecondaryField + project = project, showSecondaryField = showSecondaryField ) previewAlarmProvider = CoroutinePreviewAlarmProvider(actionScope) previewAlarm = previewAlarmProvider?.getPreviewAlarm(component, defaultDoc) @@ -155,8 +156,7 @@ open class FuzzyGrep : FuzzyAction() { try { val results = withContext(Dispatchers.IO) { findInFiles( - searchString, - project + searchString, project ) } coroutineContext.ensureActive() @@ -172,17 +172,35 @@ open class FuzzyGrep : FuzzyAction() { project: Project, ): ListModel { val listModel = DefaultListModel() - val projectBasePath = project.basePath.toString() + val projectBasePath = project.basePath - if (backend != null) { + if (backend != null && projectBasePath != null) { val secondaryFieldText = (component as FuzzyFinderComponent).getSecondaryText() - val commands = backend!!.buildCommand(grepConfig, searchString, secondaryFieldText) - commandRunner.runCommandPopulateListModel(commands, listModel, projectBasePath, backend!!) + backend!!.handleSearch( + grepConfig, searchString, secondaryFieldText, commandRunner, listModel, projectBasePath, project + ) { vf -> validVf(vf, secondaryFieldText, ChangeListManager.getInstance(project)) } } return listModel } + private fun validVf( + virtualFile: VirtualFile, secondaryFieldText: String? = null, clm: ChangeListManager + ): Boolean { + if (virtualFile.isDirectory) return false + if (virtualFile.fileType.isBinary) return false + + if (clm.isIgnoredFile(virtualFile)) return false + + if (secondaryFieldText.isNullOrBlank()) { + return true + } else if (virtualFile.extension.equals(secondaryFieldText, ignoreCase = true)) { + return true + } + + return false + } + private fun createListeners(project: Project) { // Add a listener that updates the contents of the preview pane component.fileList.addListSelectionListener { event -> @@ -199,15 +217,12 @@ open class FuzzyGrep : FuzzyAction() { private fun handleInput(project: Project) { val selectedValue = component.fileList.selectedValue - val virtualFile = - VirtualFileManager.getInstance().findFileByUrl("file://${selectedValue?.getFileUri()}") + val virtualFile = VirtualFileManager.getInstance().findFileByUrl("file://${selectedValue?.getFileUri()}") virtualFile?.let { val fileEditorManager = FileEditorManager.getInstance(project) FileOpeningUtil.openFile( - fileEditorManager, - virtualFile, - globalState.newTab + fileEditorManager, virtualFile, globalState.newTab ) { popup.cancel() ApplicationManager.getApplication().invokeLater { diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrepVariants.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrepVariants.kt index f12788e2..89a28a11 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrepVariants.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrepVariants.kt @@ -40,7 +40,7 @@ class FuzzyGrepOpenTabsCI : FuzzyGrep() { override fun getGrepConfig(project: Project): GrepConfig { val fileEditorManager = FileEditorManager.getInstance(project) val openFiles: Array = fileEditorManager.openFiles - val targets = openFiles.map { it.path } + val targets = openFiles.toSet() return GrepConfig( targets = targets, @@ -54,7 +54,7 @@ class FuzzyGrepOpenTabs : FuzzyGrep() { override fun getGrepConfig(project: Project): GrepConfig { val fileEditorManager = FileEditorManager.getInstance(project) val openFiles: Array = fileEditorManager.openFiles - val targets = openFiles.map { it.path } + val targets = openFiles.toSet() return GrepConfig( targets = targets, @@ -69,7 +69,7 @@ class FuzzyGrepCurrentBufferCI : FuzzyGrep() { val editor = FileEditorManager.getInstance(project).selectedTextEditor val virtualFile: VirtualFile? = editor?.let { FileEditorManager.getInstance(project).selectedFiles.firstOrNull() } - val targets = virtualFile?.path?.let { listOf(it) } ?: emptyList() + val targets = virtualFile?.let { setOf(it) } ?: emptySet() return GrepConfig( targets = targets, @@ -85,7 +85,7 @@ class FuzzyGrepCurrentBuffer : FuzzyGrep() { val editor = FileEditorManager.getInstance(project).selectedTextEditor val virtualFile: VirtualFile? = editor?.let { FileEditorManager.getInstance(project).selectedFiles.firstOrNull() } - val targets = virtualFile?.path?.let { listOf(it) } ?: emptyList() + val targets = virtualFile?.let { setOf(it) } ?: emptySet() return GrepConfig( targets = targets, @@ -99,7 +99,7 @@ class FuzzyGrepCurrentBuffer : FuzzyGrep() { class FuzzyGrepCI : FuzzyGrep() { override fun getGrepConfig(project: Project): GrepConfig { return GrepConfig( - targets = listOf("."), + targets = null, caseMode = CaseMode.INSENSITIVE, title = FuzzyGrepTitles.DEFAULT, ) diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendResolver.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendResolver.kt index f8f01570..c7a5a17d 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendResolver.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendResolver.kt @@ -24,23 +24,21 @@ package com.mituuz.fuzzier.grep.backend +import com.intellij.openapi.components.service import com.mituuz.fuzzier.runner.CommandRunner +import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService +import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService.GrepBackend class BackendResolver(val isWindows: Boolean) { suspend fun resolveBackend(commandRunner: CommandRunner, projectBasePath: String): Result { - return when { - isInstalled(commandRunner, "rg", projectBasePath) -> Result.success(BackendStrategy.Ripgrep) - isWindows && isInstalled( - commandRunner, - "findstr", - projectBasePath - ) -> Result.success(BackendStrategy.Findstr) - - !isWindows && isInstalled(commandRunner, "grep", projectBasePath) -> Result.success( - BackendStrategy.Grep - ) - - else -> Result.failure(Exception("No suitable grep command found")) + val grepBackendSetting = service().state.grepBackend + + return when (grepBackendSetting) { + GrepBackend.FUZZIER -> Result.success(FuzzierGrep) + GrepBackend.DYNAMIC -> when { + isInstalled(commandRunner, "rg", projectBasePath) -> Result.success(Ripgrep) + else -> Result.success(FuzzierGrep) + } } } diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendStrategy.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendStrategy.kt index 3cf53d48..9911cb2d 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendStrategy.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendStrategy.kt @@ -24,9 +24,14 @@ package com.mituuz.fuzzier.grep.backend +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile import com.mituuz.fuzzier.entities.CaseMode +import com.mituuz.fuzzier.entities.FuzzyContainer import com.mituuz.fuzzier.entities.GrepConfig import com.mituuz.fuzzier.entities.RowContainer +import com.mituuz.fuzzier.runner.CommandRunner +import javax.swing.DefaultListModel sealed interface BackendStrategy { val name: String @@ -36,104 +41,65 @@ sealed interface BackendStrategy { return RowContainer.rowContainerFromString(line, projectBasePath) } - fun supportsSecondaryField(): Boolean = false + suspend fun handleSearch( + grepConfig: GrepConfig, + searchString: String, + secondarySearchString: String?, + commandRunner: CommandRunner, + listModel: DefaultListModel, + projectBasePath: String, + project: Project? = null, + fileFilter: (VirtualFile) -> Boolean = { true } + ) { + val commands = buildCommand(grepConfig, searchString, secondarySearchString) + commandRunner.runCommandPopulateListModel(commands, listModel, projectBasePath, this) + } - object Ripgrep : BackendStrategy { - override val name = "ripgrep" + fun supportsSecondaryField(): Boolean = false +} - override fun buildCommand( - grepConfig: GrepConfig, - searchString: String, - secondarySearchString: String? - ): List { - val commands = mutableListOf("rg") +object Ripgrep : BackendStrategy { + override val name = "ripgrep" - if (grepConfig.caseMode == CaseMode.INSENSITIVE) { - commands.add("--smart-case") - commands.add("-F") - } + override fun buildCommand( + grepConfig: GrepConfig, + searchString: String, + secondarySearchString: String? + ): List { + val commands = mutableListOf("rg") - commands.addAll( - mutableListOf( - "--no-heading", - "--color=never", - "-n", - "--with-filename", - "--column" - ) - ) - secondarySearchString?.removePrefix(".").takeIf { it?.isNotEmpty() == true }?.let { ext -> - val glob = "*.${ext}" - commands.addAll(listOf("-g", glob)) - } - commands.add(searchString) - commands.addAll(grepConfig.targets) - return commands + if (grepConfig.caseMode == CaseMode.INSENSITIVE) { + commands.add("--smart-case") + commands.add("-F") } - override fun parseOutputLine(line: String, projectBasePath: String): RowContainer? { - val line = line.replace(projectBasePath, ".") - return RowContainer.rgRowContainerFromString(line, projectBasePath) + commands.addAll( + mutableListOf( + "--no-heading", + "--color=never", + "-n", + "--with-filename", + "--column" + ) + ) + secondarySearchString?.removePrefix(".").takeIf { it?.isNotEmpty() == true }?.let { ext -> + val glob = "*.${ext}" + commands.addAll(listOf("-g", glob)) } + commands.add(searchString) - override fun supportsSecondaryField(): Boolean { - return true - } + // Convert VirtualFiles to paths, or use "." if targets is null + val targetPaths = grepConfig.targets?.map { it.path } ?: listOf(".") + commands.addAll(targetPaths) + return commands } - object Findstr : BackendStrategy { - override val name = "findstr" - - override fun buildCommand( - grepConfig: GrepConfig, - searchString: String, - secondarySearchString: String? - ): List { - val commands = mutableListOf("findstr") - - if (grepConfig.caseMode == CaseMode.INSENSITIVE) { - commands.add("/I") - } - - commands.addAll( - mutableListOf( - "/p", - "/s", - "/n", - "/C:$searchString" - ) - ) - commands.addAll(grepConfig.targets.map { if (it == ".") "*" else it }) - return commands - } + override fun parseOutputLine(line: String, projectBasePath: String): RowContainer? { + val line = line.replace(projectBasePath, ".") + return RowContainer.rgRowContainerFromString(line, projectBasePath) } - object Grep : BackendStrategy { - override val name = "grep" - - override fun buildCommand( - grepConfig: GrepConfig, - searchString: String, - secondarySearchString: String? - ): List { - val commands = mutableListOf("grep") - - if (grepConfig.caseMode == CaseMode.INSENSITIVE) { - commands.add("-i") - } - - commands.addAll( - mutableListOf( - "--color=none", - "-r", - "-H", - "-n", - searchString - ) - ) - commands.addAll(grepConfig.targets) - return commands - } + override fun supportsSecondaryField(): Boolean { + return true } } - diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/FuzzierGrep.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/FuzzierGrep.kt new file mode 100644 index 00000000..6dec316c --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/FuzzierGrep.kt @@ -0,0 +1,227 @@ +/* + * MIT License + * + * Copyright (c) 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package com.mituuz.fuzzier.grep.backend + +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.components.service +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.rootManager +import com.intellij.openapi.roots.FileIndex +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.search.PsiSearchHelper +import com.intellij.util.Processor +import com.mituuz.fuzzier.entities.CaseMode +import com.mituuz.fuzzier.entities.FuzzyContainer +import com.mituuz.fuzzier.entities.GrepConfig +import com.mituuz.fuzzier.entities.RowContainer +import com.mituuz.fuzzier.runner.CommandRunner +import com.mituuz.fuzzier.settings.FuzzierSettingsService +import com.mituuz.fuzzier.util.FuzzierUtil +import kotlinx.coroutines.* +import javax.swing.DefaultListModel + +object FuzzierGrep : BackendStrategy { + override val name = "fuzzier" + private val fuzzierUtil = FuzzierUtil() + override fun supportsSecondaryField(): Boolean { + return true + } + + override fun buildCommand( + grepConfig: GrepConfig, + searchString: String, + secondarySearchString: String? + ): List = emptyList() + + override suspend fun handleSearch( + grepConfig: GrepConfig, + searchString: String, + secondarySearchString: String?, + commandRunner: CommandRunner, + listModel: DefaultListModel, + projectBasePath: String, + project: Project?, + fileFilter: (VirtualFile) -> Boolean + ) { + if (project == null) return + + val fileCollectionStart = System.nanoTime() + + val files = grepConfig.targets ?: collectFiles(searchString, fileFilter, project, grepConfig) + + val fileCollectionEnd = System.nanoTime() + val fileCollectionDuration = (fileCollectionEnd - fileCollectionStart) / 1_000_000.0 + println("File collection took $fileCollectionDuration ms") + + println("Searching from ${files.size} files with ${grepConfig.caseMode} case mode and $searchString") + + var count = 0 + val batchSize = 20 + val currentBatch = mutableListOf() + + val fileProcessingStart = System.nanoTime() + + for (file in files) { + currentCoroutineContext().ensureActive() + + val matches = withContext(Dispatchers.IO) { + val content = VfsUtil.loadText(file) + val lines = content.lines() + val fileMatches = mutableListOf() + + for ((index, line) in lines.withIndex()) { + currentCoroutineContext().ensureActive() + + val found = if (grepConfig.caseMode == CaseMode.INSENSITIVE) { + line.contains(searchString, ignoreCase = true) + } else { + line.contains(searchString) + } + + if (found) { + val (filePath, basePath) = fuzzierUtil.extractModulePath(file.path, project) + fileMatches.add( + RowContainer( + filePath, + basePath, + file.name, + index, + line.trim(), + virtualFile = file + ) + ) + } + } + fileMatches + } + + for (match in matches) { + currentCoroutineContext().ensureActive() + + currentBatch.add(match) + count++ + + if (currentBatch.size >= batchSize) { + val toAdd = currentBatch.toList() + currentBatch.clear() + withContext(Dispatchers.Main) { + listModel.addAll(toAdd) + } + } + + if (count >= 1000) break + } + + if (count >= 1000) break + } + + val fileProcessingEnd = System.nanoTime() + + val fileProcessingDuration = (fileProcessingEnd - fileProcessingStart) / 1_000_000.0 + println("File processing took $fileProcessingDuration ms") + + if (currentBatch.isNotEmpty()) { + withContext(Dispatchers.Main) { + listModel.addAll(currentBatch) + } + } + } + + private suspend fun collectFiles( + searchString: String, + fileFilter: (VirtualFile) -> Boolean, + project: Project, + grepConfig: GrepConfig + ): List { + val files = mutableListOf() + val trimmedSearch = searchString.trim() + + if (trimmedSearch.count { it == ' ' } >= 2) { + // Extract the first complete word - the only one we can be sure is a full word + val words = trimmedSearch.split(" ") + val firstCompleteWord = words[1] + + if (firstCompleteWord.isNotEmpty()) { + ReadAction.run { + val helper = PsiSearchHelper.getInstance(project) + helper.processAllFilesWithWord( + firstCompleteWord, + GlobalSearchScope.projectScope(project), + Processor { psiFile -> + psiFile.virtualFile?.let { vf -> + if (fileFilter(vf)) { + files.add(vf) + } + } + true + }, + grepConfig.caseMode == CaseMode.SENSITIVE + ) + } + } + } else { + val ctx = currentCoroutineContext() + val job = ctx.job + val projectState = project.service().state + + val indexTargets = if (projectState.isProject) { + listOf(ProjectFileIndex.getInstance(project) to project.name) + } else { + val moduleManager = ModuleManager.getInstance(project) + moduleManager.modules.map { it.rootManager.fileIndex to it.name } + } + + return collectFiles( + targets = indexTargets, + shouldContinue = { job.isActive }, + fileFilter = fileFilter + ) + } + + return files + } + + private fun collectFiles( + targets: List>, + shouldContinue: () -> Boolean, + fileFilter: (VirtualFile) -> Boolean + ): List = buildList { + for ((fileIndex, _) in targets) { + fileIndex.iterateContent { vf -> + if (!shouldContinue()) return@iterateContent false + + if (fileFilter(vf)) { + add(vf) + } + + true + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/runner/DefaultCommandRunner.kt b/src/main/kotlin/com/mituuz/fuzzier/runner/DefaultCommandRunner.kt index 283c2d91..0e16c144 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/runner/DefaultCommandRunner.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/runner/DefaultCommandRunner.kt @@ -60,9 +60,15 @@ class DefaultCommandRunner : CommandRunner { } }) - withContext(Dispatchers.IO) { - processHandler.startNotify() - processHandler.waitFor(2000) + try { + withContext(Dispatchers.IO) { + processHandler.startNotify() + processHandler.waitFor(2000) + } + } finally { + if (!processHandler.isProcessTerminated) { + processHandler.destroyProcess() + } } output.toString() } catch (_: InterruptedException) { @@ -104,9 +110,15 @@ class DefaultCommandRunner : CommandRunner { } }) - withContext(Dispatchers.IO) { - processHandler.startNotify() - processHandler.waitFor(2000) + try { + withContext(Dispatchers.IO) { + processHandler.startNotify() + processHandler.waitFor(2000) + } + } finally { + if (!processHandler.isProcessTerminated) { + processHandler.destroyProcess() + } } } catch (_: InterruptedException) { throw InterruptedException() diff --git a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt index bcdceec0..25b42249 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt @@ -68,6 +68,7 @@ class FuzzierGlobalSettingsConfigurable : Configurable { component.previewFontSize.getIntSpinner().value = state.previewFontSize component.fileListSpacing.getIntSpinner().value = state.fileListSpacing component.fuzzyGrepShowFullFile.getCheckBox().isSelected = state.fuzzyGrepShowFullFile + component.grepBackendSelector.getGrepBackendComboBox().selectedIndex = state.grepBackend.ordinal // Hide dimension settings when Auto size is selected updateDimensionVisibility(state.popupSizing) @@ -129,6 +130,7 @@ class FuzzierGlobalSettingsConfigurable : Configurable { || state.matchWeightStreakModifier != component.matchWeightStreakModifier.getIntSpinner().value || state.matchWeightFilename != component.matchWeightFilename.getIntSpinner().value || state.globalExclusionSet != newGlobalSet + || state.grepBackend != component.grepBackendSelector.getGrepBackendComboBox().selectedItem } override fun apply() { @@ -181,6 +183,9 @@ class FuzzierGlobalSettingsConfigurable : Configurable { .filter { it.isNotEmpty() } .toSet() state.globalExclusionSet = newGlobalSet + + state.grepBackend = + FuzzierGlobalSettingsService.GrepBackend.entries.toTypedArray()[component.grepBackendSelector.getGrepBackendComboBox().selectedIndex] } override fun disposeUIResources() { diff --git a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt index c8d08862..e8fac6d5 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt @@ -70,6 +70,8 @@ class FuzzierGlobalSettingsService : PersistentStateComponent