Software development is a great pleasure if you have the right set of tools to help you on your adventure. For many, this means a feature-rich and stable Integrated Development Environment or an extendable code editor. Depending on your needs, there are many platforms and flavors of IDEs and editors, with the most popular including (but not limited to): Visual Studio Code, Sublime Text, IntelliJ IDEA, PyCharm, Atom, WebStorm, PHPStorm, Visual Studio, Eclipse, NetBeans, Android Studio, CLion, Xcode, and the list could go on!
That’s great news for anyone passionate about productivity, and even better for those who want to help others reach new heights in that passion! “Why?” you may ask. Have you noticed a trend in the list of IDEs mentioned above? Many of those are based on the same flexible platform that allows you to fine-tune your experience to best suit your stack. Meet the IntelliJ Platform, a powerful foundation behind some of the world’s most popular and best-acclaimed IDEs, letting you reach millions of users.
This article is the first in a series focused on how to develop an IntelliJ plugin, where we share some of our discoveries about its API.
Code Along
Articles in this series will describe solutions that are the results of our research. You can check this project’s repository to quickly look up the code you may find helpful when solving similar problems.
Prerequisites
The IntelliJ Platform plugin series will explain how to solve intricate problems we encountered during the development of our own plugin and is a great way to showcase our open-source Kotlin SDK while doing so. While each article will focus on a narrow spectrum of functionalities (like the Drag and Drop Tree covered today), we assume you have the Pieces Suite installed if you want to try to develop an IntelliJ plugin by yourself while learning from our repo.
The Problem
While we strive for ease of use, we faced an unnecessary friction when saving and reusing snippets inside of IntelliJ. No ability to do that using our most natural interfacing pattern, the Drag and Drop, forced our users to click through menus if they wanted to import a Snippet into the editor, or save a new one.
With Snippets grouped by language, changing their classification was also unnecessarily complicated if you already have other snippets of your desired type. We aim to fix this issue by allowing users to perform those actions in the most natural, human-friendly way.
On the surface, this seems like a trivial task, after all, it’s all about dragging text around, right? What if I want to detect what language the code was dropped on, so Pieces OS doesn’t have to do the guesswork it would have to do if the code was dropped on an empty space under the list?
Another difficulty is reclassification. How do I invoke custom logic? How does IntelliJ know when to use it? How do I make sure reclassification targets the right Snippet? The task is complex and brings a lot of questions.
Requirements
Now that we know we are dealing with a complex issue, let’s break it down to bite-sized chunks and make it less scary:
- We need to display the Snippets in an organized manner, grouped by language. This requires creating a simple view with a tree structure rendered inside
- The snippet collection has to be human-readable
- The tree structure has to support dragging and dropping items onto it, as well as dragging its own elements
- On element drop, the tree needs to be able to recognize the drop location
- Depending on the recognized drop location, the tree has to trigger custom logic to be executed
- The tree has to recognize the type of data being dropped onto it
- When a tree’s node is being dragged, there has to be a way to expose a snippet’s data to the drop target
When we organize our expectations for the final product, it’s not as scary now, is it? Please note that we are not focusing on storing and retrieving Pieces data in this section, as this article’s point is a UI experience enhancement. We will cover these topics in the code, however, and everything will be explained later.
The View
This section covers creating a basic panel showing our data in an easy to read, friendly form.
The Basics
We want to enable our users to access Pieces at a glance and have the list always ready and waiting for them to interact. This calls for a side panel attached to the IntelliJ’s project window. To create such a panel, we need to build a Tool Window. To do that, we need to create a Tool Window Factory that can be registered in an IntelliJ-based IDE from a declaration in our plugin’s descriptor XML.
Our factory class is responsible for initializing the Tool Window and a Simple Tool Window Panel used as the Window’s content, and configures its header to include a refresh action to reload snippets from Pieces OS (we can have a deep dive into IntelliJ’s action system in future posts).
There is quite a bit going on in our view’s class, so we'll take it slow and go through its functions one by one, according to their importance. The first thing we need to do is to create the structure our items will fit into. com.intellij.ui.treeStructure.Tree seems to best match our needs, and that’s what we’ll use. In order to prepare it for what is coming, we need to configure it.
Create a Tree with a DefaultTreeModel
based on a DefaultMutableTreeNode
root (to both of which a reference is also stored in the view for ease of access to the structure later) and set its properties as follows:
-
dragEnabled
to true -
selectionModel.selectionMode
toTreeSelectionModel.SINGLE_TREE_SELECTION
-
dropMode
toDropMode.ON_OR_INSERT
While dragEnabled
is self-explanatory, it’s good to understand the remaining two changes. Single Tree Selection mode restricts this tree’s selection options to only one node at a time, so we can avoid making a mess when the user tries to drag two or more snippets into the editor and there’s no way of knowing which comes first and if that insertion was intended. Drop Mode being set to DropMode.ON_OR_INSERT
lets the tree know that it should allow dropping items basically everywhere and allows the drop handler to track the drop location more precisely.
No adjustments to the Selection model
Single Tree Selection
To add a cherry on top of our Tree’s configuration, we added empty state text, just to make this view look nicer if you haven’t loaded your Pieces Snippets yet. It’s an optional step, but if you have ever used the Pieces for Developers Desktop App and other Pieces products, you know we like to pay attention to details.
The last step is to add our tree to the panel. We did it in the tool window’s constructor, where is a matter of simply calling the add
method inherited from the Simple Tool Window Panel with our tree wrapped in a Scroll Pane. Why the wrap? Because IntelliJ does not handle scroll automatically, clipping the tool window’s content by default. Thankfully, the fix is easy: wrap your tree reference in com.intellij.ui.ScrollPaneFactory#createScrollPane
and pass the result to the add method.
Later on, we will add a Transfer Handler to properly serve our drag-and-drop functionality.
The Data
Great! You now have a simple tool window with a tree structure installed! It would be good to have it display something now, wouldn’t it? The eager readers will have already seen the project we are building here and have noticed our Tool Window class has a method called updateTree
. This method is fairly simple, as it only clears the tree, invokes another method to rebuild it, and triggers the tree model to reload the updated structure after the rebuild. We will focus on the method responsible for updating the tree structure itself.
What it does first is create a map of items to be displayed grouped by their type. Since we use Pieces Assets in this project, we will also filter out the types of data we cannot currently show in IntelliJ, like videos or binaries, and only allow images, text, and code assets to be left in. Once our map is built, we can quickly map each language represented by the map’s key to a basic DefaultMutableTreeNode
with the key itself passed as its userObject
, and then add all the grouped assets as its children once we map them to SnippetTreeNode
s.
SnippetTreeNode
? That’s new, I don’t see it in IDEA’s SDK. It’s a simple extension of the DefaultMutableTreeNode
allowing us to easily access the crucial data required to identify an Asset. It contains an Asset’s name so we can easily render it on our tree, and its ID, so we can access it directly via our APIs or in our plugin’s storage. A type check verifying if we are dealing with a SnippetTreeNode
is also going to be helpful in our Transfer Handler.
class SnippetTreeNode(descriptor: MinimalAssetDescriptor) : DefaultMutableTreeNode(descriptor) {
// The name of the snippet.
override fun toString(): String = (userObject as MinimalAssetDescriptor).name.orEmpty()
val id get() = (userObject as MinimalAssetDescriptor).id
val icon get() = (userObject as MinimalAssetDescriptor).icon
}
Now, we have a list of category nodes with corresponding asset nodes added to them. All that’s left to do is to add all of the category nodes to the root node. Normally, you would want to install a dedicated tree node render customizer, so you could do all kinds of interesting things, but we want to keep it simple for the purpose of demonstrating the drag-and-drop functionality, and we can rely on the default renderer, which will just invoke each tree node’s toString
method to get a simple text representation to render, and normally resolves to its userObject
’s toString
method (and to returns the asset’s name in SnippetTreeNode
).
We know how the tree is built and how it is being rendered. It is time to finally get our hands dirty with:
The Transfer Handler
A part that’s fixed, but helps to move others.
The What?
This is quite a concept to wrap your head around. For starters, we need to understand how many responsibilities it has, so we can later properly address them.
If a dragging event is detected, and our component currently has thing(s) being dragged over it the handler needs to tell in advance if the item held by the mouse cursor can be handled properly if it’s dropped here. It requires recognizing what type of data such an item represents, and if its contents can be used with our solution. This function needs to be performant, so we don’t experience UI freezes or glitches.
Thankfully, we don’t have to handle rejections of content types unsupported by our component, as those are handled by the Platform once we tell it we cannot accept a given type of data. If we can accept a given item in our component, we also need to make sure that we have all the means to import that data inside of our component’s logic.
The transfer handler is not only responsible for importing data to our drop target, but also for allowing its export if our tree becomes a drag event’s source. This calls for creating a Transferable object that can later be used by other targets. The good news is that Java’s AWT makes those Transferables nearly universally compatible system-wide, so we just have to comply with the standard and let the library know what kind of data can be transferred from our component.
The How?
Knowing the above, we can tackle these problems one by one. First we have to create a class extending a TransferHandler. Then, we will need to override methods corresponding to each of the responsibilities mentioned in The What.
Can We Import?
Let’s start with canImport
, the bread and butter of handling incoming data. Since our sole focus is on the action of dragging and dropping things with a mouse cursor, we check if the incoming transfer is a drop transfer, and only check further possibilities if that’s true. Next, check if the incoming transfer supports any of the desired data flavors.
A data flavor is an interesting way of storing what on the surface seems to be the same data, but in different formats. It allows you to copy a table in a word processor and paste its values to a spreadsheet software, as well as copy an image from your browser and paste its URL into places that don’t support images at all, without worrying about what exactly you copied, the image, or the URL itself!
So we check if the transfer supports the flavors we can handle. In our demo project, this will be a String format and an actual Pieces Asset. How can we expect a Pieces Asset to be supported, you may ask, and that’s a good question that won’t be left unanswered. We’ll touch on that later.
If the incoming transfer data flavors support does not overlap with ours, we tell the IDE we can’t handle it. Otherwise, we get 2 paths depending on what type of data is being transferred.
If we’re dealing with something that doesn’t support our own data flavor, we check if we can extract the string and if that string matches our custom criteria for import regarding length and contents. While doing it, we also ensure that the transferSupport knows we are only going to copy its data, so the source of the text or code doesn’t delete it upon importing it to Pieces.
The other path we can follow is when we are, in fact, dealing with a Pieces Asset (or your own data flavor, you do you). In our project, we assume dragging assets between language groups allows them to be reclassified easily. Funnily enough, we don’t actually interact with the transfer data at this point, but rather get the reference to our tree. We then get the source of our element and the target to which it could be dropped at this moment, and check if they are in the same language group. Having performed that check, we only allow the drop to happen if you are dragging the item to a different language, so we don’t risk causing trouble on Pieces OS when trying to reclassify a snippet to the same language.
Here’s how our Plugin will react to canImport(transferSupport) == true
And this is what it will look like for canImport(transferSupport) == false
We Can Import!
So now we are positive that the type of data being transferred is supported by our component. Awesome! Let’s importData
! We want to double-check this possibility for good measure, but shortly after, we can get to work importing our transfer. Just like in the Can we import? section, we need to differentiate between the transfer being an asset or a string.
If it’s an asset, we get the language information from the drop target node and just call Pieces AssetApi#assetReclassify
to let Pieces OS know our user’s intentions. Pieces OS will in turn return the Asset with its classification updated, so we can update it in our plugin’s storage. Once that’s done, we return true
to indicate to IDEA that the data was imported successfully.
If it’s a string, we try to establish its origin. To do that, we ask IntelliJ’s EditorManager
to tell us the currently open editor and the file that is being worked on. These are then used in tandem with the string from incoming transfer to build a seed that is later sent to Pieces OS in order to create a new snippet as a SeededAsset
.
We also try to establish if the drop happened on an empty space below the tree nodes or on one of the available nodes in the tree. This will help hint Pieces OS on what type of snippet we are dealing with to take out the guesswork. This happens by accessing the target’s node component and its userObject
, all while checking if the target node is a snippet or a language node.
Both of the import paths end with triggering a tree model reload, so we can reflect changes and additions made in the UI.
Let’s see this in action:
How do we export?
The usage of the word “transfer” is quite high in the previous section, and that’s for a reason. We are dealing with things called Transferables
all the time. These objects allow you to interact with all of your apps using just a clipboard or your mouse’s DnD. As you may have guessed by now, in order to export our data anywhere, we will need to create
(a)Transferable
for that purpose.
This method takes in the component where the drag starts. We first try to cast it to a JTree, since we know by now that is our only source of events for this handler. Since a proper drag event can only be started with an active selection on our tree, we can try to get the selection path and its last path component (our node). If there is an active selection, we will just return null
to indicate that there’s nothing to be transferred.
But - you rush to ask - what if my users want to drag an entire group of snippets by dragging a branch node? Don’t worry, we’ve got you covered. Remember when we mentioned type-checking the SnippetTreeNode
? Now is the time. You see, if our node is not a SnippetTreeNode
, and is just a Default node, we know it is a branch node that cannot be exported. This is also why we return null
if we detect this drag event originates from a language group node.
override fun createTransferable(c: JComponent): Transferable? {
// Check if the component is a JTree.
val tree = c as? JTree ?: return null
// Get the selected node from the tree.
val node = tree.selectionPath?.lastPathComponent ?: return null
// Check if the selected node is a SnippetTreeNode.
if (node !is SnippetTreeNode) return null
// Get the asset ID from the selected node.
val assetId = node.id
// Get the asset from the SnippetStore service.
val asset = service<SnippetStore>()[assetId]
// Create a SnippetNodesTransferable object with the asset.
return SnippetNodesTransferable(asset)
}
Now that we know our node is a snippet node, we can just take its ID and use it to fetch a full asset from the Pieces plugin’s storage. We pass the returned asset to a SnippetNodesTransferable
and return the latter.
The Transferable
Lean in, we’re about to let you in on the secret sauce. We have a class called SnippetNodesTransferable
. This class takes in an asset and/or some text. Its purpose is simple, but oh-so powerful. When an object is created here, it stores the asset and tries to fill in the text field with the asset’s string representation, if the text itself wasn’t provided.
The magic happens when the system asks to receive that data. This class has a way to establish whether the asset or plain text was supplied to the constructor and based on that, decide what data flavors can be provided by it.
Ah right, the data flavors. This class has a companion object (usage-wise equivalent to static methods and properties in Java) defining ASSET_FLAVOR
. It is a Data Flavor based on our asset’s data class. This is the tiniest, yet the most important detail for all of this to work. If you want to be able to move your own data types around, you have to declare a Data Flavor and store a reference to it in a way that lets you access it anywhere in your code.
With this DataFlavor defined, we can check if it’s supported by asking ourselves: Can we import? It’s always great to support more than just one Data Flavor, as it will make your data more versatile. This is why apart from defining our own flavor, we also make sure to enable String Flavor in our transferable so it can be imported to ANY text editor, not only the one in your IDE. This lets us return a string representation of an asset in the getTransferData
method, which is the way every single drop target fetches the data.
See what this enables us to do:
Summing up
Whew, it was quite a journey! We have successfully created a simple tree view to place in a side panel of IntelliJ Platform-based IDEs like WebStorm, IntelliJ IDEA, CLion, Android Studio, PyCharm, and many more. Not only that, we have it support drag and drop actions and even its own data flavor, so we can transfer our precious data in so many ways to so many different targets!
You are now knowledgeable enough to go out and develop an IntelliJ plugin, register a new view, install a TransferHandler on it, structure your data so it’s nicely organized in the form of a tree, and even perform super duper custom logic based on what you drag onto your tree and where you drop it!
Thank you for reading and feel free to join our Open Source communities if you have any questions and want to learn more. More articles on how to develop an IntelliJ plugin are coming soon, so stay tuned. Happy Coding!