diff --git a/build.gradle.kts b/build.gradle.kts index 9bc2fa3e..b3fbcaed 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.14.0" +val currentVersion = "1.15.0" val myGroup = "com.mituuz" version = currentVersion group = myGroup @@ -40,14 +40,13 @@ intellijPlatform { changeNotes = """

Version $currentVersion

""".trimIndent() diff --git a/changelog.md b/changelog.md index d1288d15..166cd6d0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## Version 1.15.0 + +- 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` +- Refactor everything +- Add auto sizing option for the popup (default) + ## Version 1.14.0 - Add a global exclusion list for convenience when working with multiple projects diff --git a/src/main/kotlin/com/mituuz/fuzzier/Fuzzier.kt b/src/main/kotlin/com/mituuz/fuzzier/Fuzzier.kt index e6ade29c..4253464d 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/Fuzzier.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/Fuzzier.kt @@ -21,6 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ + package com.mituuz.fuzzier import com.intellij.openapi.actionSystem.AnActionEvent @@ -28,159 +29,70 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.impl.EditorHistoryManager -import com.intellij.openapi.module.ModuleManager -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.progress.ProgressManager -import com.intellij.openapi.progress.Task import com.intellij.openapi.project.Project -import com.intellij.openapi.project.rootManager -import com.intellij.openapi.roots.ProjectFileIndex -import com.intellij.openapi.ui.popup.JBPopup -import com.intellij.openapi.ui.popup.JBPopupListener -import com.intellij.openapi.ui.popup.LightweightWindowEvent -import com.intellij.openapi.vcs.changes.ChangeListManager import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.util.SingleAlarm +import com.mituuz.fuzzier.actions.filesystem.FilesystemAction import com.mituuz.fuzzier.components.FuzzyFinderComponent import com.mituuz.fuzzier.entities.FuzzyContainer -import com.mituuz.fuzzier.entities.StringEvaluator -import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService.RecentFilesMode.* -import com.mituuz.fuzzier.util.FuzzierUtil +import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService +import com.mituuz.fuzzier.ui.bindings.ActivationBindings +import com.mituuz.fuzzier.ui.popup.PopupConfig import com.mituuz.fuzzier.util.InitialViewHandler -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import org.apache.commons.lang3.StringUtils -import java.awt.event.ActionEvent -import java.awt.event.KeyEvent -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Future -import javax.swing.AbstractAction import javax.swing.DefaultListModel -import javax.swing.JComponent -import javax.swing.KeyStroke -import kotlin.coroutines.cancellation.CancellationException -open class Fuzzier : FuzzyAction() { - override var popupTitle = "Fuzzy Search" - override var dimensionKey = "FuzzySearchPopup" +open class Fuzzier : FilesystemAction() { + private var previewAlarm: SingleAlarm? = null + private var lastPreviewKey: String? = null + protected open var popupTitle = "Fuzzy Search" - // Used by FuzzierVCS to check if files are tracked by the VCS - protected var changeListManager: ChangeListManager? = null + override fun buildFileFilter(project: Project): (VirtualFile) -> Boolean = + { vf -> !vf.isDirectory } override fun runAction(project: Project, actionEvent: AnActionEvent) { - setCustomHandlers() - ApplicationManager.getApplication().invokeLater { defaultDoc = EditorFactory.getInstance().createDocument("") component = FuzzyFinderComponent(project) + previewAlarm = getPreviewAlarm() createListeners(project) - showPopup(project) + val maybePopup = getPopupProvider().show( + project = project, + content = component, + focus = component.searchField, + config = PopupConfig( + title = popupTitle, + preferredSizeProvider = component.preferredSize, + dimensionKey = "FuzzySearchPopup", + resetWindow = { globalState.resetWindow }, + clearResetWindowFlag = { globalState.resetWindow = false } + ), + cleanupFunction = { cleanupPopup() } + ) + + if (maybePopup == null) return@invokeLater + popup = maybePopup + createSharedListeners(project) (component as FuzzyFinderComponent).splitPane.dividerLocation = globalState.splitPosition - if (globalState.recentFilesMode != NONE) { + if (globalState.recentFilesMode != FuzzierGlobalSettingsService.RecentFilesMode.NONE) { createInitialView(project) } } } - override fun createPopup(screenDimensionKey: String): JBPopup { - val popup = getInitialPopup(screenDimensionKey) - - popup.addListener(object : JBPopupListener { - override fun onClosed(event: LightweightWindowEvent) { - globalState.splitPosition = - (component as FuzzyFinderComponent).splitPane.dividerLocation - resetOriginalHandlers() - super.onClosed(event) - } - }) - - return popup - } - - /** - * Populates the file list with recently opened files - */ - private fun createInitialView(project: Project) { - ApplicationManager.getApplication().executeOnPooledThread { - val editorHistoryManager = EditorHistoryManager.getInstance(project) - - val listModel = when (globalState.recentFilesMode) { - RECENT_PROJECT_FILES -> InitialViewHandler.getRecentProjectFiles( - globalState, - fuzzierUtil, - editorHistoryManager, - project - ) - - RECENTLY_SEARCHED_FILES -> InitialViewHandler.getRecentlySearchedFiles(projectState) - else -> { - DefaultListModel() - } - } - - ApplicationManager.getApplication().invokeLater { - component.fileList.model = listModel - component.fileList.cellRenderer = getCellRenderer() - component.fileList.setPaintBusy(false) - if (!component.fileList.isEmpty) { - component.fileList.setSelectedValue(listModel[0], true) - } - } - } - } - - override fun updateListContents(project: Project, searchString: String) { - if (StringUtils.isBlank(searchString)) { - handleEmptySearchString(project) - return - } - - currentTask?.takeIf { !it.isDone }?.cancel(true) - currentTask = ApplicationManager.getApplication().executeOnPooledThread { - try { - // Create a reference to the current task to check if it has been cancelled - val task = currentTask - component.fileList.setPaintBusy(true) - var listModel = DefaultListModel() - - val stringEvaluator = getStringEvaluator() - - if (task?.isCancelled == true) return@executeOnPooledThread - - process(project, stringEvaluator, searchString, listModel, task) - - if (task?.isCancelled == true) return@executeOnPooledThread - - listModel = fuzzierUtil.sortAndLimit(listModel) - - if (task?.isCancelled == true) return@executeOnPooledThread - - ApplicationManager.getApplication().invokeLater { - component.fileList.model = listModel - component.fileList.cellRenderer = getCellRenderer() - component.fileList.setPaintBusy(false) - if (!component.fileList.isEmpty) { - component.fileList.setSelectedValue(listModel[0], true) - } - } - } catch (_: InterruptedException) { - return@executeOnPooledThread - } catch (_: CancellationException) { - return@executeOnPooledThread - } - } + override fun onPopupClosed() { + globalState.splitPosition = + (component as FuzzyFinderComponent).splitPane.dividerLocation + previewAlarm?.dispose() + lastPreviewKey = null } - private fun handleEmptySearchString(project: Project) { - if (globalState.recentFilesMode != NONE) { + override fun handleEmptySearchString(project: Project) { + if (globalState.recentFilesMode != FuzzierGlobalSettingsService.RecentFilesMode.NONE) { createInitialView(project) } else { ApplicationManager.getApplication().invokeLater { @@ -190,70 +102,59 @@ open class Fuzzier : FuzzyAction() { } } - private fun getStringEvaluator(): StringEvaluator { - val combinedExclusions = buildSet { - addAll(projectState.exclusionSet) - addAll(globalState.globalExclusionSet) - } - return StringEvaluator( - combinedExclusions, - projectState.modules, - changeListManager - ) - } - - private fun process( - project: Project, stringEvaluator: StringEvaluator, searchString: String, - listModel: DefaultListModel, task: Future<*>? - ) { - val moduleManager = ModuleManager.getInstance(project) - if (projectState.isProject) { - processProject(project, stringEvaluator, searchString, listModel, task) - } else { - processModules(moduleManager, stringEvaluator, searchString, listModel, task) + 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() + } } - } - private fun processProject( - project: Project, stringEvaluator: StringEvaluator, - searchString: String, listModel: DefaultListModel, task: Future<*>? - ) { - val filesToIterate = ConcurrentHashMap.newKeySet() - FuzzierUtil.fileIndexToIterationFile(filesToIterate, ProjectFileIndex.getInstance(project), project.name, task) - processFiles(filesToIterate, stringEvaluator, listModel, searchString, task) + ActivationBindings.install( + component, + onActivate = { handleInput(project) } + ) } - private fun processModules( - moduleManager: ModuleManager, stringEvaluator: StringEvaluator, - searchString: String, listModel: DefaultListModel, task: Future<*>? - ) { - val filesToIterate = ConcurrentHashMap.newKeySet() - for (module in moduleManager.modules) { - FuzzierUtil.fileIndexToIterationFile(filesToIterate, module.rootManager.fileIndex, module.name, task) + private fun handleInput(project: Project) { + val selectedValue = component.fileList.selectedValue + val virtualFile = + VirtualFileManager.getInstance().findFileByUrl("file://${selectedValue?.getFileUri()}") + virtualFile?.let { + openFile(project, selectedValue, it) } - processFiles(filesToIterate, stringEvaluator, listModel, searchString, task) } - /** - * Processes a set of IterationFiles concurrently - */ - private fun processFiles( - filesToIterate: ConcurrentHashMap.KeySetView, - stringEvaluator: StringEvaluator, listModel: DefaultListModel, - searchString: String, task: Future<*>? - ) { - val ss = FuzzierUtil.cleanSearchString(searchString, projectState.ignoredCharacters) - val processedFiles = ConcurrentHashMap.newKeySet() - runBlocking { - withContext(Dispatchers.IO) { - filesToIterate.forEach { iterationFile -> - if (task?.isCancelled == true) return@forEach - if (processedFiles.add(iterationFile.file.path)) { - launch { - stringEvaluator.evaluateFile(iterationFile, listModel, ss) - } + private fun createInitialView(project: Project) { + component.fileList.setPaintBusy(true) + ApplicationManager.getApplication().executeOnPooledThread { + try { + val editorHistoryManager = EditorHistoryManager.getInstance(project) + + val listModel = when (globalState.recentFilesMode) { + FuzzierGlobalSettingsService.RecentFilesMode.RECENT_PROJECT_FILES -> InitialViewHandler.Companion.getRecentProjectFiles( + globalState, + fuzzierUtil, + editorHistoryManager, + project + ) + + FuzzierGlobalSettingsService.RecentFilesMode.RECENTLY_SEARCHED_FILES -> InitialViewHandler.Companion.getRecentlySearchedFiles( + projectState + ) + + else -> { + DefaultListModel() } } + + ApplicationManager.getApplication().invokeLater { + component.refreshModel(listModel, getCellRenderer()) + } + } finally { + component.fileList.setPaintBusy(false) } } } @@ -281,59 +182,26 @@ open class Fuzzier : FuzzyAction() { popup.cancel() } - 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) { - ApplicationManager.getApplication().invokeLater { - defaultDoc?.let { (component as FuzzyFinderComponent).previewPane.updateFile(it) } - } - return@addListSelectionListener - } - val selectedValue = component.fileList.selectedValue - val fileUrl = "file://${selectedValue?.getFileUri()}" + private fun getPreviewAlarm(): SingleAlarm { + return SingleAlarm( + { + val fuzzyFinderComponent = (component as FuzzyFinderComponent) + val selected = component.fileList.selectedValue - ProgressManager.getInstance().run(object : Task.Backgroundable(null, "Loading file", false) { - override fun run(indicator: ProgressIndicator) { - val file = VirtualFileManager.getInstance().findFileByUrl(fileUrl) - file?.let { - (component as FuzzyFinderComponent).previewPane.updateFile(file) - } - } - }) - } - } - - // Add a mouse listener for double-click - component.fileList.addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (e.clickCount == 2) { - val selectedValue = component.fileList.selectedValue - val virtualFile = - VirtualFileManager.getInstance().findFileByUrl("file://${selectedValue?.getFileUri()}") - // Open the file in the editor - virtualFile?.let { - openFile(project, selectedValue, it) - } + if (selected == null || component.fileList.isEmpty) { + defaultDoc?.let { fuzzyFinderComponent.previewPane.updateFile(it) } + lastPreviewKey = null + return@SingleAlarm } - } - }) - // Add a listener that opens the currently selected file when pressing enter (focus on the text box) - val enterKeyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0) - val enterActionKey = "openFile" - val inputMap = component.searchField.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) - inputMap.put(enterKeyStroke, enterActionKey) - component.searchField.actionMap.put(enterActionKey, object : AbstractAction() { - override fun actionPerformed(e: ActionEvent?) { - val selectedValue = component.fileList.selectedValue - val virtualFile = - VirtualFileManager.getInstance().findFileByUrl("file://${selectedValue?.getFileUri()}") - virtualFile?.let { - openFile(project, selectedValue, it) - } - } - }) + val fileUrl = "file://${selected.getFileUri()}" + if (fileUrl == lastPreviewKey) return@SingleAlarm + lastPreviewKey = fileUrl + + val vf = VirtualFileManager.getInstance().findFileByUrl(fileUrl) + if (vf != null) fuzzyFinderComponent.previewPane.updateFile(vf) + }, + 75, + ) } } \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/FuzzierVCS.kt b/src/main/kotlin/com/mituuz/fuzzier/FuzzierVCS.kt index 5ee15ee7..a351cc87 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/FuzzierVCS.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/FuzzierVCS.kt @@ -1,38 +1,41 @@ /* -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. + * 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. + */ -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.openapi.project.Project import com.intellij.openapi.vcs.changes.ChangeListManager +import com.intellij.openapi.vfs.VirtualFile /** * Search for only VCS tracked files */ class FuzzierVCS : Fuzzier() { override var popupTitle: String = "Fuzzy Search (Only VCS Tracked Files)" - override fun updateListContents(project: Project, searchString: String) { - changeListManager = ChangeListManager.getInstance(project) - super.updateListContents(project, searchString) + + override fun buildFileFilter(project: Project): (VirtualFile) -> Boolean { + val clm = ChangeListManager.getInstance(project) + return { vf -> !vf.isDirectory && !clm.isIgnoredFile(vf) } } } \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/FuzzyGrep.kt b/src/main/kotlin/com/mituuz/fuzzier/FuzzyGrep.kt index c9cc3a34..1ba9c737 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/FuzzyGrep.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/FuzzyGrep.kt @@ -39,29 +39,23 @@ 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.ui.popup.JBPopup -import com.intellij.openapi.ui.popup.JBPopupListener -import com.intellij.openapi.ui.popup.LightweightWindowEvent 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 java.awt.event.ActionEvent -import java.awt.event.KeyEvent -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import javax.swing.AbstractAction import javax.swing.DefaultListModel -import javax.swing.JComponent -import javax.swing.KeyStroke +import javax.swing.ListModel -open class FuzzyGrep() : FuzzyAction() { +open class FuzzyGrep : FuzzyAction() { companion object { const val FUZZIER_NOTIFICATION_GROUP: String = "Fuzzier Notification Group" @@ -72,23 +66,19 @@ open class FuzzyGrep() : FuzzyAction() { const val MAX_NUMBER_OR_RESULTS = 1000 } - override var popupTitle: String = "Fuzzy Grep" - override var dimensionKey = "FuzzyGrepPopup" var useRg = true val isWindows = System.getProperty("os.name").lowercase().contains("win") private var currentLaunchJob: Job? = null - private var currentUpdateListContentJob: Job? = null - private var actionScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + protected open lateinit var popupTitle: String override fun runAction( project: Project, actionEvent: AnActionEvent ) { currentLaunchJob?.cancel() - setCustomHandlers() val projectBasePath = project.basePath.toString() - currentLaunchJob = actionScope.launch(Dispatchers.EDT) { + currentLaunchJob = actionScope?.launch(Dispatchers.EDT) { val currentJob = currentLaunchJob if (!isInstalled("rg", projectBasePath)) { @@ -136,7 +126,23 @@ open class FuzzyGrep() : FuzzyAction() { defaultDoc = EditorFactory.getInstance().createDocument("") component = FuzzyFinderComponent(project, showSecondaryField = useRg) createListeners(project) - showPopup(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 = @@ -159,21 +165,12 @@ open class FuzzyGrep() : FuzzyAction() { Notifications.Bus.notify(grepNotification, project) } - override fun createPopup(screenDimensionKey: String): JBPopup { - val popup = getInitialPopup(screenDimensionKey) - - popup.addListener(object : JBPopupListener { - override fun onClosed(event: LightweightWindowEvent) { - globalState.splitPosition = - (component as FuzzyFinderComponent).splitPane.dividerLocation - resetOriginalHandlers() - super.onClosed(event) - currentLaunchJob?.cancel() - currentUpdateListContentJob?.cancel() - } - }) + override fun onPopupClosed() { + globalState.splitPosition = + (component as FuzzyFinderComponent).splitPane.dividerLocation - return popup + currentLaunchJob?.cancel() + currentLaunchJob = null } /** @@ -199,29 +196,17 @@ open class FuzzyGrep() : FuzzyAction() { } currentUpdateListContentJob?.cancel() - currentUpdateListContentJob = actionScope.launch(Dispatchers.EDT) { - val currentJob = currentUpdateListContentJob - - if (currentJob?.isCancelled == true) return@launch - + currentUpdateListContentJob = actionScope?.launch(Dispatchers.EDT) { component.fileList.setPaintBusy(true) - val listModel = DefaultListModel() - - if (currentJob?.isCancelled == true) return@launch - - val results = withContext(Dispatchers.IO) { - findInFiles(searchString, listModel, project.basePath.toString()) - listModel - } - - if (currentJob?.isCancelled == true) return@launch - - component.fileList.model = results - component.fileList.cellRenderer = getCellRenderer() - if (!results.isEmpty) { - component.fileList.selectedIndex = 0 + try { + val results = withContext(Dispatchers.IO) { + findInFiles(searchString, project.basePath.toString()) + } + coroutineContext.ensureActive() + component.refreshModel(results, getCellRenderer()) + } finally { + component.fileList.setPaintBusy(false) } - component.fileList.setPaintBusy(false) } } @@ -299,9 +284,10 @@ open class FuzzyGrep() : FuzzyAction() { } private suspend fun findInFiles( - searchString: String, listModel: DefaultListModel, + searchString: String, projectBasePath: String - ) { + ): ListModel { + val listModel = DefaultListModel() if (useRg) { val secondary = (component as FuzzyFinderComponent).getSecondaryText().trim() val commands = mutableListOf( @@ -326,6 +312,8 @@ open class FuzzyGrep() : FuzzyAction() { runCommand(listOf("grep", "--color=none", "-r", "-n", searchString, "."), listModel, projectBasePath) } } + + return listModel } private fun createListeners(project: Project) { @@ -333,7 +321,7 @@ open class FuzzyGrep() : FuzzyAction() { component.fileList.addListSelectionListener { event -> if (!event.valueIsAdjusting) { if (component.fileList.isEmpty) { - actionScope.launch(Dispatchers.EDT) { + actionScope?.launch(Dispatchers.EDT) { defaultDoc?.let { (component as FuzzyFinderComponent).previewPane.updateFile(it) } } return@addListSelectionListener @@ -341,7 +329,7 @@ open class FuzzyGrep() : FuzzyAction() { val selectedValue = component.fileList.selectedValue val fileUrl = "file://${selectedValue?.getFileUri()}" - actionScope.launch(Dispatchers.Default) { + actionScope?.launch(Dispatchers.Default) { val file = withContext(Dispatchers.IO) { VirtualFileManager.getInstance().findFileByUrl(fileUrl) } @@ -356,36 +344,19 @@ open class FuzzyGrep() : FuzzyAction() { } } - // Add a mouse listener for double-click - component.fileList.addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (e.clickCount == 2) { - val selectedValue = component.fileList.selectedValue - val virtualFile = - VirtualFileManager.getInstance().findFileByUrl("file://${selectedValue?.getFileUri()}") - // Open the file in the editor - virtualFile?.let { - openFile(project, selectedValue, it) - } - } - } - }) - - // Add a listener that opens the currently selected file when pressing enter (focus on the text box) - val enterKeyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0) - val enterActionKey = "openFile" - val inputMap = component.searchField.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) - inputMap.put(enterKeyStroke, enterActionKey) - component.searchField.actionMap.put(enterActionKey, object : AbstractAction() { - override fun actionPerformed(e: ActionEvent?) { - val selectedValue = component.fileList.selectedValue - val virtualFile = - VirtualFileManager.getInstance().findFileByUrl("file://${selectedValue?.getFileUri()}") - virtualFile?.let { - openFile(project, selectedValue, it) - } - } - }) + 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) { diff --git a/src/main/kotlin/com/mituuz/fuzzier/FuzzyGrepCaseInsensitive.kt b/src/main/kotlin/com/mituuz/fuzzier/FuzzyGrepCaseInsensitive.kt index cea184b7..cef42f09 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/FuzzyGrepCaseInsensitive.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/FuzzyGrepCaseInsensitive.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 @@ -31,9 +29,12 @@ import javax.swing.DefaultListModel class FuzzyGrepCaseInsensitive : FuzzyGrep() { override var popupTitle: String = "Fuzzy Grep (Case Insensitive)" - override var dimensionKey = "FuzzyGrepCaseInsensitivePopup" - override suspend fun runCommand(commands: List, listModel: DefaultListModel, projectBasePath: String) { + override suspend fun runCommand( + commands: List, + listModel: DefaultListModel, + projectBasePath: String + ) { val modifiedCommands = commands.toMutableList() if (isWindows && !useRg) { // Customize findstr for case insensitivity diff --git a/src/main/kotlin/com/mituuz/fuzzier/FuzzyMover.kt b/src/main/kotlin/com/mituuz/fuzzier/FuzzyMover.kt index 7332ed2d..8dfdf0c9 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/FuzzyMover.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/FuzzyMover.kt @@ -21,6 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ + package com.mituuz.fuzzier import com.intellij.notification.Notification @@ -30,44 +31,27 @@ import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.fileEditor.FileEditorManager -import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.project.Project -import com.intellij.openapi.project.rootManager -import com.intellij.openapi.roots.ProjectFileIndex -import com.intellij.openapi.ui.popup.JBPopup -import com.intellij.openapi.ui.popup.JBPopupListener -import com.intellij.openapi.ui.popup.LightweightWindowEvent import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.psi.PsiDirectory import com.intellij.psi.PsiFile import com.intellij.psi.PsiManager +import com.mituuz.fuzzier.actions.filesystem.FilesystemAction import com.mituuz.fuzzier.components.SimpleFinderComponent -import com.mituuz.fuzzier.entities.FuzzyContainer -import com.mituuz.fuzzier.entities.StringEvaluator -import com.mituuz.fuzzier.util.FuzzierUtil -import org.apache.commons.lang3.StringUtils -import java.awt.event.ActionEvent -import java.awt.event.KeyEvent -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import java.util.concurrent.CompletableFuture -import java.util.concurrent.Future -import javax.swing.AbstractAction +import com.mituuz.fuzzier.ui.bindings.ActivationBindings +import com.mituuz.fuzzier.ui.popup.PopupConfig import javax.swing.DefaultListModel -import javax.swing.JComponent -import javax.swing.KeyStroke -import kotlin.coroutines.cancellation.CancellationException -class FuzzyMover : FuzzyAction() { - override var popupTitle = "Fuzzy File Mover" - override var dimensionKey = "FuzzyMoverPopup" +class FuzzyMover : FilesystemAction() { lateinit var movableFile: PsiFile lateinit var currentFile: VirtualFile - override fun runAction(project: Project, actionEvent: AnActionEvent) { - setCustomHandlers() + override fun buildFileFilter(project: Project): (VirtualFile) -> Boolean { + return { vf -> if (component.isDirSelector) vf.isDirectory else !vf.isDirectory } + } + override fun runAction(project: Project, actionEvent: AnActionEvent) { ApplicationManager.getApplication().invokeLater { component = SimpleFinderComponent() createListeners(project) @@ -77,47 +61,38 @@ class FuzzyMover : FuzzyAction() { component.fileList.setEmptyText("Press enter to use current file: ${currentFile.path}") } - showPopup(project) + val maybePopup = getPopupProvider().show( + project = project, + content = component, + focus = component.searchField, + config = PopupConfig( + title = "Fuzzy File Mover", + preferredSizeProvider = component.preferredSize, + dimensionKey = "FuzzyMoverPopup", + resetWindow = { globalState.resetWindow }, + clearResetWindowFlag = { globalState.resetWindow = false }), + cleanupFunction = { cleanupPopup() }, + ) + + if (maybePopup == null) return@invokeLater + popup = maybePopup + createSharedListeners(project) } } - override fun createPopup(screenDimensionKey: String): JBPopup { - val popup = getInitialPopup(screenDimensionKey) - - popup.addListener(object : JBPopupListener { - override fun onClosed(event: LightweightWindowEvent) { - resetOriginalHandlers() - super.onClosed(event) - } - }) - - return popup + override fun handleEmptySearchString(project: Project) { + ApplicationManager.getApplication().invokeLater { + component.fileList.model = DefaultListModel() + } } private fun createListeners(project: Project) { - // Add a mouse listener for double-click - component.fileList.addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (e.clickCount == 2) { - handleInput(project) - } - } - }) - - val enterKeyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0) - val enterActionKey = "openFile" - val inputMap = component.searchField.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) - inputMap.put(enterKeyStroke, enterActionKey) - component.searchField.actionMap.put(enterActionKey, object : AbstractAction() { - override fun actionPerformed(e: ActionEvent?) { - handleInput(project) - } - }) + ActivationBindings.install( + component, onActivate = { handleInput(project) }) } - fun handleInput(project: Project): CompletableFuture { - val completableFuture = CompletableFuture() + fun handleInput(project: Project) { var selectedValue = component.fileList.selectedValue?.getFileUri() if (selectedValue == null) { selectedValue = currentFile.path @@ -135,137 +110,31 @@ class FuzzyMover : FuzzyAction() { component.isDirSelector = true component.searchField.text = "" component.fileList.setEmptyText("Select target folder") - completableFuture.complete(null) } } else { ApplicationManager.getApplication().invokeLater { - val targetDirectory: CompletableFuture = CompletableFuture.supplyAsync { - ApplicationManager.getApplication().runReadAction { - val virtualDir = - VirtualFileManager.getInstance().findFileByUrl("file://$selectedValue") - virtualDir?.let { PsiManager.getInstance(project).findDirectory(it) } - } + val targetDir = ApplicationManager.getApplication().runReadAction { + val virtualDir = VirtualFileManager.getInstance().findFileByUrl("file://$selectedValue") + virtualDir?.let { PsiManager.getInstance(project).findDirectory(it) } } - targetDirectory.thenAcceptAsync { targetDir -> - val originalFilePath = movableFile.virtualFile.path - if (targetDir != null) { - WriteCommandAction.runWriteCommandAction(project) { - movableFile.virtualFile.move(movableFile.manager, targetDir.virtualFile) - } - val notification = Notification( - "Fuzzier Notification Group", - "File moved successfully", - "Moved $originalFilePath to $selectedValue", - NotificationType.INFORMATION - ) - Notifications.Bus.notify(notification, project) - ApplicationManager.getApplication().invokeLater { - popup.cancel() - } - completableFuture.complete(null) - } else { - completableFuture.complete(null) + val originalFilePath = movableFile.virtualFile.path + if (targetDir != null) { + WriteCommandAction.runWriteCommandAction(project) { + movableFile.virtualFile.move(movableFile.manager, targetDir.virtualFile) } - } - } - } - return completableFuture - } - - override fun updateListContents(project: Project, searchString: String) { - if (StringUtils.isBlank(searchString)) { - ApplicationManager.getApplication().invokeLater { - component.fileList.model = DefaultListModel() - } - return - } - - currentTask?.takeIf { !it.isDone }?.cancel(true) - currentTask = ApplicationManager.getApplication().executeOnPooledThread { - try { - // Create a reference to the current task to check if it has been cancelled - val task = currentTask - component.fileList.setPaintBusy(true) - var listModel = DefaultListModel() - - val stringEvaluator = getStringEvaluator() - - if (task?.isCancelled == true) return@executeOnPooledThread - - process(project, stringEvaluator, searchString, listModel, task) - - if (task?.isCancelled == true) return@executeOnPooledThread - - listModel = fuzzierUtil.sortAndLimit(listModel, true) - - if (task?.isCancelled == true) return@executeOnPooledThread - - ApplicationManager.getApplication().invokeLater { - component.fileList.model = listModel - component.fileList.cellRenderer = getCellRenderer() - component.fileList.setPaintBusy(false) - if (!component.fileList.isEmpty) { - component.fileList.setSelectedValue(listModel[0], true) + val notification = Notification( + "Fuzzier Notification Group", + "File moved successfully", + "Moved $originalFilePath to $selectedValue", + NotificationType.INFORMATION + ) + Notifications.Bus.notify(notification, project) + ApplicationManager.getApplication().invokeLater { + popup.cancel() } } - } catch (_: InterruptedException) { - return@executeOnPooledThread - } catch (_: CancellationException) { - return@executeOnPooledThread - } - } - } - - private fun getStringEvaluator(): StringEvaluator { - val combinedExclusions = buildSet { - addAll(projectState.exclusionSet) - addAll(globalState.globalExclusionSet) - } - return StringEvaluator( - combinedExclusions, - projectState.modules - ) - } - - private fun process( - project: Project, stringEvaluator: StringEvaluator, searchString: String, - listModel: DefaultListModel, task: Future<*>? - ) { - val moduleManager = ModuleManager.getInstance(project) - val ss = FuzzierUtil.cleanSearchString(searchString, projectState.ignoredCharacters) - if (projectState.isProject) { - processProject(project, stringEvaluator, ss, listModel, task) - } else { - processModules(moduleManager, stringEvaluator, ss, listModel, task) - } - } - - private fun processProject( - project: Project, stringEvaluator: StringEvaluator, - searchString: String, listModel: DefaultListModel, task: Future<*>? - ) { - val contentIterator = if (!component.isDirSelector) { - stringEvaluator.getContentIterator(project.name, searchString, listModel, task) - } else { - stringEvaluator.getDirIterator(project.name, searchString, listModel, task) - } - ProjectFileIndex.getInstance(project).iterateContent(contentIterator) - } - - private fun processModules( - moduleManager: ModuleManager, stringEvaluator: StringEvaluator, - searchString: String, listModel: DefaultListModel, task: Future<*>? - ) { - for (module in moduleManager.modules) { - val moduleFileIndex = module.rootManager.fileIndex - - val contentIterator = if (!component.isDirSelector) { - stringEvaluator.getContentIterator(module.name, searchString, listModel, task) - } else { - stringEvaluator.getDirIterator(module.name, searchString, listModel, task) } - moduleFileIndex.iterateContent(contentIterator) } } } \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/FuzzyAction.kt b/src/main/kotlin/com/mituuz/fuzzier/actions/FuzzyAction.kt similarity index 85% rename from src/main/kotlin/com/mituuz/fuzzier/FuzzyAction.kt rename to src/main/kotlin/com/mituuz/fuzzier/actions/FuzzyAction.kt index 7660c1b2..482da7ad 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/FuzzyAction.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/actions/FuzzyAction.kt @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package com.mituuz.fuzzier +package com.mituuz.fuzzier.actions import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.* @@ -39,29 +39,27 @@ import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.keymap.KeymapManager import com.intellij.openapi.project.Project import com.intellij.openapi.ui.popup.JBPopup -import com.intellij.openapi.ui.popup.JBPopupFactory -import com.intellij.openapi.util.DimensionService -import com.intellij.openapi.wm.WindowManager import com.mituuz.fuzzier.components.FuzzyComponent +import com.mituuz.fuzzier.components.FuzzyFinderComponent import com.mituuz.fuzzier.entities.FuzzyContainer import com.mituuz.fuzzier.entities.FuzzyContainer.FilenameType import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService import com.mituuz.fuzzier.settings.FuzzierSettingsService +import com.mituuz.fuzzier.ui.popup.AutoSizePopupProvider +import com.mituuz.fuzzier.ui.popup.DefaultPopupProvider +import com.mituuz.fuzzier.ui.popup.PopupProvider import com.mituuz.fuzzier.util.FuzzierUtil -import com.mituuz.fuzzier.util.FuzzierUtil.Companion.createDimensionKey +import kotlinx.coroutines.* import java.awt.Component import java.awt.Font import java.awt.event.ActionEvent import java.util.* import java.util.Timer import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Future import javax.swing.* import kotlin.concurrent.schedule abstract class FuzzyAction : AnAction() { - open lateinit var dimensionKey: String - open lateinit var popupTitle: String lateinit var component: FuzzyComponent lateinit var popup: JBPopup private lateinit var originalDownHandler: EditorActionHandler @@ -72,54 +70,36 @@ abstract class FuzzyAction : AnAction() { protected var defaultDoc: Document? = null private val fileTypeManager = FileTypeManager.getInstance() - @Volatile - var currentTask: Future<*>? = null val fuzzierUtil = FuzzierUtil() + protected open var currentUpdateListContentJob: Job? = null + protected open var actionScope: CoroutineScope? = null + + protected fun getPopupProvider(): PopupProvider { + return when (globalState.popupSizing) { + FuzzierGlobalSettingsService.PopupSizing.AUTO_SIZE -> { + val wf = (globalState.autoWidthPercent.coerceIn(10, 100)) / 100.0 + val hf = (globalState.autoHeightPercent.coerceIn(10, 100)) / 100.0 + AutoSizePopupProvider(wf, hf) + } + + FuzzierGlobalSettingsService.PopupSizing.VANILLA -> DefaultPopupProvider() + } + } override fun actionPerformed(actionEvent: AnActionEvent) { val project = actionEvent.project if (project != null) { projectState = project.service().state fuzzierUtil.parseModules(project) + setCustomHandlers() + actionScope?.cancel() + actionScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) runAction(project, actionEvent) } } abstract fun runAction(project: Project, actionEvent: AnActionEvent) - abstract fun createPopup(screenDimensionKey: String): JBPopup - - fun getInitialPopup(screenDimensionKey: String): JBPopup { - return JBPopupFactory - .getInstance() - .createComponentPopupBuilder(component, component.searchField) - .setFocusable(true) - .setRequestFocus(true) - .setResizable(true) - .setDimensionServiceKey(null, screenDimensionKey, true) - .setTitle(popupTitle) - .setMovable(true) - .setShowBorder(true) - .createPopup() - } - - fun showPopup(project: Project) { - val mainWindow = WindowManager.getInstance().getIdeFrame(project)?.component - mainWindow?.let { - val screenBounds = it.graphicsConfiguration.bounds - val screenDimensionKey = createDimensionKey(dimensionKey, screenBounds) - - if (globalState.resetWindow) { - DimensionService.getInstance().setSize(screenDimensionKey, component.preferredSize, null) - DimensionService.getInstance().setLocation(screenDimensionKey, null, null) - globalState.resetWindow = false - } - - popup = createPopup(screenDimensionKey) - popup.showInCenterOf(it) - } - } - fun createSharedListeners(project: Project) { val inputMap = component.searchField.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) @@ -168,7 +148,7 @@ abstract class FuzzyAction : AnAction() { document.addDocumentListener(listener, popup) // Also listen to changes in the secondary search field (if present) - (component as? com.mituuz.fuzzier.components.FuzzyFinderComponent)?.addSecondaryDocumentListener( + (component as? FuzzyFinderComponent)?.addSecondaryDocumentListener( listener, popup ) @@ -176,6 +156,19 @@ abstract class FuzzyAction : AnAction() { abstract fun updateListContents(project: Project, searchString: String) + protected open fun onPopupClosed() = Unit + + fun cleanupPopup() { + resetOriginalHandlers() + + currentUpdateListContentJob?.cancel() + currentUpdateListContentJob = null + + actionScope?.cancel() + + onPopupClosed() + } + fun setCustomHandlers() { val actionManager = EditorActionManager.getInstance() originalDownHandler = actionManager.getActionHandler(IdeActions.ACTION_EDITOR_MOVE_CARET_DOWN) diff --git a/src/main/kotlin/com/mituuz/fuzzier/actions/filesystem/FilesystemAction.kt b/src/main/kotlin/com/mituuz/fuzzier/actions/filesystem/FilesystemAction.kt new file mode 100644 index 00000000..94ba0f6f --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/actions/filesystem/FilesystemAction.kt @@ -0,0 +1,214 @@ +/* + * 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.actions.filesystem + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.EDT +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.rootManager +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.vfs.VirtualFile +import com.mituuz.fuzzier.actions.FuzzyAction +import com.mituuz.fuzzier.entities.* +import com.mituuz.fuzzier.intellij.iteration.IntelliJIterationFileCollector +import com.mituuz.fuzzier.intellij.iteration.IterationFileCollector +import com.mituuz.fuzzier.util.FuzzierUtil +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import javax.swing.DefaultListModel + +abstract class FilesystemAction : FuzzyAction() { + private var collector: IterationFileCollector = IntelliJIterationFileCollector() + + abstract override fun runAction( + project: Project, + actionEvent: AnActionEvent + ) + + abstract fun buildFileFilter(project: Project): (VirtualFile) -> Boolean + + abstract fun handleEmptySearchString(project: Project) + + suspend fun collectIterationFiles(project: Project): List { + val ctx = currentCoroutineContext() + val job = ctx.job + + val indexTargets = if (projectState.isProject) { + listOf(ProjectFileIndex.getInstance(project) to project.name) + } else { + val moduleManager = ModuleManager.getInstance(project) + moduleManager.modules.map { it.rootManager.fileIndex to it.name } + } + + return collector.collectFiles( + targets = indexTargets, + shouldContinue = { job.isActive }, + fileFilter = buildFileFilter(project) + ) + } + + fun getStringEvaluator(): StringEvaluator { + val combinedExclusions = buildSet { + addAll(projectState.exclusionSet) + addAll(globalState.globalExclusionSet) + } + return StringEvaluator( + combinedExclusions, + projectState.modules, + ) + } + + /** + * Processes a set of IterationFiles concurrently + * @return a priority list which has been size limited and sorted + */ + suspend fun processIterationEntries( + fileEntries: List, + stringEvaluator: StringEvaluator, + searchString: String, + fileListLimit: Int, + ignoredChars: String, + prioritizeShorterDirPaths: Boolean, + ): DefaultListModel { + val ss = FuzzierUtil.cleanSearchString(searchString, ignoredChars) + val processedFiles = ConcurrentHashMap.newKeySet() + val listLimit = fileListLimit + val priorityQueue = PriorityQueue( + listLimit + 1, + compareBy { it.getScore(prioritizeShorterDirPaths) } + ) + + val queueLock = Any() + var minimumScore: Int? = null + + val cores = Runtime.getRuntime().availableProcessors().coerceAtLeast(1) + val parallelism = (cores - 1).coerceIn(1, 8) + + val matchConfig = MatchConfig( + globalState.tolerance, + globalState.multiMatch, + globalState.matchWeightSingleChar, + globalState.matchWeightStreakModifier, + globalState.matchWeightPartialPath, + globalState.matchWeightFilename + ) + + coroutineScope { + val ch = Channel(capacity = parallelism * 2) + + repeat(parallelism) { + launch { + for (iterationFile in ch) { + val container = stringEvaluator.evaluateIteratorEntry(iterationFile, ss, matchConfig) + container?.let { fuzzyMatchContainer -> + synchronized(queueLock) { + minimumScore = priorityQueue.maybeAdd( + minimumScore, + fuzzyMatchContainer, + fileListLimit, + globalState.prioritizeShorterDirPaths + ) + } + } + } + } + } + + fileEntries + .filter { processedFiles.add(it.path) } + .forEach { ch.send(it) } + ch.close() + } + + + val result = DefaultListModel() + result.addAll( + priorityQueue.sortedWith( + compareByDescending { it.getScore(prioritizeShorterDirPaths) }) + ) + return result + } + + private fun PriorityQueue.maybeAdd( + minimumScore: Int?, + fuzzyMatchContainer: FuzzyMatchContainer, + fileListLimit: Int, + prioritizeShorterFilePaths: Boolean, + ): Int? { + var ret = minimumScore + + if (minimumScore == null || fuzzyMatchContainer.getScore(prioritizeShorterFilePaths) > minimumScore) { + this.add(fuzzyMatchContainer) + if (this.size > fileListLimit) { + this.remove() + ret = this.peek().getScore(prioritizeShorterFilePaths) + } + } + + return ret + } + + override fun updateListContents(project: Project, searchString: String) { + if (searchString.isEmpty()) { + handleEmptySearchString(project) + return + } + + currentUpdateListContentJob?.cancel() + currentUpdateListContentJob = actionScope?.launch(Dispatchers.EDT) { + component.fileList.setPaintBusy(true) + + try { + val stringEvaluator = getStringEvaluator() + coroutineContext.ensureActive() + + val iterationEntries = withContext(Dispatchers.Default) { + collectIterationFiles(project) + } + coroutineContext.ensureActive() + + val listModel = withContext(Dispatchers.Default) { + processIterationEntries( + iterationEntries, + stringEvaluator, + searchString, + globalState.fileListLimit, + projectState.ignoredCharacters, + globalState.prioritizeShorterDirPaths, + ) + } + coroutineContext.ensureActive() + + component.refreshModel(listModel, getCellRenderer()) + } finally { + component.fileList.setPaintBusy(false) + } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt index 18765f76..a9376ca1 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt @@ -36,8 +36,7 @@ import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.FormBuilder import com.mituuz.fuzzier.entities.FuzzyContainer.FilenameType import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService -import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService.RecentFilesMode -import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService.SearchPosition +import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService.* import java.awt.Component import javax.swing.* import javax.swing.border.LineBorder @@ -166,6 +165,33 @@ class FuzzierGlobalSettingsComponent( false ) + val popupSizingSelector = SettingsComponent( + ComboBox(), "Popup sizing", + """ + Select how the popup size is determined.

