diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d0904a1..66c97395 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,19 @@ on: pull_request: branches: [ "main" ] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest, windows-latest ] steps: - uses: actions/checkout@v4 @@ -25,6 +32,8 @@ jobs: run: ./gradlew koverXmlReport - name: Upload coverage report artifact + # Only upload one artifact + if: matrix.os == 'ubuntu-latest' uses: actions/upload-artifact@v4 with: name: kover-report diff --git a/build.gradle.kts b/build.gradle.kts index b3fbcaed..15941c93 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,7 +27,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile // Use the same version and group for the jar and the plugin -val currentVersion = "1.15.0" +val currentVersion = "2.0.0" val myGroup = "com.mituuz" version = currentVersion group = myGroup @@ -39,15 +39,64 @@ intellijPlatform { changeNotes = """

Version $currentVersion

- +

Update default list movement keys

+ + +

New actions

+

Added some new grep variations

+
com.mituuz.fuzzier.grep.FuzzyGrepOpenTabsCI
+    com.mituuz.fuzzier.grep.FuzzyGrepOpenTabs
+    com.mituuz.fuzzier.grep.FuzzyGrepCurrentBufferCI
+    com.mituuz.fuzzier.grep.FuzzyGrepCurrentBuffer
+ +

Example mappings

+
" File search
+    nmap <Leader>sf <action>(com.mituuz.fuzzier.search.Fuzzier)
+    nmap <Leader>sg <action>(com.mituuz.fuzzier.search.FuzzierVCS)
+    
+    " Mover
+    nmap <Leader>fm <action>(com.mituuz.fuzzier.operation.FuzzyMover)
+    
+    " Grepping
+    nmap <Leader>ss <action>(com.mituuz.fuzzier.grep.FuzzyGrepCI)
+    nmap <Leader>sS <action>(com.mituuz.fuzzier.grep.FuzzyGrep)
+    nmap <Leader>st <action>(com.mituuz.fuzzier.grep.FuzzyGrepOpenTabsCI)
+    nmap <Leader>sT <action>(com.mituuz.fuzzier.grep.FuzzyGrepOpenTabs)
+    nmap <Leader>sb <action>(com.mituuz.fuzzier.grep.FuzzyGrepCurrentBufferCI)
+    nmap <Leader>sB <action>(com.mituuz.fuzzier.grep.FuzzyGrepCurrentBuffer)
+ +

New features

+ + +

Other changes

