Creating an IntelliJ Plugin

Return to front page

What's missing from IntelliJ

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.

An image of the Telescope for NeoVim UI

What is fuzzy search

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:

No idea where to start

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.

Fuzzier here we go

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.

A picture of the actual class source and the IntelliJ form editor

Issues with the form

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.

Picture of IntelliJ setting for switching the form GUI generation

Using IntelliJ to build and run the project.

Picture of IntelliJ setting for switching build and run to use the IDE

With these changes I was finally able to get the form changes to the compiled file and could continue to the popup.

First goal achieved

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>
Picture of the IntelliJ IDEA tools menu with the Fuzzy File Search option Picture of the IntelliJ IDEA action menu with the Fuzzy File Search option

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.

Picture of the initial plugin popup

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.

Plugin popup example with working preview and selection

Minimum viable product

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.

MVP example of the plugin popup

Fuzzying it up

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:

  1. Check if the search string chars are found from the file path in the correct order (doesn't matter if they're sequential or not)
  2. Count the longest streak of sequential characters per file path
  3. Sort the list based on the score

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:

Additional notes

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:

IntelliJ run configration, setting the architecture

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.

Adding a VIM keybinding for plugin action

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)

ToDo list

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:

Conclusion

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.

References

Return to front page