+ Auto size uses content-aware sizing and ignores manual dimensions.
+ Vanilla uses the persistent DimensionService sizing with default dimensions below. + """.trimIndent(), + false + ) + + val autoSizePercentPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.X_AXIS) + add(JBLabel("Width %: ")) + add(JBIntSpinner(80, 10, 100)) + add(Box.createHorizontalStrut(10)) + add(JBLabel("Height %: ")) + add(JBIntSpinner(80, 10, 100)) + } + val autoSizePercentages = SettingsComponent( + autoSizePercentPanel, "Auto-size (% of window)", + """ + Percentage of the IDE window used when popup sizing is set to Auto size.

+ Min: 10, Max: 100 + """.trimIndent(), + false + ) + val dimensionComponent = JPanel().apply { layout = BoxLayout(this, BoxLayout.X_AXIS) add(JBLabel("Width: ")) @@ -309,6 +335,8 @@ class FuzzierGlobalSettingsComponent( .addComponent(filenameTypeSelector) .addComponent(highlightFilename) .addComponent(searchPosition) + .addComponent(popupSizingSelector) + .addComponent(autoSizePercentages) .addComponent(defaultDimension) .addComponent(previewFontSize) .addComponent(fileListUseEditorFont) @@ -383,6 +411,25 @@ class FuzzierGlobalSettingsComponent( searchPosition.getSearchPositionComboBox().addItem(sp) } + popupSizingSelector.getPopupSizingComboBox().renderer = object : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + val renderer = + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus) as JLabel + val sizing = value as PopupSizing + renderer.text = sizing.text + return renderer + } + } + for (s in PopupSizing.entries) { + popupSizingSelector.getPopupSizingComboBox().addItem(s) + } + filenameTypeSelector.getFilenameTypeComboBox().renderer = object : DefaultListCellRenderer() { override fun getListCellRendererComponent( list: JList<*>?, diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzyComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzyComponent.kt index fe6e39c4..71de5404 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzyComponent.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzyComponent.kt @@ -28,9 +28,19 @@ import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBList import com.mituuz.fuzzier.entities.FuzzyContainer import javax.swing.JPanel +import javax.swing.ListCellRenderer +import javax.swing.ListModel open class FuzzyComponent : JPanel() { - var fileList = JBList() + var fileList = JBList() var searchField = EditorTextField() var isDirSelector = false + + fun refreshModel(listModel: ListModel, cellRenderer: ListCellRenderer) { + fileList.model = listModel + fileList.cellRenderer = cellRenderer + if (!fileList.isEmpty) { + fileList.selectedIndex = 0 + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzyFinderComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzyFinderComponent.kt index b227d546..862e5a48 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzyFinderComponent.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzyFinderComponent.kt @@ -78,7 +78,7 @@ class FuzzyFinderComponent(project: Project, private val showSecondaryField: Boo } searchField.text = "" val fileListScrollPane = JBScrollPane() - fileList = JBList() + fileList = JBList() fileList.selectionMode = 0 fileListScrollPane.setViewportView(fileList) diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/SettingsComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/SettingsComponent.kt index 2289ea9f..594e4367 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/components/SettingsComponent.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/components/SettingsComponent.kt @@ -1,26 +1,26 @@ /* -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.components import com.intellij.icons.AllIcons @@ -33,10 +33,11 @@ import com.intellij.ui.components.JBTextField import com.intellij.util.ui.FormBuilder import com.mituuz.fuzzier.entities.FuzzyContainer.FilenameType import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService +import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService.PopupSizing import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService.RecentFilesMode import javax.swing.JButton -import javax.swing.JPanel import javax.swing.JComponent +import javax.swing.JPanel class SettingsComponent { val includesSeparateLabel: Boolean @@ -98,6 +99,11 @@ class SettingsComponent { return component as ComboBox } + fun getPopupSizingComboBox(): ComboBox { + @Suppress("UNCHECKED_CAST") + return component as ComboBox + } + fun getIntSpinner(index: Int): JBIntSpinner { return (component as JPanel).getComponent(index) as JBIntSpinner } diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt index 91e4336c..c98798bc 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt @@ -33,19 +33,23 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.project.rootManager import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.vfs.VirtualFile import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBScrollPane import com.intellij.ui.table.JBTable import com.intellij.uiDesigner.core.GridConstraints import com.intellij.uiDesigner.core.GridLayoutManager -import com.mituuz.fuzzier.entities.FuzzyContainer -import com.mituuz.fuzzier.entities.FuzzyMatchContainer -import com.mituuz.fuzzier.entities.StringEvaluator +import com.mituuz.fuzzier.entities.* +import com.mituuz.fuzzier.intellij.iteration.IntelliJIterationFileCollector +import com.mituuz.fuzzier.intellij.iteration.IterationFileCollector import com.mituuz.fuzzier.settings.FuzzierSettingsService import com.mituuz.fuzzier.util.FuzzierUtil +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel import org.apache.commons.lang3.StringUtils import java.awt.Dimension import java.util.* +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Future import javax.swing.DefaultListModel import javax.swing.JPanel @@ -63,6 +67,9 @@ class TestBenchComponent : JPanel(), Disposable { var currentTask: Future<*>? = null private lateinit var liveSettingsComponent: FuzzierGlobalSettingsComponent private lateinit var projectState: FuzzierSettingsService.State + private var currentUpdateListContentJob: Job? = null + private var actionScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var collector: IterationFileCollector = IntelliJIterationFileCollector() fun fill(settingsComponent: FuzzierGlobalSettingsComponent) { val project = ProjectManager.getInstance().openProjects[0] @@ -77,8 +84,7 @@ class TestBenchComponent : JPanel(), Disposable { scrollPane.setViewportView(table) add( - scrollPane, - GridConstraints( + scrollPane, GridConstraints( 0, 0, 1, @@ -95,8 +101,7 @@ class TestBenchComponent : JPanel(), Disposable { ) ) add( - searchField, - GridConstraints( + searchField, GridConstraints( 1, 0, 1, @@ -137,96 +142,59 @@ class TestBenchComponent : JPanel(), Disposable { } // Use live settings from the component (unsaved UI state) so changes are reflected immediately - val liveGlobalExclusions = liveSettingsComponent.globalExclusionTextArea.text - .lines() - .map { it.trim() } - .filter { it.isNotEmpty() } - .toSet() + val liveGlobalExclusions = + liveSettingsComponent.globalExclusionTextArea.text.lines().map { it.trim() }.filter { it.isNotEmpty() } + .toSet() val combinedExclusions = buildSet { addAll(projectState.exclusionSet) addAll(liveGlobalExclusions) } - val stringEvaluator = StringEvaluator( - combinedExclusions, - project.service().state.modules - ) - - currentTask?.takeIf { !it.isDone }?.cancel(true) - - currentTask = ApplicationManager.getApplication().executeOnPooledThread { + currentUpdateListContentJob?.cancel() + currentUpdateListContentJob = actionScope.launch { table.setPaintBusy(true) - val listModel = DefaultListModel() - - process(project, stringEvaluator, searchString, listModel) - - val sortedList = listModel.elements().toList().sortedByDescending { (it as FuzzyMatchContainer).getScore() } - val data: Array> = sortedList.map { - arrayOf( - (it as FuzzyMatchContainer).filename as Any, - it.filePath as Any, - it.score.streakScore as Any, - it.score.multiMatchScore as Any, - it.score.partialPathScore as Any, - it.score.filenameScore as Any, - it.score.getTotalScore() as Any + + try { + val stringEvaluator = StringEvaluator( + combinedExclusions, project.service().state.modules ) - }.toTypedArray() - val tableModel = DefaultTableModel(data, columnNames) - table.model = tableModel - table.setPaintBusy(false) - } - } + val iterationEntries = withContext(Dispatchers.Default) { + collectIterationFiles(project) + } - private fun process( - project: Project, stringEvaluator: StringEvaluator, searchString: String, - listModel: DefaultListModel - ) { - val moduleManager = ModuleManager.getInstance(project) - if (project.service().state.isProject) { - processProject(project, stringEvaluator, searchString, listModel) - } else { - processModules(moduleManager, stringEvaluator, searchString, listModel) - } - } + val prioritizeShorterDirPaths = liveSettingsComponent.prioritizeShortDirs.getCheckBox().isSelected + val listModel = withContext(Dispatchers.Default) { + processIterationEntries( + iterationEntries, + stringEvaluator, + searchString, + liveSettingsComponent.fileListLimit.getIntSpinner().value as Int, + prioritizeShorterDirPaths, + ) + } - private fun processProject( - project: Project, stringEvaluator: StringEvaluator, - searchString: String, listModel: DefaultListModel - ) { - val ss = FuzzierUtil.cleanSearchString(searchString, projectState.ignoredCharacters) - val contentIterator = stringEvaluator.getContentIterator(project.name, ss, listModel, null) - - val scoreCalculator = stringEvaluator.scoreCalculator - scoreCalculator.setMultiMatch(liveSettingsComponent.multiMatchActive.getCheckBox().isSelected) - scoreCalculator.setMatchWeightSingleChar(liveSettingsComponent.matchWeightSingleChar.getIntSpinner().value as Int) - scoreCalculator.setMatchWeightStreakModifier(liveSettingsComponent.matchWeightStreakModifier.getIntSpinner().value as Int) - scoreCalculator.setMatchWeightPartialPath(liveSettingsComponent.matchWeightPartialPath.getIntSpinner().value as Int) - scoreCalculator.setFilenameMatchWeight(liveSettingsComponent.matchWeightFilename.getIntSpinner().value as Int) - scoreCalculator.setTolerance(liveSettingsComponent.tolerance.getIntSpinner().value as Int) - ProjectFileIndex.getInstance(project).iterateContent(contentIterator) - } + val sortedList = + listModel.elements().toList() + .sortedByDescending { (it as FuzzyMatchContainer).getScore(prioritizeShorterDirPaths) } + val data: Array> = sortedList.map { + arrayOf( + (it as FuzzyMatchContainer).filename as Any, + it.filePath as Any, + it.score.streakScore as Any, + it.score.multiMatchScore as Any, + it.score.partialPathScore as Any, + it.score.filenameScore as Any, + it.score.getTotalScore() as Any + ) + }.toTypedArray() - private fun processModules( - moduleManager: ModuleManager, stringEvaluator: StringEvaluator, - searchString: String, listModel: DefaultListModel - ) { - for (module in moduleManager.modules) { - val moduleFileIndex = module.rootManager.fileIndex - val ss = FuzzierUtil.cleanSearchString(searchString, projectState.ignoredCharacters) - val contentIterator = stringEvaluator.getContentIterator(module.name, ss, listModel, null) - - val scoreCalculator = stringEvaluator.scoreCalculator - scoreCalculator.setMultiMatch(liveSettingsComponent.multiMatchActive.getCheckBox().isSelected) - scoreCalculator.setMatchWeightSingleChar(liveSettingsComponent.matchWeightSingleChar.getIntSpinner().value as Int) - scoreCalculator.setMatchWeightStreakModifier(liveSettingsComponent.matchWeightStreakModifier.getIntSpinner().value as Int) - scoreCalculator.setMatchWeightPartialPath(liveSettingsComponent.matchWeightPartialPath.getIntSpinner().value as Int) - scoreCalculator.setFilenameMatchWeight(liveSettingsComponent.matchWeightFilename.getIntSpinner().value as Int) - scoreCalculator.setTolerance(liveSettingsComponent.tolerance.getIntSpinner().value as Int) - - moduleFileIndex.iterateContent(contentIterator) + val tableModel = DefaultTableModel(data, columnNames) + table.model = tableModel + } finally { + table.setPaintBusy(false) + } } } @@ -247,4 +215,100 @@ class TestBenchComponent : JPanel(), Disposable { } } } + + suspend fun collectIterationFiles(project: Project): List { + val ctx = currentCoroutineContext() + val job = ctx.job + + val indexTargets = if (projectState.isProject) { + listOf(ProjectFileIndex.getInstance(project) to project.name) + } else { + val moduleManager = ModuleManager.getInstance(project) + moduleManager.modules.map { it.rootManager.fileIndex to it.name } + } + + return collector.collectFiles( + targets = indexTargets, shouldContinue = { job.isActive }, fileFilter = buildFileFilter() + ) + } + + private fun buildFileFilter(): (VirtualFile) -> Boolean = { vf -> !vf.isDirectory } + + suspend fun processIterationEntries( + fileEntries: List, + stringEvaluator: StringEvaluator, + searchString: String, + fileListLimit: Int, + prioritizeShorterDirPaths: Boolean, + ): DefaultListModel { + val ss = FuzzierUtil.cleanSearchString(searchString, projectState.ignoredCharacters) + val processedFiles = ConcurrentHashMap.newKeySet() + val priorityQueue = PriorityQueue( + fileListLimit + 1, + compareBy { it.getScore(prioritizeShorterDirPaths) }) + + val queueLock = Any() + var minimumScore: Int? = null + + val cores = Runtime.getRuntime().availableProcessors().coerceAtLeast(1) + val parallelism = (cores - 1).coerceIn(1, 8) + + coroutineScope { + val ch = Channel(capacity = parallelism * 2) + + repeat(parallelism) { + launch { + for (iterationFile in ch) { + val matchConfig = MatchConfig( + liveSettingsComponent.tolerance.getIntSpinner().value as Int, + liveSettingsComponent.multiMatchActive.getCheckBox().isSelected, + liveSettingsComponent.matchWeightSingleChar.getIntSpinner().value as Int, + liveSettingsComponent.matchWeightStreakModifier.getIntSpinner().value as Int, + liveSettingsComponent.matchWeightPartialPath.getIntSpinner().value as Int, + liveSettingsComponent.matchWeightFilename.getIntSpinner().value as Int + ) + + val container = stringEvaluator.evaluateIteratorEntry(iterationFile, ss, matchConfig) + container?.let { fuzzyMatchContainer -> + synchronized(queueLock) { + minimumScore = priorityQueue.maybeAdd( + minimumScore, fuzzyMatchContainer, fileListLimit, prioritizeShorterDirPaths + ) + } + } + } + } + } + + fileEntries.filter { processedFiles.add(it.path) }.forEach { ch.send(it) } + ch.close() + } + + + val result = DefaultListModel() + result.addAll( + priorityQueue.sortedWith( + compareByDescending { it.getScore(prioritizeShorterDirPaths) }) + ) + return result + } + + private fun PriorityQueue.maybeAdd( + minimumScore: Int?, + fuzzyMatchContainer: FuzzyMatchContainer, + fileListLimit: Int, + prioritizeShorterDirPaths: Boolean, + ): Int? { + var ret = minimumScore + + if (minimumScore == null || fuzzyMatchContainer.getScore(prioritizeShorterDirPaths) > minimumScore) { + this.add(fuzzyMatchContainer) + if (this.size > fileListLimit) { + this.remove() + ret = this.peek().getScore(prioritizeShorterDirPaths) + } + } + + return ret + } } \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainer.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainer.kt index f234e87b..fcad1371 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainer.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainer.kt @@ -1,26 +1,26 @@ /* -MIT License - -Copyright (c) 2025 Mitja Leino - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ + * MIT License + * + * Copyright (c) 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ package com.mituuz.fuzzier.entities import com.intellij.util.xmlb.Converter @@ -28,19 +28,16 @@ import com.mituuz.fuzzier.settings.FuzzierConfiguration.END_STYLE_TAG import com.mituuz.fuzzier.settings.FuzzierConfiguration.startStyleTag import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService import com.mituuz.fuzzier.settings.FuzzierSettingsService -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.ObjectInputStream -import java.io.ObjectOutputStream -import java.io.Serializable -import java.util.Base64 +import java.io.* +import java.util.* import javax.swing.DefaultListModel class FuzzyMatchContainer( val score: FuzzyScore, filePath: String, filename: String, - moduleBasePath: String + moduleBasePath: String, + private val fileType: FileType = FileType.FILE ) : FuzzyContainer(filePath, moduleBasePath, filename) { override fun getDisplayString(state: FuzzierGlobalSettingsService.State): String { return when (state.filenameType) { @@ -80,7 +77,7 @@ class FuzzyMatchContainer( } if (highlightIndex < source.length) { - if (i + 1 < hlIndexes.size && hlIndexes[i + 1] == highlightIndex + 1) { + if (i + 1 < hlIndexes.size && hlIndexes[i + 1] == highlightIndex + 1) { i++ continue } @@ -95,27 +92,15 @@ class FuzzyMatchContainer( return stringBuilder.toString() } - fun highlightLegacy(source: String): String { - val stringBuilder: StringBuilder = StringBuilder(source) - var offset = 0 - val hlIndexes = score.highlightCharacters.sorted() - for (i in hlIndexes) { - if (i < source.length) { - stringBuilder.insert(i + offset, startStyleTag) - offset += startStyleTag.length - stringBuilder.insert(i + offset + 1, END_STYLE_TAG) - offset += END_STYLE_TAG.length - } + fun getScore(prioritizeShorterFilePaths: Boolean): Int { + if (prioritizeShorterFilePaths && fileType == FileType.DIR) { + return getScoreWithDirLength() } - return stringBuilder.toString() - } - - fun getScore(): Int { return score.getTotalScore() } /** - * Gets score that is prioritizing shorter dir paths + * Gets a score prioritizing shorter dir paths */ fun getScoreWithDirLength(): Int { return score.getTotalScore() + filePath.length * -5 @@ -134,7 +119,7 @@ class FuzzyMatchContainer( } override fun toString(): String { - return "FuzzyMatchContainer(basePath='$basePath', filePath='$filePath', score=${getScore()}, dirScore=${getScoreWithDirLength()})" + return "FuzzyMatchContainer(basePath='$basePath', filePath='$filePath', score=${getScore(false)}, dirScore=${getScoreWithDirLength()})" } /** @@ -171,7 +156,7 @@ class FuzzyMatchContainer( } fun toFuzzyMatchContainer(): FuzzyMatchContainer { - return FuzzyMatchContainer(score!!, filePath!!, filename!!, moduleBasePath!!) + return FuzzyMatchContainer(score!!, filePath!!, filename!!, moduleBasePath!!, FileType.FILE) } var score: FuzzyScore? = null @@ -193,7 +178,7 @@ class FuzzyMatchContainer( * @see FuzzierSettingsService */ class SerializedMatchContainerConverter : Converter>() { - override fun fromString(value: String) : DefaultListModel { + override fun fromString(value: String): DefaultListModel { // Fallback to an empty list if deserialization fails try { val data = Base64.getDecoder().decode(value) @@ -206,10 +191,15 @@ class FuzzyMatchContainer( } } - override fun toString(value: DefaultListModel) : String { + override fun toString(value: DefaultListModel): String { val byteArrayOutputStream = ByteArrayOutputStream() ObjectOutputStream(byteArrayOutputStream).use { it.writeObject(value) } return Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray()) } } + + enum class FileType { + DIR, + FILE + } } \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/IterationEntry.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/IterationEntry.kt new file mode 100644 index 00000000..e5dc366b --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/IterationEntry.kt @@ -0,0 +1,32 @@ +/* + * 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 + +data class IterationEntry( + val name: String, + val path: String, + val module: String, + val isDir: Boolean +) diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/MatchConfig.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/MatchConfig.kt new file mode 100644 index 00000000..7699f021 --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/MatchConfig.kt @@ -0,0 +1,34 @@ +/* + * 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 + +data class MatchConfig( + val tolerance: Int = 0, + val multiMatch: Boolean = false, + val matchWeightSingleChar: Int = 1, + val matchWeightStreakModifier: Int = 5, + val matchWeightPartialPath: Int = 10, + val matchWeightFilename: Int = 20, +) \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/ScoreCalculator.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/ScoreCalculator.kt index 2f650080..e88d70f0 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/entities/ScoreCalculator.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/ScoreCalculator.kt @@ -1,34 +1,35 @@ /* -MIT License - -Copyright (c) 2025 Mitja Leino - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ + * MIT License + * + * Copyright (c) 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ package com.mituuz.fuzzier.entities -import com.intellij.openapi.components.service import com.mituuz.fuzzier.entities.FuzzyMatchContainer.FuzzyScore -import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService import org.apache.commons.lang3.StringUtils -class ScoreCalculator(searchString: String) { +class ScoreCalculator( + searchString: String, + private val config: MatchConfig +) { private val lowerSearchString: String = searchString.lowercase() private val searchStringParts = lowerSearchString.split(" ") private lateinit var fuzzyScore: FuzzyScore @@ -39,15 +40,6 @@ class ScoreCalculator(searchString: String) { var filePathIndex: Int = 0 private var filenameIndex: Int = 0 - // Set up match settings - private val globalState = service().state - private var tolerance = globalState.tolerance - private var multiMatch = globalState.multiMatch - private var matchWeightSingleChar = globalState.matchWeightSingleChar - private var matchWeightStreakModifier = globalState.matchWeightStreakModifier - private var matchWeightPartialPath = globalState.matchWeightPartialPath - private var matchWeightFilename = globalState.matchWeightFilename - var currentFilePath = "" private var longestStreak: Int = 0 private var currentStreak: Int = 0 @@ -66,7 +58,7 @@ class ScoreCalculator(searchString: String) { toleranceCount = 0 // Check if the search string is longer than the file path, which results in no match - if (lowerSearchString.length > (currentFilePath.length + tolerance)) { + if (lowerSearchString.length > (currentFilePath.length + config.tolerance)) { return null } @@ -82,13 +74,13 @@ class ScoreCalculator(searchString: String) { } } - if (multiMatch) { + if (config.multiMatch) { calculateMultiMatchScore() } calculateFilenameScore() - fuzzyScore.streakScore = (longestStreak * matchWeightStreakModifier) / 10 - fuzzyScore.filenameScore = (longestFilenameStreak * matchWeightFilename) / 10 + fuzzyScore.streakScore = (longestStreak * config.matchWeightStreakModifier) / 10 + fuzzyScore.filenameScore = (longestFilenameStreak * config.matchWeightFilename) / 10 return fuzzyScore } @@ -114,13 +106,13 @@ class ScoreCalculator(searchString: String) { } private fun calculateMultiMatchScore() { - fuzzyScore.multiMatchScore += (currentFilePath.count { it in uniqueLetters } * matchWeightSingleChar) / 10 + fuzzyScore.multiMatchScore += (currentFilePath.count { it in uniqueLetters } * config.matchWeightSingleChar) / 10 } private fun calculatePartialPathScore(searchStringPart: String) { StringUtils.split(currentFilePath, "/.").forEach { if (it == searchStringPart) { - fuzzyScore.partialPathScore += matchWeightPartialPath + fuzzyScore.partialPathScore += config.matchWeightPartialPath } } } @@ -134,7 +126,7 @@ class ScoreCalculator(searchString: String) { searchStringIndex++ updateStreak(true) } else { - if (currentStreak > 0 && toleranceCount < tolerance) { + if (currentStreak > 0 && toleranceCount < config.tolerance) { // When hitting tolerance increment the search string and filepath, but do not add streak searchStringIndex++ toleranceCount++ @@ -196,30 +188,6 @@ class ScoreCalculator(searchString: String) { fun canSearchStringBeContained(): Boolean { val remainingSearchStringLength = searchStringLength - searchStringIndex val remainingFilePathLength = currentFilePath.length - filePathIndex - return remainingSearchStringLength <= (remainingFilePathLength + tolerance) - } - - fun setMatchWeightStreakModifier(value: Int) { - matchWeightStreakModifier = value - } - - fun setMatchWeightSingleChar(value: Int) { - matchWeightSingleChar = value - } - - fun setMatchWeightPartialPath(value: Int) { - matchWeightPartialPath = value - } - - fun setFilenameMatchWeight(value: Int) { - matchWeightFilename = value - } - - fun setMultiMatch(value: Boolean) { - multiMatch = value - } - - fun setTolerance(value: Int) { - tolerance = value + return remainingSearchStringLength <= (remainingFilePathLength + config.tolerance) } } \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/StringEvaluator.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/StringEvaluator.kt index 6962dc65..d63fc673 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/entities/StringEvaluator.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/StringEvaluator.kt @@ -1,151 +1,67 @@ /* -MIT License - -Copyright (c) 2025 Mitja Leino - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ + * MIT License + * + * Copyright (c) 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ package com.mituuz.fuzzier.entities -import com.intellij.openapi.roots.ContentIterator -import com.intellij.openapi.vcs.changes.ChangeListManager -import com.intellij.openapi.vfs.VirtualFile -import com.mituuz.fuzzier.util.FuzzierUtil -import java.util.concurrent.Future -import javax.swing.DefaultListModel +import com.mituuz.fuzzier.entities.FuzzyMatchContainer.FileType /** * Handles creating the content iterators used for string handling and excluding files * @param exclusionList exclusion list from settings - * @param changeListManager handles VCS check if file is being tracked. Null if VCS search should not be used */ class StringEvaluator( private var exclusionList: Set, private var modules: Map, - private var changeListManager: ChangeListManager? = null ) { - lateinit var scoreCalculator: ScoreCalculator - - fun getContentIterator( - moduleName: String, searchString: String, listModel: DefaultListModel, - task: Future<*>? - ): ContentIterator { - scoreCalculator = ScoreCalculator(searchString) - return ContentIterator { file: VirtualFile -> - if (task?.isCancelled == true) { - return@ContentIterator false - } - if (!file.isDirectory) { - val moduleBasePath = modules[moduleName] ?: return@ContentIterator true - - val filePath = file.path.removePrefix(moduleBasePath) - if (isExcluded(file, filePath)) { - return@ContentIterator true - } - if (filePath.isNotBlank()) { - val fuzzyMatchContainer = createFuzzyContainer(filePath, moduleBasePath, scoreCalculator) - if (fuzzyMatchContainer != null) { - listModel.addElement(fuzzyMatchContainer) - } - } - } - true - } - } + fun evaluateIteratorEntry( + iteratorEntry: IterationEntry, + searchString: String, + matchConfig: MatchConfig + ): FuzzyMatchContainer? { + val scoreCalculator = ScoreCalculator(searchString, matchConfig) + val moduleName = iteratorEntry.module - fun getDirIterator( - moduleName: String, searchString: String, listModel: DefaultListModel, - task: Future<*>? - ): ContentIterator { - scoreCalculator = ScoreCalculator(searchString) - return ContentIterator { file: VirtualFile -> - if (task?.isCancelled == true) { - return@ContentIterator false - } - if (file.isDirectory) { - val moduleBasePath = modules[moduleName] ?: return@ContentIterator true - val filePath = getDirPath(file, moduleBasePath, moduleName) - if (isExcluded(file, filePath)) { - return@ContentIterator true - } - if (filePath.isNotBlank()) { - val fuzzyMatchContainer = createFuzzyContainer(filePath, moduleBasePath, scoreCalculator) - if (fuzzyMatchContainer != null) { - listModel.addElement(fuzzyMatchContainer) - } - } - } - true - } - } + val moduleBasePath = modules[moduleName] ?: return null - fun evaluateFile( - iterationFile: FuzzierUtil.IterationFile, listModel: DefaultListModel, - searchString: String - ) { - val scoreCalculator = ScoreCalculator(searchString) - val file = iterationFile.file - val moduleName = iterationFile.module - if (!file.isDirectory) { - val moduleBasePath = modules[moduleName] ?: return + val filePath = iteratorEntry.path.removePrefix(moduleBasePath) + if (isExcluded(filePath)) return null - val filePath = file.path.removePrefix(moduleBasePath) - if (isExcluded(file, filePath)) { - return - } - if (filePath.isNotBlank()) { - val fuzzyMatchContainer = createFuzzyContainer(filePath, moduleBasePath, scoreCalculator) - if (fuzzyMatchContainer != null) { - listModel.addElement(fuzzyMatchContainer) - } - } + val fileType = if (iteratorEntry.isDir) FileType.DIR else FileType.FILE + if (filePath.isNotBlank()) { + return createFuzzyContainer(filePath, moduleBasePath, scoreCalculator, fileType) } - } - private fun getDirPath(virtualFile: VirtualFile, basePath: String, module: String): String { - var res = virtualFile.path.removePrefix(basePath) - // Handle project root as a special case - if (res == "") { - res = "/" - } - if (res == "/$module") { - res = "/$module/" - } - return res + return null } /** * Checks if file should be excluded from the results. * - * If change list manager is set, only use it to decide if file should be ignored. - * - * Otherwise, use exclusion list from settings. - * - * @param file virtual file to check with change list manager * @param filePath to check against the exclusion list * - * @return true if file should be excluded + * @return true if filePath should be excluded */ - private fun isExcluded(file: VirtualFile, filePath: String): Boolean { - if (changeListManager !== null) { - return changeListManager!!.isIgnoredFile(file) - } + private fun isExcluded(filePath: String): Boolean { return exclusionList.any { e -> when { e.startsWith("*") -> filePath.endsWith(e.substring(1)) @@ -161,12 +77,13 @@ class StringEvaluator( */ private fun createFuzzyContainer( filePath: String, moduleBasePath: String, - scoreCalculator: ScoreCalculator + scoreCalculator: ScoreCalculator, + fileType: FileType ): FuzzyMatchContainer? { val filename = filePath.substring(filePath.lastIndexOf("/") + 1) return when (val score = scoreCalculator.calculateScore(filePath)) { null -> null - else -> FuzzyMatchContainer(score, filePath, filename, moduleBasePath) + else -> FuzzyMatchContainer(score, filePath, filename, moduleBasePath, fileType) } } } \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/intellij/iteration/IntelliJIterationFileCollector.kt b/src/main/kotlin/com/mituuz/fuzzier/intellij/iteration/IntelliJIterationFileCollector.kt new file mode 100644 index 00000000..991cca57 --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/intellij/iteration/IntelliJIterationFileCollector.kt @@ -0,0 +1,50 @@ +/* + * 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.iteration + +import com.intellij.openapi.roots.FileIndex +import com.intellij.openapi.vfs.VirtualFile +import com.mituuz.fuzzier.entities.IterationEntry + +class IntelliJIterationFileCollector : IterationFileCollector { + override fun collectFiles( + targets: List>, + shouldContinue: () -> Boolean, + fileFilter: (VirtualFile) -> Boolean + ): List = buildList { + for ((fileIndex, moduleName) in targets) { + fileIndex.iterateContent { vf -> + if (!shouldContinue()) return@iterateContent false + + if (fileFilter(vf)) { + val iteratorEntry = IterationEntry(vf.name, vf.path, moduleName, vf.isDirectory) + add(iteratorEntry) + } + + true + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/intellij/iteration/IterationFileCollector.kt b/src/main/kotlin/com/mituuz/fuzzier/intellij/iteration/IterationFileCollector.kt new file mode 100644 index 00000000..b142c938 --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/intellij/iteration/IterationFileCollector.kt @@ -0,0 +1,37 @@ +/* + * MIT License + * + * Copyright (c) 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package com.mituuz.fuzzier.intellij.iteration + +import com.intellij.openapi.roots.FileIndex +import com.intellij.openapi.vfs.VirtualFile +import com.mituuz.fuzzier.entities.IterationEntry + +interface IterationFileCollector { + fun collectFiles( + targets: List>, + shouldContinue: () -> Boolean, + fileFilter: (VirtualFile) -> Boolean, + ): List +} \ 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/search/ResultsProvider.kt new file mode 100644 index 00000000..792605a0 --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/search/ResultsProvider.kt @@ -0,0 +1,28 @@ +/* + * 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 + +interface ResultsProvider { +} \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt index 8d11bdb3..8809cb38 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt @@ -30,6 +30,7 @@ import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.NlsContexts import com.mituuz.fuzzier.components.FuzzierGlobalSettingsComponent import com.mituuz.fuzzier.entities.FuzzyContainer.FilenameType +import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService.PopupSizing import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService.RecentFilesMode import javax.swing.JComponent @@ -47,8 +48,11 @@ class FuzzierGlobalSettingsConfigurable : Configurable { component = FuzzierGlobalSettingsComponent(uiDisposable!!) component.newTabSelect.getCheckBox().isSelected = state.newTab component.recentFileModeSelector.getRecentFilesTypeComboBox().selectedIndex = state.recentFilesMode.ordinal + component.popupSizingSelector.getPopupSizingComboBox().selectedIndex = state.popupSizing.ordinal component.defaultDimension.getIntSpinner(4).value = state.defaultPopupHeight component.defaultDimension.getIntSpinner(1).value = state.defaultPopupWidth + component.autoSizePercentages.getIntSpinner(4).value = state.autoHeightPercent + component.autoSizePercentages.getIntSpinner(1).value = state.autoWidthPercent component.searchPosition.getSearchPositionComboBox().selectedIndex = state.searchPosition.ordinal component.prioritizeShortDirs.getCheckBox().isSelected = state.prioritizeShorterDirPaths component.debounceTimerValue.getIntSpinner().value = state.debouncePeriod @@ -65,6 +69,13 @@ class FuzzierGlobalSettingsConfigurable : Configurable { component.fileListSpacing.getIntSpinner().value = state.fileListSpacing component.fuzzyGrepShowFullFile.getCheckBox().isSelected = state.fuzzyGrepShowFullFile + // Hide dimension settings when Auto size is selected + updateDimensionVisibility(state.popupSizing) + component.popupSizingSelector.getPopupSizingComboBox().addItemListener { + val selected = component.popupSizingSelector.getPopupSizingComboBox().selectedItem as PopupSizing + updateDimensionVisibility(selected) + } + component.tolerance.getIntSpinner().value = state.tolerance component.multiMatchActive.getCheckBox().isSelected = state.multiMatch component.matchWeightPartialPath.getIntSpinner().value = state.matchWeightPartialPath @@ -75,6 +86,15 @@ class FuzzierGlobalSettingsConfigurable : Configurable { return component.jPanel } + private fun updateDimensionVisibility(sizing: PopupSizing) { + val vanillaVisible = sizing != PopupSizing.AUTO_SIZE + val autoVisible = sizing == PopupSizing.AUTO_SIZE + component.defaultDimension.label.isVisible = vanillaVisible + component.defaultDimension.component.isVisible = vanillaVisible + component.autoSizePercentages.label.isVisible = autoVisible + component.autoSizePercentages.component.isVisible = autoVisible + } + override fun isModified(): Boolean { val newGlobalSet = component.globalExclusionTextArea.text .lines() @@ -84,8 +104,11 @@ class FuzzierGlobalSettingsConfigurable : Configurable { return state.newTab != component.newTabSelect.getCheckBox().isSelected || state.recentFilesMode != component.recentFileModeSelector.getRecentFilesTypeComboBox().selectedItem + || state.popupSizing != component.popupSizingSelector.getPopupSizingComboBox().selectedItem || state.defaultPopupHeight != component.defaultDimension.getIntSpinner(4).value || state.defaultPopupWidth != component.defaultDimension.getIntSpinner(1).value + || state.autoHeightPercent != component.autoSizePercentages.getIntSpinner(4).value + || state.autoWidthPercent != component.autoSizePercentages.getIntSpinner(1).value || state.searchPosition != component.searchPosition.getSearchPositionComboBox().selectedItem || state.prioritizeShorterDirPaths != component.prioritizeShortDirs.getCheckBox().isSelected || state.debouncePeriod != component.debounceTimerValue.getIntSpinner().value @@ -112,9 +135,13 @@ class FuzzierGlobalSettingsConfigurable : Configurable { state.newTab = component.newTabSelect.getCheckBox().isSelected state.recentFilesMode = RecentFilesMode.entries.toTypedArray()[component.recentFileModeSelector.getRecentFilesTypeComboBox().selectedIndex] + state.popupSizing = + PopupSizing.entries.toTypedArray()[component.popupSizingSelector.getPopupSizingComboBox().selectedIndex] val newPopupHeight = component.defaultDimension.getIntSpinner(4).value as Int val newPopupWidth = component.defaultDimension.getIntSpinner(1).value as Int + val newAutoHeightPercent = component.autoSizePercentages.getIntSpinner(4).value as Int + val newAutoWidthPercent = component.autoSizePercentages.getIntSpinner(1).value as Int val newSearchPosition: FuzzierGlobalSettingsService.SearchPosition = FuzzierGlobalSettingsService.SearchPosition.entries.toTypedArray()[component.searchPosition.getSearchPositionComboBox().selectedIndex] if (state.searchPosition != newSearchPosition || @@ -128,6 +155,8 @@ class FuzzierGlobalSettingsConfigurable : Configurable { } state.defaultPopupHeight = newPopupHeight state.defaultPopupWidth = newPopupWidth + state.autoHeightPercent = newAutoHeightPercent + state.autoWidthPercent = newAutoWidthPercent state.searchPosition = newSearchPosition state.prioritizeShorterDirPaths = component.prioritizeShortDirs.getCheckBox().isSelected state.debouncePeriod = component.debounceTimerValue.getIntSpinner().value as Int diff --git a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt index e76e410c..88887a18 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt @@ -45,6 +45,11 @@ class FuzzierGlobalSettingsService : PersistentStateComponent Unit, + actionId: String = "fuzzier.activateSelection" + ) { + bindDoubleClick(component, onActivate) + bindEnter(component, onActivate, actionId) + } + + private fun bindDoubleClick(component: FuzzyComponent, onActivate: () -> Unit) { + component.fileList.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.clickCount == 2) onActivate() + } + }) + } + + private fun bindEnter( + component: FuzzyComponent, + onActivate: () -> Unit, + actionId: String, + ) { + val enterKeyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0) + val inputMap = component.searchField.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) + inputMap.put(enterKeyStroke, actionId) + + component.searchField.actionMap.put(actionId, object : AbstractAction() { + override fun actionPerformed(e: ActionEvent?) { + onActivate() + } + }) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/ui/popup/AutoSizePopupProvider.kt b/src/main/kotlin/com/mituuz/fuzzier/ui/popup/AutoSizePopupProvider.kt new file mode 100644 index 00000000..d9177cb1 --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/ui/popup/AutoSizePopupProvider.kt @@ -0,0 +1,70 @@ +/* + * 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.popup + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.wm.WindowManager +import java.awt.Component +import java.awt.Dimension +import javax.swing.JComponent + +class AutoSizePopupProvider( + private val widthFactor: Double, + private val heightFactor: Double, + windowManager: WindowManager = WindowManager.getInstance(), + popupFactory: JBPopupFactory = JBPopupFactory.getInstance(), +) : PopupProviderBase(windowManager, popupFactory) { + + override fun show( + project: Project, + content: JComponent, + focus: JComponent, + config: PopupConfig, + cleanupFunction: () -> Unit, + ): JBPopup? { + val mainWindow: Component = getMainWindow(project) + ?: return null + + val popupSize = computePopupSize(mainWindow) + + val popup = baseBuilder(content, focus, config.title) + .createPopup() + + popup.size = popupSize + popup.showInCenterOf(mainWindow) + + popup.addListener(createCleanupListener(cleanupFunction)) + + return popup + } + + internal fun computePopupSize(mainWindow: Component): Dimension { + val windowWidth = (mainWindow.width * widthFactor).toInt() + val windowHeight = (mainWindow.height * heightFactor).toInt() + return Dimension(windowWidth, windowHeight) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/ui/popup/DefaultPopupProvider.kt b/src/main/kotlin/com/mituuz/fuzzier/ui/popup/DefaultPopupProvider.kt new file mode 100644 index 00000000..d4aa12f3 --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/ui/popup/DefaultPopupProvider.kt @@ -0,0 +1,70 @@ +/* + * 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.popup + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.DimensionService +import com.intellij.openapi.wm.WindowManager +import com.mituuz.fuzzier.util.FuzzierUtil.Companion.createDimensionKey +import java.awt.Component +import javax.swing.JComponent + +class DefaultPopupProvider( + windowManager: WindowManager = WindowManager.getInstance(), + popupFactory: JBPopupFactory = JBPopupFactory.getInstance(), + private val dimensionService: DimensionService = DimensionService.getInstance(), +) : PopupProviderBase(windowManager, popupFactory) { + override fun show( + project: Project, + content: JComponent, + focus: JComponent, + config: PopupConfig, + cleanupFunction: () -> Unit, + ): JBPopup? { + val mainWindow: Component = getMainWindow(project) + ?: return null + + val screenBounds = mainWindow.graphicsConfiguration.bounds + val screenDimensionKey = createDimensionKey(config.dimensionKey, screenBounds) + + if (config.resetWindow()) { + dimensionService.setSize(screenDimensionKey, config.preferredSizeProvider, null) + dimensionService.setLocation(screenDimensionKey, null, null) + config.clearResetWindowFlag() + } + + val popup = baseBuilder(content, focus, config.title) + .setDimensionServiceKey(null, screenDimensionKey, true) + .createPopup() + + popup.showInCenterOf(mainWindow) + + popup.addListener(createCleanupListener(cleanupFunction)) + + return popup + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/ui/popup/PopupProvider.kt b/src/main/kotlin/com/mituuz/fuzzier/ui/popup/PopupProvider.kt new file mode 100644 index 00000000..eac1c25c --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/ui/popup/PopupProvider.kt @@ -0,0 +1,48 @@ +/* + * 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.popup + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopup +import java.awt.Dimension +import javax.swing.JComponent + +data class PopupConfig( + val title: String, + val dimensionKey: String, + val preferredSizeProvider: Dimension, + val resetWindow: () -> Boolean, + val clearResetWindowFlag: () -> Unit, +) + +interface PopupProvider { + fun show( + project: Project, + content: JComponent, + focus: JComponent, + config: PopupConfig, + cleanupFunction: () -> Unit + ): JBPopup? +} \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/ui/popup/PopupProviderBase.kt b/src/main/kotlin/com/mituuz/fuzzier/ui/popup/PopupProviderBase.kt new file mode 100644 index 00000000..e8725d0f --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/ui/popup/PopupProviderBase.kt @@ -0,0 +1,58 @@ +/* + * 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.popup + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.ComponentPopupBuilder +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.JBPopupListener +import com.intellij.openapi.ui.popup.LightweightWindowEvent +import com.intellij.openapi.wm.WindowManager +import java.awt.Component +import javax.swing.JComponent + +abstract class PopupProviderBase( + protected val windowManager: WindowManager = WindowManager.getInstance(), + protected val popupFactory: JBPopupFactory = JBPopupFactory.getInstance(), +) : PopupProvider { + + internal fun getMainWindow(project: Project): Component? = windowManager.getIdeFrame(project)?.component + + internal fun baseBuilder(content: JComponent, focus: JComponent, title: String): ComponentPopupBuilder = + popupFactory + .createComponentPopupBuilder(content, focus) + .setFocusable(true) + .setRequestFocus(true) + .setResizable(true) + .setTitle(title) + .setMovable(true) + .setShowBorder(true) + + internal fun createCleanupListener(cleanupFunction: () -> Unit): JBPopupListener = object : JBPopupListener { + override fun onClosed(event: LightweightWindowEvent) { + cleanupFunction() + } + } +} diff --git a/src/main/kotlin/com/mituuz/fuzzier/util/FuzzierUtil.kt b/src/main/kotlin/com/mituuz/fuzzier/util/FuzzierUtil.kt index 381a171e..1687d875 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/util/FuzzierUtil.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/util/FuzzierUtil.kt @@ -1,52 +1,37 @@ /* -MIT License - -Copyright (c) 2025 Mitja Leino - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ + * MIT License + * + * Copyright (c) 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ package com.mituuz.fuzzier.util import com.intellij.openapi.components.service +import com.intellij.openapi.module.Module import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.project.Project import com.intellij.openapi.project.rootManager -import com.mituuz.fuzzier.entities.FuzzyMatchContainer import com.mituuz.fuzzier.settings.FuzzierSettingsService -import java.util.* -import javax.swing.DefaultListModel -import com.intellij.openapi.module.Module -import com.intellij.openapi.roots.FileIndex -import com.intellij.openapi.vfs.VirtualFile -import com.mituuz.fuzzier.entities.FuzzyContainer -import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService import java.awt.Rectangle -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Future class FuzzierUtil { - private var globalState = service().state - private var listLimit: Int = globalState.fileListLimit - private var prioritizeShorterDirPaths = globalState.prioritizeShorterDirPaths - - data class IterationFile(val file: VirtualFile, val module: String) - companion object { /** * Create a dimension key for a specific screen bounds @@ -56,22 +41,6 @@ class FuzzierUtil { return "${baseDimensionKey}_${screenBounds.width}_${screenBounds.height}_${screenBounds.x}_${screenBounds.y}" } - fun fileIndexToIterationFile( - iterationFiles: ConcurrentHashMap.KeySetView, - fileIndex: FileIndex, moduleName: String, task: Future<*>?, - isDir: Boolean = false - ) { - fileIndex.iterateContent { file -> - if (task?.isCancelled == true) { - return@iterateContent false - } - if (file.isDirectory == isDir) { - iterationFiles.add(IterationFile(file, moduleName)) - } - true - } - } - fun cleanSearchString(ss: String, ignoredChars: String): String { var ret = ss.lowercase() for (i in ignoredChars.toSet()) { @@ -82,62 +51,6 @@ class FuzzierUtil { } } - /** - * Process all the elements in the listModel with a priority queue to limit the size - * and keeping the data sorted at all times - * - * Priority queue's size is limit + 1 to prevent any resizing - * Only add entries to the queue if they have larger score than the minimum in the queue - * - * @param listModel to limit and sort - * @param isDirSort defaults to false, enables using different sort for directories - * - * @return a sorted and sized list model - */ - fun sortAndLimit( - listModel: DefaultListModel, - isDirSort: Boolean = false - ): DefaultListModel { - val useShortDirPath = isDirSort && prioritizeShorterDirPaths - var comparator = getComparator(useShortDirPath, false) - val priorityQueue = PriorityQueue(listLimit + 1, comparator) - - var minimumScore: Int? = null - listModel.elements().toList().forEach { - if (it is FuzzyMatchContainer) { - if (minimumScore == null || it.getScore() > minimumScore) { - priorityQueue.add(it) - if (priorityQueue.size > listLimit) { - priorityQueue.remove() - minimumScore = priorityQueue.peek().getScore() - } - } - } - } - - comparator = getComparator(useShortDirPath, true) - val result = DefaultListModel() - result.addAll(priorityQueue.toList().sortedWith(comparator)) - - return result - } - - private fun getComparator(useShortDirPath: Boolean, isDescending: Boolean): Comparator { - return if (isDescending) { - compareByDescending { if (useShortDirPath) it.getScoreWithDirLength() else it.getScore() } - } else { - compareBy { if (useShortDirPath) it.getScoreWithDirLength() else it.getScore() } - } - } - - fun setListLimit(listLimit: Int) { - this.listLimit = listLimit - } - - fun setPrioritizeShorterDirPaths(prioritizeShortedFilePaths: Boolean) { - this.prioritizeShorterDirPaths = prioritizeShortedFilePaths - } - /** * For each module in the project, check if the file path contains the module path. * @return a pair of the file path (with the module path removed) and the module path diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 823d13d1..5755d0d7 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,89 +1,115 @@ + + - com.mituuz.fuzzier - Fuzzier - MituuZ + com.mituuz.fuzzier + Fuzzier + MituuZ - - A fuzzy file finder modeled after telescope for nvim. Designed to be completely usable through the keyboard (and ideavim). + + A fuzzy file finder modeled after telescope for nvim. Designed to be completely usable through the + keyboard (and ideavim). + - - com.intellij.modules.platform + + com.intellij.modules.platform - - - - - + + + + + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/kotlin/com/mituuz/fuzzier/ExcludeIgnoreTest.kt b/src/test/kotlin/com/mituuz/fuzzier/ExcludeIgnoreTest.kt deleted file mode 100644 index 2bbf2242..00000000 --- a/src/test/kotlin/com/mituuz/fuzzier/ExcludeIgnoreTest.kt +++ /dev/null @@ -1,126 +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 org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.condition.EnabledOnOs -import org.junit.jupiter.api.condition.OS - -class ExcludeIgnoreTest { - private var testUtil = TestUtil() - - @Test - fun excludeListTest() { - val filePaths = listOf("src/main.kt", "src/asd/main.kt", "src/asd/asd.kt", "src/not/asd.kt", "src/nope") - val filePathContainer = testUtil.setUpModuleFileIndex(filePaths, setOf("asd", "nope")) - Assertions.assertEquals(1, filePathContainer.size()) - Assertions.assertEquals("/main.kt", filePathContainer.get(0).filePath) - } - - @Test - fun excludeListTestNoMatches() { - val filePaths = listOf("src/main.kt", "src/not.kt", "src/dsa/not.kt") - val filePathContainer = testUtil.setUpModuleFileIndex(filePaths, setOf("asd")) - Assertions.assertEquals(3, filePathContainer.size()) - Assertions.assertEquals("/main.kt", filePathContainer.get(2).filePath) - Assertions.assertEquals("/not.kt", filePathContainer.get(1).filePath) - Assertions.assertEquals("/dsa/not.kt", filePathContainer.get(0).filePath) - } - - @Test - fun excludeListTestEmptyList() { - val filePaths = listOf("src/main.kt", "src/not.kt", "src/dsa/not.kt") - val filePathContainer = testUtil.setUpModuleFileIndex(filePaths, setOf()) - Assertions.assertEquals(3, filePathContainer.size()) - Assertions.assertEquals("/main.kt", filePathContainer.get(2).filePath) - Assertions.assertEquals("/not.kt", filePathContainer.get(1).filePath) - Assertions.assertEquals("/dsa/not.kt", filePathContainer.get(0).filePath) - } - - @Test - fun excludeListTestStartsWith() { - val filePaths = listOf("src/main.kt", "src/asd/main.kt", "src/asd/asd.kt", "src/not/asd.kt") - val filePathContainer = testUtil.setUpModuleFileIndex(filePaths, setOf("/asd*")) - Assertions.assertEquals(2, filePathContainer.size()) - Assertions.assertEquals("/not/asd.kt", filePathContainer.get(0).filePath) - } - - @Test - fun excludeListTestEndsWith() { - val filePaths = listOf("src/main.log", "src/asd/main.log", "src/asd/asd.kt", "src/not/asd.kt", "src/nope") - val filePathContainer = testUtil.setUpModuleFileIndex(filePaths, setOf("*.log")) - Assertions.assertEquals(3, filePathContainer.size()) - Assertions.assertEquals("/asd/asd.kt", filePathContainer.get(0).filePath) - } - - @Test - @EnabledOnOs(OS.LINUX, OS.MAC) - // TODO: Check this on Windows - fun testIgnoreOneFile() { - val filePaths = listOf("src/ignore-me.kt", "src/main.kt") - val filePathContainer = testUtil.setUpModuleFileIndex(filePaths, setOf(), listOf("src/ignore-me.kt")) - Assertions.assertEquals(1, filePathContainer.size()) - Assertions.assertEquals("/main.kt", filePathContainer.get(0).filePath) - } - - @Test - fun testIgnoreEmptyList() { - val filePaths = listOf("src/dir/file.txt", "src/main.kt", "src/other.kt") - val filePathContainer = testUtil.setUpModuleFileIndex(filePaths, setOf(), listOf()) - Assertions.assertEquals(3, filePathContainer.size()) - Assertions.assertEquals("/dir/file.txt", filePathContainer.get(0).filePath) - Assertions.assertEquals("/main.kt", filePathContainer.get(1).filePath) - Assertions.assertEquals("/other.kt", filePathContainer.get(2).filePath) - } - - @Test - @EnabledOnOs(OS.LINUX, OS.MAC) - // TODO: Check this on Windows - fun testIgnoreMultipleFiles() { - val filePaths = listOf("src/dir/file.txt", "src/main.kt", "src/other.kt") - val filePathContainer = - testUtil.setUpModuleFileIndex(filePaths, setOf(), listOf("src/dir/file.txt", "src/other.kt")) - Assertions.assertEquals(1, filePathContainer.size()) - Assertions.assertEquals("/main.kt", filePathContainer.get(0).filePath) - } - - @Test - @EnabledOnOs(OS.LINUX, OS.MAC) - // TODO: Check this on Windows - fun testIgnoreInCombinationWithExclusionList() { - /* for a FuzzierVCS action only the ignore list should be applied, and the exclusions should be skipped */ - val filePaths = - listOf("src/dir/file.txt", "src/main.kt", "src/other.kt", "src/ignore-me.kt", "src/exclude-me.kt") - val filePathContainer = - testUtil.setUpModuleFileIndex(filePaths, setOf("dir", "exclude-me.kt"), listOf("src/ignore-me.kt")) - Assertions.assertEquals(4, filePathContainer.size()) - Assertions.assertEquals("/dir/file.txt", filePathContainer.get(0).filePath) - Assertions.assertEquals("/main.kt", filePathContainer.get(1).filePath) - Assertions.assertEquals("/other.kt", filePathContainer.get(2).filePath) - Assertions.assertEquals("/exclude-me.kt", filePathContainer.get(3).filePath) - } -} \ No newline at end of file diff --git a/src/test/kotlin/com/mituuz/fuzzier/FuzzyActionTest.kt b/src/test/kotlin/com/mituuz/fuzzier/FuzzyActionTest.kt index cbfe5cc5..aa879c8c 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/FuzzyActionTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/FuzzyActionTest.kt @@ -28,13 +28,14 @@ import com.intellij.openapi.actionSystem.IdeActions import com.intellij.openapi.editor.actionSystem.EditorActionManager import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.popup.JBPopup import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.testFramework.TestApplicationManager import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.mituuz.fuzzier.actions.FuzzyAction import com.mituuz.fuzzier.components.SimpleFinderComponent import com.mituuz.fuzzier.entities.FuzzyContainer.FilenameType.* import com.mituuz.fuzzier.entities.FuzzyMatchContainer +import com.mituuz.fuzzier.entities.FuzzyMatchContainer.FileType.FILE import com.mituuz.fuzzier.entities.FuzzyMatchContainer.FuzzyScore import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test @@ -94,7 +95,7 @@ class FuzzyActionTest { action.setFiletype(FILENAME_ONLY) action.component = SimpleFinderComponent() val renderer = action.getCellRenderer() - val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd", "") + val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd", "", FILE) val dummyList = JList() val component = renderer.getListCellRendererComponent(dummyList, container, 0, false, false) as JLabel assertNotNull(component) @@ -107,7 +108,7 @@ class FuzzyActionTest { action.setFiletype(FILENAME_WITH_PATH) action.component = SimpleFinderComponent() val renderer = action.getCellRenderer() - val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd", "") + val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd", "", FILE) val dummyList = JList() val component = renderer.getListCellRendererComponent(dummyList, container, 0, false, false) as JLabel assertNotNull(component) @@ -121,7 +122,7 @@ class FuzzyActionTest { action.setHighlight(false) action.component = SimpleFinderComponent() val renderer = action.getCellRenderer() - val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd", "") + val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd", "", FILE) val dummyList = JList() val component = renderer.getListCellRendererComponent(dummyList, container, 0, false, false) as JLabel assertNotNull(component) @@ -135,7 +136,7 @@ class FuzzyActionTest { action.setHighlight(true) action.component = SimpleFinderComponent() val renderer = action.getCellRenderer() - val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd", "") + val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd", "", FILE) val dummyList = JList() val component = renderer.getListCellRendererComponent(dummyList, container, 0, false, false) as JLabel assertNotNull(component) @@ -148,7 +149,7 @@ class FuzzyActionTest { action.setFiletype(FILE_PATH_ONLY) action.component = SimpleFinderComponent() val renderer = action.getCellRenderer() - val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd", "") + val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd", "", FILE) val dummyList = JList() val component = renderer.getListCellRendererComponent(dummyList, container, 0, false, false) as JLabel assertNotNull(component) @@ -162,7 +163,7 @@ class FuzzyActionTest { action.component = SimpleFinderComponent() action.component.isDirSelector = true val renderer = action.getCellRenderer() - val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd", "") + val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd", "", FILE) val dummyList = JList() val component = renderer.getListCellRendererComponent(dummyList, container, 0, false, false) as JLabel assertNotNull(component) @@ -177,7 +178,7 @@ class FuzzyActionTest { action.component.isDirSelector = true val renderer = action.getCellRenderer() val dummyList = JList() - val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd.kt", "") + val container = FuzzyMatchContainer(FuzzyScore(), "/src/asd", "asd.kt", "", FILE) val expectedIcon = FileTypeManager.getInstance().getFileTypeByFileName("asd.kt").icon val component = renderer.getListCellRendererComponent(dummyList, container, 0, false, false) as JLabel assertNotNull(component.icon) @@ -192,10 +193,6 @@ class FuzzyActionTest { override fun runAction(project: Project, actionEvent: AnActionEvent) { } - override fun createPopup(screenDimensionKey: String): JBPopup { - throw Exception("Not required for testing") - } - override fun updateListContents(project: Project, searchString: String) { } } diff --git a/src/test/kotlin/com/mituuz/fuzzier/FuzzyMoverTest.kt b/src/test/kotlin/com/mituuz/fuzzier/FuzzyMoverTest.kt index f5927a28..49cd2128 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/FuzzyMoverTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/FuzzyMoverTest.kt @@ -1,31 +1,30 @@ /* - - 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 import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project import com.intellij.openapi.project.modules import com.intellij.openapi.project.rootManager import com.intellij.openapi.ui.popup.JBPopupFactory @@ -33,8 +32,10 @@ import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.psi.PsiManager import com.intellij.testFramework.LightVirtualFile +import com.intellij.testFramework.PlatformTestUtil import com.intellij.testFramework.TestApplicationManager import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.runInEdtAndWait import com.mituuz.fuzzier.components.SimpleFinderComponent import com.mituuz.fuzzier.entities.FuzzyContainer import com.mituuz.fuzzier.entities.FuzzyMatchContainer @@ -42,7 +43,6 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import javax.swing.DefaultListModel import javax.swing.ListModel -import javax.swing.PopupFactory class FuzzyMoverTest { @Suppress("unused") @@ -64,17 +64,18 @@ class FuzzyMoverTest { fuzzyMover.component.fileList.model = getListModel(virtualDir) fuzzyMover.component.fileList.selectedIndex = 0 fuzzyMover.component.isDirSelector = true - fuzzyMover.popup = JBPopupFactory.getInstance().createComponentPopupBuilder(fuzzyMover.component, null).createPopup() + fuzzyMover.popup = + JBPopupFactory.getInstance().createComponentPopupBuilder(fuzzyMover.component, null).createPopup() ApplicationManager.getApplication().runReadAction { fuzzyMover.movableFile = virtualFile?.let { PsiManager.getInstance(project).findFile(it) }!! } if (basePath != null) { - fuzzyMover.handleInput(project).thenRun{ - var targetFile = VirtualFileManager.getInstance().findFileByUrl("file://$basePath/asd/nope") - assertNotNull(targetFile) - targetFile = VirtualFileManager.getInstance().findFileByUrl("file://$basePath/nope") - assertNull(targetFile) - }.join() + run(project) + + var targetFile = VirtualFileManager.getInstance().findFileByUrl("file://$basePath/asd/nope") + assertNotNull(targetFile) + targetFile = VirtualFileManager.getInstance().findFileByUrl("file://$basePath/nope") + assertNull(targetFile) } myFixture.tearDown() } @@ -89,21 +90,21 @@ class FuzzyMoverTest { fuzzyMover.component = SimpleFinderComponent() val virtualFile = VirtualFileManager.getInstance().findFileByUrl("file://$basePath/nope") val virtualDir = VirtualFileManager.getInstance().findFileByUrl("file://$basePath/asd/") - fuzzyMover.popup = JBPopupFactory.getInstance().createComponentPopupBuilder(fuzzyMover.component, null).createPopup() + fuzzyMover.popup = + JBPopupFactory.getInstance().createComponentPopupBuilder(fuzzyMover.component, null).createPopup() fuzzyMover.component.fileList.model = getListModel(virtualFile) fuzzyMover.component.fileList.selectedIndex = 0 if (basePath != null) { - fuzzyMover.handleInput(project).join() + run(project) fuzzyMover.component.fileList.model = getListModel(virtualDir) fuzzyMover.component.fileList.selectedIndex = 0 - fuzzyMover.handleInput(project).thenRun{ - var targetFile = VirtualFileManager.getInstance().findFileByUrl("file://$basePath/asd/nope") - assertNotNull(targetFile) - targetFile = VirtualFileManager.getInstance().findFileByUrl("file://$basePath/nope") - assertNull(targetFile) - }.join() + run(project) + var targetFile = VirtualFileManager.getInstance().findFileByUrl("file://$basePath/asd/nope") + assertNotNull(targetFile) + targetFile = VirtualFileManager.getInstance().findFileByUrl("file://$basePath/nope") + assertNull(targetFile) } myFixture.tearDown() } @@ -123,21 +124,21 @@ class FuzzyMoverTest { fuzzyMover.currentFile = LightVirtualFile("") val virtualFile = VirtualFileManager.getInstance().findFileByUrl("$basePath/main.kt") val virtualDir = VirtualFileManager.getInstance().findFileByUrl("$basePath/test/") - fuzzyMover.popup = JBPopupFactory.getInstance().createComponentPopupBuilder(fuzzyMover.component, null).createPopup() + fuzzyMover.popup = + JBPopupFactory.getInstance().createComponentPopupBuilder(fuzzyMover.component, null).createPopup() fuzzyMover.component.fileList.model = getListModel(virtualFile) fuzzyMover.component.fileList.selectedIndex = 0 if (basePath != null) { - fuzzyMover.handleInput(project).join() + run(project) fuzzyMover.component.fileList.model = getListModel(virtualDir) fuzzyMover.component.fileList.selectedIndex = 0 - fuzzyMover.handleInput(project).thenRun{ - var targetFile = VirtualFileManager.getInstance().findFileByUrl("$basePath/test/main.kt") - assertNotNull(targetFile) - targetFile = VirtualFileManager.getInstance().findFileByUrl("$basePath/main.kt") - assertNull(targetFile) - }.join() + run(project) + var targetFile = VirtualFileManager.getInstance().findFileByUrl("$basePath/test/main.kt") + assertNotNull(targetFile) + targetFile = VirtualFileManager.getInstance().findFileByUrl("$basePath/main.kt") + assertNull(targetFile) } myFixture.tearDown() } @@ -162,25 +163,39 @@ class FuzzyMoverTest { fuzzyMover.component.fileList.model = getListModel(virtualFile) fuzzyMover.component.fileList.selectedIndex = 0 if (module1BasePath != null) { - fuzzyMover.handleInput(project).join() + run(project) fuzzyMover.component.fileList.model = getListModel(virtualDir) fuzzyMover.component.fileList.selectedIndex = 0 - fuzzyMover.popup = JBPopupFactory.getInstance().createComponentPopupBuilder(fuzzyMover.component, null).createPopup() - - fuzzyMover.handleInput(project).thenRun{ - var targetFile = VirtualFileManager.getInstance().findFileByUrl("$module2BasePath/target/MoveMe.kt") - assertNotNull(targetFile) - targetFile = VirtualFileManager.getInstance().findFileByUrl("$module1BasePath/MoveMe.kt") - assertNull(targetFile) - }.join() + fuzzyMover.popup = + JBPopupFactory.getInstance().createComponentPopupBuilder(fuzzyMover.component, null).createPopup() + + run(project) + var targetFile = VirtualFileManager.getInstance().findFileByUrl("$module2BasePath/target/MoveMe.kt") + assertNotNull(targetFile) + targetFile = VirtualFileManager.getInstance().findFileByUrl("$module1BasePath/MoveMe.kt") + assertNull(targetFile) } myFixture.tearDown() } + private fun run(project: Project) { + runInEdtAndWait { + fuzzyMover.handleInput(project) + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + } + private fun getListModel(virtualFile: VirtualFile?): ListModel { val listModel = DefaultListModel() if (virtualFile != null) { - val container = FuzzyMatchContainer(FuzzyMatchContainer.FuzzyScore(), virtualFile.path, virtualFile.name, "") + val container = + FuzzyMatchContainer( + FuzzyMatchContainer.FuzzyScore(), + virtualFile.path, + virtualFile.name, + "", + FuzzyMatchContainer.FileType.FILE + ) listModel.addElement(container) } return listModel diff --git a/src/test/kotlin/com/mituuz/fuzzier/StringEvaluatorTest.kt b/src/test/kotlin/com/mituuz/fuzzier/StringEvaluatorTest.kt new file mode 100644 index 00000000..c6786b3c --- /dev/null +++ b/src/test/kotlin/com/mituuz/fuzzier/StringEvaluatorTest.kt @@ -0,0 +1,97 @@ +/* + * 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.testFramework.TestApplicationManager +import com.mituuz.fuzzier.entities.IterationEntry +import com.mituuz.fuzzier.entities.MatchConfig +import com.mituuz.fuzzier.entities.StringEvaluator +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +class StringEvaluatorTest { + @Suppress("unused") + private var testApplicationManager: TestApplicationManager = TestApplicationManager.getInstance() + + private val moduleName = "mod1" + private val moduleBasePath = "/m1/src" + + private fun evaluate(filePaths: List, exclusionList: Set): List { + val evaluator = StringEvaluator(exclusionList, mapOf(moduleName to moduleBasePath)) + + return filePaths.mapNotNull { fp -> + // Build absolute path under a fake module root so that removePrefix(moduleBasePath) works like in production + val relativeToSrc = fp.removePrefix("src") // e.g., "src/a/b.kt" -> "/a/b.kt" + val fullPath = "/m1/src$relativeToSrc" + val name = fullPath.substring(fullPath.lastIndexOf('/') + 1) + val entry = IterationEntry(name = name, path = fullPath, module = moduleName, isDir = false) + evaluator.evaluateIteratorEntry( + entry, "", + MatchConfig() + )?.filePath + }.sorted() // deterministic order for assertions + } + + @Test + fun excludeListTest() { + val filePaths = listOf("src/main.kt", "src/asd/main.kt", "src/asd/asd.kt", "src/not/asd.kt", "src/nope") + val results = evaluate(filePaths, setOf("asd", "nope")) + Assertions.assertEquals(listOf("/main.kt"), results) + } + + @Test + fun excludeListTestNoMatches() { + val filePaths = listOf("src/main.kt", "src/not.kt", "src/dsa/not.kt") + val results = evaluate(filePaths, setOf("asd")) + Assertions.assertEquals(setOf("/main.kt", "/not.kt", "/dsa/not.kt"), results.toSet()) + } + + @Test + fun excludeListTestEmptyList() { + val filePaths = listOf("src/main.kt", "src/not.kt", "src/dsa/not.kt") + val results = evaluate(filePaths, emptySet()) + Assertions.assertEquals(setOf("/main.kt", "/not.kt", "/dsa/not.kt"), results.toSet()) + } + + @Test + fun excludeListTestStartsWith() { + val filePaths = listOf("src/main.kt", "src/asd/main.kt", "src/asd/asd.kt", "src/not/asd.kt") + val results = evaluate(filePaths, setOf("/asd*")) + Assertions.assertEquals(setOf("/main.kt", "/not/asd.kt"), results.toSet()) + } + + @Test + fun excludeListTestEndsWith() { + val filePaths = listOf("src/main.log", "src/asd/main.log", "src/asd/asd.kt", "src/not/asd.kt", "src/nope") + val results = evaluate(filePaths, setOf("*.log")) + Assertions.assertEquals(setOf("/asd/asd.kt", "/not/asd.kt", "/nope"), results.toSet()) + } + + @Test + fun testIgnoreEmptyList() { + val filePaths = listOf("src/dir/file.txt", "src/main.kt", "src/other.kt") + val results = evaluate(filePaths, emptySet()) + Assertions.assertEquals(setOf("/dir/file.txt", "/main.kt", "/other.kt"), results.toSet()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/mituuz/fuzzier/TestUtil.kt b/src/test/kotlin/com/mituuz/fuzzier/TestUtil.kt index 1f06516e..6c8722ca 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/TestUtil.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/TestUtil.kt @@ -29,8 +29,6 @@ import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.project.DumbService import com.intellij.openapi.project.modules import com.intellij.openapi.project.rootManager -import com.intellij.openapi.vcs.changes.ChangeListManager -import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiDocumentManager import com.intellij.testFramework.PsiTestUtil import com.intellij.testFramework.fixtures.CodeInsightTestFixture @@ -38,9 +36,6 @@ import com.intellij.testFramework.fixtures.IdeaProjectTestFixture import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory import com.intellij.testFramework.runInEdtAndWait import com.mituuz.fuzzier.entities.FuzzyContainer -import com.mituuz.fuzzier.entities.StringEvaluator -import io.mockk.every -import io.mockk.mockk import javax.swing.DefaultListModel class TestUtil { @@ -69,14 +64,12 @@ class TestUtil { fun setUpModuleFileIndex( filesToAdd: List, exclusionList: Set, - ignoredFiles: List? = null ): DefaultListModel { val filePathContainer = DefaultListModel() val factory = IdeaTestFixtureFactory.getFixtureFactory() val fixtureBuilder = factory.createLightFixtureBuilder(null, "Test") val fixture = fixtureBuilder.fixture val myFixture = IdeaTestFixtureFactory.getFixtureFactory().createCodeInsightFixture(fixture) - val stringEvaluator: StringEvaluator myFixture.setUp() addFilesToProject(filesToAdd, myFixture, fixture) @@ -86,24 +79,6 @@ class TestUtil { val module = myFixture.project.modules[0] map[module.name] = module.rootManager.contentRoots[1].path - if (ignoredFiles !== null) { - val changeListManager = mockk() - every { changeListManager.isIgnoredFile(any()) } answers { - val file = firstArg() - val tempDirPath = myFixture.tempDirPath - ignoredFiles.any { ("$tempDirPath/$it") == file.path } - } - stringEvaluator = StringEvaluator(exclusionList, map, changeListManager) - } else { - stringEvaluator = StringEvaluator(exclusionList, map) - } - - val contentIterator = stringEvaluator.getContentIterator(myFixture.module.name, "", filePathContainer, null) - val index = myFixture.module.rootManager.fileIndex - runInEdtAndWait { - index.iterateContent(contentIterator) - } - // Handle clearing ProjectFileIndex between tests myFixture.tearDown() return filePathContainer diff --git a/src/test/kotlin/com/mituuz/fuzzier/actions/FuzzyActionProviderSelectionTest.kt b/src/test/kotlin/com/mituuz/fuzzier/actions/FuzzyActionProviderSelectionTest.kt new file mode 100644 index 00000000..32f1ed8b --- /dev/null +++ b/src/test/kotlin/com/mituuz/fuzzier/actions/FuzzyActionProviderSelectionTest.kt @@ -0,0 +1,73 @@ +/* + * 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.actions + +import com.intellij.openapi.components.service +import com.intellij.testFramework.TestApplicationManager +import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService +import com.mituuz.fuzzier.ui.popup.AutoSizePopupProvider +import com.mituuz.fuzzier.ui.popup.DefaultPopupProvider +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +private class TestFuzzyAction : FuzzyAction() { + override fun runAction( + project: com.intellij.openapi.project.Project, + actionEvent: com.intellij.openapi.actionSystem.AnActionEvent + ) { /* no-op */ + } + + override fun updateListContents(project: com.intellij.openapi.project.Project, searchString: String) { /* no-op */ + } + + fun peekProvider() = getPopupProvider() +} + +class FuzzyActionProviderSelectionTest { + @Suppress("unused") + private val app = TestApplicationManager.getInstance() + private val state = service().state + + private lateinit var action: TestFuzzyAction + + @BeforeEach + fun setUp() { + action = TestFuzzyAction() + } + + @Test + fun returnsAutoSizeProvider_whenAutoSizeSelected() { + state.popupSizing = FuzzierGlobalSettingsService.PopupSizing.AUTO_SIZE + val provider = action.peekProvider() + assertTrue(provider is AutoSizePopupProvider) + } + + @Test + fun returnsDefaultProvider_whenVanillaSelected() { + state.popupSizing = FuzzierGlobalSettingsService.PopupSizing.VANILLA + val provider = action.peekProvider() + assertTrue(provider is DefaultPopupProvider) + } +} diff --git a/src/test/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainerTest.kt b/src/test/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainerTest.kt index c963a945..ba089fb0 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainerTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainerTest.kt @@ -1,33 +1,34 @@ /* -MIT License - -Copyright (c) 2025 Mitja Leino - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ + * MIT License + * + * Copyright (c) 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ package com.mituuz.fuzzier.entities import com.intellij.testFramework.TestApplicationManager +import com.mituuz.fuzzier.entities.FuzzyMatchContainer.FileType.FILE import com.mituuz.fuzzier.entities.FuzzyMatchContainer.FuzzyScore import com.mituuz.fuzzier.settings.FuzzierConfiguration.END_STYLE_TAG import com.mituuz.fuzzier.settings.FuzzierConfiguration.startStyleTag -import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -44,7 +45,7 @@ class FuzzyMatchContainerTest { val score = FuzzyScore() score.highlightCharacters.add(0) score.highlightCharacters.add(4) - val container = FuzzyMatchContainer(score, "", "Hello", "") + val container = FuzzyMatchContainer(score, "", "Hello", "", FILE) val res = container.highlight(container.filename) assertEquals("${startStyleTag}H${END_STYLE_TAG}ell${startStyleTag}o$END_STYLE_TAG", res) } @@ -54,7 +55,7 @@ class FuzzyMatchContainerTest { val score = FuzzyScore() score.highlightCharacters.add(0) score.highlightCharacters.add(1) - val container = FuzzyMatchContainer(score, "", "Hello", "") + val container = FuzzyMatchContainer(score, "", "Hello", "", FILE) val res = container.highlight(container.filename) assertEquals("${startStyleTag}He${END_STYLE_TAG}llo", res) } @@ -70,7 +71,7 @@ class FuzzyMatchContainerTest { score.highlightCharacters.add(17) // e score.highlightCharacters.add(18) // r - val container = FuzzyMatchContainer(score, "", "FuzzyMatchContainerTest.kt", "") + val container = FuzzyMatchContainer(score, "", "FuzzyMatchContainerTest.kt", "", FILE) val res = container.highlight(container.filename) val sb = StringBuilder() @@ -90,13 +91,17 @@ class FuzzyMatchContainerTest { @Test fun `Test serialization`() { val score = FuzzyScore() - val container = FuzzyMatchContainer(score, "", "FuzzyMatchContainerTest.kt", "") + val container = FuzzyMatchContainer( + score, "", "FuzzyMatchContainerTest.kt", "", + FILE + ) val serializableContainer = FuzzyMatchContainer.SerializedMatchContainer.fromFuzzyMatchContainer(container) val byteArrayOutputStream = ByteArrayOutputStream() ObjectOutputStream(byteArrayOutputStream).use { it.writeObject(serializableContainer) } val byteArrayInputStream = ByteArrayInputStream(byteArrayOutputStream.toByteArray()) - val deserialized = ObjectInputStream(byteArrayInputStream).use { it.readObject() as FuzzyMatchContainer.SerializedMatchContainer } + val deserialized = + ObjectInputStream(byteArrayInputStream).use { it.readObject() as FuzzyMatchContainer.SerializedMatchContainer } val fmc = deserialized.toFuzzyMatchContainer() assertEquals("", fmc.filePath) assertEquals("FuzzyMatchContainerTest.kt", fmc.filename) @@ -106,13 +111,17 @@ class FuzzyMatchContainerTest { fun `Test default list serialization`() { val list = DefaultListModel() val score = FuzzyScore() - val container = FuzzyMatchContainer(score, "", "FuzzyMatchContainerTest.kt", "") + val container = FuzzyMatchContainer( + score, "", "FuzzyMatchContainerTest.kt", "", + FILE + ) list.addElement(container) val converter = FuzzyMatchContainer.SerializedMatchContainerConverter() val stringRep = converter.toString(FuzzyMatchContainer.SerializedMatchContainer.fromListModel(list)) - val deserialized: DefaultListModel = converter.fromString(stringRep) + val deserialized: DefaultListModel = + converter.fromString(stringRep) assertEquals(1, deserialized.size) assertEquals("", deserialized.get(0).filePath) assertEquals("FuzzyMatchContainerTest.kt", deserialized.get(0).filename) @@ -121,7 +130,8 @@ class FuzzyMatchContainerTest { @Test fun `Deserialization fails`() { val converter = FuzzyMatchContainer.SerializedMatchContainerConverter() - val deserialized: DefaultListModel = converter.fromString("This should not work") + val deserialized: DefaultListModel = + converter.fromString("This should not work") assertEquals(0, deserialized.size) } } \ No newline at end of file diff --git a/src/test/kotlin/com/mituuz/fuzzier/entities/ScoreCalculatorTest.kt b/src/test/kotlin/com/mituuz/fuzzier/entities/ScoreCalculatorTest.kt index 04868240..687a0432 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/entities/ScoreCalculatorTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/entities/ScoreCalculatorTest.kt @@ -1,39 +1,35 @@ /* -MIT License - -Copyright (c) 2025 Mitja Leino - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ + * MIT License + * + * Copyright (c) 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ package com.mituuz.fuzzier.entities -import com.intellij.testFramework.TestApplicationManager import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test class ScoreCalculatorTest { - @Suppress("unused") // Required for the tests - private var testApplicationManager: TestApplicationManager = TestApplicationManager.getInstance() - @Test fun `Search string contained same index`() { - val sc = ScoreCalculator("test") + val sc = ScoreCalculator("test", MatchConfig()) sc.searchStringIndex = 0 sc.searchStringLength = 4 @@ -45,7 +41,7 @@ class ScoreCalculatorTest { @Test fun `Search string contained different index`() { - val sc = ScoreCalculator("test") + val sc = ScoreCalculator("test", MatchConfig()) sc.searchStringIndex = 0 sc.searchStringLength = 4 @@ -57,82 +53,91 @@ class ScoreCalculatorTest { @Test fun `Basic streak happy case`() { - val sc = ScoreCalculator("test") + val matchConfig = MatchConfig( + matchWeightStreakModifier = 10 + ) + val sc = ScoreCalculator("test", matchConfig) - sc.setMatchWeightStreakModifier(10) val fScore = sc.calculateScore("/test") assertEquals(4, fScore!!.streakScore) } @Test fun `Basic streak longer path`() { - val sc = ScoreCalculator("test") + val matchConfig = MatchConfig( + matchWeightStreakModifier = 10 + ) + val sc = ScoreCalculator("test", matchConfig) - sc.setMatchWeightStreakModifier(10) val fScore = sc.calculateScore("/te/st") assertEquals(2, fScore!!.streakScore) } @Test fun `Basic streak no possible match`() { - val sc = ScoreCalculator("test") + val matchConfig = MatchConfig( + matchWeightStreakModifier = 10 + ) + val sc = ScoreCalculator("test", matchConfig) - sc.setMatchWeightStreakModifier(10) val fScore = sc.calculateScore("/te") assertNull(fScore) } @Test fun `Multi match basic test`() { - val sc = ScoreCalculator("test") + val matchConfig = MatchConfig( + matchWeightSingleChar = 10, multiMatch = true + ) + val sc = ScoreCalculator("test", matchConfig) - sc.setMultiMatch(true) - sc.setMatchWeightSingleChar(10) val fScore = sc.calculateScore("/test") assertEquals(4, fScore!!.multiMatchScore) } @Test fun `Multi match basic test multiples`() { - val sc = ScoreCalculator("test") - - sc.setMultiMatch(true) - sc.setMatchWeightSingleChar(10) + val matchConfig = MatchConfig( + matchWeightSingleChar = 10, multiMatch = true + ) + val sc = ScoreCalculator("test", matchConfig) val fScore = sc.calculateScore("/testtest") assertEquals(8, fScore!!.multiMatchScore) } @Test fun `Multi match basic test multiples multiples`() { - val sc = ScoreCalculator("test test") - - sc.setMultiMatch(true) - sc.setMatchWeightSingleChar(10) + val matchConfig = MatchConfig( + matchWeightSingleChar = 10, multiMatch = true + ) + val sc = ScoreCalculator("test test", matchConfig) val fScore = sc.calculateScore("/testtest") assertEquals(8, fScore!!.multiMatchScore) } @Test fun `Partial path score basic test`() { - val sc = ScoreCalculator("test") - - sc.setMatchWeightPartialPath(1) + val matchConfig = MatchConfig( + matchWeightPartialPath = 1 + ) + val sc = ScoreCalculator("test", matchConfig) val fScore = sc.calculateScore("/test.kt") assertEquals(1, fScore!!.partialPathScore) } @Test fun `Filename score basic test`() { - val sc = ScoreCalculator("test") - - sc.setFilenameMatchWeight(10) + val matchConfig = MatchConfig( + matchWeightFilename = 10 + ) + val sc = ScoreCalculator("test", matchConfig) val fScore = sc.calculateScore("/test.kt") assertEquals(4, fScore!!.filenameScore) } @Test fun `Empty ss and fp`() { - val sc = ScoreCalculator("") + val sc = ScoreCalculator("", MatchConfig()) val fScore = sc.calculateScore("") assertEquals(0, fScore!!.getTotalScore()) @@ -141,12 +146,10 @@ class ScoreCalculatorTest { // Legacy tests @Test fun `Non-consecutive streak`() { - val sc = ScoreCalculator("kif") - - sc.setMatchWeightStreakModifier(10) - sc.setMultiMatch(true) - sc.setMatchWeightSingleChar(10) - sc.setFilenameMatchWeight(10) + val matchConfig = MatchConfig( + matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 + ) + val sc = ScoreCalculator("kif", matchConfig) val fScore = sc.calculateScore("/KotlinIsFun") @@ -158,12 +161,10 @@ class ScoreCalculatorTest { @Test fun `Consecutive streak`() { - val sc = ScoreCalculator("kot") - - sc.setMatchWeightStreakModifier(10) - sc.setMultiMatch(true) - sc.setMatchWeightSingleChar(10) - sc.setFilenameMatchWeight(10) + val matchConfig = MatchConfig( + matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 + ) + val sc = ScoreCalculator("kot", matchConfig) val fScore = sc.calculateScore("/KotlinIsFun") @@ -175,21 +176,21 @@ class ScoreCalculatorTest { @Test fun `Too long ss`() { - val sc = ScoreCalculator("TooLongSearchString") + val sc = ScoreCalculator("TooLongSearchString", MatchConfig()) val fScore = sc.calculateScore("/KIF") assertNull(fScore) } @Test fun `No possible match`() { - val sc = ScoreCalculator("A") + val sc = ScoreCalculator("A", MatchConfig()) val fScore = sc.calculateScore("/KIF") assertNull(fScore) } @Test fun `Empty ss`() { - val sc = ScoreCalculator("") + val sc = ScoreCalculator("", MatchConfig()) val fScore = sc.calculateScore("/KIF") assertEquals(0, fScore!!.getTotalScore()) @@ -197,26 +198,24 @@ class ScoreCalculatorTest { @Test fun `No possible match split`() { - val sc = ScoreCalculator("A A B") + val sc = ScoreCalculator("A A B", MatchConfig()) val fScore = sc.calculateScore("/Kotlin/Is/Fun/kif.kt") assertNull(fScore) } @Test fun `Partial match split`() { - val sc = ScoreCalculator("A A K") + val sc = ScoreCalculator("A A K", MatchConfig()) val fScore = sc.calculateScore("/Kotlin/Is/Fun/kif.kt") assertNull(fScore) } @Test fun `Split match for space`() { - val sc = ScoreCalculator("fun kotlin") - - sc.setMatchWeightStreakModifier(10) - sc.setMultiMatch(true) - sc.setMatchWeightSingleChar(10) - sc.setFilenameMatchWeight(10) + val matchConfig = MatchConfig( + matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 + ) + val sc = ScoreCalculator("fun kotlin", matchConfig) val fScore = sc.calculateScore("/Kotlin/Is/Fun/kif.kt") @@ -228,12 +227,10 @@ class ScoreCalculatorTest { @Test fun `Legacy test 1`() { - val sc = ScoreCalculator("kif") - - sc.setMatchWeightStreakModifier(10) - sc.setMultiMatch(true) - sc.setMatchWeightSingleChar(10) - sc.setFilenameMatchWeight(10) + val matchConfig = MatchConfig( + matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 + ) + val sc = ScoreCalculator("kif", matchConfig) val fScore = sc.calculateScore("/Kotlin/Is/Fun/kif.kt") @@ -245,12 +242,10 @@ class ScoreCalculatorTest { @Test fun `Legacy test 2`() { - val sc = ScoreCalculator("kif") - - sc.setMatchWeightStreakModifier(10) - sc.setMultiMatch(true) - sc.setMatchWeightSingleChar(10) - sc.setFilenameMatchWeight(10) + val matchConfig = MatchConfig( + matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 + ) + val sc = ScoreCalculator("kif", matchConfig) val fScore = sc.calculateScore("/Kiffer/Is/Fun/kiffer.kt") @@ -262,12 +257,10 @@ class ScoreCalculatorTest { @Test fun `Multiple partial file path matches`() { - val sc = ScoreCalculator("kif") - - sc.setMatchWeightStreakModifier(10) - sc.setMultiMatch(true) - sc.setMatchWeightSingleChar(10) - sc.setFilenameMatchWeight(10) + val matchConfig = MatchConfig( + matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 + ) + val sc = ScoreCalculator("kif", matchConfig) val fScore = sc.calculateScore("/Kif/Is/Fun/kif.kt") @@ -279,12 +272,10 @@ class ScoreCalculatorTest { @Test fun `Legacy test 3`() { - val sc = ScoreCalculator("kif fun kotlin") - - sc.setMatchWeightStreakModifier(10) - sc.setMultiMatch(true) - sc.setMatchWeightSingleChar(10) - sc.setFilenameMatchWeight(10) + val matchConfig = MatchConfig( + matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 + ) + val sc = ScoreCalculator("kif fun kotlin", matchConfig) val fScore = sc.calculateScore("/Kotlin/Is/Fun/kif.kt") @@ -296,64 +287,80 @@ class ScoreCalculatorTest { @Test fun `5 tolerance matching normal match`() { - val sc = ScoreCalculator("kotlin") - sc.setTolerance(5) + val matchConfig = MatchConfig( + tolerance = 5, + ) + val sc = ScoreCalculator("kotlin", matchConfig) assertNotNull(sc.calculateScore("/Kotlin")) } @Test fun `5 tolerance matching 1 letter difference`() { - val sc = ScoreCalculator("korlin") - sc.setTolerance(5) + val matchConfig = MatchConfig( + tolerance = 5, + ) + val sc = ScoreCalculator("korlin", matchConfig) assertNotNull(sc.calculateScore("/Kotlin")) } @Test fun `1 tolerance matching 1 letter difference`() { - val sc = ScoreCalculator("korlin") - sc.setTolerance(1) + val matchConfig = MatchConfig( + tolerance = 1, + ) + val sc = ScoreCalculator("korlin", matchConfig) assertNotNull(sc.calculateScore("/Kotlin")) } @Test fun `1 tolerance matching 2 letter difference`() { - val sc = ScoreCalculator("korlnn") - sc.setTolerance(1) + val matchConfig = MatchConfig( + tolerance = 1, + ) + val sc = ScoreCalculator("korlnn", matchConfig) assertNull(sc.calculateScore("/Kotlin")) } @Test fun `1 tolerance matching 1 letter difference with split path`() { - val sc = ScoreCalculator("korlin") - sc.setTolerance(1) + val matchConfig = MatchConfig( + tolerance = 1, + ) + val sc = ScoreCalculator("korlin", matchConfig) assertNotNull(sc.calculateScore("/Kot/lin")) } @Test fun `1 tolerance matching 2 letter difference with split path`() { - val sc = ScoreCalculator("korlin") - sc.setTolerance(1) + val matchConfig = MatchConfig( + tolerance = 1, + ) + val sc = ScoreCalculator("korlin", matchConfig) assertNull(sc.calculateScore("/Kot/sin")) } @Test fun `2 tolerance matching 2 letter difference with split path`() { - val sc = ScoreCalculator("korlin") - sc.setTolerance(2) + val matchConfig = MatchConfig( + tolerance = 2, + ) + val sc = ScoreCalculator("korlin", matchConfig) assertNotNull(sc.calculateScore("/Kot/sin")) } @Test fun `Don't match longer strings even if there is tolerance left`() { - val sc = ScoreCalculator("kotlin12345") - sc.setTolerance(5) + val matchConfig = MatchConfig( + tolerance = 5, + ) + val sc = ScoreCalculator("kotlin12345", matchConfig) assertNull(sc.calculateScore("/Kotlin")) } diff --git a/src/test/kotlin/com/mituuz/fuzzier/intellij/iteration/IntelliJIteratorEntryCollectorTest.kt b/src/test/kotlin/com/mituuz/fuzzier/intellij/iteration/IntelliJIteratorEntryCollectorTest.kt new file mode 100644 index 00000000..de7eca70 --- /dev/null +++ b/src/test/kotlin/com/mituuz/fuzzier/intellij/iteration/IntelliJIteratorEntryCollectorTest.kt @@ -0,0 +1,148 @@ +/* + * 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.iteration + +import com.intellij.openapi.roots.ContentIterator +import com.intellij.openapi.roots.FileIndex +import com.intellij.testFramework.LightVirtualFile +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +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 +import java.util.concurrent.atomic.AtomicInteger + +class IntelliJIteratorEntryCollectorTest { + private lateinit var collector: IntelliJIterationFileCollector + + @BeforeEach + fun setUp() { + collector = IntelliJIterationFileCollector() + } + + @Test + fun `collectFiles returns empty list when targets are empty`() { + val result = collector.collectFiles( + targets = emptyList(), + shouldContinue = { true }, + fileFilter = { true }, + ) + assertTrue(result.isEmpty()) + } + + @Test + fun `stops iterating when shouldContinue becomes false`() { + val file1 = LightVirtualFile("a.txt") + val file2 = LightVirtualFile("b.txt") + val index = mockk() + val iteratorSlot = slot() + + every { index.iterateContent(capture(iteratorSlot)) } answers { + iteratorSlot.captured.processFile(file1) + iteratorSlot.captured.processFile(file2) + true + } + + val calls = AtomicInteger(0) + val res = collector.collectFiles( + targets = listOf(index to "mod"), + shouldContinue = { calls.incrementAndGet() == 1 }, + fileFilter = { true } + ) + + assertEquals(1, res.size) + assertEquals("a.txt", res[0].name) + } + + @Test + fun `skips files that match filter`() { + val file1 = LightVirtualFile("a.txt") + val dir = mockk() + val file2 = LightVirtualFile("b.txt") + val index = mockk() + val iteratorSlot = slot() + + every { dir.isDirectory } returns true + every { index.iterateContent(capture(iteratorSlot)) } answers { + iteratorSlot.captured.processFile(file1) + iteratorSlot.captured.processFile(dir) + iteratorSlot.captured.processFile(file2) + true + } + + val res = collector.collectFiles( + targets = listOf(index to "mod"), + shouldContinue = { true }, + fileFilter = { vf -> !vf.isDirectory } + ) + + assertEquals(2, res.size) + assertEquals("a.txt", res[0].name) + assertEquals("b.txt", res[1].name) + } + + @Test + fun `collects files from multiple file indexes`() { + val file1 = LightVirtualFile("a.txt") + val file2 = LightVirtualFile("b.txt") + val file3 = LightVirtualFile("c.txt") + val file4 = LightVirtualFile("d.txt") + + val index1 = mockk() + val index2 = mockk() + val iteratorSlot1 = slot() + val iteratorSlot2 = slot() + + every { index1.iterateContent(capture(iteratorSlot1)) } answers { + iteratorSlot1.captured.processFile(file1) + iteratorSlot1.captured.processFile(file2) + true + } + + every { index2.iterateContent(capture(iteratorSlot2)) } answers { + iteratorSlot2.captured.processFile(file3) + iteratorSlot2.captured.processFile(file4) + true + } + + val res = collector.collectFiles( + targets = listOf(index1 to "mod1", index2 to "mod2"), + shouldContinue = { true }, + fileFilter = { true } + ) + + assertEquals(4, res.size) + assertEquals("a.txt", res[0].name) + assertEquals("mod1", res[0].module) + assertEquals("b.txt", res[1].name) + assertEquals("mod1", res[1].module) + assertEquals("c.txt", res[2].name) + assertEquals("mod2", res[2].module) + assertEquals("d.txt", res[3].name) + assertEquals("mod2", res[3].module) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurableTest.kt b/src/test/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurableTest.kt index f87c8505..97d2e29c 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurableTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurableTest.kt @@ -64,6 +64,71 @@ class FuzzierGlobalSettingsConfigurableTest { state.matchWeightFilename = 15 } + @Test + fun popupSizing_isPopulatedFromState() { + state.popupSizing = FuzzierGlobalSettingsService.PopupSizing.AUTO_SIZE + settingsConfigurable.createComponent() + val selected = settingsConfigurable.component.popupSizingSelector + .getPopupSizingComboBox().selectedItem as FuzzierGlobalSettingsService.PopupSizing + assertEquals(FuzzierGlobalSettingsService.PopupSizing.AUTO_SIZE, selected) + } + + @Test + fun popupSizing_isModifiedAndApply() { + settingsConfigurable.createComponent() + assertFalse(settingsConfigurable.isModified) + + val newValue = if (state.popupSizing == FuzzierGlobalSettingsService.PopupSizing.AUTO_SIZE) + FuzzierGlobalSettingsService.PopupSizing.VANILLA else FuzzierGlobalSettingsService.PopupSizing.AUTO_SIZE + state.popupSizing = newValue + assertTrue(settingsConfigurable.isModified) + + val uiSelected = settingsConfigurable.component.popupSizingSelector + .getPopupSizingComboBox().selectedItem as FuzzierGlobalSettingsService.PopupSizing + settingsConfigurable.apply() + assertEquals(uiSelected, state.popupSizing) + assertFalse(settingsConfigurable.isModified) + } + + @Test + fun popupSizing_dimensionVisibilityToggles() { + state.popupSizing = FuzzierGlobalSettingsService.PopupSizing.AUTO_SIZE + settingsConfigurable.createComponent() + val defaultDim = settingsConfigurable.component.defaultDimension + assertFalse(defaultDim.label.isVisible) + assertFalse(defaultDim.component.isVisible) + + val combo = settingsConfigurable.component.popupSizingSelector.getPopupSizingComboBox() + combo.selectedItem = FuzzierGlobalSettingsService.PopupSizing.VANILLA + settingsConfigurable.apply() + assertTrue(defaultDim.label.isVisible) + assertTrue(defaultDim.component.isVisible) + } + + @Test + fun popupSizing_persistsAcrossRecreate() { + settingsConfigurable.createComponent() + val combo = settingsConfigurable.component.popupSizingSelector.getPopupSizingComboBox() + combo.selectedItem = FuzzierGlobalSettingsService.PopupSizing.VANILLA + settingsConfigurable.apply() + + val recreated = FuzzierGlobalSettingsConfigurable() + recreated.createComponent() + val selected = recreated.component.popupSizingSelector.getPopupSizingComboBox().selectedItem + as FuzzierGlobalSettingsService.PopupSizing + assertEquals(FuzzierGlobalSettingsService.PopupSizing.VANILLA, selected) + } + + @Test + fun settingsComponent_getPopupSizingComboBox_smoke() { + val configurable = FuzzierGlobalSettingsConfigurable() + configurable.createComponent() + val combo = configurable.component.popupSizingSelector.getPopupSizingComboBox() + assertNotNull(combo) + combo.selectedItem = FuzzierGlobalSettingsService.PopupSizing.AUTO_SIZE + assertEquals(FuzzierGlobalSettingsService.PopupSizing.AUTO_SIZE, combo.selectedItem) + } + @Test fun newTab() { pre() diff --git a/src/test/kotlin/com/mituuz/fuzzier/ui/bindings/ActivationBindingsTest.kt b/src/test/kotlin/com/mituuz/fuzzier/ui/bindings/ActivationBindingsTest.kt new file mode 100644 index 00000000..686c984d --- /dev/null +++ b/src/test/kotlin/com/mituuz/fuzzier/ui/bindings/ActivationBindingsTest.kt @@ -0,0 +1,113 @@ +/* + * 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.bindings + +import com.intellij.testFramework.TestApplicationManager +import com.mituuz.fuzzier.components.FuzzyComponent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.KeyStroke + +class ActivationBindingsTest { + @Suppress("unused") + private var testApplicationManager: TestApplicationManager = TestApplicationManager.getInstance() + + private lateinit var component: FuzzyComponent + + @BeforeEach + fun setUp() { + component = FuzzyComponent() + } + + @Test + fun `Single-click on list doesn't trigger activation`() { + var activations = 0 + ActivationBindings.install(component, onActivate = { activations++ }) + + // Simulate double-click on the file list + val evt = MouseEvent( + component.fileList, + MouseEvent.MOUSE_CLICKED, + System.currentTimeMillis(), + 0, + 1, + 1, + 1, + false + ) + // Dispatch to all listeners + for (listener in component.fileList.mouseListeners) { + listener.mouseClicked(evt) + } + + assertEquals(0, activations) + } + + @Test + fun `Double-click on list triggers activation`() { + var activations = 0 + ActivationBindings.install(component, onActivate = { activations++ }) + + // Simulate double-click on the file list + val evt = MouseEvent( + component.fileList, + MouseEvent.MOUSE_CLICKED, + System.currentTimeMillis(), + 0, + 1, + 1, + 2, + false + ) + // Dispatch to all listeners + for (listener in component.fileList.mouseListeners) { + listener.mouseClicked(evt) + } + + assertEquals(1, activations) + } + + @Test + fun `Enter key binding triggers activation`() { + var activations = 0 + val actionId = "test.activateSelection" + ActivationBindings.install(component, onActivate = { activations++ }, actionId = actionId) + + // Verify InputMap contains the mapping for ENTER to our actionId + val enter = KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_ENTER, 0) + val inputMap = component.searchField.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) + val mapped = inputMap.get(enter) + assertEquals(actionId, mapped) + + // Retrieve and invoke the action directly from the ActionMap + val action = component.searchField.actionMap.get(actionId) + action.actionPerformed(null) + + assertEquals(1, activations) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/mituuz/fuzzier/ui/popup/AutoSizePopupProviderTest.kt b/src/test/kotlin/com/mituuz/fuzzier/ui/popup/AutoSizePopupProviderTest.kt new file mode 100644 index 00000000..38faa2bb --- /dev/null +++ b/src/test/kotlin/com/mituuz/fuzzier/ui/popup/AutoSizePopupProviderTest.kt @@ -0,0 +1,105 @@ +/* + * 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.popup + +import com.intellij.openapi.ui.popup.LightweightWindow +import com.intellij.openapi.ui.popup.LightweightWindowEvent +import com.intellij.testFramework.TestApplicationManager +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaProjectTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.awt.Dimension +import javax.swing.JLabel +import javax.swing.JPanel + +class AutoSizePopupProviderTest { + @Suppress("unused") + private var testApplicationManager: TestApplicationManager = TestApplicationManager.getInstance() + + private lateinit var fixture: IdeaProjectTestFixture + private lateinit var codeFixture: CodeInsightTestFixture + + private lateinit var provider: AutoSizePopupProvider + + @BeforeEach + fun setUp() { + // 50% width, 25% height + provider = AutoSizePopupProvider(0.5, 0.25) + } + + @Test + fun `show returns null and exits when no IDE frame is available`() { + val factory = IdeaTestFixtureFactory.getFixtureFactory() + fixture = factory.createLightFixtureBuilder(null, "Test").fixture + codeFixture = IdeaTestFixtureFactory.getFixtureFactory().createCodeInsightFixture(fixture) + codeFixture.setUp() + val project = fixture.project + val content = JPanel() + val focus = JLabel("focus") + var cleared = false + val cfg = PopupConfig( + title = "Test", + dimensionKey = "fuzzier.test", + preferredSizeProvider = Dimension(800, 600), + resetWindow = { true }, + clearResetWindowFlag = { cleared = true } + ) + + try { + val popup = provider.show(project, content, focus, cfg) { /* cleanup */ } + assertNull(popup) + // No side-effects should occur since we exit early before using DimensionService + assertEquals(false, cleared) + } finally { + codeFixture.tearDown() + } + } + + @Test + fun `cleanup function is called when popup is closed`() { + var cleanupCalled = false + val listener = provider.createCleanupListener { cleanupCalled = true } + + val lw = object : LightweightWindow {} + + val evt = LightweightWindowEvent(lw, true) + listener.onClosed(evt) + + assertEquals(true, cleanupCalled) + } + + @Test + fun `computePopupSize uses factors to scale main window size`() { + val fakeWindow = JPanel() + fakeWindow.size = Dimension(1000, 800) + + val size = provider.computePopupSize(fakeWindow) + assertEquals(500, size.width) + assertEquals(200, size.height) + } +} diff --git a/src/test/kotlin/com/mituuz/fuzzier/ui/popup/DefaultPopupProviderTest.kt b/src/test/kotlin/com/mituuz/fuzzier/ui/popup/DefaultPopupProviderTest.kt new file mode 100644 index 00000000..e3b042f4 --- /dev/null +++ b/src/test/kotlin/com/mituuz/fuzzier/ui/popup/DefaultPopupProviderTest.kt @@ -0,0 +1,96 @@ +/* + * 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.popup + +import com.intellij.openapi.ui.popup.LightweightWindow +import com.intellij.openapi.ui.popup.LightweightWindowEvent +import com.intellij.testFramework.TestApplicationManager +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaProjectTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.awt.Dimension +import javax.swing.JLabel +import javax.swing.JPanel + +class DefaultPopupProviderTest { + @Suppress("unused") + private var testApplicationManager: TestApplicationManager = TestApplicationManager.getInstance() + + private lateinit var provider: DefaultPopupProvider + private lateinit var fixture: IdeaProjectTestFixture + private lateinit var codeFixture: CodeInsightTestFixture + + @BeforeEach + fun setUp() { + provider = DefaultPopupProvider() + } + + @Test + fun `show returns null and exits when no IDE frame is available`() { + // Set up a lightweight project fixture to get a Project instance + val factory = IdeaTestFixtureFactory.getFixtureFactory() + fixture = factory.createLightFixtureBuilder(null, "Test").fixture + codeFixture = IdeaTestFixtureFactory.getFixtureFactory().createCodeInsightFixture(fixture) + codeFixture.setUp() + val project = fixture.project + val content = JPanel() + val focus = JLabel("focus") + var cleared = false + val cfg = PopupConfig( + title = "Test", + dimensionKey = "fuzzier.test", + preferredSizeProvider = Dimension(800, 600), + resetWindow = { true }, + clearResetWindowFlag = { cleared = true } + ) + + try { + val popup = provider.show(project, content, focus, cfg) { /* cleanup */ } + // Should return null and not throw + assertNull(popup) + // Since we exit early due to missing IDE frame, ensure no side-effects were triggered + assertEquals(false, cleared) + } finally { + codeFixture.tearDown() + } + } + + @Test + fun `cleanup function is called when popup is closed`() { + var cleanupCalled = false + val listener = provider.createCleanupListener { cleanupCalled = true } + + val lw = object : LightweightWindow {} + + val evt = LightweightWindowEvent(lw, true) + listener.onClosed(evt) + + assertEquals(true, cleanupCalled) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/mituuz/fuzzier/util/FuzzierUtilTest.kt b/src/test/kotlin/com/mituuz/fuzzier/util/FuzzierUtilTest.kt index d0b8a6c0..ae48d384 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/util/FuzzierUtilTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/util/FuzzierUtilTest.kt @@ -1,36 +1,33 @@ /* -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.util import com.intellij.openapi.components.service import com.intellij.testFramework.TestApplicationManager import com.mituuz.fuzzier.TestUtil import com.mituuz.fuzzier.entities.FuzzyContainer -import com.mituuz.fuzzier.entities.FuzzyMatchContainer -import com.mituuz.fuzzier.entities.FuzzyMatchContainer.FuzzyScore import com.mituuz.fuzzier.settings.FuzzierSettingsService -import kotlinx.collections.immutable.persistentMapOf import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -41,7 +38,6 @@ class FuzzierUtilTest { private val testApplicationManager = TestApplicationManager.getInstance() private val fuzzierUtil = FuzzierUtil() private val listModel = DefaultListModel() - private lateinit var result: DefaultListModel private val testUtil = TestUtil() @BeforeEach @@ -51,7 +47,11 @@ class FuzzierUtilTest { @Test fun `Parse modules multiple modules and roots`() { - val myFixture = testUtil.setUpMultiModuleProject(listOf("src1", "/src1/file1"), listOf("src2", "/src2/file2"), listOf("src3", "/src3/file3")) + val myFixture = testUtil.setUpMultiModuleProject( + listOf("src1", "/src1/file1"), + listOf("src2", "/src2/file2"), + listOf("src3", "/src3/file3") + ) fuzzierUtil.parseModules(myFixture.project) val modules = myFixture.project.service().state.modules @@ -64,7 +64,11 @@ class FuzzierUtilTest { @Test fun `Parse modules multiple modules and unique roots`() { - val myFixture = testUtil.setUpMultiModuleProject(listOf("path/src1", "/path/src1/file1"), listOf("to/src2", "/to/src2/file2"), listOf("module/src3", "/module/src3/file3")) + val myFixture = testUtil.setUpMultiModuleProject( + listOf("path/src1", "/path/src1/file1"), + listOf("to/src2", "/to/src2/file2"), + listOf("module/src3", "/module/src3/file3") + ) fuzzierUtil.parseModules(myFixture.project) val modules = myFixture.project.service().state.modules @@ -78,7 +82,11 @@ class FuzzierUtilTest { @Test fun `Parse modules multiple modules with single root`() { - val myFixture = testUtil.setUpMultiModuleProject(listOf("src1", "/src1/file1"), listOf("src1/module1", "/src1/module1/file1"), listOf("src1/module2", "/src1/module2/file1")) + val myFixture = testUtil.setUpMultiModuleProject( + listOf("src1", "/src1/file1"), + listOf("src1/module1", "/src1/module1/file1"), + listOf("src1/module2", "/src1/module2/file1") + ) fuzzierUtil.parseModules(myFixture.project) val modules = myFixture.project.service().state.modules @@ -91,8 +99,10 @@ class FuzzierUtilTest { @Test fun `Remove module paths, mixed set of modules`() { - val myFixture = testUtil.setUpMultiModuleProject(listOf("src1", "/src1/file1"), - listOf("src1/module1", "/src1/module1/file1"), listOf("src2", "/src2/file1")) + val myFixture = testUtil.setUpMultiModuleProject( + listOf("src1", "/src1/file1"), + listOf("src1/module1", "/src1/module1/file1"), listOf("src2", "/src2/file1") + ) val project = myFixture.project fuzzierUtil.parseModules(project) @@ -117,7 +127,11 @@ class FuzzierUtilTest { @Test fun `Remove module paths, include module dir on multi module project`() { - val myFixture = testUtil.setUpMultiModuleProject(listOf("path/src1", "/path/src1/file1"), listOf("to/src2", "/to/src2/file2"), listOf("module/src3", "/module/src3/file3")) + val myFixture = testUtil.setUpMultiModuleProject( + listOf("path/src1", "/path/src1/file1"), + listOf("to/src2", "/to/src2/file2"), + listOf("module/src3", "/module/src3/file3") + ) val project = myFixture.project fuzzierUtil.parseModules(project) @@ -172,96 +186,6 @@ class FuzzierUtilTest { assertEquals(1, modules.size) } - @Test - fun `Sort and limit under limit`() { - addElement(1, "file1") - addElement(2, "file2") - addElement(3, "file3") - - runWithLimit(5) - assertEquals(3, result.size) - assertEquals("file3", result[0].filename) - assertEquals("file2", result[1].filename) - assertEquals("file1", result[2].filename) - } - - @Test - fun `Sort and limit equal to limit`() { - addElement(1, "file1") - addElement(8, "file2") - addElement(3, "file3") - - runWithLimit(3) - assertEquals(3, result.size) - assertEquals("file2", result[0].filename) - assertEquals("file3", result[1].filename) - assertEquals("file1", result[2].filename) - } - - @Test - fun `Sort and limit over limit`() { - addElement(1, "file1") - addElement(8, "file2") - addElement(3, "file3") - addElement(4, "file4") - - runWithLimit(2) - assertEquals(2, result.size) - assertEquals("file2", result[0].filename) - assertEquals("file4", result[1].filename) - } - - @Test - fun `Empty list`() { - runWithLimit(2) - assertEquals(0, result.size) - } - - @Test - fun `Prioritize file paths with same score`() { - addElement(0, "file1", "1") - addElement(0, "file2", "123") - addElement(0, "file3", "12") - addElement(0, "file4", "1234") - - runPrioritizedList() - assertEquals(4, result.size) - assertEquals("file1", result[0].filename) - assertEquals("file3", result[1].filename) - assertEquals("file2", result[2].filename) - assertEquals("file4", result[3].filename) - } - - @Test - fun `Prioritize file paths with different scores`() { - addElement(10, "file1", "1") - addElement(0, "file2", "123") - addElement(0, "file3", "12") - addElement(0, "file4", "1234") - - runPrioritizedList() - assertEquals(4, result.size) - assertEquals("file1", result[0].filename) - assertEquals("file3", result[1].filename) - assertEquals("file2", result[2].filename) - assertEquals("file4", result[3].filename) - } - - @Test - fun `Prioritize empty paths`() { - addElement(4, "file1", "") - addElement(3, "file2", "") - addElement(1, "file3", "") - addElement(2, "file4", "") - - runPrioritizedList() - assertEquals(4, result.size) - assertEquals("file1", result[0].filename) - assertEquals("file2", result[1].filename) - assertEquals("file4", result[2].filename) - assertEquals("file3", result[3].filename) - } - @Test fun `Test ignored characters`() { val searchString = "HELLO/THERE/GENERAL/KENOBI" @@ -275,42 +199,4 @@ class FuzzierUtilTest { val ignoredChars = "" assertEquals(searchString.lowercase(), FuzzierUtil.cleanSearchString(searchString, ignoredChars)) } - - private fun addElement(score: Int, fileName: String) { - val fuzzyScore = FuzzyScore() - fuzzyScore.streakScore = score - val container = FuzzyMatchContainer(fuzzyScore, "", fileName, "") - listModel.addElement(container) - } - - private fun addElement(score: Int, filename: String, filePath: String) { - val fuzzyScore = FuzzyScore() - fuzzyScore.streakScore = score - val container = FuzzyMatchContainer(fuzzyScore, filePath, filename, "") - listModel.addElement(container) - } - - private fun runPrioritizedList() { - fuzzierUtil.setListLimit(4) - fuzzierUtil.setPrioritizeShorterDirPaths(true) - result = fuzzierUtil.sortAndLimit(listModel, true) - } - - /** - * Tests both the dir sort without priority and the file sorting - */ - private fun runWithLimit(limit: Int) { - fuzzierUtil.setListLimit(limit) - fuzzierUtil.setPrioritizeShorterDirPaths(false) - val dirResult = fuzzierUtil.sortAndLimit(listModel, true) - result = fuzzierUtil.sortAndLimit(listModel) - - assertEquals(dirResult.size, result.size) - - var i = 0 - while (i < result.size) { - assertEquals(result[i], dirResult[i]) - i++ - } - } } \ No newline at end of file diff --git a/src/test/kotlin/com/mituuz/fuzzier/util/InitialViewHandlerTest.kt b/src/test/kotlin/com/mituuz/fuzzier/util/InitialViewHandlerTest.kt index ed13e6cc..5e83e23f 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/util/InitialViewHandlerTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/util/InitialViewHandlerTest.kt @@ -29,6 +29,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.intellij.testFramework.TestApplicationManager import com.mituuz.fuzzier.entities.FuzzyMatchContainer +import com.mituuz.fuzzier.entities.FuzzyMatchContainer.FileType.FILE import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService import com.mituuz.fuzzier.settings.FuzzierSettingsService import com.mituuz.fuzzier.settings.FuzzierSettingsService.State @@ -151,7 +152,7 @@ class InitialViewHandlerTest { val fuzzierSettingsServiceInstance: FuzzierSettingsService = service() val fgss = service().state val score = FuzzyMatchContainer.FuzzyScore() - val container = FuzzyMatchContainer(score, "", "", "") + val container = FuzzyMatchContainer(score, "", "", "", FILE) fuzzierSettingsServiceInstance.state.recentlySearchedFiles = null InitialViewHandler.addFileToRecentlySearchedFiles(container, fuzzierSettingsServiceInstance.state, fgss) @@ -165,11 +166,11 @@ class InitialViewHandlerTest { val fgss = service().state val fileListLimit = 2 val score = FuzzyMatchContainer.FuzzyScore() - val container = FuzzyMatchContainer(score, "", "", "") + val container = FuzzyMatchContainer(score, "", "", "", FILE) val largeList: DefaultListModel = DefaultListModel() for (i in 0..25) { - largeList.addElement(FuzzyMatchContainer(score, "" + i, "" + i, "")) + largeList.addElement(FuzzyMatchContainer(score, "" + i, "" + i, "", FILE)) } fgss.fileListLimit = fileListLimit @@ -189,11 +190,11 @@ class InitialViewHandlerTest { val fgss = service().state val fileListLimit = 20 val score = FuzzyMatchContainer.FuzzyScore() - val container = FuzzyMatchContainer(score, "", "", "") + val container = FuzzyMatchContainer(score, "", "", "", FILE) val largeList: DefaultListModel = DefaultListModel() repeat(26) { - largeList.addElement(FuzzyMatchContainer(score, "", "", "")) + largeList.addElement(FuzzyMatchContainer(score, "", "", "", FILE)) } fgss.fileListLimit = fileListLimit