+ + """.trimIndent() ideaVersion { diff --git a/changelog.md b/changelog.md index 166cd6d0..2f90f7f7 100644 --- a/changelog.md +++ b/changelog.md @@ -1,12 +1,68 @@ # Changelog -## Version 1.15.0 +## Version 2.0.0 + +This version contains larger refactors and multiple new actions enabled by them. + +I'm updating the existing package structure to keep things nicer and not supporting the old actions to avoid possible +problems in the future. + +### Breaking changes + +**Rename existing actions** + +- `com.mituuz.fuzzier.FuzzyGrepCaseInsensitive` to `com.mituuz.fuzzier.grep.FuzzyGrepCI` +- `com.mituuz.fuzzier.FuzzyGrep` to `com.mituuz.fuzzier.grep.FuzzyGrep` +- `com.mituuz.fuzzier.Fuzzier` to `com.mituuz.fuzzier.search.Fuzzier` +- `com.mituuz.fuzzier.FuzzierVCS` to `com.mituuz.fuzzier.search.FuzzierVCS` +- `com.mituuz.fuzzier.FuzzyMover` to `com.mituuz.fuzzier.operation.FuzzyMover` + +**Update default list movement keys** + +- From `CTRL + j` and `CTRL + k` to `CTRL + n` and `CTRL + p` + +### New actions + +Added some new grep variations + +``` +com.mituuz.fuzzier.grep.FuzzyGrepOpenTabsCI +com.mituuz.fuzzier.grep.FuzzyGrepOpenTabs +com.mituuz.fuzzier.grep.FuzzyGrepCurrentBufferCI +com.mituuz.fuzzier.grep.FuzzyGrepCurrentBuffer +``` + +### Example mappings + +``` +" File search +nmap sf (com.mituuz.fuzzier.search.Fuzzier) +nmap sg (com.mituuz.fuzzier.search.FuzzierVCS) + +" Mover +nmap fm (com.mituuz.fuzzier.operation.FuzzyMover) + +" Grepping +nmap ss (com.mituuz.fuzzier.grep.FuzzyGrepCI) +nmap sS (com.mituuz.fuzzier.grep.FuzzyGrep) +nmap st (com.mituuz.fuzzier.grep.FuzzyGrepOpenTabsCI) +nmap sT (com.mituuz.fuzzier.grep.FuzzyGrepOpenTabs) +nmap sb (com.mituuz.fuzzier.grep.FuzzyGrepCurrentBufferCI) +nmap sB (com.mituuz.fuzzier.grep.FuzzyGrepCurrentBuffer) +``` + +### New features + +- Popup now defaults to auto-sized, which scales with the current window +- You can revert this from the settings + +### Other changes - Refactor file search to use coroutines - Handle list size limiting during processing instead of doing them separately -- Add debouncing for fuzzy file preview using `SingleAlarm` +- Add debouncing for file preview using `SingleAlarm` - Refactor everything -- Add auto sizing option for the popup (default) +- Remove manual handling of the divider location (use JBSplitter instead) and unify styling ## Version 1.14.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ac8b8fc8..876062a3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ junit5 = "6.0.1" junit4 = "4.13.2" kotlin = "2.2.21" -intellijPlatform = "2.10.4" +intellijPlatform = "2.10.5" kover = "0.9.3" communityVersion = "2025.1" mockk = "1.14.6" diff --git a/readme.md b/readme.md index c939b1b4..1eb536c2 100644 --- a/readme.md +++ b/readme.md @@ -42,11 +42,12 @@ List movement can be remapped from settings -> keymaps, but do not support chord ## Features -- Fuzzy file finder - - Search all except excluded files - - Search only from VCS-tracked files +- Fuzzy file search + - Search all except excluded files + - Search only from VCS-tracked files - Text search leveraging [ripgrep](https://github.com/BurntSushi/ripgrep "Link to GitHub - ripgrep"), grep or findstr - - With file globbing support for ripgrep + - Support for searching from the whole project, within open tabs or the current buffer + - With file extension support for ripgrep in the secondary search field - File mover ## Documentation @@ -60,14 +61,23 @@ The goal is to have a central place for all the documentation and to keep the RE ### Adding ideavim mapping for the plugin -Example of a .ideavimrc-row to add a vim keybinding for the plugin +Example `.ideavimrc` rows to add a vim keybindings for the plugin ``` -map pf (com.mituuz.fuzzier.Fuzzier) -map gf (com.mituuz.fuzzier.FuzzierVCS) -map mf (com.mituuz.fuzzier.FuzzyMover) -map ff (com.mituuz.fuzzier.FuzzyGrep) -map ff (com.mituuz.fuzzier.FuzzyGrepCaseInsensitive) +" File search +nmap sf (com.mituuz.fuzzier.search.Fuzzier) +nmap sg (com.mituuz.fuzzier.search.FuzzierVCS) + +" Mover +nmap fm (com.mituuz.fuzzier.operation.FuzzyMover) + +" Grepping +nmap ss (com.mituuz.fuzzier.grep.FuzzyGrepCI) +nmap sS (com.mituuz.fuzzier.grep.FuzzyGrep) +nmap st (com.mituuz.fuzzier.grep.FuzzyGrepOpenTabsCI) +nmap sT (com.mituuz.fuzzier.grep.FuzzyGrepOpenTabs) +nmap sb (com.mituuz.fuzzier.grep.FuzzyGrepCurrentBufferCI) +nmap sB (com.mituuz.fuzzier.grep.FuzzyGrepCurrentBuffer) ``` ### Adding an editor shortcut @@ -76,7 +86,8 @@ map ff (com.mituuz.fuzzier.FuzzyGrepCaseInsensitive) ## Installation -The plugin can be installed from the [JetBrains Marketplace](https://plugins.jetbrains.com/plugin/23451-fuzzier "Link to the JetBrains Marketplace - Fuzzier") +The plugin can be installed from +the [JetBrains Marketplace](https://plugins.jetbrains.com/plugin/23451-fuzzier "Link to the JetBrains Marketplace - Fuzzier") ## Contact diff --git a/src/main/kotlin/com/mituuz/fuzzier/FuzzyGrep.kt b/src/main/kotlin/com/mituuz/fuzzier/FuzzyGrep.kt deleted file mode 100644 index 1ba9c737..00000000 --- a/src/main/kotlin/com/mituuz/fuzzier/FuzzyGrep.kt +++ /dev/null @@ -1,388 +0,0 @@ -/* - * 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 - -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.execution.process.OSProcessHandler -import com.intellij.execution.process.ProcessEvent -import com.intellij.execution.process.ProcessListener -import com.intellij.notification.Notification -import com.intellij.notification.NotificationType -import com.intellij.notification.Notifications -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.EDT -import com.intellij.openapi.editor.EditorFactory -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.util.Key -import com.intellij.openapi.vfs.VirtualFile -import com.intellij.openapi.vfs.VirtualFileManager -import com.mituuz.fuzzier.FuzzyGrep.Companion.MAX_NUMBER_OR_RESULTS -import com.mituuz.fuzzier.FuzzyGrep.Companion.MAX_OUTPUT_SIZE -import com.mituuz.fuzzier.actions.FuzzyAction -import com.mituuz.fuzzier.components.FuzzyFinderComponent -import com.mituuz.fuzzier.entities.FuzzyContainer -import com.mituuz.fuzzier.entities.RowContainer -import com.mituuz.fuzzier.ui.bindings.ActivationBindings -import com.mituuz.fuzzier.ui.popup.PopupConfig -import kotlinx.coroutines.* -import org.apache.commons.lang3.StringUtils -import javax.swing.DefaultListModel -import javax.swing.ListModel - -open class FuzzyGrep : FuzzyAction() { - companion object { - const val FUZZIER_NOTIFICATION_GROUP: String = "Fuzzier Notification Group" - - /** - * Limit command output size, this is only used to check installations - */ - const val MAX_OUTPUT_SIZE = 10000 - const val MAX_NUMBER_OR_RESULTS = 1000 - } - - var useRg = true - val isWindows = System.getProperty("os.name").lowercase().contains("win") - private var currentLaunchJob: Job? = null - protected open lateinit var popupTitle: String - - override fun runAction( - project: Project, - actionEvent: AnActionEvent - ) { - currentLaunchJob?.cancel() - - val projectBasePath = project.basePath.toString() - currentLaunchJob = actionScope?.launch(Dispatchers.EDT) { - val currentJob = currentLaunchJob - - if (!isInstalled("rg", projectBasePath)) { - showNotification( - "No `rg` command found", - """ - No ripgrep found
- Fallback to `grep` or `findstr`
- This notification can be disabled - """.trimIndent(), - project, - NotificationType.WARNING - ) - - if (isWindows) { - if (!isInstalled("findstr", projectBasePath)) { - showNotification( - "No `findstr` command found", - "Fuzzy Grep failed: no `findstr` found", - project - ) - return@launch - } - popupTitle = "Fuzzy Grep (findstr)" - } else { - if (!isInstalled("grep", projectBasePath)) { - showNotification( - "No `grep` command found", - "Fuzzy Grep failed: no `grep` found", - project - ) - return@launch - } - popupTitle = "Fuzzy Grep (grep)" - } - useRg = false - } else { - popupTitle = "Fuzzy Grep (ripgrep)" - useRg = true - } - - if (currentJob?.isCancelled == true) return@launch - - yield() - defaultDoc = EditorFactory.getInstance().createDocument("") - component = FuzzyFinderComponent(project, showSecondaryField = useRg) - createListeners(project) - val maybePopup = getPopupProvider().show( - project = project, - content = component, - focus = component.searchField, - config = PopupConfig( - title = popupTitle, - preferredSizeProvider = component.preferredSize, - dimensionKey = "FuzzyGrepPopup", - resetWindow = { globalState.resetWindow }, - clearResetWindowFlag = { globalState.resetWindow = false } - ), - cleanupFunction = { cleanupPopup() }, - ) - - if (maybePopup == null) return@launch - popup = maybePopup - - createSharedListeners(project) - - (component as FuzzyFinderComponent).splitPane.dividerLocation = - globalState.splitPosition - } - } - - private fun showNotification( - title: String, - content: String, - project: Project, - type: NotificationType = NotificationType.ERROR - ) { - val grepNotification = Notification( - FUZZIER_NOTIFICATION_GROUP, - title, - content, - type - ) - Notifications.Bus.notify(grepNotification, project) - } - - override fun onPopupClosed() { - globalState.splitPosition = - (component as FuzzyFinderComponent).splitPane.dividerLocation - - currentLaunchJob?.cancel() - currentLaunchJob = null - } - - /** - * OS-specific to see if a specific executable is found - * @return true if the command was found (possibly not a general check/solution) - */ - private suspend fun isInstalled(executable: String, projectBasePath: String): Boolean { - val command = if (isWindows) { - listOf("where", executable) - } else { - listOf("which", executable) - } - - val result = runCommand(command, projectBasePath) - - return !(result.isNullOrBlank() || result.contains("Could not find files")) - } - - override fun updateListContents(project: Project, searchString: String) { - if (StringUtils.isBlank(searchString)) { - component.fileList.model = DefaultListModel() - return - } - - currentUpdateListContentJob?.cancel() - currentUpdateListContentJob = actionScope?.launch(Dispatchers.EDT) { - component.fileList.setPaintBusy(true) - try { - val results = withContext(Dispatchers.IO) { - findInFiles(searchString, project.basePath.toString()) - } - coroutineContext.ensureActive() - component.refreshModel(results, getCellRenderer()) - } finally { - component.fileList.setPaintBusy(false) - } - } - } - - /** - * Run the command and collect the output to a string variable with a limited size - * @see MAX_OUTPUT_SIZE - */ - protected open suspend fun runCommand(commands: List, projectBasePath: String): String? { - return try { - val commandLine = GeneralCommandLine(commands) - .withWorkDirectory(projectBasePath) - .withRedirectErrorStream(true) - val output = StringBuilder() - val processHandler = OSProcessHandler(commandLine) - - processHandler.addProcessListener(object : ProcessListener { - override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { - if (output.length < MAX_OUTPUT_SIZE) { - output.appendLine(event.text.replace("\n", "")) - } - } - }) - - withContext(Dispatchers.IO) { - processHandler.startNotify() - processHandler.waitFor(2000) - } - output.toString() - } catch (_: InterruptedException) { - throw InterruptedException() - } - } - - /** - * Run the command and stream a limited number of results to the list model - * @see MAX_NUMBER_OR_RESULTS - */ - protected open suspend fun runCommand( - commands: List, - listModel: DefaultListModel, - projectBasePath: String - ) { - try { - val commandLine = GeneralCommandLine(commands) - .withWorkDirectory(projectBasePath) - .withRedirectErrorStream(true) - - val processHandler = OSProcessHandler(commandLine) - var count = 0 - - processHandler.addProcessListener(object : ProcessListener { - override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { - if (count >= MAX_NUMBER_OR_RESULTS) return - - event.text.lines().forEach { line -> - if (count >= MAX_NUMBER_OR_RESULTS) return@forEach - if (line.isNotBlank()) { - val rowContainer = RowContainer.rowContainerFromString(line, projectBasePath, useRg) - if (rowContainer != null) { - listModel.addElement(rowContainer) - count++ - } - } - } - } - }) - - withContext(Dispatchers.IO) { - processHandler.startNotify() - processHandler.waitFor(2000) - } - } catch (_: InterruptedException) { - throw InterruptedException() - } - } - - private suspend fun findInFiles( - searchString: String, - projectBasePath: String - ): ListModel { - val listModel = DefaultListModel() - if (useRg) { - val secondary = (component as FuzzyFinderComponent).getSecondaryText().trim() - val commands = mutableListOf( - "rg", - "--no-heading", - "--color=never", - "-n", - "--with-filename", - "--column" - ) - if (secondary.isNotEmpty()) { - val ext = secondary.removePrefix(".") - val glob = "*.${ext}" - commands.addAll(listOf("-g", glob)) - } - commands.addAll(listOf(searchString, ".")) - runCommand(commands, listModel, projectBasePath) - } else { - if (isWindows) { - runCommand(listOf("findstr", "/p", "/s", "/n", searchString, "*"), listModel, projectBasePath) - } else { - runCommand(listOf("grep", "--color=none", "-r", "-n", searchString, "."), listModel, projectBasePath) - } - } - - return listModel - } - - private fun createListeners(project: Project) { - // Add a listener that updates the contents of the preview pane - component.fileList.addListSelectionListener { event -> - if (!event.valueIsAdjusting) { - if (component.fileList.isEmpty) { - actionScope?.launch(Dispatchers.EDT) { - defaultDoc?.let { (component as FuzzyFinderComponent).previewPane.updateFile(it) } - } - return@addListSelectionListener - } - val selectedValue = component.fileList.selectedValue - val fileUrl = "file://${selectedValue?.getFileUri()}" - - actionScope?.launch(Dispatchers.Default) { - val file = withContext(Dispatchers.IO) { - VirtualFileManager.getInstance().findFileByUrl(fileUrl) - } - - file?.let { - (component as FuzzyFinderComponent).previewPane.coUpdateFile( - file, - (selectedValue as RowContainer).rowNumber - ) - } - } - } - } - - ActivationBindings.install( - component, - onActivate = { handleInput(project) } - ) - } - - private fun handleInput(project: Project) { - val selectedValue = component.fileList.selectedValue - val virtualFile = - VirtualFileManager.getInstance().findFileByUrl("file://${selectedValue?.getFileUri()}") - virtualFile?.let { - openFile(project, selectedValue, it) - } - } - - private fun openFile(project: Project, fuzzyContainer: FuzzyContainer?, virtualFile: VirtualFile) { - val fileEditorManager = FileEditorManager.getInstance(project) - val currentEditor = fileEditorManager.selectedTextEditor - val previousFile = currentEditor?.virtualFile - - if (fileEditorManager.isFileOpen(virtualFile)) { - fileEditorManager.openFile(virtualFile, true) - } else { - fileEditorManager.openFile(virtualFile, true) - if (currentEditor != null && !globalState.newTab) { - fileEditorManager.selectedEditor?.let { - if (previousFile != null) { - fileEditorManager.closeFile(previousFile) - } - } - } - } - popup.cancel() - ApplicationManager.getApplication().invokeLater { - val rc = fuzzyContainer as RowContainer - val lp = LogicalPosition(rc.rowNumber, rc.columnNumber) - val editor = fileEditorManager.selectedTextEditor - editor?.scrollingModel?.scrollTo(lp, ScrollType.CENTER) - editor?.caretModel?.moveToLogicalPosition(lp) - } - } -} diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzyFinderComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzyFinderComponent.kt index 862e5a48..c0494e7a 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzyFinderComponent.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzyFinderComponent.kt @@ -32,6 +32,7 @@ import com.intellij.openapi.editor.event.DocumentListener import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.project.Project import com.intellij.ui.EditorTextField +import com.intellij.ui.OnePixelSplitter import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.uiDesigner.core.GridConstraints @@ -46,15 +47,16 @@ import java.awt.KeyboardFocusManager import java.awt.event.InputEvent import java.awt.event.KeyEvent import javax.swing.JPanel -import javax.swing.JSplitPane import javax.swing.KeyStroke import javax.swing.SwingUtilities class FuzzyFinderComponent(project: Project, private val showSecondaryField: Boolean = false) : FuzzyComponent() { var previewPane: PreviewEditor = PreviewEditor(project) var fuzzyPanel: JPanel = JPanel() - var splitPane: JSplitPane = JSplitPane() - private val secondaryField = EditorTextField() + var splitPane: OnePixelSplitter = OnePixelSplitter() + private val secondaryField = EditorTextField().apply { + setPlaceholder("File extension") + } init { val settingsState = service().state @@ -64,16 +66,19 @@ class FuzzyFinderComponent(project: Project, private val showSecondaryField: Boo previewPane.fileType = PlainTextFileType.INSTANCE previewPane.isViewer = true + splitPane.setAndLoadSplitterProportionKey("Fuzzier.FuzzyFinder.Splitter") splitPane.preferredSize = Dimension(settingsState.defaultPopupWidth, settingsState.defaultPopupHeight) + splitPane.dividerWidth = 3 fuzzyPanel.layout = GridLayoutManager(1, 1, JBUI.emptyInsets(), -1, -1) val searchPanel = JPanel() val cols = if (showSecondaryField) 2 else 1 searchPanel.layout = GridLayoutManager(3, cols, JBUI.emptyInsets(), -1, -1) + searchPanel.border = JBUI.Borders.empty(3, 0, 0, 3) // Configure the secondary field to be roughly a single word wide run { - val width = JBUI.scale(90) + val width = JBUI.scale(110) secondaryField.preferredSize = Dimension(width, secondaryField.preferredSize.height) } searchField.text = "" @@ -82,8 +87,6 @@ class FuzzyFinderComponent(project: Project, private val showSecondaryField: Boo fileList.selectionMode = 0 fileListScrollPane.setViewportView(fileList) - splitPane.dividerSize = 10 - when (val searchPosition = settingsState.searchPosition) { BOTTOM, TOP -> vertical(searchPosition, searchPanel, fileListScrollPane) RIGHT, LEFT -> horizontal(searchPosition, searchPanel, fileListScrollPane) @@ -170,20 +173,21 @@ class FuzzyFinderComponent(project: Project, private val showSecondaryField: Boo getGridConstraints(0) ) - splitPane.orientation = JSplitPane.VERTICAL_SPLIT + // Vertical orientation: first = top, second = bottom + splitPane.orientation = true var searchFieldGridRow: Int var fileListGridRow: Int if (searchPosition == TOP) { searchFieldGridRow = 0 fileListGridRow = 1 - splitPane.topComponent = searchPanel - splitPane.bottomComponent = previewPane + splitPane.firstComponent = searchPanel + splitPane.secondComponent = previewPane } else { searchFieldGridRow = 1 fileListGridRow = 0 - splitPane.topComponent = previewPane - splitPane.bottomComponent = searchPanel + splitPane.firstComponent = previewPane + splitPane.secondComponent = searchPanel } searchPanel.add( @@ -228,12 +232,14 @@ class FuzzyFinderComponent(project: Project, private val showSecondaryField: Boo getGridConstraints(0, 0, colSpan) ) + // Horizontal orientation: first = left, second = right + splitPane.orientation = false if (searchPosition == LEFT) { - splitPane.leftComponent = searchPanel - splitPane.rightComponent = previewPane + splitPane.firstComponent = searchPanel + splitPane.secondComponent = previewPane } else { - splitPane.rightComponent = searchPanel - splitPane.leftComponent = previewPane + splitPane.secondComponent = searchPanel + splitPane.firstComponent = previewPane } } diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/GrepConfig.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/GrepConfig.kt new file mode 100644 index 00000000..f5b70b33 --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/GrepConfig.kt @@ -0,0 +1,44 @@ +/* + * 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 + +enum class CaseMode { + SENSITIVE, + INSENSITIVE, +} + +class GrepConfig( + val targets: List, + val caseMode: CaseMode, + val title: String = "", + val supportsSecondaryField: Boolean = true, +) { + fun getPopupTitle(): String { + if (caseMode == CaseMode.INSENSITIVE) { + return "$title (Case Insensitive)" + } + return title + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/RowContainer.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/RowContainer.kt index 7feaa671..c3f7cd7e 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/entities/RowContainer.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/RowContainer.kt @@ -1,27 +1,25 @@ /* - - 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 @@ -33,64 +31,49 @@ class RowContainer( basePath: String, filename: String, val rowNumber: Int, - val columnNumber: Int, - val trimmedRow: String + val trimmedRow: String, + val columnNumber: Int = 0 ) : FuzzyContainer(filePath, basePath, filename) { companion object { private val FILE_SEPARATOR: String = File.separator private val RG_PATTERN: Regex = Regex("""^.+:\d+:\d+:\s*.+$""") private val COMMON_PATTERN: Regex = Regex("""^.+:\d+:\s*.+$""") - /** - * Create a row container from a string - *

- *

ripgrep

- * ``` - * ./src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt:205:33: moduleFileIndex.iterateContent(contentIterator) - * ``` - *

grep

- * ``` - * ./src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt:205: moduleFileIndex.iterateContent(contentIterator) - * ``` - *

findstr

- * ``` - * src\main\kotlin\com\mituuz\fuzzier\components\TestBenchComponent.kt:205: moduleFileIndex.iterateContent(contentIterator) - * ``` - */ - fun rowContainerFromString(row: String, basePath: String, isRg: Boolean): RowContainer? { - if (!row.matches(if (isRg) RG_PATTERN else COMMON_PATTERN)) { + fun rowContainerFromString(row: String, basePath: String): RowContainer? { + if (!row.matches(COMMON_PATTERN)) { return null } - val parts = getParts(row, isRg) - if (parts.size != if (isRg) 4 else 3) { + val parts = row.split(":", limit = 3) + val filePath = getFilePath(parts[0]) + val filename = filePath.replace('\\', '/').substringAfterLast('/') + val rowNumber = parts[1].toInt() - 1 + val trimmedRow: String = parts[2].trim() + return RowContainer(filePath, basePath, filename, rowNumber, trimmedRow) + } + + fun rgRowContainerFromString(row: String, basePath: String): RowContainer? { + if (!row.matches(RG_PATTERN)) { return null } - var filePath = parts[0].removePrefix(".") - val filename = filePath.substringAfterLast(FILE_SEPARATOR) + val parts = row.split(":", limit = 4) + val filePath = getFilePath(parts[0]) + val filename = filePath.replace('\\', '/').substringAfterLast('/') val rowNumber = parts[1].toInt() - 1 - val columnNumber: Int - val trimmedRow: String - if (isRg) { - columnNumber = parts[2].toInt() - 1 - trimmedRow = parts[3].trim() - } else { - if (!filePath.startsWith(FILE_SEPARATOR)) { - filePath = "$FILE_SEPARATOR$filePath" - } - columnNumber = 0 - trimmedRow = parts[2].trim() - } - return RowContainer(filePath, basePath, filename, rowNumber, columnNumber, trimmedRow) + val columnNumber: Int = parts[2].toInt() - 1 + val trimmedRow: String = parts[3].trim() + + return RowContainer(filePath, basePath, filename, rowNumber, trimmedRow, columnNumber) } - private fun getParts(row: String, isRg: Boolean): List { - return if (isRg) { - row.split(":", limit = 4) - } else { - row.split(":", limit = 3) + private fun getFilePath(filePath: String): String { + val filePath = filePath.removePrefix(".") + if (!filePath.startsWith(FILE_SEPARATOR)) { + return "$FILE_SEPARATOR$filePath" } + + return filePath } } diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrep.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrep.kt new file mode 100644 index 00000000..6b635ecd --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrep.kt @@ -0,0 +1,223 @@ +/* + * 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 + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationType +import com.intellij.notification.Notifications +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.editor.EditorFactory +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.vfs.VirtualFileManager +import com.intellij.util.SingleAlarm +import com.mituuz.fuzzier.actions.FuzzyAction +import com.mituuz.fuzzier.components.FuzzyFinderComponent +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.grep.backend.BackendResolver +import com.mituuz.fuzzier.grep.backend.BackendStrategy +import com.mituuz.fuzzier.intellij.files.FileOpeningUtil +import com.mituuz.fuzzier.runner.DefaultCommandRunner +import com.mituuz.fuzzier.ui.bindings.ActivationBindings +import com.mituuz.fuzzier.ui.popup.PopupConfig +import com.mituuz.fuzzier.ui.preview.CoroutinePreviewAlarmProvider +import com.mituuz.fuzzier.ui.preview.PreviewAlarmProvider +import kotlinx.coroutines.* +import org.apache.commons.lang3.StringUtils +import javax.swing.DefaultListModel +import javax.swing.ListModel + +open class FuzzyGrep : FuzzyAction() { + companion object { + const val FUZZIER_NOTIFICATION_GROUP: String = "Fuzzier Notification Group" + } + + val isWindows = System.getProperty("os.name").lowercase().contains("win") + private val backendResolver = BackendResolver(isWindows) + private val commandRunner = DefaultCommandRunner() + private var currentLaunchJob: Job? = null + private var backend: BackendStrategy? = null + private var previewAlarm: SingleAlarm? = null + private var previewAlarmProvider: PreviewAlarmProvider? = null + private lateinit var grepConfig: GrepConfig + + open fun getGrepConfig(project: Project): GrepConfig { + return GrepConfig( + targets = listOf("."), + caseMode = CaseMode.SENSITIVE, + title = "Fuzzy Grep", + ) + } + + override fun runAction( + project: Project, actionEvent: AnActionEvent + ) { + currentLaunchJob?.cancel() + grepConfig = getGrepConfig(project) + val popupTitle = grepConfig.getPopupTitle() + + val projectBasePath = project.basePath.toString() + currentLaunchJob = actionScope?.launch(Dispatchers.EDT) { + val backendResult: Result = backendResolver.resolveBackend(commandRunner, projectBasePath) + backend = backendResult.getOrNull() + + if (backendResult.isFailure) { + showNotification( + "No search command found", "Fuzzy Grep failed: no suitable grep command found", project + ) + return@launch + } + if (backend == null) return@launch + + yield() + defaultDoc = EditorFactory.getInstance().createDocument("") + val showSecondaryField = backend!!.supportsSecondaryField() && grepConfig.supportsSecondaryField + component = FuzzyFinderComponent( + project = project, + showSecondaryField = showSecondaryField + ) + previewAlarmProvider = CoroutinePreviewAlarmProvider(actionScope) + previewAlarm = previewAlarmProvider?.getPreviewAlarm(component, defaultDoc) + createListeners(project) + val maybePopup = getPopupProvider().show( + project = project, + content = component, + focus = component.searchField, + config = PopupConfig( + title = popupTitle, + preferredSizeProvider = component.preferredSize, + dimensionKey = "FuzzyGrepPopup", + resetWindow = { globalState.resetWindow }, + clearResetWindowFlag = { globalState.resetWindow = false }), + cleanupFunction = { cleanupPopup() }, + ) + + if (maybePopup == null) return@launch + popup = maybePopup + + createSharedListeners(project) + } + } + + private fun showNotification( + title: String, content: String, project: Project, type: NotificationType = NotificationType.ERROR + ) { + val grepNotification = Notification( + FUZZIER_NOTIFICATION_GROUP, title, content, type + ) + Notifications.Bus.notify(grepNotification, project) + } + + override fun onPopupClosed() { + previewAlarm?.dispose() + currentLaunchJob?.cancel() + currentLaunchJob = null + } + + override fun updateListContents(project: Project, searchString: String) { + if (StringUtils.isBlank(searchString)) { + component.fileList.model = DefaultListModel() + return + } + + currentUpdateListContentJob?.cancel() + currentUpdateListContentJob = actionScope?.launch(Dispatchers.EDT) { + component.fileList.setPaintBusy(true) + try { + val results = withContext(Dispatchers.IO) { + findInFiles( + searchString, + project + ) + } + coroutineContext.ensureActive() + component.refreshModel(results, getCellRenderer()) + } finally { + component.fileList.setPaintBusy(false) + } + } + } + + private suspend fun findInFiles( + searchString: String, + project: Project, + ): ListModel { + val listModel = DefaultListModel() + val projectBasePath = project.basePath.toString() + + if (backend != null) { + val secondaryFieldText = (component as FuzzyFinderComponent).getSecondaryText() + val commands = backend!!.buildCommand(grepConfig, searchString, secondaryFieldText) + commandRunner.runCommandPopulateListModel(commands, listModel, projectBasePath, backend!!) + } + + return listModel + } + + private fun createListeners(project: Project) { + // Add a listener that updates the contents of the preview pane + component.fileList.addListSelectionListener { event -> + if (event.valueIsAdjusting) { + return@addListSelectionListener + } else { + previewAlarm?.cancelAndRequest() + } + } + + ActivationBindings.install( + component, onActivate = { handleInput(project) }) + } + + private fun handleInput(project: Project) { + val selectedValue = component.fileList.selectedValue + val virtualFile = + VirtualFileManager.getInstance().findFileByUrl("file://${selectedValue?.getFileUri()}") + virtualFile?.let { + val fileEditorManager = FileEditorManager.getInstance(project) + + FileOpeningUtil.openFile( + fileEditorManager, + virtualFile, + globalState.newTab + ) { + popup.cancel() + ApplicationManager.getApplication().invokeLater { + val rc = selectedValue as RowContainer + val lp = LogicalPosition(rc.rowNumber, rc.columnNumber) + val editor = fileEditorManager.selectedTextEditor + editor?.scrollingModel?.scrollTo(lp, ScrollType.CENTER) + editor?.caretModel?.moveToLogicalPosition(lp) + } + } + } + } +} diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrepVariants.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrepVariants.kt new file mode 100644 index 00000000..f12788e2 --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/grep/FuzzyGrepVariants.kt @@ -0,0 +1,107 @@ +/* + * 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 + +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.mituuz.fuzzier.entities.CaseMode +import com.mituuz.fuzzier.entities.GrepConfig + +private object FuzzyGrepTitles { + const val OPEN_TABS = "Fuzzy Grep Open Tabs" + const val CURRENT_BUFFER = "Fuzzy Grep Current Buffer" + const val DEFAULT = "Fuzzy Grep" +} + +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 } + + return GrepConfig( + targets = targets, + caseMode = CaseMode.INSENSITIVE, + title = FuzzyGrepTitles.OPEN_TABS, + ) + } +} + +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 } + + return GrepConfig( + targets = targets, + caseMode = CaseMode.SENSITIVE, + title = FuzzyGrepTitles.OPEN_TABS, + ) + } +} + +class FuzzyGrepCurrentBufferCI : FuzzyGrep() { + override fun getGrepConfig(project: Project): GrepConfig { + val editor = FileEditorManager.getInstance(project).selectedTextEditor + val virtualFile: VirtualFile? = + editor?.let { FileEditorManager.getInstance(project).selectedFiles.firstOrNull() } + val targets = virtualFile?.path?.let { listOf(it) } ?: emptyList() + + return GrepConfig( + targets = targets, + caseMode = CaseMode.INSENSITIVE, + title = FuzzyGrepTitles.CURRENT_BUFFER, + supportsSecondaryField = false, + ) + } +} + +class FuzzyGrepCurrentBuffer : FuzzyGrep() { + override fun getGrepConfig(project: Project): GrepConfig { + val editor = FileEditorManager.getInstance(project).selectedTextEditor + val virtualFile: VirtualFile? = + editor?.let { FileEditorManager.getInstance(project).selectedFiles.firstOrNull() } + val targets = virtualFile?.path?.let { listOf(it) } ?: emptyList() + + return GrepConfig( + targets = targets, + caseMode = CaseMode.SENSITIVE, + title = FuzzyGrepTitles.CURRENT_BUFFER, + supportsSecondaryField = false, + ) + } +} + +class FuzzyGrepCI : FuzzyGrep() { + override fun getGrepConfig(project: Project): GrepConfig { + return GrepConfig( + targets = listOf("."), + 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 new file mode 100644 index 00000000..9ec90a95 --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendResolver.kt @@ -0,0 +1,62 @@ +/* + * 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.mituuz.fuzzier.runner.CommandRunner + +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, "com/mituuz/fuzzier/grep", projectBasePath) -> Result.success( + BackendStrategy.Grep + ) + + else -> Result.failure(Exception("No suitable grep command found")) + } + } + + private suspend fun isInstalled( + commandRunner: CommandRunner, + executable: String, + projectBasePath: String + ): Boolean { + val command = if (isWindows) { + listOf("where", executable) + } else { + listOf("which", executable) + } + + val result = commandRunner.runCommandForOutput(command, projectBasePath) + + return !(result.isNullOrBlank() || result.contains("Could not find files")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendStrategy.kt b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendStrategy.kt new file mode 100644 index 00000000..ba998804 --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/grep/backend/BackendStrategy.kt @@ -0,0 +1,138 @@ +/* + * 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.mituuz.fuzzier.entities.CaseMode +import com.mituuz.fuzzier.entities.GrepConfig +import com.mituuz.fuzzier.entities.RowContainer + +sealed interface BackendStrategy { + val name: String + fun buildCommand(grepConfig: GrepConfig, searchString: String, secondarySearchString: String?): List + fun parseOutputLine(line: String, projectBasePath: String): RowContainer? { + val line = line.replace(projectBasePath, ".") + return RowContainer.rowContainerFromString(line, projectBasePath) + } + + fun supportsSecondaryField(): Boolean = false + + object Ripgrep : BackendStrategy { + override val name = "ripgrep" + + override fun buildCommand( + grepConfig: GrepConfig, + searchString: String, + secondarySearchString: String? + ): List { + val commands = mutableListOf("rg") + + if (grepConfig.caseMode == CaseMode.INSENSITIVE) { + commands.add("--smart-case") + commands.add("-F") + } + + 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 + } + + override fun parseOutputLine(line: String, projectBasePath: String): RowContainer? { + val line = line.replace(projectBasePath, ".") + return RowContainer.rgRowContainerFromString(line, projectBasePath) + } + + override fun supportsSecondaryField(): Boolean { + return true + } + } + + 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", + searchString + ) + ) + commands.addAll(grepConfig.targets) + return commands + } + } + + object Grep : BackendStrategy { + override val name = "com/mituuz/fuzzier/grep" + + override fun buildCommand( + grepConfig: GrepConfig, + searchString: String, + secondarySearchString: String? + ): List { + val commands = mutableListOf("com/mituuz/fuzzier/grep") + + if (grepConfig.caseMode == CaseMode.INSENSITIVE) { + commands.add("-i") + } + + commands.addAll( + mutableListOf( + "--color=none", + "-r", + "-n", + searchString + ) + ) + commands.addAll(grepConfig.targets) + return commands + } + } +} + diff --git a/src/main/kotlin/com/mituuz/fuzzier/intellij/files/FileOpeningUtil.kt b/src/main/kotlin/com/mituuz/fuzzier/intellij/files/FileOpeningUtil.kt new file mode 100644 index 00000000..753931db --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/intellij/files/FileOpeningUtil.kt @@ -0,0 +1,54 @@ +/* + * 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.intellij.files + +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.vfs.VirtualFile + +object FileOpeningUtil { + fun openFile( + fileEditorManager: FileEditorManager, + virtualFile: VirtualFile, + newTab: Boolean, + onAfterOpen: () -> Unit = {}, + ) { + val currentEditor = fileEditorManager.selectedTextEditor + val previousFile = currentEditor?.virtualFile + + if (fileEditorManager.isFileOpen(virtualFile)) { + fileEditorManager.openFile(virtualFile, true) + } else { + fileEditorManager.openFile(virtualFile, true) + if (currentEditor != null && !newTab) { + fileEditorManager.selectedEditor?.let { + if (previousFile != null) { + fileEditorManager.closeFile(previousFile) + } + } + } + } + onAfterOpen() + } +} diff --git a/src/main/kotlin/com/mituuz/fuzzier/FuzzyMover.kt b/src/main/kotlin/com/mituuz/fuzzier/operation/FuzzyMover.kt similarity index 99% rename from src/main/kotlin/com/mituuz/fuzzier/FuzzyMover.kt rename to src/main/kotlin/com/mituuz/fuzzier/operation/FuzzyMover.kt index 8dfdf0c9..9943b0ad 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/FuzzyMover.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/operation/FuzzyMover.kt @@ -22,7 +22,7 @@ * SOFTWARE. */ -package com.mituuz.fuzzier +package com.mituuz.fuzzier.operation import com.intellij.notification.Notification import com.intellij.notification.NotificationType diff --git a/src/main/kotlin/com/mituuz/fuzzier/FuzzyGrepCaseInsensitive.kt b/src/main/kotlin/com/mituuz/fuzzier/runner/CommandRunner.kt similarity index 64% rename from src/main/kotlin/com/mituuz/fuzzier/FuzzyGrepCaseInsensitive.kt rename to src/main/kotlin/com/mituuz/fuzzier/runner/CommandRunner.kt index cef42f09..40523c14 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/FuzzyGrepCaseInsensitive.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/runner/CommandRunner.kt @@ -22,31 +22,22 @@ * SOFTWARE. */ -package com.mituuz.fuzzier +package com.mituuz.fuzzier.runner import com.mituuz.fuzzier.entities.FuzzyContainer +import com.mituuz.fuzzier.grep.backend.BackendStrategy import javax.swing.DefaultListModel -class FuzzyGrepCaseInsensitive : FuzzyGrep() { - override var popupTitle: String = "Fuzzy Grep (Case Insensitive)" +interface CommandRunner { + suspend fun runCommandForOutput( + commands: List, + projectBasePath: String + ): String? - override suspend fun runCommand( + suspend fun runCommandPopulateListModel( commands: List, listModel: DefaultListModel, - projectBasePath: String - ) { - val modifiedCommands = commands.toMutableList() - if (isWindows && !useRg) { - // Customize findstr for case insensitivity - modifiedCommands.add(1, "/I") - } else if (!useRg) { - // Customize grep for case insensitivity - modifiedCommands.add(1, "-i") - } else { - // Customize ripgrep for case insensitivity - modifiedCommands.add(1, "--smart-case") - modifiedCommands.add(2, "-F") - } - super.runCommand(modifiedCommands, listModel, projectBasePath) - } -} + projectBasePath: String, + backend: BackendStrategy + ) +} \ 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 new file mode 100644 index 00000000..283c2d91 --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/runner/DefaultCommandRunner.kt @@ -0,0 +1,115 @@ +/* + * 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.runner + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.OSProcessHandler +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessListener +import com.intellij.openapi.util.Key +import com.mituuz.fuzzier.entities.FuzzyContainer +import com.mituuz.fuzzier.grep.backend.BackendStrategy +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.swing.DefaultListModel + +class DefaultCommandRunner : CommandRunner { + companion object { + const val MAX_OUTPUT_SIZE = 10000 + const val MAX_NUMBER_OR_RESULTS = 1000 + } + + override suspend fun runCommandForOutput( + commands: List, + projectBasePath: String + ): String? { + return try { + val commandLine = GeneralCommandLine(commands) + .withWorkDirectory(projectBasePath) + .withRedirectErrorStream(true) + val output = StringBuilder() + val processHandler = OSProcessHandler(commandLine) + + processHandler.addProcessListener(object : ProcessListener { + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + if (output.length < MAX_OUTPUT_SIZE) { + output.appendLine(event.text.replace("\n", "")) + } + } + }) + + withContext(Dispatchers.IO) { + processHandler.startNotify() + processHandler.waitFor(2000) + } + output.toString() + } catch (_: InterruptedException) { + throw InterruptedException() + } + } + + /** + * Run the command and stream a limited number of results to the list model + */ + override suspend fun runCommandPopulateListModel( + commands: List, + listModel: DefaultListModel, + projectBasePath: String, + backend: BackendStrategy + ) { + try { + val commandLine = GeneralCommandLine(commands) + .withWorkDirectory(projectBasePath) + .withRedirectErrorStream(true) + + val processHandler = OSProcessHandler(commandLine) + var count = 0 + + processHandler.addProcessListener(object : ProcessListener { + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + if (count >= MAX_NUMBER_OR_RESULTS) return + + event.text.lines().forEach { line -> + if (count >= MAX_NUMBER_OR_RESULTS) return@forEach + if (line.isNotBlank()) { + val rowContainer = backend.parseOutputLine(line, projectBasePath) + if (rowContainer != null) { + listModel.addElement(rowContainer) + count++ + } + } + } + } + }) + + withContext(Dispatchers.IO) { + processHandler.startNotify() + processHandler.waitFor(2000) + } + } catch (_: InterruptedException) { + throw InterruptedException() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/Fuzzier.kt b/src/main/kotlin/com/mituuz/fuzzier/search/Fuzzier.kt similarity index 85% rename from src/main/kotlin/com/mituuz/fuzzier/Fuzzier.kt rename to src/main/kotlin/com/mituuz/fuzzier/search/Fuzzier.kt index 4253464d..8432d556 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/Fuzzier.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/search/Fuzzier.kt @@ -22,7 +22,7 @@ * SOFTWARE. */ -package com.mituuz.fuzzier +package com.mituuz.fuzzier.search import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.ApplicationManager @@ -36,6 +36,7 @@ import com.intellij.util.SingleAlarm import com.mituuz.fuzzier.actions.filesystem.FilesystemAction import com.mituuz.fuzzier.components.FuzzyFinderComponent import com.mituuz.fuzzier.entities.FuzzyContainer +import com.mituuz.fuzzier.intellij.files.FileOpeningUtil import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService import com.mituuz.fuzzier.ui.bindings.ActivationBindings import com.mituuz.fuzzier.ui.popup.PopupConfig @@ -75,9 +76,6 @@ open class Fuzzier : FilesystemAction() { createSharedListeners(project) - (component as FuzzyFinderComponent).splitPane.dividerLocation = - globalState.splitPosition - if (globalState.recentFilesMode != FuzzierGlobalSettingsService.RecentFilesMode.NONE) { createInitialView(project) } @@ -85,8 +83,6 @@ open class Fuzzier : FilesystemAction() { } override fun onPopupClosed() { - globalState.splitPosition = - (component as FuzzyFinderComponent).splitPane.dividerLocation previewAlarm?.dispose() lastPreviewKey = null } @@ -123,7 +119,18 @@ open class Fuzzier : FilesystemAction() { val virtualFile = VirtualFileManager.getInstance().findFileByUrl("file://${selectedValue?.getFileUri()}") virtualFile?.let { - openFile(project, selectedValue, it) + val fileEditorManager = FileEditorManager.getInstance(project) + + FileOpeningUtil.openFile( + fileEditorManager, + virtualFile, + globalState.newTab + ) { + if (selectedValue != null) { + InitialViewHandler.addFileToRecentlySearchedFiles(selectedValue, projectState, globalState) + } + popup.cancel() + } } } @@ -159,29 +166,6 @@ open class Fuzzier : FilesystemAction() { } } - private fun openFile(project: Project, fuzzyContainer: FuzzyContainer?, virtualFile: VirtualFile) { - val fileEditorManager = FileEditorManager.getInstance(project) - val currentEditor = fileEditorManager.selectedTextEditor - val previousFile = currentEditor?.virtualFile - - if (fileEditorManager.isFileOpen(virtualFile)) { - fileEditorManager.openFile(virtualFile, true) - } else { - fileEditorManager.openFile(virtualFile, true) - if (currentEditor != null && !globalState.newTab) { - fileEditorManager.selectedEditor?.let { - if (previousFile != null) { - fileEditorManager.closeFile(previousFile) - } - } - } - } - if (fuzzyContainer != null) { - InitialViewHandler.addFileToRecentlySearchedFiles(fuzzyContainer, projectState, globalState) - } - popup.cancel() - } - private fun getPreviewAlarm(): SingleAlarm { return SingleAlarm( { diff --git a/src/main/kotlin/com/mituuz/fuzzier/FuzzierVCS.kt b/src/main/kotlin/com/mituuz/fuzzier/search/FuzzierVCS.kt similarity index 97% rename from src/main/kotlin/com/mituuz/fuzzier/FuzzierVCS.kt rename to src/main/kotlin/com/mituuz/fuzzier/search/FuzzierVCS.kt index a351cc87..47f5acd1 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/FuzzierVCS.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/search/FuzzierVCS.kt @@ -22,7 +22,7 @@ * SOFTWARE. */ -package com.mituuz.fuzzier +package com.mituuz.fuzzier.search import com.intellij.openapi.project.Project import com.intellij.openapi.vcs.changes.ChangeListManager diff --git a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt index 8809cb38..bcdceec0 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt @@ -148,10 +148,7 @@ class FuzzierGlobalSettingsConfigurable : Configurable { state.defaultPopupHeight != newPopupHeight || state.defaultPopupWidth != newPopupWidth ) { - - // Reset window size and split position to defaults state.resetWindow = true - state.splitPosition = FuzzierGlobalSettingsService.DEFAULT_SPLIT_POSITION } state.defaultPopupHeight = newPopupHeight state.defaultPopupWidth = newPopupWidth diff --git a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt index 88887a18..c8d08862 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt @@ -37,14 +37,8 @@ import com.mituuz.fuzzier.entities.FuzzyContainer.FilenameType.FILE_PATH_ONLY ) @Service(Service.Level.APP) class FuzzierGlobalSettingsService : PersistentStateComponent { - companion object { - @JvmStatic - val DEFAULT_SPLIT_POSITION: Int = 350 - } - class State { var searchPosition: SearchPosition = SearchPosition.LEFT - var splitPosition: Int = DEFAULT_SPLIT_POSITION // Popup sizing settings var popupSizing: PopupSizing = PopupSizing.AUTO_SIZE diff --git a/src/main/kotlin/com/mituuz/fuzzier/ui/popup/PopupProviderBase.kt b/src/main/kotlin/com/mituuz/fuzzier/ui/popup/PopupProviderBase.kt index e8725d0f..9142315a 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/ui/popup/PopupProviderBase.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/ui/popup/PopupProviderBase.kt @@ -49,6 +49,8 @@ abstract class PopupProviderBase( .setTitle(title) .setMovable(true) .setShowBorder(true) + .setCancelOnClickOutside(true) + .setCancelOnWindowDeactivation(true) internal fun createCleanupListener(cleanupFunction: () -> Unit): JBPopupListener = object : JBPopupListener { override fun onClosed(event: LightweightWindowEvent) { diff --git a/src/main/kotlin/com/mituuz/fuzzier/ui/preview/CoroutinePreviewAlarmProvider.kt b/src/main/kotlin/com/mituuz/fuzzier/ui/preview/CoroutinePreviewAlarmProvider.kt new file mode 100644 index 00000000..b25fa7c1 --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/ui/preview/CoroutinePreviewAlarmProvider.kt @@ -0,0 +1,68 @@ +/* + * 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.ui.preview + +import com.intellij.openapi.application.EDT +import com.intellij.openapi.editor.Document +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.util.SingleAlarm +import com.mituuz.fuzzier.components.FuzzyComponent +import com.mituuz.fuzzier.components.FuzzyFinderComponent +import com.mituuz.fuzzier.entities.RowContainer +import kotlinx.coroutines.* + +class CoroutinePreviewAlarmProvider(val actionScope: CoroutineScope?) : PreviewAlarmProvider { + private var previewJob: Job? = null + override fun getPreviewAlarm(component: FuzzyComponent, defaultDoc: Document?): SingleAlarm { + return SingleAlarm( + { + if (component.fileList.isEmpty) { + actionScope?.launch(Dispatchers.EDT) { + defaultDoc?.let { (component as FuzzyFinderComponent).previewPane.updateFile(it) } + } + return@SingleAlarm + } + val selectedValue = component.fileList.selectedValue + val fileUrl = "file://${selectedValue?.getFileUri()}" + + previewJob?.cancel() + previewJob = actionScope?.launch(Dispatchers.Default) { + val file = withContext(Dispatchers.IO) { + VirtualFileManager.getInstance().findFileByUrl(fileUrl) + } + + if (file == null) return@launch + + if (component.fileList.selectedValue != selectedValue) return@launch + + (component as FuzzyFinderComponent).previewPane.coUpdateFile( + file, (selectedValue as RowContainer).rowNumber + ) + } + }, + 75, + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/search/ResultsProvider.kt b/src/main/kotlin/com/mituuz/fuzzier/ui/preview/PreviewAlarmProvider.kt similarity index 79% rename from src/main/kotlin/com/mituuz/fuzzier/search/ResultsProvider.kt rename to src/main/kotlin/com/mituuz/fuzzier/ui/preview/PreviewAlarmProvider.kt index 792605a0..750989d0 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/search/ResultsProvider.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/ui/preview/PreviewAlarmProvider.kt @@ -22,7 +22,12 @@ * SOFTWARE. */ -package com.mituuz.fuzzier.search +package com.mituuz.fuzzier.ui.preview -interface ResultsProvider { +import com.intellij.openapi.editor.Document +import com.intellij.util.SingleAlarm +import com.mituuz.fuzzier.components.FuzzyComponent + +interface PreviewAlarmProvider { + fun getPreviewAlarm(component: FuzzyComponent, defaultDoc: Document?): SingleAlarm } \ No newline at end of file diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 5755d0d7..82d03a99 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -26,13 +26,13 @@ com.mituuz.fuzzier Fuzzier - MituuZ + MituuZ - A fuzzy file finder modeled after telescope for nvim. Designed to be completely usable through the - keyboard (and ideavim). + A fuzzy file finder and grepper modeled after telescope for nvim. + Designed to be completely usable through the keyboard (and ideavim). - + + - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/kotlin/com/mituuz/fuzzier/FuzzierTest.kt b/src/test/kotlin/com/mituuz/fuzzier/FuzzierTest.kt index 35939958..83f60250 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/FuzzierTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/FuzzierTest.kt @@ -24,6 +24,7 @@ package com.mituuz.fuzzier import com.intellij.testFramework.TestApplicationManager +import com.mituuz.fuzzier.search.Fuzzier import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Assertions.assertFalse diff --git a/src/test/kotlin/com/mituuz/fuzzier/FuzzyActionTest.kt b/src/test/kotlin/com/mituuz/fuzzier/FuzzyActionTest.kt index aa879c8c..20b6f242 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/FuzzyActionTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/FuzzyActionTest.kt @@ -63,11 +63,11 @@ class FuzzyActionTest { assertNotNull(action.component) val inputMap = action.component.searchField.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) - val kShiftKeyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_K, InputEvent.CTRL_DOWN_MASK) - val jShiftKeyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_J, InputEvent.CTRL_DOWN_MASK) + val pShiftKeyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_P, InputEvent.CTRL_DOWN_MASK) + val nShiftKeyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_N, InputEvent.CTRL_DOWN_MASK) - val moveUpAction = inputMap.get(kShiftKeyStroke) - val moveDownAction = inputMap.get(jShiftKeyStroke) + val moveUpAction = inputMap.get(pShiftKeyStroke) + val moveDownAction = inputMap.get(nShiftKeyStroke) assertEquals("moveUp", moveUpAction) assertEquals("moveDown", moveDownAction) diff --git a/src/test/kotlin/com/mituuz/fuzzier/FuzzyMoverTest.kt b/src/test/kotlin/com/mituuz/fuzzier/FuzzyMoverTest.kt index 49cd2128..4e208813 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/FuzzyMoverTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/FuzzyMoverTest.kt @@ -39,6 +39,7 @@ import com.intellij.testFramework.runInEdtAndWait import com.mituuz.fuzzier.components.SimpleFinderComponent import com.mituuz.fuzzier.entities.FuzzyContainer import com.mituuz.fuzzier.entities.FuzzyMatchContainer +import com.mituuz.fuzzier.operation.FuzzyMover import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import javax.swing.DefaultListModel diff --git a/src/test/kotlin/com/mituuz/fuzzier/components/FuzzyFinderComponentTest.kt b/src/test/kotlin/com/mituuz/fuzzier/components/FuzzyFinderComponentTest.kt index c46ab6b4..827e0a8f 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/components/FuzzyFinderComponentTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/components/FuzzyFinderComponentTest.kt @@ -41,7 +41,6 @@ import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.util.concurrent.atomic.AtomicInteger -import javax.swing.JSplitPane class FuzzyFinderComponentTest { companion object { @@ -84,10 +83,11 @@ class FuzzyFinderComponentTest { state.searchPosition = LEFT runInEdtAndWait { val component = FuzzyFinderComponent(fixture.project, showSecondaryField = false) - assertEquals(JSplitPane.HORIZONTAL_SPLIT, component.splitPane.orientation) - assertSame(component.previewPane, component.splitPane.rightComponent) - assertNotNull(component.splitPane.leftComponent) - assertNotSame(component.previewPane, component.splitPane.leftComponent) + // Horizontal split -> orientation=false, preview on right -> second component + assertFalse(component.splitPane.orientation) + assertSame(component.previewPane, component.splitPane.secondComponent) + assertNotNull(component.splitPane.firstComponent) + assertNotSame(component.previewPane, component.splitPane.firstComponent) } } @@ -97,10 +97,11 @@ class FuzzyFinderComponentTest { state.searchPosition = RIGHT runInEdtAndWait { val component = FuzzyFinderComponent(fixture.project, showSecondaryField = false) - assertEquals(JSplitPane.HORIZONTAL_SPLIT, component.splitPane.orientation) - assertSame(component.previewPane, component.splitPane.leftComponent) - assertNotNull(component.splitPane.rightComponent) - assertNotSame(component.previewPane, component.splitPane.rightComponent) + // Horizontal split -> orientation=false, preview on left -> first component + assertFalse(component.splitPane.orientation) + assertSame(component.previewPane, component.splitPane.firstComponent) + assertNotNull(component.splitPane.secondComponent) + assertNotSame(component.previewPane, component.splitPane.secondComponent) } } @@ -110,9 +111,10 @@ class FuzzyFinderComponentTest { state.searchPosition = TOP runInEdtAndWait { val component = FuzzyFinderComponent(fixture.project, showSecondaryField = false) - assertEquals(JSplitPane.VERTICAL_SPLIT, component.splitPane.orientation) - assertSame(component.previewPane, component.splitPane.bottomComponent) - assertNotSame(component.previewPane, component.splitPane.topComponent) + // Vertical split -> orientation=true, preview at bottom -> second component + assertTrue(component.splitPane.orientation) + assertSame(component.previewPane, component.splitPane.secondComponent) + assertNotSame(component.previewPane, component.splitPane.firstComponent) } } @@ -122,9 +124,10 @@ class FuzzyFinderComponentTest { state.searchPosition = BOTTOM runInEdtAndWait { val component = FuzzyFinderComponent(fixture.project, showSecondaryField = false) - assertEquals(JSplitPane.VERTICAL_SPLIT, component.splitPane.orientation) - assertSame(component.previewPane, component.splitPane.topComponent) - assertNotSame(component.previewPane, component.splitPane.bottomComponent) + // Vertical split -> orientation=true, preview at top -> first component + assertTrue(component.splitPane.orientation) + assertSame(component.previewPane, component.splitPane.firstComponent) + assertNotSame(component.previewPane, component.splitPane.secondComponent) } } diff --git a/src/test/kotlin/com/mituuz/fuzzier/entities/RowContainerTest.kt b/src/test/kotlin/com/mituuz/fuzzier/entities/RowContainerTest.kt index 144000bb..b582d69f 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/entities/RowContainerTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/entities/RowContainerTest.kt @@ -1,34 +1,36 @@ /* - - 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.mituuz.fuzzier.entities.RowContainer.Companion.rgRowContainerFromString +import com.mituuz.fuzzier.entities.RowContainer.Companion.rowContainerFromString import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNotNull import org.junit.jupiter.api.condition.EnabledOnOs import org.junit.jupiter.api.condition.OS @@ -36,7 +38,7 @@ class RowContainerTest { @Test fun displayString() { val state = FuzzierGlobalSettingsService.State() - val container = RowContainer("", "", "filename", 0, 3, "trimmed row content") + val container = RowContainer("", "", "filename", 0, "trimmed row content", 3) assertEquals("filename:0:3: trimmed row content", container.getDisplayString(state)) } @@ -46,10 +48,12 @@ class RowContainerTest { fun fromRGString() { val input = "./src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt:205:33: moduleFileIndex.iterateContent(contentIterator)" - val rc = getRowContainer(input, true) + val basePath = "/home/user/IdeaProjects/fuzzier" + val rc = rgRowContainerFromString(input, basePath) + assertNotNull(rc, "Could not create row container from $input") assertEquals("/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt", rc.filePath) - assertEquals("/base/", rc.basePath) + assertEquals(basePath, rc.basePath) assertEquals("TestBenchComponent.kt", rc.filename) assertEquals(204, rc.rowNumber) assertEquals(32, rc.columnNumber) @@ -61,10 +65,12 @@ class RowContainerTest { fun fromGrepString() { val input = "./src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt:205: moduleFileIndex.iterateContent(contentIterator)" - val rc = getRowContainer(input, false) + val basePath = "/home/user/IdeaProjects/fuzzier" + val rc = rowContainerFromString(input, basePath) + assertNotNull(rc, "Could not create row container from $input") assertEquals("/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt", rc.filePath) - assertEquals("/base/", rc.basePath) + assertEquals(basePath, rc.basePath) assertEquals("TestBenchComponent.kt", rc.filename) assertEquals(204, rc.rowNumber) assertEquals(0, rc.columnNumber) @@ -76,10 +82,12 @@ class RowContainerTest { fun fromRGString_windows() { val input = ".\\src\\main\\kotlin\\com\\mituuz\\fuzzier\\components\\TestBenchComponent.kt:205:33: moduleFileIndex.iterateContent(contentIterator)" - val rc = getRowContainer(input, true) + val basePath = "C:\\Users\\user\\IdeaProjects\\fuzzier" + val rc = rgRowContainerFromString(input, basePath) + assertNotNull(rc, "Could not create row container from $input") assertEquals("\\src\\main\\kotlin\\com\\mituuz\\fuzzier\\components\\TestBenchComponent.kt", rc.filePath) - assertEquals("/base/", rc.basePath) + assertEquals(basePath, rc.basePath) assertEquals("TestBenchComponent.kt", rc.filename) assertEquals(204, rc.rowNumber) assertEquals(32, rc.columnNumber) @@ -91,22 +99,51 @@ class RowContainerTest { fun fromFindstrString_windows() { val input = ".\\src\\main\\kotlin\\com\\mituuz\\fuzzier\\components\\TestBenchComponent.kt:205: moduleFileIndex.iterateContent(contentIterator)" - val rc = getRowContainer(input, false) + val basePath = "C:\\Users\\user\\IdeaProjects\\fuzzier" + val rc = rowContainerFromString(input, basePath) + assertNotNull(rc, "Could not create row container from $input") assertEquals("\\src\\main\\kotlin\\com\\mituuz\\fuzzier\\components\\TestBenchComponent.kt", rc.filePath) - assertEquals("/base/", rc.basePath) + assertEquals(basePath, rc.basePath) assertEquals("TestBenchComponent.kt", rc.filename) assertEquals(204, rc.rowNumber) assertEquals(0, rc.columnNumber) assertEquals("moduleFileIndex.iterateContent(contentIterator)", rc.trimmedRow) } - private fun getRowContainer(input: String, isRg: Boolean): RowContainer { - val rc = RowContainer.rowContainerFromString(input, "/base/", isRg) - if (rc == null) { - throw Exception("RowContainer could not be created from input: $input") - } + @Test + fun fromGrepStringInvalidPattern() { + val input = "invalid input without proper format" + val basePath = "/home/user/IdeaProjects/fuzzier" + val rc = rowContainerFromString(input, basePath) + + assertNull(rc, "Expected null for input that doesn't match COMMON_PATTERN") + } + + @Test + fun fromGrepStringInsufficientParts() { + val input = "file.kt:205" + val basePath = "/home/user/IdeaProjects/fuzzier" + val rc = rowContainerFromString(input, basePath) + + assertNull(rc, "Expected null for input with insufficient parts") + } + + @Test + fun fromRGStringInvalidPattern() { + val input = "./file.kt:205: content without column number" + val basePath = "/home/user/IdeaProjects/fuzzier" + val rc = rgRowContainerFromString(input, basePath) + + assertNull(rc, "Expected null for input that doesn't match RG_PATTERN") + } + + @Test + fun fromRGStringInsufficientParts() { + val input = "file.kt:205:33" + val basePath = "/home/user/IdeaProjects/fuzzier" + val rc = rgRowContainerFromString(input, basePath) - return rc + assertNull(rc, "Expected null for input with insufficient parts") } } diff --git a/src/test/kotlin/com/mituuz/fuzzier/search/BackendResolverTest.kt b/src/test/kotlin/com/mituuz/fuzzier/search/BackendResolverTest.kt new file mode 100644 index 00000000..0322158b --- /dev/null +++ b/src/test/kotlin/com/mituuz/fuzzier/search/BackendResolverTest.kt @@ -0,0 +1,159 @@ +/* + * 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.search + +import com.mituuz.fuzzier.grep.backend.BackendResolver +import com.mituuz.fuzzier.grep.backend.BackendStrategy +import com.mituuz.fuzzier.runner.CommandRunner +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class BackendResolverTest { + private lateinit var commandRunner: CommandRunner + private val projectBasePath = "/test/project" + + @BeforeEach + fun setUp() { + commandRunner = mockk() + } + + @Test + fun `resolveBackend returns Ripgrep when rg is available on Windows`() = runBlocking { + val resolver = BackendResolver(isWindows = true) + coEvery { commandRunner.runCommandForOutput(listOf("where", "rg"), projectBasePath) } returns "/usr/bin/rg" + + val result = resolver.resolveBackend(commandRunner, projectBasePath) + + assertTrue(result.isSuccess) + assertEquals(BackendStrategy.Ripgrep, result.getOrNull()) + coVerify { commandRunner.runCommandForOutput(listOf("where", "rg"), projectBasePath) } + } + + @Test + fun `resolveBackend returns Ripgrep when rg is available on non-Windows`() = runBlocking { + val resolver = BackendResolver(isWindows = false) + coEvery { commandRunner.runCommandForOutput(listOf("which", "rg"), projectBasePath) } returns "/usr/bin/rg" + + val result = resolver.resolveBackend(commandRunner, projectBasePath) + + assertTrue(result.isSuccess) + assertEquals(BackendStrategy.Ripgrep, result.getOrNull()) + coVerify { commandRunner.runCommandForOutput(listOf("which", "rg"), projectBasePath) } + } + + @Test + fun `resolveBackend returns Findstr when rg is not available on Windows`() = runBlocking { + val resolver = BackendResolver(isWindows = true) + coEvery { commandRunner.runCommandForOutput(listOf("where", "rg"), projectBasePath) } returns null + coEvery { + commandRunner.runCommandForOutput( + listOf("where", "findstr"), projectBasePath + ) + } returns "C:\\Windows\\System32\\findstr.exe" + + val result = resolver.resolveBackend(commandRunner, projectBasePath) + + assertTrue(result.isSuccess) + assertEquals(BackendStrategy.Findstr, result.getOrNull()) + coVerify { commandRunner.runCommandForOutput(listOf("where", "rg"), projectBasePath) } + coVerify { commandRunner.runCommandForOutput(listOf("where", "findstr"), projectBasePath) } + } + + @Test + fun `resolveBackend returns Grep when rg is not available on non-Windows`() = runBlocking { + val resolver = BackendResolver(isWindows = false) + coEvery { commandRunner.runCommandForOutput(listOf("which", "rg"), projectBasePath) } returns "" + coEvery { + commandRunner.runCommandForOutput( + listOf("which", "com/mituuz/fuzzier/grep"), + projectBasePath + ) + } returns "/usr/bin/grep" + + val result = resolver.resolveBackend(commandRunner, projectBasePath) + + assertTrue(result.isSuccess) + assertEquals(BackendStrategy.Grep, result.getOrNull()) + coVerify { commandRunner.runCommandForOutput(listOf("which", "rg"), projectBasePath) } + coVerify { commandRunner.runCommandForOutput(listOf("which", "com/mituuz/fuzzier/grep"), projectBasePath) } + } + + @Test + fun `resolveBackend returns failure when no backend is available on Windows`() = runBlocking { + val resolver = BackendResolver(isWindows = true) + coEvery { + commandRunner.runCommandForOutput( + listOf("where", "rg"), projectBasePath + ) + } returns "Could not find files" + coEvery { commandRunner.runCommandForOutput(listOf("where", "findstr"), projectBasePath) } returns null + + val result = resolver.resolveBackend(commandRunner, projectBasePath) + + assertTrue(result.isFailure) + assertEquals("No suitable grep command found", result.exceptionOrNull()?.message) + } + + @Test + fun `resolveBackend returns failure when no backend is available on non-Windows`() = runBlocking { + val resolver = BackendResolver(isWindows = false) + coEvery { commandRunner.runCommandForOutput(listOf("which", "rg"), projectBasePath) } returns " " + coEvery { + commandRunner.runCommandForOutput( + listOf("which", "com/mituuz/fuzzier/grep"), + projectBasePath + ) + } returns "" + + val result = resolver.resolveBackend(commandRunner, projectBasePath) + + assertTrue(result.isFailure) + assertEquals("No suitable grep command found", result.exceptionOrNull()?.message) + } + + @Test + fun `resolveBackend prioritizes Ripgrep over Findstr on Windows`() = runBlocking { + val resolver = BackendResolver(isWindows = true) + coEvery { commandRunner.runCommandForOutput(listOf("where", "rg"), projectBasePath) } returns "/usr/bin/rg" + coEvery { + commandRunner.runCommandForOutput( + listOf("where", "findstr"), projectBasePath + ) + } returns "C:\\Windows\\System32\\findstr.exe" + + val result = resolver.resolveBackend(commandRunner, projectBasePath) + + assertTrue(result.isSuccess) + assertEquals(BackendStrategy.Ripgrep, result.getOrNull()) + coVerify { commandRunner.runCommandForOutput(listOf("where", "rg"), projectBasePath) } + coVerify(exactly = 0) { commandRunner.runCommandForOutput(listOf("where", "findstr"), projectBasePath) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/mituuz/fuzzier/search/BackendStrategyTest.kt b/src/test/kotlin/com/mituuz/fuzzier/search/BackendStrategyTest.kt new file mode 100644 index 00000000..b75c0eb1 --- /dev/null +++ b/src/test/kotlin/com/mituuz/fuzzier/search/BackendStrategyTest.kt @@ -0,0 +1,244 @@ +/* + * 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.search + +import com.mituuz.fuzzier.entities.CaseMode +import com.mituuz.fuzzier.entities.GrepConfig +import com.mituuz.fuzzier.grep.backend.BackendStrategy +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class BackendStrategyTest { + + @Nested + inner class RipgrepTest { + @Test + fun `buildCommand should include basic ripgrep flags`() { + val config = GrepConfig( + targets = listOf("/path/to/search"), + caseMode = CaseMode.SENSITIVE, + ) + + val result = BackendStrategy.Ripgrep.buildCommand(config, "test", null) + + assertEquals( + listOf( + "rg", + "--no-heading", + "--color=never", + "-n", + "--with-filename", + "--column", + "test", + "/path/to/search" + ), + result + ) + } + + @Test + fun `buildCommand should include file extension glob flag without dot`() { + val config = GrepConfig( + targets = listOf("/path/to/search"), + caseMode = CaseMode.SENSITIVE, + ) + + val result = BackendStrategy.Ripgrep.buildCommand(config, "test", "java") + + assertEquals( + listOf( + "rg", + "--no-heading", + "--color=never", + "-n", + "--with-filename", + "--column", + "-g", + "*.java", + "test", + "/path/to/search" + ), + result + ) + } + + @Test + fun `buildCommand should include file extension glob flag with dot`() { + val config = GrepConfig( + targets = listOf("/path/to/search"), + caseMode = CaseMode.SENSITIVE, + ) + + val result = BackendStrategy.Ripgrep.buildCommand(config, "test", ".java") + + assertEquals( + listOf( + "rg", + "--no-heading", + "--color=never", + "-n", + "--with-filename", + "--column", + "-g", + "*.java", + "test", + "/path/to/search" + ), + result + ) + } + + @Test + fun `buildCommand should add smart-case and -F for insensitive mode`() { + val config = GrepConfig( + targets = listOf("/path"), + caseMode = CaseMode.INSENSITIVE, + ) + + val result = BackendStrategy.Ripgrep.buildCommand(config, "test", null) + + assertTrue(result.contains("--smart-case")) + assertTrue(result.contains("-F")) + } + + @Test + fun `buildCommand should not add smart-case for sensitive mode`() { + val config = GrepConfig( + targets = listOf("/path"), + caseMode = CaseMode.SENSITIVE, + ) + + val result = BackendStrategy.Ripgrep.buildCommand(config, "test", null) + + assertTrue(!result.contains("--smart-case")) + assertTrue(!result.contains("-F")) + } + + @Test + fun `name should return ripgrep`() { + assertEquals("ripgrep", BackendStrategy.Ripgrep.name) + } + } + + @Nested + inner class FindstrTest { + @Test + fun `buildCommand should include basic findstr flags`() { + val config = GrepConfig( + targets = listOf("C:\\path\\to\\search"), + caseMode = CaseMode.SENSITIVE, + ) + + val result = BackendStrategy.Findstr.buildCommand(config, "test", null) + + assertTrue(result.contains("findstr")) + assertTrue(result.contains("/p")) + assertTrue(result.contains("/s")) + assertTrue(result.contains("/n")) + assertTrue(result.contains("test")) + assertTrue(result.contains("C:\\path\\to\\search")) + } + + @Test + fun `buildCommand should add case insensitive flag when needed`() { + val config = GrepConfig( + targets = listOf("C:\\path"), + caseMode = CaseMode.INSENSITIVE, + ) + + val result = BackendStrategy.Findstr.buildCommand(config, "test", null) + + assertTrue(result.contains("/I")) + } + + @Test + fun `buildCommand should not add case insensitive flag for sensitive mode`() { + val config = GrepConfig( + targets = listOf("C:\\path"), + caseMode = CaseMode.SENSITIVE, + ) + + val result = BackendStrategy.Findstr.buildCommand(config, "test", null) + + assertTrue(!result.contains("/I")) + } + + @Test + fun `name should return findstr`() { + assertEquals("findstr", BackendStrategy.Findstr.name) + } + } + + @Nested + inner class GrepTest { + @Test + fun `buildCommand should include basic grep flags`() { + val config = GrepConfig( + targets = listOf("/path/to/search"), + caseMode = CaseMode.SENSITIVE, + ) + + val result = BackendStrategy.Grep.buildCommand(config, "test", null) + + assertTrue(result.contains("com/mituuz/fuzzier/grep")) + assertTrue(result.contains("--color=none")) + assertTrue(result.contains("-r")) + assertTrue(result.contains("-n")) + assertTrue(result.contains("test")) + assertTrue(result.contains("/path/to/search")) + } + + @Test + fun `buildCommand should add case insensitive flag when needed`() { + val config = GrepConfig( + targets = listOf("/path"), + caseMode = CaseMode.INSENSITIVE, + ) + + val result = BackendStrategy.Grep.buildCommand(config, "test", null) + + assertTrue(result.contains("-i")) + } + + @Test + fun `buildCommand should not add case insensitive flag for sensitive mode`() { + val config = GrepConfig( + targets = listOf("/path"), + caseMode = CaseMode.SENSITIVE, + ) + + val result = BackendStrategy.Grep.buildCommand(config, "test", null) + + assertTrue(!result.contains("-i")) + } + + @Test + fun `name should return grep`() { + assertEquals("com/mituuz/fuzzier/grep", BackendStrategy.Grep.name) + } + } +} \ No newline at end of file