Lately I've been checking out some videos from The Primeagen and was inspired to get back into vim. Although I'm not ready to give up IntelliJ IDEA, I set up the IdeaVim plugin two months ago and I'm still loving it.
I was curious about NeoVim and decided to follow The Primeagen's video 0 to LSP : Neovim RC From Scratch. I absolutely loved how the telescope search works and it became really clear that the double shift (or CTRL + SHIFT + n) file search in IntelliJ does not support fuzzy search nor include the project file path in the search. After spending almost five minutes to check the marketplace for a fuzzy search plugin, I already knew the answer; I wanted to make a plugin that feels and looks more like Telescope for Nvim.
Spoilers, you can check out the repository here. I also published the plugin on the JetBrains Marketplace.
Here's a reference picture what the telescope search looks like and what the goal of this plugin looks like. The idea is to have a simple and fuzzy file search that allows you to stay on the home row when looking for the correct file.
Fuzzy search is a search method that finds matches even when the search term is not exactly the same as the wanted string. It is extremly useful for many applications, one of which are code editors. And it is often a built-in feature.
Note! The current implementatio is not a true fuzzy search, it does not handle wrong characters at the moment.
For example having several gradle modules in single project means that there are several build.gradle files.
If I want to quickly switch to the correct one:
I've never created a plugin before, so I've no idea where to start. For most of the development, I've used the IntelliJ Platform Plugin SDK as reference. This contains the basics and has some good tips what tools can be used.
The first step was to install the Plugin DevKit-plugin, this allows us to create a new project from the IntelliJ plugin template. It defaults to Kotlin, which is fine for me. I'm mainly a Java developer, but enjoy some Kotlin here and there.
The first step was to set a goal that would be easy and simple to implement. Using the
createComponentPopupBuilder
I could show any swing component, so I can just create a component
that has the structure that I need.
The component needs three parts:
I separated the popup component from the plugin class and used the IntelliJ forms functionality to create the component structure.
I called this component FuzzyFinder. Here's an example picture of the IntelliJ form editor.
You may have noticed, that the FuzzyFinder is a Java class. It seems like the form functionality does not support Kotlin files, even when manually converting the generated Java file to Kotlin. The changes were not being applied. Because of this I decided to stay with the Java file for now, I might delete the form file and convert the FuzzyFinder to Kotlin later, when using the form is no longer necessary to modify the component.
The Java file was not without issues either, it didn't get compiled when running the plugin. No class file was found from the built sources. To fix this, I had to add a separate declaration to the build.gralde.kts file to specify where to find Java files to compile. I used the Kotlin source folder, mostly because I didn't want to create two source folders and I might end up converting the Java class to Kotlin.
sourceSets {
main {
java {
srcDirs("src/main/kotlin")
}
}
}
Unfortunately, this did not still work correctly; even though the Java file was being compiled, the form itself was missing. It should exist with the Java file.
I opted to use the form in a different way, IntelliJ can generate the form into Java source code, which updates the actual Java class when building the project with IntelliJ (this needs to be set separately as shown below).
Setting the option to Generate GUI into Java source code.
Using IntelliJ to build and run the project.
With these changes I was finally able to get the form changes to the compiled file and could continue to the popup.
Creating the actual popup itself required using the plugin.xml to register a new menu item where I could
trigger an action that would be the starting point of my plugin. The actual class has to extend
AnAction
which will be defined in the actions
section. This adds the Fuzzy File
Search option to the tools menu and the editor actions.
<actions>
<action
id="com.mituuz.fuzzier.Fuzzier"
class="com.mituuz.fuzzier.Fuzzier"
text="Fuzzy File Search"
description="Search files using a basic fuzzy search">
<override-text place="MainMenu" text="Fuzzy File Search"/>
<add-to-group group-id="ToolsMenu" anchor="first"/>
</action>
</actions>
Initially, I had extended JComponent with the FuzzyFinder class as this is what is needed for
createComponentPopupBuilder
, but this was wrong. The actual JComponent class does not show, but
is the base class for another Swing component, which can be used for the base. I chose JPanel with
BorderLayout to cleanly contain my FuzzyFinder class.
Here's the awesome popup that I managed to create.
Logically, the next thing would be to get the project files and list them on the left. I try to tackle the visual issues as I'll run into them.
Per the documentation I can get the project files from the ProjectFileIndex class. Using this, I can get the full paths of the files and the actual VirtualFiles if necessary. Just iterating over the ProjectFileIndex and adding the file paths to a DefaultListModel<String>, which then can be set as the JBList's list model. I added a basic skipping of null or blank file paths and using the virtual file information to skip directories and removed the project base path from the full file paths.
override fun actionPerformed(p0: AnActionEvent) {
p0.project?.let {
val listModel = DefaultListModel<String>()
val projectFileIndex = ProjectFileIndex.getInstance(it)
val projectBasePath = it.basePath
val contentIterator = ContentIterator { file: VirtualFile ->
if (!file.isDirectory) {
val filePath = projectBasePath?.let { it1 -> file.path.removePrefix(it1) }
if (!filePath.isNullOrBlank()) {
listModel.addElement(filePath)
}
}
true
}
projectFileIndex.iterateContent(contentIterator)
component.fileList?.model = listModel
This just works and the files are listed on the left side of the popup. I had to embed the JBList into a JScrollPane, because the whole content was trying to visible at the same time, which meant that the popup didn't fit the screen vertically at all. I modified the JBList to have a selection mode of a single row and added a listener to it that when the selected value changes; fetch the file from VirtualFileManager and populate the preview pane with the file contents.
component.fileList.addListSelectionListener { event ->
run {
if (!event.valueIsAdjusting) {
val selectedValue = component.fileList.selectedValue
val file = VirtualFileManager.getInstance().findFileByUrl("file://$projectBasePath$selectedValue")
file?.let {
val document = FileDocumentManager.getInstance().getDocument(it)
component.previewPane.text = document?.text ?: "Cannot read file"
}
}
}
}
The end result looks like this. The sizing needs some adjusting, but the basics seem to work.
What do I need to make the plugin actually usable?
The only thing missing from the plugin is a way to open the selected file from the list. For some reason my text field wont focus at all, so I chose to first enable double clicking a list item to open the file in the editor and close the popup. Implementing this was really simple, just adding a MouseListener to the JBList, that opens the file and closes the popup.
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://$projectBasePath$selectedValue")
// Open the file in the editor
virtualFile?.let {
openFile(project, it)
}
}
}
I might want to make a setting later to make it configurable if the editor file will be opened in a new tab or the current one, but for me I want the current tab to be used.
private fun openFile(project: Project, virtualFile: VirtualFile) {
val fileEditorManager = FileEditorManager.getInstance(project)
val currentEditor = fileEditorManager.selectedTextEditor
// Either open the file if there is already a tab for it or close current tab and open the file in a new one
if (fileEditorManager.isFileOpen(virtualFile)) {
fileEditorManager.openFile(virtualFile, true)
} else {
if (currentEditor != null) {
fileEditorManager.selectedEditor?.let { fileEditorManager.closeFile(it.file) }
}
fileEditorManager.openFile(virtualFile, true)
}
popup?.cancel()
}
Now to implement the last missing feature, filtering the files using the search field. But I had a problem, I
couldn't get the search field into focus at all. No matter that all of the components and popup is visible
and focusable and such which were suggested, calling requestFocusInWindow
always returned
false. Optionally the createComponentPopupBuilder
takes a popup and an optional focus component
as parameters, but that too was not working.
Finding the solution took way too long, but in the end I found it. I'm kinda confused how hard it was to find the answer. I was trying with both, google and GPT-4. GPT-4 happened to find the answer at the same time, but I stumbled upon the stackoverflow answer while waiting GPT-4 to generate an answer.
Simply, I just had to set the request focus to the componentPopupBuilder
. I could also set the
popup to be resizable and have persistent size using the JBPopupFactory
.
popup = JBPopupFactory
.getInstance()
.createComponentPopupBuilder(component, component.searchField)
.setFocusable(true)
.setRequestFocus(true)
.setResizable(true)
.setDimensionServiceKey(project, "FuzzySearchPopup", true)
.setTitle("Fuzzy Search")
.setMovable(true)
.setShowBorder(true)
.createPopup()
Now I finally could implement updating the list contents when the search field value changes. This was easily
done by adding a DocumentListener
to the search field, which updates the file list based on the
current string value of the search field.
val document = component.searchField.document
document.addDocumentListener(object : DocumentListener {
override fun documentChanged(event: DocumentEvent) {
updateListContents(project, component.searchField.text)
}
})
To finish the "MVP" I added the enter key as an additional way to open the currently selected file.
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://$projectBasePath$selectedValue")
virtualFile?.let {
openFile(project, it)
}
}
})
I also implemented some visual structure changes, for example using a split pane to allow manual resizing of the file list/search field and preview pane split.
This feature is the most important one and does not have a single correct answer. I have no idea how to implement fuzzy search in a proper way, so it will most likely be quite bad at the beginning, but I will keep working on it until it feels right.
The first iteration was just a StringUtils.contains call, which was not good at all as I didn't have any exclusions on the file list. So it would quickly fill up with .git and .idea files. The exlusion list should be configurable, but for now I'll live with a static one that exludes file paths that contain one of the following values:
I also made the search case insensitive by turning both strings to lowercase before comparing them.
I had a few different implementations in between before ending up with the following:
This is where people might have different options on how the actual list should be sorted. This will not be the last version of the search, and I'd like to get closer to a true fuzzy search by handling some characters that are not present, or in the wrong order in the search string. I would also like to implement a weight system that could be used to refine the results.
And finished up with a few quality of life changes:
I had a strange issue with the gradle. After updating my editor version to 2023.3.2 every time I tried to run the plugin using IntelliJ's run configuration or calling gradle wrapper from the terminal I would get the following error:
Unsupported JVM architecture was selected for running Gradle tasks: x86. Supported architectures: amd64, aarch64
To fix this, I had to modify the run command to specify the architecture: build -Dos.arch=amd64
:
I fought long and hard to get up and down arrow keys to work from the search field to move the list selection up and down, but did not have any luck. Might have to replace the EditorTextField with something else or find some other solution.
As I stated at the beginning I wanted to create this plugin to enable switching files without leaving the
keyboard. To add a plugin action as a IdeaVim mapping, you can add the following line to your
.ideavimrc
:
map <Leader>pf <action>(com.mituuz.fuzzier.Fuzzier)
Implemented:
I have several things that I want to keep on refining, but here are some of the more important ones that I want to implement:
I wanted to document my plugin creation journey, even more so after running into a few problems. It was surprisingly easy and I could see myself creating some other plugin in the future. I now have a working plugin that I actually use daily.
Publishing the plugin to the JetBrains Marketplace was really easy. I just followed the guidelines and pushed it for review.
One maybe expected take away from this was that kotlin is kind of great. I could also see myself using it for more of my personal projects instead of Java. I especially enjoy trailing lambdas, null handling and let functions.