Advanced guide to creating IntelliJ IDEA plugins
- Overview
- IDEA threading model
- PSI access and mutation
- Dynamic plugins
- VFS, Document, PSI
- UI (JetBrains UI components, Swing and Kotlin UI DSL)
- Simple UI components - notifications, popups, dialogs
- Create complex UIs using Kotlin UI DSL (forms, dialogs, settings screens)
- Understanding the structure of the DSL (layout, row, and cell)
- How to bind data to the form UI
- What UI elements are available for use in the forms
- How to display a form in a dialog
- How to persist the data created/selected by the user, between IDE restarts (PersistentStateComponent)
- Example of a form UI
- MigLayout, panel, and grow
- Create IDE Settings UI for plugin
- Complex UI creation in dialogs
- Adding your plugin UI in Tool windows
- Add Line marker provider in your plugin
Overview #
This article is a reference covering a lot of advanced topics related to creating IDEA plugins using the JetBrains IntelliJ Platform SDK. However there are many advanced topics that are not covered here (such as custom language support).
In order to be successful creating advanced plugins, this is what I suggest:
- Read thru the Introduction to creating IntelliJ IDEA plugins in detail. Modify the idea-plugin-example, idea-plugin-example2, or shorty-idea-plugin to get some hands on experience working with the plugin APIs.
- Use the Official JetBrains IntelliJ Platform SDK docs, along with the source code examples (from the list of repos above) to get a better understanding of how to create plugins.
- When you are looking for something not in tutorials here (on developerlife.com) and in the
official docsd, then search the
intellij-community
repo itself to find code examples on how to use APIs that might not have any documentation. An effective approach is to find some functionality in IDEA that is similar to what you are looking to build, and then locate the source code for that feature in the repo and see how JetBrains has done it.
IDEA threading model #
For the most part, the code that you write in your plugins is executed on the main thread. Some operations such as changing anything in the IDE’s “data model” (PSI, VFS, project root model) all have to done in the main thread in order to keep race conditions from occurring. Here’s the official docs on this.
There are many situations where some of your plugin code needs to run in a background thread so that
the UI doesn’t get frozen when long running operations occur. The plugin SDK has quite a few
strategies to allow you to do this. However, one thing that you should not do is use
SwingUtilities.invokeLater()
.
- One of the ways that you could run background tasks is by using
TransactionGuard.submitTransaction()
but that has been deprecated. - The new way is to use
ApplicationManager.getApplication().invokeLater()
.
API deprecations #
You can find a detailed list of the major API deprecations and changes in various versions of IDEA here.
You can search the JB platform codebase for deprecations by looking for
@ApiStatus.ScheduledForRemoval(inVersion = <version>)
, where<version>
can be2020.1
, etc.
However, before we can understand what all of this means exactly, there are some important concepts
that we have to understand - “modality”, and “write lock”, and “write-safe context”. So we will
start with talking about submitTransaction()
and get to each of these concepts along the way to
using invokeLater()
.
What was submitTransaction() all about? #
The now deprecated TransactionGuard.submitTransaction(Runnable)
basically runs the passed
Runnable
in:
- a write-safe context (which simply ensures that no one will be
able to perform an unexpected IDE model data changes using
SwingUtilities#invokeLater()
, or equivalent APIs, while a dialog is shown). - in a write thread (eg the EDT).
However, submitTransaction()
does not acquire a write lock or start a write action (this must be
done explicitly by your code if you need to modify IDE model data).
What does a write action have to do with a write-safe context? Nothing. However, it easy to conflate the two concepts and think that a write-safe context has a write lock; it does not. These are two separate things -
- write-safe context,
- write lock.
You can be in a write-safe context, and you will still need to acquire a write lock to mutate IDE model data. You can also use the following methods to check if your execution context already holds the write lock:
ApplicationManager.getApplication().isWriteAccessAllowed()
.ApplicationManager.getApplication().assertWriteAccessAllowed()
.
What is a write-safe context? #
The
JavaDocs from
TransactionGuard.java
explain what a write-safe context is:
- A mechanism to ensure that no one will be able to perform an unexpected IDE model data changes
using
SwingUtilities#invokeLater()
or analogs while a dialog is shown.
Here are some examples of write-safe contexts:
Application#invokeLater(Runnable, ModalityState)
calls with a modality state that’s either non-modal or was started inside a write-safe context. The use cases shown in the sections below are related to the non-modal scenario.- Direct user activity processing (key/mouse presses, actions) in non-modal state.
- User activity processing in a modality state that was started (e.g. by showing a dialog or progress) in a write-safe context.
Here is more information about how to handle code running on the EDT:
- Code running in the EDT is not necessarily in a write-safe context, which is why
submitTransaction()
provided the mechanism to execute a givenRunnable
in a write-safe context (on the EDT itself). - There are some exceptions to this, for example code running in actions in a non-modal state, while running on the EDT are also running in a write-safe context (described above).
-
The JavaDocs from
@DirtyUI
annotation source also provide some more information about UI code running in the EDT and write actions./** * <p> * This annotation specifies code which runs on Swing Event Dispatch Thread and accesses IDE model (PSI, etc.) * at the same time. * <p> * Accessing IDE model from EDT is prohibited by default, but many existing components are designed without * such limitations. Such code can be marked with this annotation which will cause a dedicated instrumenter * to modify bytecode to acquire Write Intent lock before the execution and release after the execution. * <p> * Marked methods will be modified to acquire/release IW lock. Marked classes will have a predefined set of their methods * modified in the same way. This list of methods can be found at {@link com.intellij.ide.instrument.LockWrappingClassVisitor#METHODS_TO_WRAP} * * @see com.intellij.ide.instrument.WriteIntentLockInstrumenter * @see com.intellij.openapi.application.Application */
Here are some best practices for code that runs in the EDT:
- Any writes to the IDE data model must happen on the write thread, which is the EDT.
- Even though you can safely read IDE model data from the EDT (without acquiring a read lock), you will still need to acquire a write lock in order to modify IDE model data.
- Code running in the EDT must explicitly acquire write locks explicitly (by wrapping that code in
a write action) in order to change any IDE model data. This can be done by wrapping the code in a
write action with
ApplicationManager.getApplication().runWriteAction()
or,WriteAction
run()
/compute()
. - If your code is running in a dialog, and you don’t need to perform any write operations to IDE
data models, then you can simply use
SwingUtilities.invokeLater()
or analogs. You only need a write-safe context to run in the right modality if you plan to change the IDE data models. - The IntelliJ Platform SDK docs have more details on IDEA threading here.
What is the replacement for submitTransaction()? #
The replacement for using TransactionGuard.submitTransaction(Runnable, ...)
is
ApplicationManager.getApplication().invokeLater(Runnable, ...)
. invokeLater()
makes sure that
your code is running in a write-safe context, but you still need to acquire a write lock if you want
to modify any IDE model data.
invokeLater() and ModalityState #
invokeLater()
can take a ModalityState
parameter in addition to a Runnable
. Basically you can
choose when to actually execute your Runnable
, either ASAP, during the period when a dialog box is
displayed, or when all dialog boxes have been closed.
To see source code examples of how
ModailtyState
can be used in a dialog box used in a plugin (that is written using the Kotlin UI DSL) check out this sample ShowKotlinUIDSLSampleInDialogAction.kt
To learn more about what the official docs say about this, check out the JavaDocs in
ModalityState.java
. The following is an in-depth explanation of how this all works.
There’s a user flow that is described in the docs that goes something like this:
- Some action runs in a plugin, which enqueues
Runnable
A on the EDT for later invocation, usingSwingUtilities.invokeLater()
. - Then, the action shows a dialog box, and waits for user input.
- While the user is thinking about what choice to make on the dialog box, that
Runnable
A has already started execution, and it does something drastic to the IDE data models (like delete a project or something). - The user finally makes up their mind and makes a choice. At this point the code that is running
in the action is not aware of the changes that have been made by
Runnable
A. And this can cause some big problems in the action. - ModalityState allows you to exert control over when the
Runnable
A is actually executed at a later time.
Here’s some more information about this scenario. If you set a breakpoint at the start of the
action’s execution, before it enqueued Runnable
A, you might see the following if you look at
ApplicationManager.getApplication().myTransactionGuard.myWriteSafeModalities
in the debugger. This
is BEFORE the dialog box is shown.
myWriteSafeModalities = {ConcurrentWeakHashMap@39160} size = 3
{ModalityStateEx@39091} "ModalityState.NON_MODAL" -> {Boolean@39735} true
{ModalityStateEx@41404} "ModalityState:{}" -> {Boolean@39735} true
{ModalityStateEx@40112} "ModalityState:{}" -> {Boolean@39735} true
AFTER the dialog box is shown you might see something like the following for the same
myWriteSafeModalities
object in the debugger (if you set a breakpoint after the dialog box has
returned the user selected value).
myWriteSafeModalities = {ConcurrentWeakHashMap@39160} size = 4
{ModalityStateEx@39091} "ModalityState.NON_MODAL" -> {Boolean@39735} true
{ModalityStateEx@41404} "ModalityState:{}" -> {Boolean@39735} true
{ModalityStateEx@43180} "ModalityState:{[...APPLICATION_MODAL,title=Sample..." -> {Boolean@39735} true
{ModalityStateEx@40112} "ModalityState:{}" -> {Boolean@39735} true
Notice that a new ModalityState
has been added, which basically points to the dialog box being
displayed.
So this is how the modality state parameter to invokeLater() works.
- If you don’t want the
Runnable
that you pass to it to be executed immediately, then you can specifyModalityState.NON_MODAL
, and this will run it after the dialog box has closed (there are no more modal dialogs in the stack of active modal dialogs). - Instead if you wanted to run it immediately, then you can run it using
ModalityState.any()
. - However, if you pass the
ModalityState
of the dialog box as a parameter, then thisRunnable
will only execute while that dialog box is being displayed. - Now, even when the dialog box is displayed, if you enqueued another
Runnable
to run withModalityState.NON_MODAL
, then it will be run after the dialog box is closed.
How to use invokeLater to perform asynchronous and synchronous tasks #
The following sub sections demonstrate how to get away from using submitTransaction()
and switch
to using invokeLater()
for the given use cases, an even remove the use of them altogether when
possible.
Synchronous execution of Runnable (from an already write-safe context using submitTransaction()/invokeLater()) #
In the scenario where your code is already running in a
write-safe context, there is no need to use submitTransaction()
or invokeLater()
to queue your Runnable
, and you can execute its contents directly.
From
TransactionGuard.java#submitTransaction()
JavaDoc on Line 76: “In a definitely write-safe context, just replace this call with {@code transaction} contents. Otherwise, replace with {@link Application#invokeLater} and take care that the default or explicitly passed modality state is write-safe.”
Let’s say that you have a button which has a listener, in a dialog, or tool window. When the button
is clicked, it should execute a Runnable
in a write-safe context.
Here’s the code that registers the action listener to the button.
init {
button.addActionListener {
processButtonClick()
}
}
Here’s the code that queues the Runnable
. If you run
TransactionGuard.getInstance().isWriteSafeModality(ModalityState.NON_MODAL)
inside the following
function it returns true
.
private fun processButtonClick() {
TransactionGuard.submitTransaction(project, Runnable {
// Do something to the IDE data model in a write action.
})
}
However this code is running in the EDT, and since processButtonClick()
uses a write action to
perform its job, so there’s really no need to wrap it in a submitTransaction()
or invokeLater()
call. Here we have code that is:
- Running the EDT,
- Already running in a write-safe context,
- And,
processButtonClick()
itself wraps its work in a write action.
In this case, it is possible to safely remove the Runnable
and just directly call its contents.
So, we can replace it with something like this.
ApplicationManager.getApplication().assertIsDispatchThread()
// Do something to the IDE data model in a write action.
Asynchronous execution of Runnable (from a write-unsafe / write-safe context using submitTransaction()) #
The simplest replacement for the Runnable
and Disposable
that are typically passed to
submitTransaction()
, is simply to replace the call:
TransactionGuard.submitTransaction(Disposable, Runnable)
With:
ApplicationManager.ApplicationManager.getApplication().invokeLater(
Runnable, ComponentManager.getDisposed())
- Note that IDE data model objects like project, etc. all expose a
Condition
object from a call togetDisposed().
- If you have to create a
Condition
yourself, then you can just wrap yourDisposable
in a call toDisposer.isDisposed(Disposable)
.
Let’s take a look at a simple example of replacing old code that uses submitTransaction()
.
-
OId code that uses
submitTransaction
.TransactionGuard.submitTransaction(myProject, ()->{ /* Runnable */ }
-
New code that uses
invokeLater()
.ApplicationManager.getApplication().invokeLater(()->{ /* Runnable */ }, myProject.getDisposed());
Here’s a slightly more complex example where a Condition
object must be created.
-
OId code that uses
submitTransaction
.TransactionGuard.submitTransaction(myComponent.getModel(), ()->{ /* Runnable */ })
-
New code that uses
invokeLater
.ApplicationManager.getApplication().invokeLater( ()->{ /* Runnable */ }, ignore -> Disposer.isDisposed(myComponent.getModel()) );
-
You can also just check for the condition
Disposer.isDisposed(myComponent.getModel())
at the start of theRunnable
. And not even pass theCondition
ininvokeLater()
if you like.
Side effect - invokeLater() vs submitTransaction() and impacts on test code #
When moving from submitTransaction()
to invokeLater()
some tests might need to be updated, due
to one of the major differences between how these two functions actually work.
In some cases, one of the consequences of using invokeLater()
instead of submitTransaction()
is
that in tests, you might have to call
PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue()
to ensure that the Runnables
that
are queued for execution on the EDT are actually executed (since the tests run in a headless
environment). You can also call UIUtil.dispatchAllInvocationEvents()
instead of
PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue()
. When using submitTransaction()
in
the past, this was not necessary.
Here’s old code, and test code.
@JvmStatic
fun myFunction(project: Project) {
TransactionGuard.submitTransaction(
project, Runnable {
// Do stuff.
})
})
}
fun testMyFunction() {
myFunction(project)
verify(/* that myFunction() runs */)
}
Here’s the new code, and test code.
@JvmStatic
fun myFunction(project: Project) {
ApplicationManager.getApplication().invokeLater(
Runnable {
// Do stuff.
})
}, project.disposed)
}
fun testMyFunction() {
myFunction(project)
PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue()
verify(/* that myFunction() runs */)
}
PSI access and mutation #
This section covers Program Structure Interface (PSI) and how to use it to access files in various languages. And how to use it to create language specific content. Threading considerations that must be taken into account to make sure the IDE UI is responsive is also covered here.
Please read the official docs on PSI before proceeding further.
How to create a PSIFile or get a reference to one #
There are many ways of creating a PSI file. Here are some examples of you can get a reference to a PSI file in your plugin.
- From an action’s event:
e.getData(LangDataKeys.PSI_FILE)
- From a
VirtualFile
:PsiManager.getInstance(myProject).findFile(vFile)
- From a
Document
:PsiDocumentManager.getInstance(project).getPsiFile(document)
- From a
PsiElement
:psiElement.getContainingFile()
- From any project you can get a
PSIFile[]
:FilenameIndex .getFilesByName(project, "file.ext", GlobalSearchScope.allScope(project))
There is some information on navigating PSI trees here.
PSI Access #
Visitor pattern for top down navigation (without threading considerations) #
A very common thing to do when you have a reference to a PSI file is to navigate it from the root node to find something inside of the file. This is where the visitor pattern comes into play.
- In order to get access to all the classes you might need for this, you might need to import the
right set of
built in modules.
There are scenarios where you might want to add functionality in the IJ platform itself. For example, let’s say you wanted to add
JavaRecursiveElementVisitor
to your project to visit a PSI tree. Well, this class isn’t available to the plugin by default, and needs to be “imported” in 2 steps, just as you would for a published plugin.setPlugins()
inbuild.gradle.kts
, and<depends>
inplugin.xml
.
Here’s what you have to do to add support for the Java language.
build.gradle.kts
:setPlugins("java", <other plugins, if they exist>)
- In many cases, you can also use more specific APIs for top-down navigation. For example, if you
need to get a list of all methods in a Java class, you can do that using a visitor, but a much
easier way to do that is to call
PsiClass.getMethods()
. - PsiTreeUtil.java
contains a number of general-purpose, language-independent functions for PSI tree navigation,
some of which (for example,
findChildrenOfType()
) perform top-down navigation.
⚠️ Threading issues might arise, since all this computation is done in the EDT, and for a really long PSI tree, this can problematic. Especially w/ the use of a write lock to make some big changes in the tree. There are many sections below which deal w/ cancellation and read and write locking.
The basic visitor pattern for top down navigation looks like this. The following snippet counts the
number of header and paragraph elements in a Markdown file, where psiFile
is a reference to this
file.
val count = object {
var paragraph: Int = 0
var header: Int = 0
}
psiFile.accept(object : MarkdownRecursiveElementVisitor() {
override fun visitParagraph(paragraph: MarkdownParagraphImpl) {
count.paragraph++
// The following line ensures that ProgressManager.checkCancelled()
// is called.
super.visitParagraph(paragraph)
}
override fun visitHeader(header: MarkdownHeaderImpl) {
count.header++
// The following line ensures that ProgressManager.checkCancelled()
// is called.
super.visitHeader(header)
}
})
This is great sample code to navigate the tree for a Java class: PsiNavigationDemoAction.java.
Threading, locking, and progress cancellation check during PSI access #
When creating long running actions that work on a PSI tree, you have to take care of the following things:
- Make sure that you are accessing the PSI tree after acquiring a read lock (in a read action).
- Make sure that your long running tasks can be cancelled by the user.
- Make sure that you don’t freeze the UI and make it unresponsive for a long running task that uses the PSI tree.
Here is an example of running a task in a background thread in IDEA that uses a read action, but is NOT cancellable. Better ways of doing this are shown below.
ApplicationManager.getApplication().executeOnPooledThread {
ApplicationManager.getApplication().runReadAction {
// Your potentially long running task that uses something in the PSI tree.
}
}
Background tasks and multiple threads - The incorrect way #
The following is a bad example of using Task.Backgroundable
object. It is cancellable, and it
works, but it accesses some PSI structures inside of this task (in a background thread) which is
actually a bad thing that leads to race conditions w/ the data in the EDT and the data that the
background thead is currently getting from the PSI objects.
Here’s the code for an action that creates the task and runs it in the background (without acquiring a read lock in a read action):
override fun actionPerformed(e: AnActionEvent) {
val psiFile = e.getRequiredData(CommonDataKeys.PSI_FILE)
val psiFileViewProvider = psiFile.viewProvider
val project = e.getRequiredData(CommonDataKeys.PROJECT)
val progressTitle = "Doing heavy PSI computation"
val task = object : Backgroundable(project, progressTitle) {
override fun run(indicator: ProgressIndicator) {
doWorkInBackground(project, psiFile, psiFileViewProvider, indicator)
}
}
task.queue()
}
Here’s the code for the doWorkInBackground(...)
. As you can see this function does not acquire a
read lock, and simply runs the navigateXXXTree()
functions based on whether the current file has
Markdown or Java code in it.
private data class Count(var paragraph: Int = 0, var links: Int = 0, var header: Int = 0)
private val count = Count()
private fun doWorkInBackground(project: Project,
psiFile: PsiFile,
psiFileViewProvider: FileViewProvider,
indicator: ProgressIndicator,
editor: Editor
) {
indicator.isIndeterminate = true
val languages = psiFileViewProvider.languages
val message = buildString {
when {
languages.contains("Markdown") -> navigateMarkdownTree(psiFile, indicator, project)
languages.contains("Java") -> navigateJavaTree(psiFile, indicator, project, editor)
else -> append("No supported languages found")
}
append("languages: $languages\n")
append("count.header: ${count.header}\n")
append("count.paragraph: ${count.paragraph}\n")
checkCancelled(indicator, project)
}
println(message)
}
Let’s look at the navigateXXXTree()
functions next. They simply generate some analytics depending
on whether a Java or Markdown file is open in the editor.
- For Markdown files, it simply generates the number of headers and paragraphs that exist in the document in the editor.
- For Java files, if a method is selected, it generates information about the enclosing class and any local variables that are declared inside that method.
For Markdown files, here’s the function.
private fun navigateMarkdownTree(psiFile: PsiFile,
indicator: ProgressIndicator,
project: Project
) {
psiFile.accept(object : MarkdownRecursiveElementVisitor() {
override fun visitParagraph(paragraph: MarkdownParagraphImpl) {
this.count.paragraph++
checkCancelled(indicator, project)
// The following line ensures that ProgressManager.checkCancelled() is called.
super.visitParagraph(paragraph)
}
override fun visitHeader(header: MarkdownHeaderImpl) {
this.count.header++
checkCancelled(indicator, project)
// The following line ensures that ProgressManager.checkCancelled() is called.
super.visitHeader(header)
}
})
}
For Java files, here’s the function.
private fun navigateJavaTree(psiFile: PsiFile,
indicator: ProgressIndicator,
project: Project,
editor: Editor
) {
val offset = editor.caretModel.offset
val element: PsiElement? = psiFile.findElementAt(offset)
val javaPsiInfo = buildString {
checkCancelled(indicator, project)
element?.apply {
append("Element at caret: $element\n")
val containingMethod: PsiMethod? = PsiTreeUtil.getParentOfType(element, PsiMethod::class.java)
containingMethod?.apply {
append("Containing method: ${containingMethod.name}\n")
containingMethod.containingClass?.apply {
append("Containing class: ${this.name} \n")
}
val list = mutableListOf<PsiLocalVariable>()
containingMethod.accept(object : JavaRecursiveElementVisitor() {
override fun visitLocalVariable(variable: PsiLocalVariable) {
list.add(variable)
// The following line ensures that ProgressManager.checkCancelled() is called.
super.visitLocalVariable(variable)
}
})
if (list.isNotEmpty())
append(list.joinToString(prefix = "Local variables:\n", separator = "\n") { it -> "- ${it.name}" })
}
}
}
checkCancelled(indicator, project)
val message = if (javaPsiInfo == "") "No PsiElement at caret!" else javaPsiInfo
println(message)
ApplicationManager.getApplication().invokeLater {
Messages.showMessageDialog(project, message, "PSI Java Info", null)
}
}
Now, when you run this code, the navigateMarkdownTree()
does not throw an exception because the
PSI was accessed outside of a read lock. However, navigateJavaTree()
does throw the exception
below.
java.lang.Throwable: Read access is allowed from event dispatch thread or inside
read-action only (see com.intellij.openapi.application.Application.runReadAction())
This exception will most likely get thrown for any complicated access of the PSI from a non-EDT thread without a read lock.
Note that if you access the PSI data from the EDT itself there is no need to acquire a read lock.
Notes on navigateMarkdownTree()
:
- The code above doesn’t trigger any read lock assertion exceptions, and it is probably because it
is too simple. There are a few flavors of
assertReadAccessAllowed()
, one even inPsiFileImpl
, and they are called from various methods accessing the PSI. Maybe they’re not called everywhere (I assume it’d be expensive to check read access for everything), just in the common APIs, and maybe this example didn’t get to any. - Also, it’s probably possible to read state without a read lock, i.e the IDE won’t necessarily throw an exception, it’s just that you’re not guaranteed any consistent results as you’re racing with the UI thread (making changes to the data within write actions).
- So just because an exception isn’t throw when failing to acquire a read lock to access PSI data doesn’t mean the results are consistent due to possibly racing w/ the UI thread making changes to that data within write actions.
- Here are the official JetBrains docs on threading and locking.
Notes on navigateJavaTree()
:
- This code throws the exception right away, which is why it is important to wrap all PSI read access inside of a read action.
Background tasks and multiple threads - The correct way #
These are the steps we can take to fix the code above.
- We don’t have to deal w/ read locks in the implementation of these two functions
(
navigateJavaTree()
andnavigateMarkdownTree()
). ThedoWorkInBackground()
function should really be dealing with this. - Wrapping the code in
doWorkInBackground()
that calls these two functions in a read lock by usingrunReadAction {}
eliminates these exceptions.
Note that both functions
navigateMarkdownTree()
ornavigateJavaTree()
do not need to be modified in any way!
private fun doWorkInBackground(project: Project,
psiFile: PsiFile,
psiFileViewProvider: FileViewProvider,
indicator: ProgressIndicator,
editor: Editor
) {
indicator.isIndeterminate = true
val languages = psiFileViewProvider.languages
buildString {
when {
languages.contains("Markdown") -> runReadAction { navigateMarkdownTree(psiFile, indicator, project) }
languages.contains("Java") -> runReadAction { navigateJavaTree(psiFile, indicator, project, editor) }
else -> append("No supported languages found")
}
append("languages: $languages\n")
append("count.header: ${count.header}\n")
append("count.paragraph: ${count.paragraph}\n")
checkCancelled(indicator, project)
}.printlnAndLog()
}
For any long running background task, we have to check for cancellation to ensure that unnecessary work does nto get done after the user has requested that our long running background task be cancelled.
For tasks that require a lot of virtual files, or PSI elements to be looped over it becomes
necessary to check for cancellation (in addition to acquiring a read lock). Here’s an example from
MarkdownRecursiveElementVisitor
which can be overridden to walk the PSI tree.
open class MarkdownRecursiveElementVisitor : MarkdownElementVisitor(), PsiRecursiveVisitor {
override fun visitElement(element: PsiElement) {
ProgressManager.checkCanceled()
element.acceptChildren(this)
}
}
Note about the call to ProgressManager.checkCanceled()
above - A subclass would have to make sure
to call super.visitElement(...)
to ensure that this cancellation check is actually made.
Dispatching UI events during
checkCanceled()
. #There is a mechanism for updating the UI during write actions, but it seems quite limited.
A
ProgressIndicator
may choose to implement thePingProgress
interface. If it does, thenPingProgress.interact()
will be called whenevercheckCanceled()
is called. For details seeProgressManagerImpl.executeProcessUnderProgress()
andProgressManagerImpl.addCheckCanceledHook()
.The
PingProgress
mechanism is used byPotemkinProgress
: “A progress indicator for write actions. Paints itself explicitly, without resorting to normal Swing’s delayed repaint API. Doesn’t dispatch Swing events, except for handling manually those that can cancel it or affect the visual presentation.” I.e.,PotemkinProgress
dispatches certain UI events during a write action to ensure that the progress dialog remains responsive. But, note the comment, “Repaint just the dialog panel. We must not call custom paint methods during write action, because they might access the model, which might be inconsistent at that moment.”
Don’t try to acquire read or write locks directly, use actions instead #
Instead of trying to acquire read or write locks directly, Use lambdas and read or write actions instead. For example:
runReadAction()
,runWriteAction()
,WriteCommandAction#runWriteCommandAction()
, etc.The APIs for acquiring the read or write lock are deprecated and marked for removal in
2020.3
. It is good they’re being deprecated, because if you think about offering nonblocking read action semantics (from the platform perspective), if a “read” actions is done via a lock acquire / release, how can one interrupt and re-start it?
PSI Mutation #
PsiViewer plugin #
One of the most important things to do before modifying the PSI is to understand its structure. And the best way to do this is to install the PsiViewer plugin and use it to study what the PSI tree looks like.
In this example, we will be modifying a Markdown document. So we will use this plugin to examine a Markdown file that’s open in an IDE editor window. Here are the options that you should enable for the plugin while you’re browsing around the editor:
- Open Settings -> PSIViewer and change the highlight colors to something that you like and make sure to set the alpha to 255 for both references and highlighting.
- Open the PSIViewer tool window and enable the following options in the toolbar:
- Enable highlight (you might want to disable this when you’re done playing around w/ the tool window)
- Enable properties
- Enable scroll to source
- Enable scroll from source
A great example that can help us understand how PSI modification can work is taking a look at the built-in Markdown plugin actions themselves. There are a few actions in the toolbar of every Markdown editor: “toggle bold”, “toggle italic”, etc.
These are great to walk thru to understand how to make our own. The source files from
intellij-community
repo on github are:
- Files in
plugins/markdown/src/org/intellij/plugins/markdown/ui/actions/styling/
ToggleBoldAction.java
BaseToggleStateAction.java
The example we will build for this section entails finding hyperlinks and replacing the links w/ some modified version of the link string. This will require using a write lock to mutate the PSI in a cancellable action.
Generate PSI elements from text #
It might seem strange but the preferred way to
create PSI elements
by generating text for the new element and then having IDEA parse this into a PSI element. Kind of
like how a browser parses text (containing HTML) into DOM elements by setting
innerHTML()
.
Here is some code that does this for Markdown elements.
private fun createNewLinkElement(project: Project, linkText: String, linkDestination: String): PsiElement? {
val markdownText = "[$linkText]($linkDestination)"
val newFile = MarkdownPsiElementFactory.createFile(project, markdownText)
val newParentLinkElement = findChildElement(newFile, MarkdownTokenTypeSets.LINKS)
return newParentLinkElement
}
Example of walking up and down PSI trees to find elements #
This is a dump from the PSI viewer of a snippet from this README.md file.
MarkdownParagraphImpl(Markdown:PARAGRAPH)(1201,1498)
PsiElement(Markdown:Markdown:TEXT)('The main goal of this plugin is to show')(1201,1240)
PsiElement(Markdown:WHITE_SPACE)(' ')(1240,1241)
ASTWrapperPsiElement(Markdown:Markdown:INLINE_LINK)(1241,1274) <============[🔥 WE WANT THIS PARENT 🔥]=========
ASTWrapperPsiElement(Markdown:Markdown:LINK_TEXT)(1241,1252)
PsiElement(Markdown:Markdown:[)('[')(1241,1242)
PsiElement(Markdown:Markdown:TEXT)('SonarQube')(1242,1251) <============[🔥 EDITOR CARET IS HERE 🔥]========
PsiElement(Markdown:Markdown:])(']')(1251,1252)
PsiElement(Markdown:Markdown:()('(')(1252,1253)
MarkdownLinkDestinationImpl(Markdown:Markdown:LINK_DESTINATION)(1253,1273)
PsiElement(Markdown:Markdown:GFM_AUTOLINK)('http://sonarqube.org')(1253,1273)
PsiElement(Markdown:Markdown:))(')')(1273,1274)
PsiElement(Markdown:WHITE_SPACE)(' ')(1274,1275)
PsiElement(Markdown:Markdown:TEXT)('issues directly within your IntelliJ IDE.')(1275,1316)
PsiElement(Markdown:Markdown:EOL)('\n')(1316,1317)
PsiElement(Markdown:Markdown:TEXT)('Currently the plugin is build to work in IntelliJ IDEA,')(1317,1372)
PsiElement(Markdown:WHITE_SPACE)(' ')(1372,1373)
PsiElement(Markdown:Markdown:TEXT)('RubyMine,')(1373,1382)
PsiElement(Markdown:WHITE_SPACE)(' ')(1382,1383)
PsiElement(Markdown:Markdown:TEXT)('WebStorm,')(1383,1392)
PsiElement(Markdown:WHITE_SPACE)(' ')(1392,1393)
PsiElement(Markdown:Markdown:TEXT)('PhpStorm,')(1393,1402)
PsiElement(Markdown:WHITE_SPACE)(' ')(1402,1403)
PsiElement(Markdown:Markdown:TEXT)('PyCharm,')(1403,1411)
PsiElement(Markdown:WHITE_SPACE)(' ')(1411,1412)
PsiElement(Markdown:Markdown:TEXT)('AppCode and Android Studio with any programming ... SonarQube.')(1412,1498)
PsiElement(Markdown:Markdown:EOL)('\n')(1498,1499)
There are a few things to note from this tree.
- The caret in the editor selects a PSI element that is at the leaf level of the selection.
- This will require us to walk up the tree (navigate to the parents, and their parents, and so on).
We have to use a
MarkdownTokenTypes
(singular) orMarkdownTokenTypeSets
(a set).
- An example is that we start w/ a
TEXT
, then move up toLINK_TEXT
, then move up toINLINE_LINK
.
- This will require us to walk down the tree (navigate to the children, and their children, and so on). Similarly, we can use the same token types or token type sets above.
- An example is that we start w/ a
INLINE_LINK
and drill down the kids, then move down to theLINK_DESTINATION
.
Here’s the code that does exactly this. And it stores the result in a LinkInfo
data class object.
data class LinkInfo(var parentLinkElement: PsiElement, var linkText: String, var linkDestination: String)
/**
* This function tries to find the first element which is a link, by walking up the tree starting w/ the element that
* is currently under the caret.
*
* To simplify, something like `PsiUtilCore.getElementType(element) == INLINE_LINK` is evaluated for each element
* starting from the element under the caret, then visiting its parents, and their parents, etc, until a node of type
* `INLINE_LINK` is found, actually, a type contained in [MarkdownTokenTypeSets.LINKS].
*/
private fun findLink(editor: Editor, project: Project, psiFile: PsiFile): LinkInfo? {
val offset = editor.caretModel.offset
val elementAtCaret: PsiElement? = psiFile.findElementAt(offset)
// Find the first parent of the element at the caret, which is a link.
val parentLinkElement = findParentElement(elementAtCaret, MarkdownTokenTypeSets.LINKS)
val linkTextElement = findChildElement(parentLinkElement, MarkdownTokenTypeSets.LINK_TEXT)
val textElement = findChildElement(linkTextElement, MarkdownTokenTypes.TEXT)
val linkDestinationElement = findChildElement(parentLinkElement, MarkdownTokenTypeSets.LINK_DESTINATION)
val linkText = textElement?.text
val linkDestination = linkDestinationElement?.text
if (linkText == null || linkDestination == null || parentLinkElement == null) return null
println("Top level element of type contained in MarkdownTokenTypeSets.LINKS found! 🎉")
println("linkText: $linkText, linkDest: $linkDestination")
return LinkInfo(parentLinkElement, linkText, linkDestination)
}
Finding elements up the tree (parents) #
private fun findParentElement(element: PsiElement?, tokenSet: TokenSet): PsiElement? {
if (element == null) return null
return PsiTreeUtil.findFirstParent(element, false) {
callCheckCancelled()
val node = it.node
node != null && tokenSet.contains(node.elementType)
}
}
Finding elements down the tree (children) #
private fun findChildElement(element: PsiElement?, token: IElementType?): PsiElement? {
return findChildElement(element, TokenSet.create(token))
}
private fun findChildElement(element: PsiElement?, tokenSet: TokenSet): PsiElement? {
if (element == null) return null
val processor: FindElement<PsiElement> =
object : FindElement<PsiElement>() {
// If found, returns false. Otherwise returns true.
override fun execute(each: PsiElement): Boolean {
callCheckCancelled()
if (tokenSet.contains(each.node.elementType)) return setFound(each)
else return true
}
}
element.accept(object : PsiRecursiveElementWalkingVisitor() {
override fun visitElement(element: PsiElement) {
callCheckCancelled()
val isFound = !processor.execute(element)
if (isFound) stopWalking()
else super.visitElement(element)
}
})
return processor.foundElement
}
Threading considerations #
Here are the threading rules:
- PSI access can happen in any thread (EDT or background), as long as the read lock (via its read action) is acquired before doing so.
- PSI mutation can only happen in the EDT (not a background thread), since as soon as the write lock (via its write action) is acquired, that means that code is now running in the EDT.
Here’s the action implementation that calls the code shown above. And it uses a very optimized approach to acquiring read and write locks, and using the background thread for blocking network IO.
- The background thread w/ read action is used to find the hyperlink.
- The background thread is used to shorten this long hyperlink w/ a short one. This entails making blocking network IO call. And no locks are held during this phase.
- Finally, a write action is acquired in the EDT to actually mutate the PSI tree w/ the information from the first 2 parts above. This is where the long link is replaced w/ the short link and the PSI is mutated.
And all 3 operations are done in a Task.Backgroundable
which can be cancelled at anytime and it
will end as soon as it can w/out changing anything under the caret in the editor.
- If the task actually goes to completion then a notification is shown reporting that the background task was run successfully.
- And if it gets cancelled in the meantime, then a dialog box is shown w/ an error message.
class EditorReplaceLink(val shortenUrlService: ShortenUrlService = TinyUrl()) : AnAction() {
/**
* For some tests this is not initialized, but accessed when running [doWorkInBackground]. Use [callCheckCancelled]
* instead of a direct call to `CheckCancelled.invoke()`.
*/
private lateinit var checkCancelled: CheckCancelled
@VisibleForTesting
private var myIndicator: ProgressIndicator? = null
override fun actionPerformed(e: AnActionEvent) {
val editor = e.getRequiredData(CommonDataKeys.EDITOR)
val psiFile = e.getRequiredData(CommonDataKeys.PSI_FILE)
val project = e.getRequiredData(CommonDataKeys.PROJECT)
val progressTitle = "Doing heavy PSI mutation"
object : Task.Backgroundable(project, progressTitle) {
var result: Boolean = false
override fun run(indicator: ProgressIndicator) {
if (PluginManagerCore.isUnitTestMode) {
println("🔥 Is in unit testing mode 🔥️")
// Save a reference to this indicator for testing.
myIndicator = indicator
}
checkCancelled = CheckCancelled(indicator, project)
result = doWorkInBackground(editor, psiFile, project)
}
override fun onFinished() {
Pair("Background task completed", if (result) "Link shortened" else "Nothing to do").notify()
}
}.queue()
}
enum class RunningState {
NOT_STARTED, IS_RUNNING, HAS_STOPPED, IS_CANCELLED
}
@VisibleForTesting
fun isRunning(): RunningState {
if (myIndicator == null) {
return NOT_STARTED
}
else {
return when {
myIndicator!!.isCanceled -> IS_CANCELLED
myIndicator!!.isRunning -> IS_RUNNING
else -> HAS_STOPPED
}
}
}
@VisibleForTesting
fun isCanceled(): Boolean = myIndicator?.isCanceled ?: false
/**
* This function returns true when it executes successfully. If there is no work for this function to do then it
* returns false. However, if the task is cancelled (when wrapped w/ a [Task.Backgroundable], then it will throw
* an exception (and aborts) when [callCheckCancelled] is called.
*/
@VisibleForTesting
fun doWorkInBackground(editor: Editor, psiFile: PsiFile, project: Project): Boolean {
// Acquire a read lock in order to find the link information.
val linkInfo = runReadAction { findLink(editor, project, psiFile) }
callCheckCancelled()
// Actually shorten the link in this background thread (ok to block here).
if (linkInfo == null) return false
linkInfo.linkDestination = shortenUrlService.shorten(linkInfo.linkDestination) // Blocking call, does network IO.
callCheckCancelled()
// Mutate the PSI in this write command action.
// - The write command action enables undo.
// - The lambda inside of this call runs in the EDT.
WriteCommandAction.runWriteCommandAction(project) {
if (!psiFile.isValid) return@runWriteCommandAction
replaceExistingLinkWith(project, linkInfo)
}
callCheckCancelled()
return true
}
// All the other functions shown in paragraphs above about going up and down the PSI tree.
}
Notes on callCheckCancelled()
:
- It is also important to have checks to see whether the task has been cancelled or not through out the code. You will find these calls in the functions to walk and up down the tree above. The idea is to include these in every iteration in a loop to ensure that the task is aborted if this task cancellation is detected as soon as possible.
- Also, in some other places in the code where make heavy use of the PSI API, we can avoid making these checks, since the JetBrains code is doing these cancellation checks for us. However, in places where we are mainly manipulating our own code, we have to make these checks manually.
Here’s the CheckCancelled
class that is used throughout the code.
fun callCheckCancelled() {
try {
checkCancelled.invoke()
}
catch (e: UninitializedPropertyAccessException) {
// For some tests [checkCancelled] is not initialized. And accessing a lateinit var will throw an exception.
}
}
/**
* Both parameters are marked Nullable for testing. In unit tests, a class of this object is not created.
*/
class CheckCancelled(private val indicator: ProgressIndicator?, private val project: Project?) {
operator fun invoke() {
if (indicator == null || project == null) return
println("Checking for cancellation")
if (indicator.isCanceled) {
println("Task was cancelled")
ApplicationManager
.getApplication()
.invokeLater {
Messages.showWarningDialog(
project, "Task was cancelled", "Cancelled")
}
}
indicator.checkCanceled()
// Can use ProgressManager.checkCancelled() as well, if we don't want to pass the indicator around.
}
}
Additional references #
Please take a look at the JetBrains (JB) Platform SDK DevGuide section on PSI.
Also, please take a look at the VFS and Document section to get an idea of the other ways to access file contents in your plugin.
Dynamic plugins #
One of the biggest changes that JetBrains has introduced to the platform SDK in 2020 is the introduction of Dynamic Plugins. Moving forwards the use of components of any kind are banned.
Here are some reasons why:
- The use of components result in plugins that are unloadable (due to it being impossible to dereference the Plugin component classes that was loaded by a classloader when IDEA itself launched).
- Also, they impact startup performance since code is not lazily loaded if its needed, which slows down IDEA startup.
- Plugins can be kept around for a long time even after they might be unloaded, due to attaching disposer to a parent that might outlive the lifetime of the project itself.
In the new dynamic world, everything is loaded lazily and can be garbage collected. Here is more information on the deprecation of components.
There are some caveats of doing this that you have to keep in mind if you are used to working with components.
- Here’s a very short migration guide from JetBrains that provides some highlights of what to do in order to move components over to be services, startup activities, listeners, or extensions.
- You have to pick different parent disposables for services, extensions, or listeners (in what used to be a component). You can’t scope a Disposable to the project anymore, since the plugin can be unloaded during the life of a project.
- Don’t cache copies of the implementations of registered extension points, as these might cause leaks due to the dynamic nature of the plugin. Here’s more information on dynamic extension points. These are extension points that are marked as dynamic so that IDEA can reload them if needed.
- Please read up on the dynamic plugins restrictions and troubleshooting guide that might be of use as you migrate your components to be dynamic.
- Plugins now support auto-reloading, which you can disable if this causes you issues.
Extension points postStartupActivity, backgroundPostStartupActivity to initialize a plugin on project load #
There are 2 extension points to do just this com.intellij.postStartupActivity
and
com.intellij.backgroundPostStartupActivity
.
Here are all the ways in which to use a StartupActivity
:
- Use a
postStartupActivity
to run something on the EDT during project open. - Use a
postStartupActivity
implementingDumbAware
to run something on a background thread during project open in parallel with other dumb-aware post-startup activities. Indexing is not complete when these are running. - Use a
backgroundPostStartupActivity
to run something on a background thread approx 5 seconds after project open. - More information from the IntelliJ platform codebase about these startup activities.
💡 You wil find many examples of how these can be used in the migration strategies section.
Light services #
A light service allows you to declare a class as a service simply by using an annotation and not
having to create a corresponding entry in
plugin.xml
.
Read all about light services here.
The following are some code examples of using the @Service
annotation for a very simple service
that isn’t project or module scoped (but is application scoped).
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.ServiceManager
@Service
class LightService {
val instance: LightService
get() = ServiceManager.getService(LightService::class.java)
fun serviceFunction() {
println("LightService.serviceFunction() run")
}
}
Notes on the snippet:
- There is no need to register these w/
plugin.xml
making them really easy to use. - Depending on the constructor that is overloaded, IDEA will figure out whether this is a project, module, or application scope service.
- The only restriction to using light services is that they must be
final
(which all Kotlin classes are by default).
⚠️ The use of module scoped light services are discouraged, and not supported.
⚠️ You might find yourself looking for a
projectService
declaration that’s missing fromplugin.xml
but is still available as a service, in this case, make sure to look out for the following annotation on the service class@Service
.
Here’s a code snippet for a light service that is scoped to a project.
@Service
class LightService(private val project: Project) {
companion object {
fun getInstance(project: Project): LightService {
return ServiceManager.getService(project, LightService::class.java)
}
}
fun serviceFunction() {
println("LightService.serviceFunction() run w/ project: $project")
}
}
💡️ You can save a reference to the open project, since a new instance of a service is created per project (for project-scope services).
Migration strategies #
There are a handful of ways to go about removing components and replacing them w/ services, startup activities, listeners, etc. The following is a list of common refactoring strategies that you can use depending on your specific needs.
1. Component -> Service #
In many cases you can just replace the component w/ a service, and get rid of the project opened and closed methods, along w/ the component name and dispose component methods.
Another thing to watch out for is to make sure that the getInstance()
methods all make a
getService()
call and not getComponent()
. Look at tests as well to see if they are using
getComponent()
instead of getService()
to get an instance of the migrated component.
Here’s an XML snippet of what this might look like:
<projectService serviceImplementation="MyServiceClass" />
💡️ If you use a light service then you can skip registering the service class in
plugin.xml
.
Here’s the code for the service class:
class MyServiceClass : Disposable {
fun dispose() { /** Custom logic that runs when the project is closed. */ }
companion object {
@JvmStatic
fun getInstance(project: Project) = project.getService(MyServiceClass::class.java)
}
}
💡️ If you don’t need to perform any custom login in your service when the project is closed, then there is no need to implement
Disposable
and you can just remove thedispose()
method.
Disposing the service and choosing a parent disposable #
In order to clean up after the service, it can simply implement the
Disposable
interface and put the logic for clean up in thedispose()
method. This should suffice for most situations, since IDEA will automatically take care of cleaning up the service instance.
- Application-level services are automatically disposed by the platform when the IDE is closed, or the plugin providing the service is unloaded.
- Project-level services are automatically disposed when the project is closed or the plugin is unloaded.
However, if you still want to exert finer control over when you want your service to be disposed, you can use
Disposer.register()
by passing aProject
orApplication
service instance as the parent argument.
💡️ Here’s more information from JetBrains official docs on choosing a disposable parent.
Summary #
- For resources required for the entire lifetime of a plugin use an application-level or project-level service.
- For resources required while a dialog is displayed, use a
DialogWrapper.getDisposable()
.- For resources required while a tool window is displayed, pass your instance implementing
Disposable
toContext.setDisposer()
.- For resources w/ a shorter lifetime, create a disposable using a
Disposer.newDisposable()
and dispose it manually usingDisposable.dispose()
.- Finally, when passing our own parent object be careful about non-capturing-lambdas.
2. Component -> postStartupActivity #
This is a very straightforward replacement of a component w/ a
startup activity.
The logic that is in projectOpened()
simply goes into the runActivity(project: Project)
method.
The same approach used in Component -> Service still applies (w/ removing
needless methods and using getService()
calls).
3. Component -> postStartupActivity + Service #
This is a combination of the two strategies above. Here’s a pattern that you can use to detect if
this is the right approach or not. If the component had some logic that executed in
projectOpened()
which requires a Project
instance then you can do the following:
- Make the component a service in the
plugin.xml
file. Also, add a startup activity. - Instead of your component extending
ProjectComponent
have it implementDisposable
if you need to run some logic when it is disposed (when the project is closed). Or just have it not implement any interface or extend any class. Make sure to accept a parameter ofProject
in the constructor. - Rename the
projectOpened()
method toonProjectOpened()
. Add any logic you might have had in anyinit{}
block or any other constructors to this method. - Create a
getInstance(project: Project)
function that looks up the service instance from the given project. - Create a startup activity inner class called eg:
MyStartupActivity
which simply callsonProjectOpened()
.
This is roughly what things will end up looking like:
<projectService serviceImplementation="MyServiceClass" />
<postStartupActivity implementation="MyServiceClass$MyStartupActivity"/>
And the Kotlin code changes:
class MyServiceClass {
fun onProjectOpened() { /** Stuff. */ }
class MyStartupActivity : StartupActivity.DumbAware {
override fun runActivity(project: Project) = getInstance(project).onProjectOpened()
}
companion object {
@JvmStatic
fun getInstance(project: Project): MyServiceClass = project.getService(YourServiceClass::class.java)
}
}
4. Component -> projectListener #
Many components just subscribe to a topic on the message bus in the projectOpened()
method. In
these cases, it is possible to replace the component entirely by (declaratively) registering a
projectListener
in your module’s plugin.xml
.
Here’s an XML snippet of what this might look like (goes in plugin.xml
):
<listener class="MyListenerClass"
topic="com.intellij.execution.testframework.sm.runner.SMTRunnerEventsListener"/>
<listener class="MyListenerClass"
topic="com.intellij.execution.ExecutionListener"/>
And the listener class itself:
class MyListenerClass(val project: Project) : SMTRunnerEventsAdapter(), ExecutionListener {}
5. Component -> projectListener + Service #
Sometimes a component can be replaced w/ a service and a projectListener
, which is simply
combining two of the strategies shown above.
6. Delete Component #
There are some situations where the component might have been deprecated already. In this case
simply remove it from the appropriate module’s plugin.xml
and you can delete those files as well.
7. Component -> AppLifecycleListener #
There are some situations where an application component has to be launched when IDEA starts up and it has to be notified when it shuts down. In this case you can use AppLifecycleListener to attach a listener to IDEA that does just this.
VFS, Document, PSI #
The virtual file system (VFS) is an abstraction that is available inside the IntelliJ Platform that allows access to files on your computer (on your local file system, or even in JAR files), or from a source code repository, or over the network. There are multiple ways of accessing the contents of files in the IDE:
- VFS allows access to files at the lowest level (closest the actual file system and not an abstraction like PSI).
- Document provides an object model to access file contents as plain text (so its somewhere between VFS and PSI).
- PSI (Program Structure Interface) allows access to the contents of a file in a hierarchical object model which takes the syntax and semantics of specific languages into account (kind of like how DOM represents HTML and CSS content in a web browser). You can learn more about PSI in in this section.
VFS #
The VFS is what the IntelliJ Platform uses to work with files. It provides:
- A universal API for working with files regardless of where they are located (on disk, in a JAR file, on a HTTP server, in a VCS, etc).
- Information for tracking file modifications and providing both new and old versions of a file when a change is detected in a file.
- Ability to associate additional persistent data with a file in the VFS.
The VFS manages a persistent snapshot of the files that are accessed via the IDE. These snapshots store only those files which have been requested at least once via the VFS API. Here are some important things to keep in mind about the nature of VFS:
- The contents of the cache is refreshed asynchronously to match any changes on disk.
- Keep in mind that the snapshot is stored at the application level, as you might expect, so a file that is open in multiple projects will only have one snapshot.
- The snapshot is updated from disk during refresh operations, which generally happen asynchronously. All write operations made through the VFS are synchronous and the contents is saved to disk immediately.
Read more about VFS from the official docs.
There are quite a few common scenarios that you face when using VFS to work with files that your plugin needs. The following are some of these scenarios with code samples to help you deal with them.
Getting a list of all the virtual files in a project #
Snippet to get a list of virtual files by name Lambdas.kt
.
fun getListOfProjectVirtualFilesByName(project: Project,
caseSensitivity: Boolean = true,
fileName: String = "Lambdas.kt"
): MutableCollection<VirtualFile> {
val scope = GlobalSearchScope.projectScope(project)
return FilenameIndex.getVirtualFilesByName(
project, fileName, caseSensitivity, scope)
}
Snippet to get a list of virtual files with extension kt
.
fun getListOfProjectVirtualFilesByExt(project: Project,
caseSensitivity: Boolean = true,
extName: String = "kt"
): MutableCollection<VirtualFile> {
val scope = GlobalSearchScope.projectScope(project)
return FilenameIndex.getAllFilesByExt(project, extName, scope)
}
Snippet to get a list of all virtual files in a project.
fun getListOfAllProjectVFiles(project: Project): MutableCollection<VirtualFile> {
val collection = mutableListOf<VirtualFile>()
ProjectFileIndex.getInstance(project).iterateContent {
collection += it
// Return true to process all the files (no early escape).
true
}
return collection
}
Attach listeners to see changes to virtual files programmatically #
You can attach the listeners programmatically or declaratively.
This is the deprecated way of programmatically attaching a listener for VFS changes:
VirtualFileManager.getInstance().addVirtualFileListener()
The new way to add a listener programmatically is to listen to VirtualFileManager.VFS_CHANGES
events on the bus (aka the topic).
There is no way to filter for these change events by path or filename, so the logic to filer out unwanted events needs to go in the listener.
The following function shows how to register this in code. Note that this function runs in the EDT.
/**
* VFS listeners are application level and will receive events for changes happening in all the projects opened by the
* user. You may need to filter out events that aren’t relevant to your task (e.g., via
* `ProjectFileIndex#isInContent()`). A listener for VFS events, invoked inside write-action.
*/
private fun attachListenerForProjectVFileChanges(): Unit {
println("MyPlugin: attachListenerForProjectFileChanges()")
val connection = project.messageBus.connect(/*parentDisposable=*/ project)
connection.subscribe(
VirtualFileManager.VFS_CHANGES,
object : BulkFileListener {
override fun after(events: List<VFileEvent>) = doAfter(events)
})
}
fun handleEvent(event: VFileEvent) {
when (event) {
is VFilePropertyChangeEvent -> {
println("VFile property change event: $event")
}
is VFileContentChangeEvent -> {
println("VFile content change event: $event")
}
}
}
fun doAfter(events: List<VFileEvent>) {
println("VFS_CHANGES: #events: ${events.size}")
val projectFileIndex = ProjectRootManager.getInstance(project).fileIndex
events.withIndex().forEach { (index, event) ->
println("$index. VFile event: $event")
// Filter out file events that are not in the project's content.
events
.filter { it.file != null && projectFileIndex.isInContent(it.file!!) }
.forEach { handleEvent(it) }
}
}
Using this approach, the code attaching the listener itself has to be run at some point. So if this
is a listener that should run when your project is opened, then you might need to add a
postStartupActivity
, which is just a class supplied by your plugin that will be run after the IDE
opens a project. Please read all about this in the dynamic plugins section.
💡 You might want to use this approach over the declarative approach shown below in case you want a reference to the currently open project.
Attach listeners to see changes to virtual files declaratively #
You can also register a listener declaratively in your plugin.xml
. There are some differences to
using this approach over the code approach shown above. Instead of subscribing to a topic, you can
simply register a listener for a specific event class in your plugin.xml
.
Here’s a snippet that does something similar to the code above. So the
VirtualFileManager.VFS_CHANGES
topic is equivalent to the
com.intellij.openapi.vfs.newvfs.BulkFileListener
class.
<applicationListeners>
<listener class="MyListener" topic="com.intellij.openapi.vfs.newvfs.BulkFileListener"/>
</applicationListeners>
Here’s the code.
class MyVfsListener : BulkFileListener {
@Override
fun after(@NotNull events: List<VFileEvent?>?) {
// handle the events
}
}
Here’s more information on registering VFS listeners in the official docs.
💡 You can also declaratively attach listeners that are scoped to a project. However, this requires that the interface / class that you will register in the XML can take a project object as a parameter. In the case of VFS listeners it does not take a project parameter, since VFS operations are application level. So if you want to get a hold of the currently open project, then you have to use the programmatic approach.
Asynchronously process file system events #
It is possible to get these file system events asynchronously (in a background thread). Take a Look
at the
AsyncFileListener.java
class for examples on how to do this. The async versions are not as trivial to implement as the
version that runs on the UI thread. Here are some notes on this:
- You can register an extension for
vfs.asyncListener
that registers your async listener inplugin.xml
. - Or you can call
VirtualFileManager.java
’saddVirtualFileListener()
method.
Intercept when the currently open file gets saved #
This is a code snippet that can use the AppTopics.FILE_DOCUMENT_SYNC
topic to get an event just
before a file is saved. Read more about
Document
to get an understanding of what this event does and where it comes from.
Note that this listener will fire on any file that is being saved, not just the only one that is currently being edited.
- So if you’re relying on this to fire JUST for the file that is currently open, then this is not the way to go.
- However, if you want to do something before any files that are open in editors are going to be saved, then this is the place to trap these events.
/**
* - [Tutorial](http://arhipov.blogspot.com/2011/04/code-snippet-intercepting-on-save.html)
* - [FileDocumentManagerListener]
* - [AppTopics]
*/
private fun attachFileSaveListener() {
printHeader()
val connection = project.messageBus.connect(/*parentDisposable=*/ project)
connection.subscribe(
AppTopics.FILE_DOCUMENT_SYNC,
object : FileDocumentManagerListener {
override fun beforeDocumentSaving(document: Document) {
val vFile = FileDocumentManager.getInstance().getFile(document)
println(StringBuilder().apply {
append("A VirtualFile is about to be saved\n")
append("\tvFile: $vFile\n")
append("\tdocument: $document\n")
})
}
})
}
Document #
The Document API is a way to access a file from the IDE as a simple text file. The IntelliJ Platform
handles encoding and line break conversions when loading or saving the file transparently. There are
a few ways of getting a Document
object.
- From an action using
e.getRequiredData(CommonDataKeys.EDITOR).document
. - From a virtual file using
FileDocumentManager.document
. - From a PSI file using
PsiDocumentManager.getInstance().document
.
Example of an action that uses the Document API #
Here’s an example of an action that uses the Document API to replace some selected text in the IDE with another string.
class EditorReplaceTextAction : AnAction() {
/**
* [Tutorial](https://www.jetbrains.org/intellij/sdk/docs/tutorials/editor_basics/working_with_text.html)
*/
override fun actionPerformed(e: AnActionEvent) {
val editor: Editor = e.getRequiredData(CommonDataKeys.EDITOR)
val project: Project = e.getRequiredData(CommonDataKeys.PROJECT)
val document: Document = editor.document
val caretModel: CaretModel = editor.caretModel
val primaryCaret = caretModel.primaryCaret
// start and end offsets of the selected text (based on the primaryCaret).
val selection =
Pair<Int, Int>(primaryCaret.selectionStart, primaryCaret.selectionEnd)
// Actual content that is selected at the caret.
val selectedText: String = primaryCaret.selectedText!!
// Change the document in a write action in a command (for undo).
WriteCommandAction.runWriteCommandAction(project) {
document.replaceString(selection.first, selection.second, ">> $selectedText <<")
}
// Deselect the selection of the text that that was just replaced.
primaryCaret.removeSelection()
}
override fun update(e: AnActionEvent) {
val project: Project? = e.project
val editor: Editor? = e.getData(CommonDataKeys.EDITOR)
// Action visible only if the editor in the open project has text selected.
e.presentation.isEnabledAndVisible =
project != null
&& editor != null
&& editor.selectionModel.hasSelection()
}
}
Read more about Documents from the official docs.
UI (JetBrains UI components, Swing and Kotlin UI DSL) #
There are many ways for a plugin to interact w/ an end user - keyboard shortcuts that bind to actions, and UI components that the plugin provides, which can be totally custom, or integrated into the existing IDE’s UI components. JetBrains also provides many UI controls that are applicable for different scenarios.
There is a range of components that span from simple fire and forget notifications, to more sophisticated UI that allows intense interaction w/ the user. There is even a Kotlin DSL for UI components that makes it relatively easy to create forms in IDEA.
There is even a way to create forms using Swing, which is being phased out in favor of the Kotlin UI DSL. In addition to all of this, you can even create custom themes for IDEA.
One effective approach to writing UI code for plugins is to take a look at how some UI code is
written in intellij-community
repo itself, to see how JetBrains does it. Documentation is sparse
to non existent, and the best way sometimes is to take a look at the source for IDEA itself to find
out how certain UI is created.
Here are good resources to take a look when considering writing UI code for plugins.
Simple UI components - notifications, popups, dialogs #
This section covers some simple UI components, and the next sections get into more sophisticated interactions w/ users (via forms, dialogs, and the settings UI).
Notifications #
Notifications are a really simple way to provide some information to the user. You can even attach actions to notifications in case you want the user to perform an action when they get notified.
- Notifications show up in the “Event Log” tool window in the IDE.
- You can choose to have any shown notifications to be logged as well.
- Notifications can be project specific (and be shown in the IDE window containing a project), or be a general notification for the entire IDE.
- Here are the official docs on notifications.
This is an example of a simple action that displays two notifications using slightly different ways.
Here is the snippet for this action that goes into plugin.xml
.
<actions>
<!-- Add notification action. -->
<action id="MyPlugin.actions.ShowNotificationSample" class="ui.ShowNotificationSampleAction"
description="Show sample notifications" text="Sample Notification" icon="/icons/ic_extension.svg" />
</actions>
Here’s the start of the class that implements this action, to set things up.
package ui
class ShowNotificationSampleAction : AnAction() {
private val GROUP_DISPAY_ID = "UI Samples"
private val messageTitle = "Title of notification"
private val messageDetails = "Details of notification"
override fun actionPerformed(e: AnActionEvent) {
aNotification()
anotherNotification(e)
}
private fun anotherNotification(e: AnActionEvent) {...}
private fun aNotification() {...}
Approach 1 - The following is an example of using a static method on the
Notifications.Bus
to display a notification.
/**
* One way to show notifications.
* 1) This notification won't be logged to "Event Log" tool window.
* 2) And it is project specific.
*/
private fun aNotification() {
val notification = Notification(GROUP_DISPAY_ID,
"1 .$messageTitle",
"1 .$messageDetails",
NotificationType.INFORMATION)
Notifications.Bus.notify(notification)
}
Approach 2 - Here’s an another way to show notifications by creating a notification group.
/**
* Another way to show notifications.
* 1) This will be logged to "Event Log" and is not tied to a specific project.
*/
private fun anotherNotification(e: AnActionEvent) {
val project = e.getRequiredData(CommonDataKeys.PROJECT)
val notificationGroup = NotificationGroup(GROUP_DISPAY_ID, NotificationDisplayType.BALLOON, false)
val notification = notificationGroup
.createNotification("2. $messageTitle",
"2. $messageDetails",
NotificationType.INFORMATION,
null)
notification.notify(project)
}
Popups #
Popups are a way to get some input from the user w/out interrupting what they’re doing. Popups are displayed w/out any chrome (UI to close them), and they disappear automatically when they lose keyboard focus. IDE uses them extensively in type ahead completion, auto complete, etc.
- Popups allow the user to make a single choice from a list of options that are displayed. Once the
user makes a choice you can provide some code (
itemChosenCallback
) that will be run on this selection. - Popups can display a title.
- They can be movable and resizable (and support remembering their size).
- They can even be nested (show another popup when an item is selected).
The following is an example of an action that shows two nested popups. Here’s the snippet from
plugin.xml
for registering the action.
<!-- Add popup action. -->
<action id="MyPlugin.actions.ShowPopupSample" class="ui.ShowPopupSampleAction" text="Sample Popup"
description="Shows sample popups" icon="/icons/ic_extension.svg" />
Here’s the class that implements the action (which shows one popup, and when the user selects an
option in the first popup, the second one is shown). It shows multiple ways in which a popup can be
created to display a list of items. One approach is to use the createPopupChooserBuilder()
, and
the other is to use createListPopup()
, both of which are methods on
JBPopupFactory.getInstance()
method.
package ui
class ShowPopupSampleAction : AnAction() {
lateinit var editor: Editor
override fun actionPerformed(e: AnActionEvent) {
editor = e.getRequiredData(CommonDataKeys.EDITOR)
// One way to show a list.
JBPopupFactory
.getInstance()
.createListPopup(MyList {
// Another way to show a list, using the builder.
JBPopupFactory
.getInstance()
.createPopupChooserBuilder(mutableListOf("one", "two", "three"))
.setTitle("PopupChooserBuilder")
.setItemChosenCallback { println("PopupChooserBuilder.onChosen $it") }
.createPopup()
.showInBestPositionFor(editor)
})
.showInBestPositionFor(editor)
}
}
class MyList(val onChosenHandler: (String) -> Unit) : BaseListPopupStep<String>() {
init {
init("Popup title", mutableListOf("Choice 1", "Choice 2", "Choice 3"), null)
}
override fun getTextFor(value: String): String {
return "TEXT: $value"
}
override fun onChosen(selectedValue: String, finalChoice: Boolean): PopupStep<*>? {
println("MyList.onChosen $selectedValue")
onChosenHandler(selectedValue)
return PopupStep.FINAL_CHOICE
}
}
Dialogs #
When user input is required by your plugin dialogs might be the right component to use. They are modal unlike notifications and popups. IDEA allows a simple boolean response to be returned from a dialog.
In order to create sophisticated UIs that go inside the dialog, it is best to use the Kotlin UI DSL.
Here’s a really simple example of an action that shows a dialog displaying “Press OK or Cancel” and
allowing the user and showing OK and Cancel buttons. Here’s the snippet for plugin.xml
.
<!-- Add dialog action. -->
<action id="MyPlugin.actions.ShowDialogSample" class="ui.ShowDialogSampleAction" text="Sample Dialog"
description="Show sample dialog" icon="/icons/ic_extension.svg" />
Here’s the action that uses the
DialogWrapper
class to actually show the dialog.
package ui
class ShowDialogSampleAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val response = SampleDialogWrapper().showAndGet()
println("Response selected:${if (response) "Yes" else "No"}")
}
}
class SampleDialogWrapper : DialogWrapper(true) {
init {
init()
title = "Sample Dialog"
}
// More info on layout managers: https://docs.oracle.com/javase/tutorial/uiswing/layout/visual.html
override fun createCenterPanel(): JComponent {
val panel = JPanel(BorderLayout())
val label = JLabel("Press OK or Cancel")
label.preferredSize = Dimension(100, 100)
panel.add(label, BorderLayout.CENTER)
return panel
}
}
Note that in this case, showAndGet()
is used to both show the dialog, and get the response. You
can also break this into two steps with show()
and isOK()
.
Please refer to the official docs for more information on dialogs.
For more information on Swing layout managers, please refer to this Java Tutorial article.
Create complex UIs using Kotlin UI DSL (forms, dialogs, settings screens) #
In the sections below, we will be using DialogWrapper
to take the UI we create using the Kotlin UI
DSL and show them.
In order to create more complex UIs, you can use the Kotlin UI DSL. You can display these forms in the IDEA Settings UI, or directly in a dialog box. You can also bind the forms to data objects, that you can persist across IDE restarts as well.
Understanding the structure of the DSL (layout, row, and cell) #
The Kotlin UI DSL allows UIs to be expressed in a layout that contains rows and cells. Things are left to right aligned inside of each row, but you can specify if an item needs to be right aligned. Type ahead completion in the IDE also provides guidance on how to use this DSL.
Here’s an example of a simple UI using this DSL, which contains the following UI components.
textField
spinner
checkBox
noteRow
data class MyData(var myFlag: Boolean, var myString: String, var myInt: Int, var myStringChoice: String)
val myDataObject = MyData()
fun createDialogPanel(): DialogPanel = panel {
// Restore the selection state of the combo box.
val comboBoxChoices = listOf("choice1", "choice2", "choice3")
val savedSelection = myDataObject.myStringChoice // This is an empty string by default.
val selection = if (savedSelection.isEmpty()) comboBoxChoices.first() else savedSelection
val comboBoxModel = CollectionComboBoxModel(comboBoxChoices, selection)
noteRow("This is a row with a note")
row {
label("a string")
textField(myDataObject::myString)
}
row {
label("an int")
spinner(myDataObject::myInt, minValue = 0, maxValue = 50, step = 5)
}
row("ComboBox / Drop down list") {
comboBox(comboBoxModel, myDataObject::myStringChoice)
}
row {
cell {
checkBox("", myDataObject::myFlag)
label("Boolean state::myFlag")
}
}
noteRow("""Note with a link. <a href="http://github.com">Open source</a>""") {
BrowserUtil.browse(it)
}
}
How to bind data to the form UI #
One really simple way of binding the UI to a data object that you create is by passing a reference
to the KProperty
for each property that should be rendered by a UI component. This ensures that:
- The data object populates the UI component to its correct initial state before it is shown.
- When the user manipulates the UI component and its state changes, this is reflected in the data object’s property.
In the example above, note that some UI components that hold state information require a Kotlin property (from a data object that you provide) to bind to. This is an easy way that the DSL handles data binding in the forms for you.
It is possible to provide explicit setters and getters as well instead of just using the
KProperty
. This makes
it really simple to create forms, since the state is loaded and saved for you, automatically.
What UI elements are available for use in the forms #
- The
panel
function creates aLayoutBuilder
and inside of this you can addrow
(ornoteRow
, ortitledRow
, etc). Check outRowBuilder
to see all the row based components you can add here. - To see what objects can be added inside each
row
, check outCell
. You can add things likebrowserLink
,comboBox
,checkBox
, etc.
Please make sure to read the official docs on the DSL.
How to display a form in a dialog #
The form that’s created using this DSL can be displayed anywhere a DisplayPanel
can be used (which
is just a subclass of JPanel
). This includes the Settings UI (shown in the
section below) and in a dialog. Here’s an sample action that
takes the object returned by
createDialogPanel()
above, and
displays it in a dialog, using a DialogWrapper
.
class ShowKotlinUIDSLSampleInDialogAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
MyDialogWrapper().show()
}
}
private class MyDialogWrapper : DialogWrapper(true) {
init {
init()
title = "Sample Dialog with Kotlin UI DSL"
}
override fun createCenterPanel(): JComponent = createDialogPanel()
}
How to persist the data created/selected by the user, between IDE restarts (PersistentStateComponent) #
If you want the data that you’ve created in your form to be used in the rest of the IDE, it makes
sense to persist this data and make it available to other pieces of your plugin. This is where
PersistentStateComponent
comes in. It allows you serialize the state of your data object to disk, and deserialize it from
disk. In order to have IDEA persist your data object, simply do two things:
- Make your data class extend
PersistentStateComponent
. - Wrap this in a
Service
so that you can access it in any code in your plugin.
Here’s an example that shows this. The KotlinDSLUISampleService
service contains a State
object
called myState
that actually holds the data which is bound to the forms. State
simply holds 4
properties (which can be bound to various UI components in a form): myFlag: Boolean
,
myString: String
, myInt: Int
, myStringChoice: String
.
@Service
@State(name = "KotlinDSLUISampleData", storages = [Storage("kotlinDSLUISampleData.xml")])
class KotlinDSLUISampleService : PersistentStateComponent<KotlinDSLUISampleService.State> {
companion object {
val instance: KotlinDSLUISampleService
get() = getService(KotlinDSLUISampleService::class.java)
}
var myState = State()
// PersistentStateComponent methods.
override fun getState(): State {
println("KotlinDSLUISampleData.getState, state: $myState")
return myState
}
override fun loadState(stateLoadedFromPersistence: State) {
println("KotlinDSLUISampleData.loadState, stateLoadedFromPersistence: $stateLoadedFromPersistence")
myState = stateLoadedFromPersistence
}
// Properties in this class are bound to the Kotlin DSL UI.
class State {
var myFlag: Boolean by object : LoggingProperty<State, Boolean>(false) {}
var myString: String by object : LoggingProperty<State, String>("") {}
var myInt: Int by object : LoggingProperty<State, Int>(0) {}
var myStringChoice: String by object : LoggingProperty<State, String>("") {}
override fun toString(): String =
"State{ myFlag: '$myFlag', myString: '$myString', myInt: '$myInt', myStringChoice: '$myStringChoice' }"
/** Factory class to generate synthetic properties, that log every access and mutation to each property. */
open class LoggingProperty<R, T>(initValue: T) : ReadWriteProperty<R, T> {
var backingField: T = initValue
override fun getValue(thisRef: R, property: KProperty<*>): T {
println("State::${property.name}.getValue(), value: '$backingField'")
return backingField
}
override fun setValue(thisRef: R, property: KProperty<*>, value: T) {
backingField = value
println("State::${property.name}.setValue(), value: '$backingField'")
}
}
}
}
In the form UI example above, we just bound the data for the UI components to an object created from a simple data class. Here’s an example.
row {
label("a string")
textField(myDataObject::myString)
}
In order to replace that with the PersistentStateComponent
instance, we would do something like
this instead.
row {
label("a string")
textField(KotlinDSLUISampleService.instance.myState::myString)
}
In other words, myDataObject
is replaced with KotlinDSLUISampleService.instance.myState
.
There is one very important thing to note in the code above. The getState()
and loadState(...)
methods should not be called by your code. These are hooks for IDEA to call into the service in
order to manage loading and saving the state to persistence. You should provide accessors to your
data properties that do not involve the use of getState()
. And make sure that these accessors can
handle loadState(...)
providing the “initial” state (if there is anything stored in persistence).
And you must make sure that by default, your initial state has to be defined (if there is no
customized data to load from persistence, then your state will contain default values). IDEA uses
this default state to figure out if the user changed anything in the form UI (the diffs will deviate
from the default state).
Also, you can specify many options to IDEA on where to store the persistent data. In this example,
we have told IDEA to store any user modified data to an XML file called kotlinDSLUISampleData.xml
in the config/options/
folder where IDEA settings are stored. You can also specify options for
storages
that determine whether this data should be roaming disabled or not, and even if you
should only store the data on specific platforms.
Example of a form UI #
Here’s a form that uses Kotlin DSL and the State
object (provided by the
PersistentStateComponent
service).
fun createDialogPanel(): DialogPanel {
val comboBoxChoices = listOf("choice1", "choice2", "choice3")
// Restore the selection state of the combo box.
val savedSelection = instance.myState.myStringChoice // This is an empty string by default.
val selection = if (savedSelection.isEmpty()) comboBoxChoices.first() else savedSelection
val comboBoxModel = CollectionComboBoxModel(comboBoxChoices, selection)
return panel {
noteRow("This is a row with a note")
row("[Boolean]") {
row {
cell {
checkBox("", instance.myState::myFlag)
label("Boolean state::myFlag")
}
}
}
row("[String]") {
row {
label("String state::myString")
textField(instance.myState::myString)
}
}
row("[Int]") {
row {
label("Int state::myInt")
spinner(instance.myState::myInt, minValue = 0, maxValue = 50, step = 5)
}
}
row("ComboBox / Drop down list") {
comboBox(comboBoxModel, instance.myState::myStringChoice)
}
noteRow("""Note with a link. <a href="http://github.com">Open source</a>""") {
println("link url: '$it' clicked")
BrowserUtil.browse(it)
}
}
}
MigLayout, panel, and grow #
The panel
function (in Kotlin UI DSL) actually creates a
MigLayout
.
MigLayout
can produce flowing, grid based, absolute (with links), grouped and docking layouts. You
can read the docs here.
panel
acceptsrow
functions, which in turn acceptscell
functions.- You can pass
grow
andfill
attributes topanel
andcell
functions. - You can also wrap
JComponents
using thecomponent
function, which can also takegrow
andfill
attributes. panel
automatically sizes the dialog, however, you can override it using your own predefined width and height.
Here’s an example (myJComponent
is just a JComponent
that is created somewhere else).
override fun createCenterPanel(): JComponent {
return panel() {
row {
cell {
label("Type into this editor component")
}
}
row {
cell {
component(myJComponent).constraints(growX, growY, pushX, pushY)
}
}
}.apply {
preferredSize = JBUI.size(WIDTH, HEIGHT)
}
}
companion object {
const val WIDTH = 1000
const val HEIGHT = 400
}
Note that
JComponent
objects become callable within thepanel
function and they acceptCCFLags
that constrain their growth.
Please note that it might be simpler at times to use the Swing layout managers directly w/out using this DSL.
Create IDE Settings UI for plugin #
Let’s say that we wanted to display the form created above (by createDialogPanel()
) and we want to
display it in a Settings UI, in addition to it being available in a Dialog
. Note that you can
display the form in both places, and have it update the data in the same PersistentStateComponent
service, which is really convenient.
In order to tell IDEA that you want a form to be displayed in the Settings UI, you can use one of
two extension points. In plugin.xml
you can declare 2 types of “configurables” that allow you to
customize the IDE settings UI, where a “configurable” is a base class provided by the JetBrains
platform that allows you show your form UI.
projectConfigurable
- these are settings that are specific to a given project.applicationConfigurable
- these are settings that apply to the entire IDE.
References:
When using the Kotlin UI DSL instead of implementing Configurable
interface, simply extend
BoundConfigurable
. When doing this you can:
- Pass a
displayName
to the constructor of theBoundConfigurable
that will actually show up in the Settings UI, (and you can type-ahead search for). - Pass any number of JB platform objects in the constructor, eg:
class MyConfigurable(private val lafManager: LafManager) : BoundConfigurable("Display Name")
The following is an example of this (plugin.xml
entry, and a class that extends
BoundConfigurable
).
<!-- Add Settings Dialog that is similar to what ShowKotlinUIDSLSampleAction does. -->
<extensions defaultExtensionNs="com.intellij">
<applicationConfigurable instance="ui.KotlinDSLUISampleConfigurable" />
</extensions>
The code from the previous section (Kotlin UI DSL)) is used here to generate the form itself.
package ui
/**
* This application level configurable shows up the in IDE Settings UI.
*/
class KotlinDSLUISampleConfigurable : BoundConfigurable("Kotlin UI DSL") {
override fun apply() {
println("KotlinDSLUISampleConfigurable apply() called")
super.apply()
}
override fun cancel() {
println("KotlinDSLUISampleConfigurable cancel() called")
super.cancel()
}
/** When the form is changed by the user, this returns `true` and enables the "Apply" button. */
override fun isModified(): Boolean {
println("KotlinDSLUISampleConfigurable isModified() called, return ${super.isModified()}")
return super.isModified()
}
override fun reset() {
println("KotlinDSLUISampleConfigurable reset() called")
super.reset()
}
override fun createPanel(): DialogPanel = createDialogPanel()
}
Complex UI creation in dialogs #
There are cases where complex UI components need to be created in a DialogWrapper
. In this case,
Kotlin UI DSL can be used or directly creating Swing components using Swing layout managers.
Please take a look at the idea-plugin-example2 repo that contains a plugin that has some of the functionality shown below.
The following is an example of doing the latter to create a dialog that allows the user to paste
text from the clipboard into an Editor
component. The paste operation is actually implemented as
an undoable command. It automatically pastes any text that is in the clipboard into the editor when
the dialog is shown.
class ComplexDialog(private val project: Project) : DialogWrapper(true) {
private lateinit var editor: Editor
init {
createEditorPanel()
init()
title = "Type a bunch of text into the editor in this dialog"
}
// More info on layout managers: https://docs.oracle.com/javase/tutorial/uiswing/layout/visual.html
override fun createCenterPanel(): JComponent {
val panel = JPanel()
panel.preferredSize = JBUI.size(WIDTH, HEIGHT)
panel.layout = GridLayout(0, 1)
panel.add(editor.component)
return panel
}
fun createEditorPanel() {
val editorFactory: EditorFactory = EditorFactory.getInstance()
val document: Document = editorFactory.createDocument("")
editor = editorFactory.createEditor(document, project)
val settings: EditorSettings = editor.settings
with(settings) {
isFoldingOutlineShown = false
isLineMarkerAreaShown = false
isIndentGuidesShown = false
isLineNumbersShown = false
isRightMarginShown = false
}
Disposer.register(myDisposable, Disposable {
EditorFactory.getInstance().releaseEditor(editor)
})
pasteTextFromClipboard()
}
fun pasteTextFromClipboard() {
getTextInClipboard()?.let(::setText)
}
fun setText(text: String) {
val runnable = Runnable {
ApplicationManager.getApplication().runWriteAction {
editor.document.let {
it.replaceString(0, it.textLength, StringUtil.convertLineSeparators(text))
}
}
}
// Undoable command to set the text.
CommandProcessor.getInstance().executeCommand(project, runnable, "", this)
}
private fun getTextInClipboard(): String? {
return CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)
}
companion object {
const val WIDTH = 1000
const val HEIGHT = 400
}
}
Adding your plugin UI in Tool windows #
Tool windows provide in IDEA access to useful development tasks such as viewing your project structure, running and debugging your code, Git integration, and so on. Your plugin can create UI that will fit in tool windows in IDEA, instead of for example displaying it in a dialog box.
Read more about Tool windows in the official docs.
For both of these types of Tool windows, the following applies:
- Each tool window can have multiple tabs (aka “contents”).
- Each side of the IDE can only show 2 tool windows at any given time, as the primary or the secondary. For eg: you can move the “Project” tool window to “Left Top”, and move the “Structure” tool window to “Left Bottom”. This way you can open both of them at the same time. Note that when you move these tool windows to “Left Top” or “Left Bottom” how they actually move to the top or bottom of the side of the IDE.
There are two main types of tool windows: 1) Declarative, and 2) Programmatic.
Please take a look at the idea-plugin-example2 repo that contains a plugin that has some of the functionality shown below.
1. Declarative tool window #
Always visible and the user can interact with it at anytime (eg: Gradle plugin tool window).
- This type of tool window must be registered in
plugin.xml
using thecom.intellij.toolWindow
extension point. You can specify things to register this in XML:id
: Text displayed in the tool window button.anchor
: Side of the screen in which the tool window is displayed (“left”, “right”, or “bottom”).secondary
: Specify whether it is displayed in the primary or secondary group.icon
: Icon displayed in the tool window button (13px
x13px
).factoryClass
: A class implementingToolWindowFactory
interface, which is used to instantiate the tool window when the user clicks on the tool window button (by callingcreateToolWindowContent()
). Note that if a user does not interact with the button, then a tool window doesn’t get created.- For versions 2020.1 and later, also implement the
isApplicable(Project)
method if there’s no need to display a tool window for all projects. Note this condition is only evaluated the first time a project is loaded.
Here’s an example.
The factory class.
class DeclarativeToolWindowFactory : ToolWindowFactory, DumbAware {
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
val contentManager = toolWindow.contentManager
val content = contentManager.factory.createContent(createDialogPanel(), null, false)
contentManager.addContent(content)
}
}
fun createDialogPanel(): DialogPanel = panel {
noteRow("""Note with a link. <a href="http://github.com">Open source</a>""") {
colorConsole {
printLine {
span(Colors.Purple, "link url: '$it' clicked")
}
}
BrowserUtil.browse(it)
}
}
The plugin.xml
snippet.
<extensions defaultExtensionNs="com.intellij">
<toolWindow
icon="/icons/ic_toolwindow.svg"
id="Declarative tool window"
anchor="left"
secondary="true"
factoryClass="ui.DeclarativeToolWindowFactory" />
</extensions>
2. Programmatic tool window #
Only visible when a plugin creates it to show the results of an operation (eg: Analyze Dependencies
action). This type of tool window must be added programmatically by calling
ToolWindowManager.getInstance().registerToolWindow(RegisterToolWindowTask)
.
A couple of things to remember.
- You have to register the tool window (w/ the tool window manager) before using it. This is a one time operation. There’s no need to register the tool window if it’s already been registered. Registering simply shows the tool window in the IDEA UI. Unregistering removes it from the UI.
- You can tell the tool window to auto hide itself when there are no contents inside of it.
- You can create as many “contents” as you want and add it to the tool window. Each content is basically a tab. You can also specify that the content is closable.
- You can also attach a disposer to a content so that you can take some action when the content or tab is closed. For eg you can just unregister the tool window when there are no contents left in the tool window.
Here’s an example of all of the things listed above.
internal class AnotherToolWindow : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val project: Project = e.getRequiredData(CommonDataKeys.PROJECT)
val toolWindowManager = ToolWindowManager.getInstance(project)
var toolWindow = toolWindowManager.getToolWindow(ID)
// One time registration of the tool window (does not add any content).
if (toolWindow == null) {
toolWindow = toolWindowManager.registerToolWindow(
RegisterToolWindowTask(
id = ID,
icon = IconLoader.getIcon("/icons/ic_extension.svg", javaClass),
component = null,
canCloseContent = true,
))
toolWindow.setToHideOnEmptyContent(true)
}
val contentManager = toolWindow.contentManager
val contentFactory: ContentFactory = ContentFactory.SERVICE.getInstance()
val contentTab = contentFactory.createContent(
createComponent(project),
"${LocalDate.now()}",
false)
contentTab.setDisposer {
colorConsole {
printLine {
span(Colors.Purple, "contentTab is disposed, contentCount: ${contentManager.contentCount}")
}
}
if (contentManager.contents.isEmpty()) {
toolWindowManager.unregisterToolWindow(ID)
}
}
contentManager.addContent(contentTab)
toolWindow.show { contentManager.setSelectedContent(contentTab, true) }
}
private fun createComponent(project: Project): JComponent {
val panel = JPanel(BorderLayout())
panel.add(BorderLayout.CENTER, JLabel("TODO add component"))
return panel
}
companion object {
const val ID = "AnotherToolWindow"
}
}
The plugin.xml
snippet, to register the action.
<actions>
<action id="MyPlugin.AnotherToolWindow" class="actions.AnotherToolWindow" text="Open Tool Window"
description="Opens tool window programmatically" icon="/icons/ic_extension.svg">
<add-to-group group-id="EditorPopupMenu" anchor="first" />
</action>
</actions>
Indices and dumb aware #
Displaying the contents of many tool windows requires access to the indices. Because of that, tool
windows are normally disabled while building indices, unless true is passed as the value of
canWorkInDumbMode
to the registerToolWindow()
function (for programmatic tool windows). You can
also implement DumbAware
in your factory class to let IDEA know that your tool window can be shown
while indices are being built.
Creating a content for any kind of tool window #
Regardless of the type of tool window (declarative or programmatic) here is the sequence of operations that you have to perform in order to add a content:
- Create the component / UI that you need for the content, ie, a Swing component (eg:
createDialogPanel()
above). - Add the component / UI to the content to the
ToolWindowManager
(programmatic) or the tool windowContentManager
(declarative).
Content closeability #
A plugin can control whether the user is allowed to close tabs either 1) globally or 2) on a per content basis.
- Globally: This is done by passing the
canCloseContents
parameter to theregisterToolWindow()
function, or by specifyingcanCloseContents="true"
inplugin.xml
. The default value isfalse
. Note that callingsetClosable(true)
onContentManager
content will be ignored unlesscanCloseContents
is explicitly set. - Per content basis: This is done by calling
setCloseable(Boolean)
on each content object itself.
If closing tabs is enabled in general, a plugin can disable closing of specific tabs by calling
Content.setCloseable(false)
.
Add Line marker provider in your plugin #
Line marker providers allow your plugin to display an icon in the gutter of an editor window. You can also provide actions that can be run when the user interacts with the gutter icon, along with a tooltip that can be generated when the user hovers over the gutter icon.
Please take a look at the idea-plugin-example2 repo that contains a plugin that has some of the functionality shown below.
In order to use line marker providers, you have to do two things:
- Create a class that implements
LineMarkerProvider
that generates theLineMarkerInfo
for the correctPsiElement
that you want IDEA to highlight in the IDE. - Register this provider in
plugin.xml
and associate it to be run for a specific language.
When your plugin is loaded, IDEA will then run your line marker provider when a file of that language type is loaded in the editor. This happens in two passes for performance reasons.
- IDEA will first call your provider implementation with the
PsiElements
that are currently visible. - IDEA will then call your provider implementation with the
PsiElements
that are currently hidden.
It is very important that you only return a LineMarkerInfo
for the more specific PsiElement
that
you wish IDEA to highlight, as if you scope it too broadly, there will be scenarios where your
gutter icon will blink! Here’s a detailed explanation as to why (a comment from the source for
LineMarkerProvider.java source file
).
Please create line marker info for leaf elements only - i.e. the smallest possible elements. For example, instead of returning method marker for
PsiMethod
, create the marker for thePsiIdentifier
which is a name of this method.Highlighting (specifically,
LineMarkersPass
) queries allLineMarkerProvider
s in two passes (for performance reasons):
- first pass for all elements in visible area
- second pass for all the rest elements If provider returned nothing for both areas, its line markers are cleared.
So imagine a
LineMarkerProvider
which (incorrectly) written like this:class MyBadLineMarkerProvider implements LineMarkerProvider { public LineMarkerInfo getLineMarkerInfo(PsiElement element) { if (element instanceof PsiMethod) { // ACTUALLY DONT! return new LineMarkerInfo(element, element.getTextRange(), icon, null,null, alignment); } else { return null; } } ... }
Note that it create
LineMarkerInfo
for the whole method body. Following will happen when this method is half-visible (e.g. its name is visible but a part of its body isn’t):
- the first pass would remove line marker info because the whole
PsiMethod
isn’t visible- the second pass would try to add line marker info back because
LineMarkerProvider
was called for thePsiMethod
at lastAs a result, line marker icon will blink annoyingly. Instead, write this:
class MyGoodLineMarkerProvider implements LineMarkerProvider { public LineMarkerInfo getLineMarkerInfo(PsiElement element) { if (element instanceof PsiIdentifier && (parent = element.getParent()) instanceof PsiMethod && ((PsiMethod)parent).getMethodIdentifier() == element)) { // aha, we are at method name return new LineMarkerInfo(element, element.getTextRange(), icon, null,null, alignment); } else { return null; } } ... }
Example of a provider for Markdown language #
Let’s say that for Markdown files that are open in the IDE, we want to highlight any lines that have links in them. We want an icon to show up in the gutter area that the user can see and click on to take some actions. For example, they can open the link.
1. Declare dependencies #
Also, because we are relying on the Markdown plugin, in our plugin, we have to add the following dependencies.
To plugin.xml
, we must add.
<!-- please see http://www.jetbrains.org/intellij/sdk/docs/basics/getting_started/build_number_ranges.html for description -->
<idea-version since-build="2020.1" until-build="2020.*" />
<!--
Declare dependency on IntelliJ module `com.intellij.modules.platform` which provides the following:
Messaging, UI Themes, UI Components, Files, Documents, Actions, Components, Services, Extensions, Editors
More info: https://www.jetbrains.org/intellij/sdk/docs/basics/getting_started/plugin_compatibility.html
-->
<depends>com.intellij.modules.platform</depends>
<!-- Markdown plugin. -->
<depends>org.intellij.plugins.markdown</depends>
To build.gradle.kts
we must add.
// See https://github.com/JetBrains/gradle-intellij-plugin/
intellij {
// Information on IJ versions https://www.jetbrains.org/intellij/sdk/docs/reference_guide/intellij_artifacts.html
// You can use release build numbers or snapshot name for the version.
// 1) IJ Release Repository w/ build numbers https://www.jetbrains.com/intellij-repository/releases/
// 2) IJ Snapshots Repository w/ snapshot names https://www.jetbrains.com/intellij-repository/snapshots/
version = "2020.1" // You can also use LATEST-EAP-SNAPSHOT here.
// Declare a dependency on the markdown plugin to be able to access the
// MarkdownRecursiveElementVisitor.kt file. More info:
// https://www.jetbrains.org/intellij/sdk/docs/basics/plugin_structure/plugin_dependencies.html
// https://plugins.jetbrains.com/plugin/7793-markdown/versions
setPlugins("java", "org.intellij.plugins.markdown:201.6668.27")
}
2. Register the provider in XML #
The first thing we need to do is register our line marker provider in plugin.xml
.
<extensions defaultExtensionNs="com.intellij">
<codeInsight.lineMarkerProvider language="Markdown" implementationClass="ui.MarkdownLineMarkerProvider" />
</extensions>
3. Provide an implementation of LineMarkerProvider #
Then we have to provide an implementation of LineMarkerProvider
that returns a LineMarkerInfo
for the most fine grained PsiElement
that it successfully matches against. In other words, we can
either match against the LINK_DESTINATION
or the LINK_TEXT
elements.
Here’s an example. For the string containing an inline Markdown link:
[`LineMarkerProvider.java`](https://github.com/JetBrains/intellij-community/blob/master/platform/lang-api/src/com/intellij/codeInsight/daemon/LineMarkerProvider.java)
This is what its PSI looks like:
ASTWrapperPsiElement(Markdown:Markdown:INLINE_LINK)(1644,1810)
ASTWrapperPsiElement(Markdown:Markdown:LINK_TEXT)(1644,1671)
PsiElement(Markdown:Markdown:[)('[')(1644,1645)
ASTWrapperPsiElement(Markdown:Markdown:CODE_SPAN)(1645,1670)
PsiElement(Markdown:Markdown:BACKTICK)('`')(1645,1646)
PsiElement(Markdown:Markdown:TEXT)('LineMarkerProvider.java')(1646,1669)
PsiElement(Markdown:Markdown:BACKTICK)('`')(1669,1670)
PsiElement(Markdown:Markdown:])(']')(1670,1671)
PsiElement(Markdown:Markdown:()('(')(1671,1672)
MarkdownLinkDestinationImpl(Markdown:Markdown:LINK_DESTINATION)(1672,1809)
PsiElement(Markdown:Markdown:GFM_AUTOLINK)('https://github.com/JetBrains/intellij-community/blob/master/platform/lang-api/src/com/intellij/codeInsight/daemon/LineMarkerProvider.java')(1672,1809)
PsiElement(Markdown:Markdown:))(')')(1809,1810)
Here’s what the implementation of the line marker provider that matches INLINE_LINK
might look
like.
package ui
import com.intellij.codeInsight.daemon.LineMarkerInfo
import com.intellij.codeInsight.daemon.LineMarkerProvider
import com.intellij.openapi.editor.markup.GutterIconRenderer
import com.intellij.openapi.util.IconLoader
import com.intellij.psi.PsiElement
import com.intellij.psi.tree.TokenSet
import org.intellij.plugins.markdown.lang.MarkdownElementTypes
internal class MarkdownLineMarkerProvider : LineMarkerProvider {
override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? {
val node = element.node
val tokenSet = TokenSet.create(MarkdownElementTypes.INLINE_LINK)
val icon = IconLoader.getIcon("/icons/ic_linemarkerprovider.svg")
if (tokenSet.contains(node.elementType))
return LineMarkerInfo(element,
element.textRange,
icon,
null,
null,
GutterIconRenderer.Alignment.CENTER)
return null
}
}
You can add the ic_linemarkerprovider.svg
icon here (create this file in the
$PROJECT_DIR/src/main/resources/icons/
folder.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="13px" width="13px">
<path d="M0 0h24v24H0z" fill="none" />
<path
d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z" />
</svg>
4. Provide a more complex implementation of LineMarkerProvider #
The example we have so far, simply shows a gutter icon beside the lines in the editor window, that
match our matching criteria. Let’s say that we want to show some relevant actions that can be
performed on the PsiElement
(s) that matched and are associated with the gutter icon. In this case
we have to delve a little deeper into the LineMarkerInfo
class.
If you look at
LineMarkerInfo.java
,
you will find a createGutterRenderer()
method. We can actually override this method and create our
own GutterIconRenderer
objects that have an action group inside of them which will hold all our
related actions.
The following class
RunLineMarkerProvider.java
actually provides us some clue of how to use all of this. In IDEA, when there are targets that you
can run, a gutter icon (play button) that allows you to execute the run target. This class actually
provides an implementation of that functionality. Using it as inspiration, we can create the more
complex version of our line marker provider.
We are going to change our initial implementation of MarkdownLineMarkerProvider
quite drastically.
First we have to add a class that is our new LineMarkerInfo
implementation called
RunLineMarkerInfo
. This class simply allows us to return an ActionGroup
that we will now have to
provide.
class RunLineMarkerInfo(element: PsiElement,
icon: Icon,
private val myActionGroup: DefaultActionGroup,
tooltipProvider: Function<in PsiElement, String>?
) : LineMarkerInfo<PsiElement>(element,
element.textRange,
icon,
tooltipProvider,
null,
GutterIconRenderer.Alignment.CENTER) {
override fun createGutterRenderer(): GutterIconRenderer? {
return object : LineMarkerGutterIconRenderer<PsiElement>(this) {
override fun getClickAction(): AnAction? {
return null
}
override fun isNavigateAction(): Boolean {
return true
}
override fun getPopupMenuActions(): ActionGroup? {
return myActionGroup
}
}
}
}
Next, is the new version of MarkdownLineMarkerProvider
class itself.
class MarkdownLineMarkerProvider : LineMarkerProvider {
override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? {
val node = element.node
val tokenSet = TokenSet.create(MarkdownElementTypes.INLINE_LINK)
if (tokenSet.contains(node.elementType))
return RunLineMarkerInfo(element,
IconLoader.getIcon("/icons/ic_linemarkerprovider.svg"),
createActionGroup(element),
createToolTipProvider(element))
else return null
}
private fun createToolTipProvider(inlineLinkElement: PsiElement): Function<in PsiElement, String> {
val tooltipProvider =
Function { element1: PsiElement ->
val current = LocalDateTime.now()
val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
val formatted = current.format(formatter)
buildString {
append("Tooltip calculated at ")
append(formatted)
}
}
return tooltipProvider
}
fun createActionGroup(inlineLinkElement: PsiElement): DefaultActionGroup {
val linkDestinationElement =
findChildElement(inlineLinkElement, MarkdownTokenTypeSets.LINK_DESTINATION, null)
val linkDestination = linkDestinationElement?.text
val group = DefaultActionGroup()
group.add(OpenUrlAction(linkDestination))
return group
}
}
The createActionGroup(...)
method actually creates an ActionGroup
and adds a bunch of actions
that will be available when the user clicks on the gutter icon for this plugin. Note that you can
also add actions that are registered in your plugin.xml
using something like this.
group.add(ActionManager.getInstance().getAction("ID of your plugin action"))
Finally, here’s the action to open a URL that is associated with the INLINE_LINK that is highlighted in the gutter.
class OpenUrlAction(val linkDestination: String?) :
AnAction("Open Link", "Open URL destination in browser", IconLoader.getIcon("/icons/ic_extension.svg")) {
override fun actionPerformed(e: AnActionEvent) {
linkDestination?.apply {
BrowserUtil.open(this)
}
}
}
References #
Docs
Code samples
- idea-plugin-example2 repo
- HaxeLineMarkerProvider.java
- SimpleLineMarkerProvider.java
- Lots of examples of using
LineMarkerInfo
Discussions
- JB forums: LineMarker for PsiElement, no target
- JB forums: Adding “Actions” to Line Markers
- JB forums: Is it possible to add line markers (gutter icons) without a file change?
- JB forums: Set a LineMarker on click, similar to bookmark
👀 Watch Rust 🦀 live coding videos on our YouTube Channel.
📦 Install our useful Rust command line apps usingcargo install r3bl-cmdr
(they are from the r3bl-open-core project):
- 🐱
giti
: run interactive git commands with confidence in your terminal- 🦜
edi
: edit Markdown with style in your terminalgiti in action
edi in action