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
-
- - Refactor file search to use coroutines
+ 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
- - Handle list size limiting during processing instead of doing them separately
+ 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
- - Add debouncing for fuzzy file preview using `SingleAlarm`
- - Refactor everything
- - Add auto sizing option for the popup (default)
-
+ 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 <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
+
+ - 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 file preview using
SingleAlarm
+ - Refactor everything
+ - Remove manual handling of the divider location (use JBSplitter instead) and unify styling
+
+
""".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