diff --git a/.gitignore b/.gitignore index c61bfd55..f7252b73 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ libs/ !**/src/main/**/build/ !**/src/test/**/build/ .aider* +.junie/ ### IntelliJ IDEA ### .intellijPlatform diff --git a/build.gradle.kts b/build.gradle.kts index 3db9a490..d4943384 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 = "2.3.0" +val currentVersion = "2.4.0" val myGroup = "com.mituuz" version = currentVersion group = myGroup @@ -41,16 +41,15 @@ intellijPlatform {

Version $currentVersion

""".trimIndent() diff --git a/changelog.md b/changelog.md index 8b8e1c15..8f4472f7 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## Version 2.4.0 + +- Add file recency scoring + - LRU cache for file paths + - Scoring is based on the recency of the file access and the frequency of the file access + ## Version 2.3.0 - Update dependencies diff --git a/src/main/kotlin/com/mituuz/fuzzier/actions/filesystem/FilesystemAction.kt b/src/main/kotlin/com/mituuz/fuzzier/actions/filesystem/FilesystemAction.kt index cce75ecb..bb95e4ae 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/actions/filesystem/FilesystemAction.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/actions/filesystem/FilesystemAction.kt @@ -31,6 +31,7 @@ import com.intellij.openapi.vfs.VirtualFile import com.mituuz.fuzzier.actions.FuzzyAction import com.mituuz.fuzzier.entities.* 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 @@ -64,12 +65,19 @@ abstract class FilesystemAction : FuzzyAction() { addAll(projectState.exclusionSet) addAll(globalState.globalExclusionSet) } + val fileUsageStats = getFileUsageMap(projectState) return StringEvaluator( combinedExclusions, projectState.modules, + fileUsageStats ) } + fun getFileUsageMap(state: FuzzierSettingsService.State): Map = + state.recentFiles.filter { it.filePath.isNotBlank() }.withIndex().associate { (recentIndex, stats) -> + stats.filePath to FileUsageStats(recentIndex, stats.accessCount) + } + /** * Processes a set of IterationFiles concurrently * @return a priority list which has been size limited and sorted @@ -100,7 +108,9 @@ abstract class FilesystemAction : FuzzyAction() { globalState.matchWeightSingleChar, globalState.matchWeightStreakModifier, globalState.matchWeightPartialPath, - globalState.matchWeightFilename + globalState.matchWeightFilename, + globalState.matchWeightFrequency, + globalState.matchWeightRecency ) coroutineScope { diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt index 593355ff..317233ff 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/components/FuzzierGlobalSettingsComponent.kt @@ -311,6 +311,24 @@ class FuzzierGlobalSettingsComponent( false ) + val matchWeightFrequency = SettingsComponent( + JBIntSpinner(10, 0, 100), "Match weight: Frequency boost", + """ + How much score should a frequency boost give.

+ Frequency boost is based on how many times a file has been accessed. + """.trimIndent(), + false + ) + + val matchWeightRecency = SettingsComponent( + JBIntSpinner(10, 0, 100), "Match weight: Recency boost", + """ + How much score should a recency boost give.

+ Recency boost is based on how recently a file has been accessed. + """.trimIndent(), + false + ) + ///////////////////////////////////////////////////////////////// // Test bench ///////////////////////////////////////////////////////////////// @@ -362,6 +380,8 @@ class FuzzierGlobalSettingsComponent( .addComponent(matchWeightPartialPath) .addComponent(matchWeightStreakModifier) .addComponent(matchWeightFilename) + .addComponent(matchWeightFrequency) + .addComponent(matchWeightRecency) .addSeparator() .addComponent(JBLabel("

Test bench

")) diff --git a/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt b/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt index ac871b04..a2a3afa9 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/components/TestBenchComponent.kt @@ -53,7 +53,7 @@ import javax.swing.table.DefaultTableModel class TestBenchComponent : JPanel(), Disposable { private val columnNames = - arrayOf("Filename", "Filepath", "Streak", "MultiMatch", "PartialPath", "Filename", "Total") + arrayOf("Filename", "Filepath", "Streak", "MultiMatch", "PartialPath", "Filename", "Freq", "Recency", "Total") private val table = JBTable() private var searchField = EditorTextField() private var debounceJob: Job? = null @@ -145,13 +145,18 @@ class TestBenchComponent : JPanel(), Disposable { addAll(liveGlobalExclusions) } + val fileUsageMap = + projectState.recentFiles.filter { it.filePath.isNotBlank() }.withIndex().associate { (recentIndex, stats) -> + stats.filePath to FileUsageStats(recentIndex, stats.accessCount) + } + currentUpdateListContentJob?.cancel() currentUpdateListContentJob = actionScope.launch { table.setPaintBusy(true) try { val stringEvaluator = StringEvaluator( - combinedExclusions, project.service().state.modules + combinedExclusions, project.service().state.modules, fileUsageMap ) val iterationEntries = withContext(Dispatchers.Default) { @@ -169,9 +174,8 @@ class TestBenchComponent : JPanel(), Disposable { ) } - val sortedList = - listModel.elements().toList() - .sortedByDescending { (it as FuzzyMatchContainer).getScore(prioritizeShorterDirPaths) } + val sortedList = listModel.elements().toList() + .sortedByDescending { (it as FuzzyMatchContainer).getScore(prioritizeShorterDirPaths) } val data: Array> = sortedList.map { arrayOf( (it as FuzzyMatchContainer).filename as Any, @@ -180,6 +184,8 @@ class TestBenchComponent : JPanel(), Disposable { it.score.multiMatchScore as Any, it.score.partialPathScore as Any, it.score.filenameScore as Any, + it.score.frequencyScore as Any, + it.score.recencyScore as Any, it.score.getTotalScore() as Any ) }.toTypedArray() @@ -234,8 +240,7 @@ class TestBenchComponent : JPanel(), Disposable { val ss = FuzzierUtil.cleanSearchString(searchString, projectState.ignoredCharacters) val processedFiles = ConcurrentHashMap.newKeySet() val priorityQueue = PriorityQueue( - fileListLimit + 1, - compareBy { it.getScore(prioritizeShorterDirPaths) }) + fileListLimit + 1, compareBy { it.getScore(prioritizeShorterDirPaths) }) val queueLock = Any() var minimumScore: Int? = null @@ -255,7 +260,9 @@ class TestBenchComponent : JPanel(), Disposable { liveSettingsComponent.matchWeightSingleChar.getIntSpinner().value as Int, liveSettingsComponent.matchWeightStreakModifier.getIntSpinner().value as Int, liveSettingsComponent.matchWeightPartialPath.getIntSpinner().value as Int, - liveSettingsComponent.matchWeightFilename.getIntSpinner().value as Int + liveSettingsComponent.matchWeightFilename.getIntSpinner().value as Int, + liveSettingsComponent.matchWeightFrequency.getIntSpinner().value as Int, + liveSettingsComponent.matchWeightRecency.getIntSpinner().value as Int ) val container = stringEvaluator.evaluateIteratorEntry(iterationFile, ss, matchConfig) diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/FileAccessData.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/FileAccessData.kt new file mode 100644 index 00000000..77caba74 --- /dev/null +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/FileAccessData.kt @@ -0,0 +1,44 @@ +/* + * MIT License + * + * Copyright (c) 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package com.mituuz.fuzzier.entities + +import java.io.Serializable + +class FileAccessData : Serializable { + var filePath: String = "" + var accessCount: Int = 0 + + constructor() + + constructor(filePath: String, accessCount: Int) { + this.filePath = filePath + this.accessCount = accessCount + } +} + +data class FileUsageStats( + val recentIndex: Int, + val accessCount: Int, +) \ 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 fcad1371..ff4255d5 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainer.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainer.kt @@ -30,7 +30,6 @@ import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService import com.mituuz.fuzzier.settings.FuzzierSettingsService import java.io.* import java.util.* -import javax.swing.DefaultListModel class FuzzyMatchContainer( val score: FuzzyScore, @@ -111,10 +110,12 @@ class FuzzyMatchContainer( var multiMatchScore = 0 var partialPathScore = 0 var filenameScore = 0 + var frequencyScore = 0 + var recencyScore = 0 val highlightCharacters: MutableSet = HashSet() fun getTotalScore(): Int { - return streakScore + multiMatchScore + partialPathScore + filenameScore + return streakScore + multiMatchScore + partialPathScore + filenameScore + frequencyScore + recencyScore } } @@ -137,26 +138,15 @@ class FuzzyMatchContainer( serialized.moduleBasePath = container.basePath return serialized } - - fun fromListModel(listModel: DefaultListModel): DefaultListModel { - val serializedList = DefaultListModel() - for (i in 0 until listModel.size) { - serializedList.addElement(fromFuzzyMatchContainer(listModel[i])) - } - return serializedList - } - - fun toListModel(serializedList: DefaultListModel): DefaultListModel { - val listModel = DefaultListModel() - for (i in 0 until serializedList.size) { - listModel.addElement(serializedList[i].toFuzzyMatchContainer()) - } - return listModel - } } - fun toFuzzyMatchContainer(): FuzzyMatchContainer { - return FuzzyMatchContainer(score!!, filePath!!, filename!!, moduleBasePath!!, FileType.FILE) + fun toFuzzyMatchContainer(): FuzzyMatchContainer? { + val score = score ?: return null + val filePath = filePath ?: return null + val filename = filename ?: return null + val moduleBasePath = moduleBasePath ?: return null + + return FuzzyMatchContainer(score, filePath, filename, moduleBasePath, FileType.FILE) } var score: FuzzyScore? = null @@ -177,21 +167,21 @@ class FuzzyMatchContainer( * * @see FuzzierSettingsService */ - class SerializedMatchContainerConverter : Converter>() { - override fun fromString(value: String): DefaultListModel { + class SerializedMatchContainerConverter : Converter>() { + override fun fromString(value: String): List { // Fallback to an empty list if deserialization fails try { val data = Base64.getDecoder().decode(value) val byteArrayInputStream = ByteArrayInputStream(data) @Suppress("UNCHECKED_CAST") - return ObjectInputStream(byteArrayInputStream).use { it.readObject() as DefaultListModel } + return ObjectInputStream(byteArrayInputStream).use { it.readObject() as List } } catch (_: Exception) { - return DefaultListModel() + return listOf() } } - override fun toString(value: DefaultListModel): String { + override fun toString(value: List): String { val byteArrayOutputStream = ByteArrayOutputStream() ObjectOutputStream(byteArrayOutputStream).use { it.writeObject(value) } return Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray()) diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/MatchConfig.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/MatchConfig.kt index 7699f021..a57f7426 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/entities/MatchConfig.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/MatchConfig.kt @@ -27,8 +27,10 @@ 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 matchWeightSingleChar: Int = 5, + val matchWeightStreakModifier: Int = 10, val matchWeightPartialPath: Int = 10, val matchWeightFilename: Int = 20, + val matchWeightFrequency: Int = 10, + val matchWeightRecency: Int = 10, ) \ 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 e88d70f0..2e7a853a 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/entities/ScoreCalculator.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/ScoreCalculator.kt @@ -28,7 +28,8 @@ import org.apache.commons.lang3.StringUtils class ScoreCalculator( searchString: String, - private val config: MatchConfig + private val config: MatchConfig, + private val fileUsageStats: Map ) { private val lowerSearchString: String = searchString.lowercase() private val searchStringParts = lowerSearchString.split(" ") @@ -82,9 +83,28 @@ class ScoreCalculator( fuzzyScore.streakScore = (longestStreak * config.matchWeightStreakModifier) / 10 fuzzyScore.filenameScore = (longestFilenameStreak * config.matchWeightFilename) / 10 + val fileStats = fileUsageStats[currentFilePath] + calculateUsageBoost(fileStats) + return fuzzyScore } + internal fun calculateUsageBoost(fileStats: FileUsageStats?): Int { + if (fileStats == null) { + return 0 + } + + val recencyBoost = ((10 - fileStats.recentIndex / 2).coerceAtLeast(0) * config.matchWeightRecency) / 10 + val frequencyBoost = ((fileStats.accessCount / 5).coerceAtMost(10) * config.matchWeightFrequency) / 10 + + if (this::fuzzyScore.isInitialized) { + fuzzyScore.recencyScore = recencyBoost + fuzzyScore.frequencyScore = frequencyBoost + } + + return recencyBoost + frequencyBoost + } + /** * Returns false if no match can be found, this stops the search */ @@ -148,7 +168,7 @@ class ScoreCalculator( longestFilenameStreak = 0 filePathIndex = filenameIndex - while (searchStringIndex < searchStringLength && filePathIndex < currentFilePath.length) { + while (searchStringIndex < lowerSearchString.length && filePathIndex < currentFilePath.length) { processFilenameChar(lowerSearchString[searchStringIndex]) } } diff --git a/src/main/kotlin/com/mituuz/fuzzier/entities/StringEvaluator.kt b/src/main/kotlin/com/mituuz/fuzzier/entities/StringEvaluator.kt index d63fc673..34910155 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/entities/StringEvaluator.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/entities/StringEvaluator.kt @@ -32,13 +32,14 @@ import com.mituuz.fuzzier.entities.FuzzyMatchContainer.FileType class StringEvaluator( private var exclusionList: Set, private var modules: Map, + private val fileUsageStats: Map ) { fun evaluateIteratorEntry( iteratorEntry: IterationEntry, searchString: String, matchConfig: MatchConfig ): FuzzyMatchContainer? { - val scoreCalculator = ScoreCalculator(searchString, matchConfig) + val scoreCalculator = ScoreCalculator(searchString, matchConfig, fileUsageStats) val moduleName = iteratorEntry.module val moduleBasePath = modules[moduleName] ?: return null diff --git a/src/main/kotlin/com/mituuz/fuzzier/search/Fuzzier.kt b/src/main/kotlin/com/mituuz/fuzzier/search/Fuzzier.kt index ad402980..f2ced081 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/search/Fuzzier.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/search/Fuzzier.kt @@ -144,7 +144,8 @@ open class Fuzzier : FilesystemAction() { addFileToRecentlySearchedFiles( selectedValue, projectState, - globalState + globalState.fileListLimit, + globalState.fileMetadataCacheSize ) } popup.cancel() diff --git a/src/main/kotlin/com/mituuz/fuzzier/search/initialview/DefaultInitialListModelProvider.kt b/src/main/kotlin/com/mituuz/fuzzier/search/initialview/DefaultInitialListModelProvider.kt index 57ded887..aea3cc92 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/search/initialview/DefaultInitialListModelProvider.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/search/initialview/DefaultInitialListModelProvider.kt @@ -45,7 +45,7 @@ class DefaultInitialListModelProvider( } FuzzierGlobalSettingsService.RecentFilesMode.RECENTLY_SEARCHED_FILES -> { - getRecentlySearchedFiles() + getRecentlySearchedFiles(projectState) } else -> { @@ -82,16 +82,8 @@ class DefaultInitialListModelProvider( return listModel } - fun getRecentlySearchedFiles(): DefaultListModel { - val result = DefaultListModel() - projectState.getRecentlySearchedFilesAsFuzzyMatchContainer() - .elements() - .toList() - .filterNotNull() - .reversed() - .let { - result.addAll(it) - } - return result - } + fun getRecentlySearchedFiles(state: FuzzierSettingsService.State): DefaultListModel = + DefaultListModel().apply { + state.recentlySearchedFiles?.asReversed()?.forEach { addElement(it.toFuzzyMatchContainer()) } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/mituuz/fuzzier/search/initialview/RecentlySearchedFilesUtil.kt b/src/main/kotlin/com/mituuz/fuzzier/search/initialview/RecentlySearchedFilesUtil.kt index 25001289..201f612f 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/search/initialview/RecentlySearchedFilesUtil.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/search/initialview/RecentlySearchedFilesUtil.kt @@ -24,36 +24,75 @@ package com.mituuz.fuzzier.search.initialview +import com.mituuz.fuzzier.entities.FileAccessData import com.mituuz.fuzzier.entities.FuzzyContainer import com.mituuz.fuzzier.entities.FuzzyMatchContainer -import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService +import com.mituuz.fuzzier.entities.FuzzyMatchContainer.SerializedMatchContainer.Companion.fromFuzzyMatchContainer import com.mituuz.fuzzier.settings.FuzzierSettingsService -import javax.swing.DefaultListModel +/** + * Adds a file to the list of recently searched files while ensuring that the list does not exceed + * the specified limit, maintains uniqueness, and updates the file metadata cache. + * + * @param incomingContainer The container representing the file to be added to the recently + * searched files list. + * @param projectState The current state of the project, which holds the recently searched + * files and related metadata. + * @param fileListLimit The maximum number of files that can be maintained in the list of + * recently searched files. + * @param fileMetadataCacheSize The maximum size allowed for the file metadata cache. + */ fun addFileToRecentlySearchedFiles( - fuzzyContainer: FuzzyContainer, + incomingContainer: FuzzyContainer, projectState: FuzzierSettingsService.State, - globalState: FuzzierGlobalSettingsService.State + fileListLimit: Int, + fileMetadataCacheSize: Int, ) { - val listModel: DefaultListModel = - projectState.getRecentlySearchedFilesAsFuzzyMatchContainer() + val recentFiles: MutableList = + projectState.recentlySearchedFiles?.mapNotNull { it.toFuzzyMatchContainer() }?.toMutableList() + ?: mutableListOf() var i = 0 - while (i < listModel.size) { - if (listModel[i].filePath == fuzzyContainer.filePath) { - listModel.remove(i) + while (i < recentFiles.size) { + if (recentFiles[i].filePath == incomingContainer.filePath) { + recentFiles.removeAt(i) } else { i++ } } - while (listModel.size > globalState.fileListLimit - 1) { - listModel.remove(listModel.size - 1) + while (recentFiles.size > fileListLimit - 1) { + recentFiles.removeAt(recentFiles.size - 1) } - if (fuzzyContainer is FuzzyMatchContainer) { - listModel.addElement(fuzzyContainer) - projectState.recentlySearchedFiles = - FuzzyMatchContainer.SerializedMatchContainer.fromListModel(listModel) + if (incomingContainer is FuzzyMatchContainer) { + recentFiles.add(incomingContainer) + projectState.recentlySearchedFiles = recentFiles.map { fromFuzzyMatchContainer(it) } } + + projectState.recentFiles = addFileToLRUCache( + incomingContainer, projectState.recentFiles, fileMetadataCacheSize + ) +} + +fun addFileToLRUCache( + incomingContainer: FuzzyContainer, recentFiles: MutableList, maxSize: Int +): MutableList { + val lower = incomingContainer.filePath.lowercase() + val existingIndex = recentFiles.indexOfFirst { it.filePath.lowercase() == lower } + + val existingEntry = if (existingIndex != -1) recentFiles.removeAt(existingIndex) else null + + val newEntry = FileAccessData( + filePath = lower, + accessCount = (existingEntry?.accessCount ?: 0) + 1 + ) + + recentFiles.add(0, newEntry) + + while (recentFiles.size > maxSize) { + recentFiles.removeLast() + } + + return recentFiles } diff --git a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt index 25b42249..85c5d7b9 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurable.kt @@ -84,6 +84,8 @@ class FuzzierGlobalSettingsConfigurable : Configurable { component.matchWeightSingleChar.getIntSpinner().isEnabled = state.multiMatch component.matchWeightStreakModifier.getIntSpinner().value = state.matchWeightStreakModifier component.matchWeightFilename.getIntSpinner().value = state.matchWeightFilename + component.matchWeightFrequency.getIntSpinner().value = state.matchWeightFrequency + component.matchWeightRecency.getIntSpinner().value = state.matchWeightRecency return component.jPanel } @@ -129,6 +131,8 @@ class FuzzierGlobalSettingsConfigurable : Configurable { || state.matchWeightSingleChar != component.matchWeightSingleChar.getIntSpinner().value || state.matchWeightStreakModifier != component.matchWeightStreakModifier.getIntSpinner().value || state.matchWeightFilename != component.matchWeightFilename.getIntSpinner().value + || state.matchWeightFrequency != component.matchWeightFrequency.getIntSpinner().value + || state.matchWeightRecency != component.matchWeightRecency.getIntSpinner().value || state.globalExclusionSet != newGlobalSet || state.grepBackend != component.grepBackendSelector.getGrepBackendComboBox().selectedItem } @@ -176,6 +180,8 @@ class FuzzierGlobalSettingsConfigurable : Configurable { state.matchWeightSingleChar = component.matchWeightSingleChar.getIntSpinner().value as Int state.matchWeightStreakModifier = component.matchWeightStreakModifier.getIntSpinner().value as Int state.matchWeightFilename = component.matchWeightFilename.getIntSpinner().value as Int + state.matchWeightFrequency = component.matchWeightFrequency.getIntSpinner().value as Int + state.matchWeightRecency = component.matchWeightRecency.getIntSpinner().value as Int val newGlobalSet = component.globalExclusionTextArea.text .lines() diff --git a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt index e8fac6d5..3f30251b 100644 --- a/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt +++ b/src/main/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsService.kt @@ -61,6 +61,7 @@ class FuzzierGlobalSettingsService : PersistentStateComponent = emptySet() @@ -70,6 +71,8 @@ class FuzzierGlobalSettingsService : PersistentStateComponent { class State { + /** Map of module identifiers and base paths. */ var modules: Map = HashMap() + + /** Flag indicating if we should use ProjectFileIndex or modules for file iteration. */ var isProject = false + + /** List of recently searched files. */ @OptionTag(converter = FuzzyMatchContainer.SerializedMatchContainerConverter::class) - var recentlySearchedFiles: DefaultListModel? = DefaultListModel() + var recentlySearchedFiles: List? = listOf() + + /** List of recently searched files. */ + var recentFiles: MutableList = mutableListOf() + /** Set of file patterns to be excluded from searches. */ var exclusionSet: Set = setOf("/.idea/*", "/.git/*", "/target/*", "/build/*", "/.gradle/*", "/.run/*") - var ignoredCharacters: String = "" - fun getRecentlySearchedFilesAsFuzzyMatchContainer(): DefaultListModel { - val list = recentlySearchedFiles ?: DefaultListModel() - return FuzzyMatchContainer.SerializedMatchContainer.toListModel(list) - } + /** Characters to ignore during text matching. */ + var ignoredCharacters: String = "" } private var state = State() diff --git a/src/test/kotlin/com/mituuz/fuzzier/StringEvaluatorTest.kt b/src/test/kotlin/com/mituuz/fuzzier/StringEvaluatorTest.kt index c6786b3c..b439f031 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/StringEvaluatorTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/StringEvaluatorTest.kt @@ -38,7 +38,7 @@ class StringEvaluatorTest { private val moduleBasePath = "/m1/src" private fun evaluate(filePaths: List, exclusionList: Set): List { - val evaluator = StringEvaluator(exclusionList, mapOf(moduleName to moduleBasePath)) + val evaluator = StringEvaluator(exclusionList, mapOf(moduleName to moduleBasePath), mapOf()) return filePaths.mapNotNull { fp -> // Build absolute path under a fake module root so that removePrefix(moduleBasePath) works like in production diff --git a/src/test/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainerTest.kt b/src/test/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainerTest.kt index ba089fb0..25d1e8aa 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainerTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/entities/FuzzyMatchContainerTest.kt @@ -34,7 +34,6 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.ObjectInputStream import java.io.ObjectOutputStream -import javax.swing.DefaultListModel class FuzzyMatchContainerTest { @Suppress("unused") @@ -103,34 +102,33 @@ class FuzzyMatchContainerTest { val deserialized = ObjectInputStream(byteArrayInputStream).use { it.readObject() as FuzzyMatchContainer.SerializedMatchContainer } val fmc = deserialized.toFuzzyMatchContainer() - assertEquals("", fmc.filePath) - assertEquals("FuzzyMatchContainerTest.kt", fmc.filename) + assertEquals("", fmc?.filePath) + assertEquals("FuzzyMatchContainerTest.kt", fmc?.filename) } @Test fun `Test default list serialization`() { - val list = DefaultListModel() val score = FuzzyScore() val container = FuzzyMatchContainer( score, "", "FuzzyMatchContainerTest.kt", "", FILE ) - list.addElement(container) + val list = listOf(FuzzyMatchContainer.SerializedMatchContainer.fromFuzzyMatchContainer(container)) val converter = FuzzyMatchContainer.SerializedMatchContainerConverter() - val stringRep = converter.toString(FuzzyMatchContainer.SerializedMatchContainer.fromListModel(list)) + val stringRep = converter.toString(list) - val deserialized: DefaultListModel = + val deserialized: List = converter.fromString(stringRep) assertEquals(1, deserialized.size) - assertEquals("", deserialized.get(0).filePath) - assertEquals("FuzzyMatchContainerTest.kt", deserialized.get(0).filename) + assertEquals("", deserialized[0].filePath) + assertEquals("FuzzyMatchContainerTest.kt", deserialized[0].filename) } @Test fun `Deserialization fails`() { val converter = FuzzyMatchContainer.SerializedMatchContainerConverter() - val deserialized: DefaultListModel = + val deserialized: List = converter.fromString("This should not work") assertEquals(0, deserialized.size) } diff --git a/src/test/kotlin/com/mituuz/fuzzier/entities/ScoreCalculatorTest.kt b/src/test/kotlin/com/mituuz/fuzzier/entities/ScoreCalculatorTest.kt index 687a0432..bc281916 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/entities/ScoreCalculatorTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/entities/ScoreCalculatorTest.kt @@ -29,7 +29,7 @@ import org.junit.jupiter.api.Test class ScoreCalculatorTest { @Test fun `Search string contained same index`() { - val sc = ScoreCalculator("test", MatchConfig()) + val sc = ScoreCalculator("test", MatchConfig(), mapOf()) sc.searchStringIndex = 0 sc.searchStringLength = 4 @@ -41,7 +41,7 @@ class ScoreCalculatorTest { @Test fun `Search string contained different index`() { - val sc = ScoreCalculator("test", MatchConfig()) + val sc = ScoreCalculator("test", MatchConfig(), mapOf()) sc.searchStringIndex = 0 sc.searchStringLength = 4 @@ -56,7 +56,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightStreakModifier = 10 ) - val sc = ScoreCalculator("test", matchConfig) + val sc = ScoreCalculator("test", matchConfig, mapOf()) val fScore = sc.calculateScore("/test") assertEquals(4, fScore!!.streakScore) @@ -67,7 +67,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightStreakModifier = 10 ) - val sc = ScoreCalculator("test", matchConfig) + val sc = ScoreCalculator("test", matchConfig, mapOf()) val fScore = sc.calculateScore("/te/st") assertEquals(2, fScore!!.streakScore) @@ -78,7 +78,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightStreakModifier = 10 ) - val sc = ScoreCalculator("test", matchConfig) + val sc = ScoreCalculator("test", matchConfig, mapOf()) val fScore = sc.calculateScore("/te") assertNull(fScore) @@ -89,7 +89,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightSingleChar = 10, multiMatch = true ) - val sc = ScoreCalculator("test", matchConfig) + val sc = ScoreCalculator("test", matchConfig, mapOf()) val fScore = sc.calculateScore("/test") assertEquals(4, fScore!!.multiMatchScore) @@ -100,7 +100,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightSingleChar = 10, multiMatch = true ) - val sc = ScoreCalculator("test", matchConfig) + val sc = ScoreCalculator("test", matchConfig, mapOf()) val fScore = sc.calculateScore("/testtest") assertEquals(8, fScore!!.multiMatchScore) } @@ -110,7 +110,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightSingleChar = 10, multiMatch = true ) - val sc = ScoreCalculator("test test", matchConfig) + val sc = ScoreCalculator("test test", matchConfig, mapOf()) val fScore = sc.calculateScore("/testtest") assertEquals(8, fScore!!.multiMatchScore) } @@ -120,7 +120,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightPartialPath = 1 ) - val sc = ScoreCalculator("test", matchConfig) + val sc = ScoreCalculator("test", matchConfig, mapOf()) val fScore = sc.calculateScore("/test.kt") assertEquals(1, fScore!!.partialPathScore) } @@ -130,14 +130,14 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightFilename = 10 ) - val sc = ScoreCalculator("test", matchConfig) + val sc = ScoreCalculator("test", matchConfig, mapOf()) val fScore = sc.calculateScore("/test.kt") assertEquals(4, fScore!!.filenameScore) } @Test fun `Empty ss and fp`() { - val sc = ScoreCalculator("", MatchConfig()) + val sc = ScoreCalculator("", MatchConfig(), mapOf()) val fScore = sc.calculateScore("") assertEquals(0, fScore!!.getTotalScore()) @@ -149,7 +149,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 ) - val sc = ScoreCalculator("kif", matchConfig) + val sc = ScoreCalculator("kif", matchConfig, mapOf()) val fScore = sc.calculateScore("/KotlinIsFun") @@ -164,7 +164,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 ) - val sc = ScoreCalculator("kot", matchConfig) + val sc = ScoreCalculator("kot", matchConfig, mapOf()) val fScore = sc.calculateScore("/KotlinIsFun") @@ -176,21 +176,21 @@ class ScoreCalculatorTest { @Test fun `Too long ss`() { - val sc = ScoreCalculator("TooLongSearchString", MatchConfig()) + val sc = ScoreCalculator("TooLongSearchString", MatchConfig(), mapOf()) val fScore = sc.calculateScore("/KIF") assertNull(fScore) } @Test fun `No possible match`() { - val sc = ScoreCalculator("A", MatchConfig()) + val sc = ScoreCalculator("A", MatchConfig(), mapOf()) val fScore = sc.calculateScore("/KIF") assertNull(fScore) } @Test fun `Empty ss`() { - val sc = ScoreCalculator("", MatchConfig()) + val sc = ScoreCalculator("", MatchConfig(), mapOf()) val fScore = sc.calculateScore("/KIF") assertEquals(0, fScore!!.getTotalScore()) @@ -198,14 +198,14 @@ class ScoreCalculatorTest { @Test fun `No possible match split`() { - val sc = ScoreCalculator("A A B", MatchConfig()) + val sc = ScoreCalculator("A A B", MatchConfig(), mapOf()) val fScore = sc.calculateScore("/Kotlin/Is/Fun/kif.kt") assertNull(fScore) } @Test fun `Partial match split`() { - val sc = ScoreCalculator("A A K", MatchConfig()) + val sc = ScoreCalculator("A A K", MatchConfig(), mapOf()) val fScore = sc.calculateScore("/Kotlin/Is/Fun/kif.kt") assertNull(fScore) } @@ -215,7 +215,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 ) - val sc = ScoreCalculator("fun kotlin", matchConfig) + val sc = ScoreCalculator("fun kotlin", matchConfig, mapOf()) val fScore = sc.calculateScore("/Kotlin/Is/Fun/kif.kt") @@ -230,7 +230,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 ) - val sc = ScoreCalculator("kif", matchConfig) + val sc = ScoreCalculator("kif", matchConfig, mapOf()) val fScore = sc.calculateScore("/Kotlin/Is/Fun/kif.kt") @@ -245,7 +245,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 ) - val sc = ScoreCalculator("kif", matchConfig) + val sc = ScoreCalculator("kif", matchConfig, mapOf()) val fScore = sc.calculateScore("/Kiffer/Is/Fun/kiffer.kt") @@ -260,7 +260,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 ) - val sc = ScoreCalculator("kif", matchConfig) + val sc = ScoreCalculator("kif", matchConfig, mapOf()) val fScore = sc.calculateScore("/Kif/Is/Fun/kif.kt") @@ -275,7 +275,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( matchWeightStreakModifier = 10, multiMatch = true, matchWeightSingleChar = 10, matchWeightFilename = 10 ) - val sc = ScoreCalculator("kif fun kotlin", matchConfig) + val sc = ScoreCalculator("kif fun kotlin", matchConfig, mapOf()) val fScore = sc.calculateScore("/Kotlin/Is/Fun/kif.kt") @@ -290,7 +290,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( tolerance = 5, ) - val sc = ScoreCalculator("kotlin", matchConfig) + val sc = ScoreCalculator("kotlin", matchConfig, mapOf()) assertNotNull(sc.calculateScore("/Kotlin")) } @@ -300,7 +300,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( tolerance = 5, ) - val sc = ScoreCalculator("korlin", matchConfig) + val sc = ScoreCalculator("korlin", matchConfig, mapOf()) assertNotNull(sc.calculateScore("/Kotlin")) } @@ -310,7 +310,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( tolerance = 1, ) - val sc = ScoreCalculator("korlin", matchConfig) + val sc = ScoreCalculator("korlin", matchConfig, mapOf()) assertNotNull(sc.calculateScore("/Kotlin")) } @@ -320,7 +320,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( tolerance = 1, ) - val sc = ScoreCalculator("korlnn", matchConfig) + val sc = ScoreCalculator("korlnn", matchConfig, mapOf()) assertNull(sc.calculateScore("/Kotlin")) } @@ -330,7 +330,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( tolerance = 1, ) - val sc = ScoreCalculator("korlin", matchConfig) + val sc = ScoreCalculator("korlin", matchConfig, mapOf()) assertNotNull(sc.calculateScore("/Kot/lin")) } @@ -340,7 +340,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( tolerance = 1, ) - val sc = ScoreCalculator("korlin", matchConfig) + val sc = ScoreCalculator("korlin", matchConfig, mapOf()) assertNull(sc.calculateScore("/Kot/sin")) } @@ -350,7 +350,7 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( tolerance = 2, ) - val sc = ScoreCalculator("korlin", matchConfig) + val sc = ScoreCalculator("korlin", matchConfig, mapOf()) assertNotNull(sc.calculateScore("/Kot/sin")) } @@ -360,8 +360,151 @@ class ScoreCalculatorTest { val matchConfig = MatchConfig( tolerance = 5, ) - val sc = ScoreCalculator("kotlin12345", matchConfig) + val sc = ScoreCalculator("kotlin12345", matchConfig, mapOf()) assertNull(sc.calculateScore("/Kotlin")) } + + + @Test + fun `Usage boost with null stats`() { + val sc = ScoreCalculator("", MatchConfig(), mapOf()) + assertEquals(0, sc.calculateUsageBoost(null)) + } + + @Test + fun `Usage boost with recent index 0`() { + val sc = ScoreCalculator("", MatchConfig(), mapOf()) + assertEquals(10, sc.calculateUsageBoost(FileUsageStats(0, 0))) + } + + @Test + fun `Usage boost with recent index 10`() { + val sc = ScoreCalculator("", MatchConfig(), mapOf()) + assertEquals(5, sc.calculateUsageBoost(FileUsageStats(10, 0))) + } + + @Test + fun `Usage boost with recent index 20`() { + val sc = ScoreCalculator("", MatchConfig(), mapOf()) + assertEquals(0, sc.calculateUsageBoost(FileUsageStats(20, 0))) + } + + @Test + fun `Usage boost with access count`() { + val sc = ScoreCalculator("", MatchConfig(), mapOf()) + assertEquals(11, sc.calculateUsageBoost(FileUsageStats(0, 5))) + } + + @Test + fun `Usage boost affects total score`() { + val fileUsageStats = mapOf("/test.kt" to FileUsageStats(0, 5)) + val sc = ScoreCalculator("test", MatchConfig(), fileUsageStats) + val fScore = sc.calculateScore("/test.kt") + assertNotNull(fScore) + assertEquals(1, fScore!!.frequencyScore) + assertEquals(10, fScore.recencyScore) + // streakScore = (4 * 10) / 10 = 4 + // filenameScore = (4 * 20) / 10 = 8 + // partialPathScore = 10 (default weight) + // total = 1 + 10 + 4 + 8 + 10 = 33 + assertEquals(33, fScore.getTotalScore()) + } + + @Test + fun `Usage boost affects total score with weights`() { + val fileUsageStats = mapOf("/test.kt" to FileUsageStats(0, 5)) + val matchConfig = MatchConfig(matchWeightFrequency = 20, matchWeightRecency = 5) + val sc = ScoreCalculator("test", matchConfig, fileUsageStats) + val fScore = sc.calculateScore("/test.kt") + assertNotNull(fScore) + // Frequency boost: (1 * 20) / 10 = 2 + // Recency boost: (10 * 5) / 10 = 5 + assertEquals(2, fScore!!.frequencyScore) + assertEquals(5, fScore.recencyScore) + // streakScore = (4 * 10) / 10 = 4 + // filenameScore = (4 * 20) / 10 = 8 + // partialPathScore = 10 (default weight) + // total = 2 + 5 + 4 + 8 + 10 = 29 + assertEquals(29, fScore.getTotalScore()) + } + + @Test + fun `Recency boost is smooth`() { + val sc = ScoreCalculator("", MatchConfig(), mapOf()) + assertEquals(10, sc.calculateUsageBoost(FileUsageStats(0, 0))) + assertEquals(9, sc.calculateUsageBoost(FileUsageStats(2, 0))) + assertEquals(8, sc.calculateUsageBoost(FileUsageStats(4, 0))) + assertEquals(5, sc.calculateUsageBoost(FileUsageStats(10, 0))) + assertEquals(0, sc.calculateUsageBoost(FileUsageStats(20, 0))) + assertEquals(0, sc.calculateUsageBoost(FileUsageStats(30, 0))) + } + + @Test + fun `Not in recent list is not better than late in recent list`() { + val sc = ScoreCalculator("", MatchConfig(), mapOf()) + + val notInListBoost = sc.calculateUsageBoost(null) // 0 + val lateInListBoost = sc.calculateUsageBoost(FileUsageStats(20, 0)) // 0 + + assertEquals(notInListBoost, lateInListBoost, "File NOT in list should be equal to a file that is very old in the list") + } + + @Test + fun `Frequency boost is capped`() { + val sc = ScoreCalculator("", MatchConfig(), mapOf()) + // 100 accesses gives 10 points (capped) + // index 10 gives 5 points + assertEquals(15, sc.calculateUsageBoost(FileUsageStats(10, 100))) + } + + @Test + fun `Usage boost with weights set to 0`() { + val matchConfig = MatchConfig(matchWeightFrequency = 0, matchWeightRecency = 0) + val sc = ScoreCalculator("", matchConfig, mapOf()) + assertEquals(0, sc.calculateUsageBoost(FileUsageStats(0, 100))) + } + + @Test + fun `Multi match weight affects total score`() { + val matchConfig = MatchConfig( + multiMatch = true, + matchWeightSingleChar = 20 + ) + val sc = ScoreCalculator("test", matchConfig, mapOf()) + val fScore = sc.calculateScore("/test.kt") + assertNotNull(fScore) + + // "test.kt" contains 't', 'e', 's', 't', 't' -> 5 matches. + // weight 20. (5 * 20) / 10 = 10. + assertEquals(10, fScore!!.multiMatchScore) + } + + @Test + fun `All weights affect total score`() { + val fileUsageStats = mapOf("/test.kt" to FileUsageStats(0, 5)) + val matchConfig = MatchConfig( + matchWeightStreakModifier = 20, + matchWeightFilename = 30, + matchWeightPartialPath = 5, + matchWeightFrequency = 20, + matchWeightRecency = 5 + ) + val sc = ScoreCalculator("test", matchConfig, fileUsageStats) + val fScore = sc.calculateScore("/test.kt") + assertNotNull(fScore) + + // streakScore: longestStreak=4, weight=20. (4 * 20) / 10 = 8 + // filenameScore: longestFilenameStreak=4, weight=30. (4 * 30) / 10 = 12 + // partialPathScore: weight=5. 5 + // frequencyScore: access=5, weight=20. (1 * 20) / 10 = 2 + // recencyScore: index=0, weight=5. (10 * 5) / 10 = 5 + + assertEquals(8, fScore!!.streakScore) + assertEquals(12, fScore.filenameScore) + assertEquals(5, fScore.partialPathScore) + assertEquals(2, fScore.frequencyScore) + assertEquals(5, fScore.recencyScore) + assertEquals(8 + 12 + 5 + 2 + 5, fScore.getTotalScore()) // 32 + } } \ No newline at end of file diff --git a/src/test/kotlin/com/mituuz/fuzzier/search/initialview/RecentlySearchedFilesUtilTest.kt b/src/test/kotlin/com/mituuz/fuzzier/search/initialview/RecentlySearchedFilesUtilTest.kt index d7ff4831..5a4c5e6a 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/search/initialview/RecentlySearchedFilesUtilTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/search/initialview/RecentlySearchedFilesUtilTest.kt @@ -26,93 +26,153 @@ package com.mituuz.fuzzier.search.initialview import com.intellij.openapi.components.service import com.intellij.testFramework.TestApplicationManager +import com.mituuz.fuzzier.entities.FileAccessData 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 io.mockk.unmockkAll import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import javax.swing.DefaultListModel class RecentlySearchedFilesUtilTest { @Suppress("unused") // Required for add to recently used files (fuzzierSettingsServiceInstance) private var testApplicationManager: TestApplicationManager = TestApplicationManager.getInstance() + private lateinit var fuzzierSettingsServiceInstance: FuzzierSettingsService + + @BeforeEach + fun setUp() { + fuzzierSettingsServiceInstance = service() + fuzzierSettingsServiceInstance.state.recentlySearchedFiles = mutableListOf() + } @AfterEach fun tearDown() { unmockkAll() } + private fun createContainer(path: String = ""): FuzzyMatchContainer { + return FuzzyMatchContainer(FuzzyMatchContainer.FuzzyScore(), path, path, path, FILE) + } + @Test fun `Add file to recently used files - Null list should default to empty`() { - val fuzzierSettingsServiceInstance: FuzzierSettingsService = service() - val fgss = service().state - val score = FuzzyMatchContainer.FuzzyScore() - val container = FuzzyMatchContainer(score, "", "", "", FILE) + val container = createContainer() fuzzierSettingsServiceInstance.state.recentlySearchedFiles = null addFileToRecentlySearchedFiles( container, fuzzierSettingsServiceInstance.state, - fgss + 20, 100 ) - assertNotNull(fuzzierSettingsServiceInstance.state.getRecentlySearchedFilesAsFuzzyMatchContainer()) - assertEquals(1, fuzzierSettingsServiceInstance.state.getRecentlySearchedFilesAsFuzzyMatchContainer().size) + assertNotNull(fuzzierSettingsServiceInstance.state.recentlySearchedFiles) + assertEquals(1, fuzzierSettingsServiceInstance.state.recentlySearchedFiles?.size) } @Test fun `Add file to recently used files - Too large list is truncated`() { - val fuzzierSettingsServiceInstance: FuzzierSettingsService = service() - val fgss = service().state val fileListLimit = 2 - val score = FuzzyMatchContainer.FuzzyScore() - val container = FuzzyMatchContainer(score, "", "", "", FILE) + val container = createContainer() - val largeList: DefaultListModel = DefaultListModel() + val largeList: MutableList = mutableListOf() for (i in 0..25) { - largeList.addElement(FuzzyMatchContainer(score, "" + i, "" + i, "", FILE)) + largeList.add(createContainer("" + i)) } - fgss.fileListLimit = fileListLimit - fuzzierSettingsServiceInstance.state.recentlySearchedFiles = - FuzzyMatchContainer.SerializedMatchContainer.fromListModel(largeList) + largeList.map { FuzzyMatchContainer.SerializedMatchContainer.fromFuzzyMatchContainer(it) } addFileToRecentlySearchedFiles( container, fuzzierSettingsServiceInstance.state, - fgss + fileListLimit, 100 ) assertEquals( fileListLimit, - fuzzierSettingsServiceInstance.state.getRecentlySearchedFilesAsFuzzyMatchContainer().size + fuzzierSettingsServiceInstance.state.recentlySearchedFiles?.size ) } @Test fun `Add file to recently used files - Duplicate filenames are removed`() { - val fuzzierSettingsServiceInstance: FuzzierSettingsService = service() - val fgss = service().state - val fileListLimit = 20 - val score = FuzzyMatchContainer.FuzzyScore() - val container = FuzzyMatchContainer(score, "", "", "", FILE) + val container = createContainer() - val largeList: DefaultListModel = DefaultListModel() + val largeList: MutableList = mutableListOf() repeat(26) { - largeList.addElement(FuzzyMatchContainer(score, "", "", "", FILE)) + largeList.add(createContainer()) } - fgss.fileListLimit = fileListLimit - fuzzierSettingsServiceInstance.state.recentlySearchedFiles = - FuzzyMatchContainer.SerializedMatchContainer.fromListModel(largeList) + largeList.map { FuzzyMatchContainer.SerializedMatchContainer.fromFuzzyMatchContainer(it) } addFileToRecentlySearchedFiles( container, fuzzierSettingsServiceInstance.state, - fgss + 20, 100 ) - assertEquals(1, fuzzierSettingsServiceInstance.state.getRecentlySearchedFilesAsFuzzyMatchContainer().size) + assertEquals(1, fuzzierSettingsServiceInstance.state.recentlySearchedFiles?.size) + } + + @Test + fun `addFileToLRUCache - Add new file to empty cache`() { + val recentFiles = mutableListOf() + val container = createContainer("path1") + val result = addFileToLRUCache(container, recentFiles, 5) + + assertEquals(1, result.size) + assertEquals("path1", result[0].filePath) + assertEquals(1, result[0].accessCount) + } + + @Test + fun `addFileToLRUCache - Add new file to non-empty cache`() { + val recentFiles = mutableListOf( + FileAccessData("path1", 1) + ) + val container = createContainer("path2") + val result = addFileToLRUCache(container, recentFiles, 5) + + assertEquals(2, result.size) + assertEquals("path2", result[0].filePath) + assertEquals(1, result[0].accessCount) + assertEquals("path1", result[1].filePath) + } + + @Test + fun `addFileToLRUCache - Add existing file`() { + val recentFiles = mutableListOf( + FileAccessData("path1", 1), + FileAccessData("path2", 1) + ) + val container = createContainer("path2") + val result = addFileToLRUCache(container, recentFiles, 5) + + assertEquals(2, result.size) + assertEquals("path2", result[0].filePath) + assertEquals(2, result[0].accessCount) + assertEquals("path1", result[1].filePath) + } + + @Test + fun `addFileToLRUCache - Exceeding max size`() { + val recentFiles = mutableListOf( + FileAccessData("path2", 1), + FileAccessData("path1", 1) + ) + val container = createContainer("path3") + val result = addFileToLRUCache(container, recentFiles, 2) + + assertEquals(2, result.size) + assertEquals("path3", result[0].filePath) + assertEquals("path2", result[1].filePath) + } + + @Test + fun `addFileToLRUCache - Max size 0`() { + val recentFiles = mutableListOf() + val container = createContainer("path1") + val result = addFileToLRUCache(container, recentFiles, 0) + + assertEquals(0, result.size) } } diff --git a/src/test/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurableTest.kt b/src/test/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurableTest.kt index 39a42cf2..8110227d 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurableTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/settings/FuzzierGlobalSettingsConfigurableTest.kt @@ -62,6 +62,8 @@ class FuzzierGlobalSettingsConfigurableTest { state.matchWeightSingleChar = 6 state.matchWeightStreakModifier = 20 state.matchWeightFilename = 15 + state.matchWeightFrequency = 12 + state.matchWeightRecency = 13 } @Test @@ -270,6 +272,20 @@ class FuzzierGlobalSettingsConfigurableTest { assertTrue(settingsConfigurable.isModified) } + @Test + fun matchWeightFrequency() { + pre() + state.matchWeightFrequency = 13 + assertTrue(settingsConfigurable.isModified) + } + + @Test + fun matchWeightRecency() { + pre() + state.matchWeightRecency = 14 + assertTrue(settingsConfigurable.isModified) + } + @Test fun grepBackend_isPopulatedFromState() { state.grepBackend = FuzzierGlobalSettingsService.GrepBackend.FUZZIER diff --git a/src/test/kotlin/com/mituuz/fuzzier/util/DefaultInitialListModelProviderTest.kt b/src/test/kotlin/com/mituuz/fuzzier/util/DefaultInitialListModelProviderTest.kt index dec7861e..d71c20c6 100644 --- a/src/test/kotlin/com/mituuz/fuzzier/util/DefaultInitialListModelProviderTest.kt +++ b/src/test/kotlin/com/mituuz/fuzzier/util/DefaultInitialListModelProviderTest.kt @@ -28,6 +28,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.SerializedMatchContainer.Companion.fromFuzzyMatchContainer import com.mituuz.fuzzier.search.initialview.DefaultInitialListModelProvider import com.mituuz.fuzzier.settings.FuzzierGlobalSettingsService import com.mituuz.fuzzier.settings.FuzzierSettingsService @@ -40,15 +41,12 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import javax.swing.DefaultListModel class DefaultInitialListModelProviderTest { private lateinit var project: Project private lateinit var fuzzierSettingsService: FuzzierSettingsService private lateinit var fuzzierGlobalSettingsService: FuzzierGlobalSettingsService - private lateinit var fuzzierUtil: FuzzierUtil private lateinit var defaultInitialListModelProvider: DefaultInitialListModelProvider - private lateinit var state: State private lateinit var editorHistoryManager: EditorHistoryManager @Suppress("unused") // Required for add to recently used files (fuzzierSettingsServiceInstance) @@ -59,12 +57,10 @@ class DefaultInitialListModelProviderTest { project = mockk() fuzzierSettingsService = mockk() fuzzierGlobalSettingsService = mockk() - state = mockk() val globalState = FuzzierGlobalSettingsService.State() globalState.recentFilesMode = FuzzierGlobalSettingsService.RecentFilesMode.RECENT_PROJECT_FILES - defaultInitialListModelProvider = DefaultInitialListModelProvider(project, globalState, state) + defaultInitialListModelProvider = DefaultInitialListModelProvider(project, globalState, State()) editorHistoryManager = mockk() - every { fuzzierSettingsService.state } returns state } @AfterEach @@ -90,7 +86,7 @@ class DefaultInitialListModelProviderTest { every { virtualFile2.path } returns "/project/path/file2" every { virtualFile2.name } returns "filename2" - val settingsState = FuzzierSettingsService.State() + val settingsState = State() settingsState.modules = mapOf("module" to "/project/path/") defaultInitialListModelProvider = DefaultInitialListModelProvider(project, fgss, settingsState) @@ -118,7 +114,7 @@ class DefaultInitialListModelProviderTest { every { virtualFile2.path } returns "/other/path/file2" every { virtualFile2.name } returns "filename2" - val settingsState = FuzzierSettingsService.State() + val settingsState = State() settingsState.modules = mapOf("module" to "/project/path/") defaultInitialListModelProvider = DefaultInitialListModelProvider(project, fgss, settingsState) @@ -143,31 +139,40 @@ class DefaultInitialListModelProviderTest { } @Test - fun `Recently searched files - Order of multiple files`() { - val fuzzyMatchContainer1 = mockk() - val fuzzyMatchContainer2 = mockk() - val listModel = DefaultListModel() - listModel.addElement(fuzzyMatchContainer1) - listModel.addElement(fuzzyMatchContainer2) - every { state.getRecentlySearchedFilesAsFuzzyMatchContainer() } returns listModel - - val result = defaultInitialListModelProvider.getRecentlySearchedFiles() - - assertEquals(fuzzyMatchContainer2, result[0]) - assertEquals(fuzzyMatchContainer1, result[1]) - } + fun `getRecentlySearchedFiles returns recently searched files in reverse order`() { + val state = State() + state.recentlySearchedFiles = listOf( + fromFuzzyMatchContainer( + FuzzyMatchContainer( + FuzzyMatchContainer.FuzzyScore(), + "/old.kt", + "old.kt", + "/module", + FuzzyMatchContainer.FileType.FILE + ) + ), + fromFuzzyMatchContainer( + FuzzyMatchContainer( + FuzzyMatchContainer.FuzzyScore(), + "/new.kt", + "new.kt", + "/module", + FuzzyMatchContainer.FileType.FILE + ) + ), + ) - @Test - fun `Recently searched files - Remove null elements from the list`() { - val fuzzyMatchContainer = mockk() - val listModel = DefaultListModel() - listModel.addElement(fuzzyMatchContainer) - listModel.addElement(null) - listModel.addElement(null) - every { state.getRecentlySearchedFilesAsFuzzyMatchContainer() } returns listModel + val model = defaultInitialListModelProvider.getRecentlySearchedFiles(state) - val result = defaultInitialListModelProvider.getRecentlySearchedFiles() + assertEquals("new.kt", model.getElementAt(0).filename) + assertEquals("old.kt", model.getElementAt(1).filename) + } - assertEquals(1, result.size) + @Test + fun `Recently searched files - No files`() { + val state = State() + state.recentlySearchedFiles = mutableListOf() + val result = defaultInitialListModelProvider.getRecentlySearchedFiles(state) + assertEquals(0, result.size()) } } \ No newline at end of file