Hello!
In this short post, I would like to show you, how to create a simple plugin for IntelliJ, which uses JCEF, the new WebView framework. But first, short story - why and how do we use JCEF?
As you can read in our previous blog post, for a few months in our R&D team we are developing a CodeTale. It is a plugin to your IDE, which enables you to browse comments from PR's. To make the UI easier to reuse with many IDEs, we have decided to use WebView. If you are not familiar with it - it's an embedded web browser, which allows you to create a UI once in the form of a normal webpage.
From the beginning of the project, we have decided to support two IDEs: IntelliJ and VSCode, as one of most popular IDEs. VSCode is built on WebView technology - Electron, so it uses an efficient Chromium web engine. On the opposite, IntelliJ up to the middle of this year supports only the obsolete JavaFX version of WebView. It is based on the old Safari engine, which makes some problems with the UI (mainly very poor performance). However, in version 2020.2
there was introduced a new officially supported version of WebView - JCEF. Such improvement was requested for a long time, and finally, there is it! It is based on Chromium, so it is much faster and easier to debug. Whatsmore, the old one is no longer officially supported. To be able to run JavaFX's WebView there must be an additional plugin installed. Maybe in the next blog post, I would present an example of migration - please, let me know if you are interested in :)
But now, I would like to show you how to build a toy plugin for IntelliJ, which would use JCEF - including a nice trick, how to use files from the resources
folder in jar
archive (jar
protocol is unfortunately not supported by JCEF).
What do you need?
- installed
Java
- installed or downloaded
Gradle
- your favorite IDE
- 10 minutes
- cup of
coffee
or mug of tea ;)
So, let's start!
At first, we need to create plugin.xml
file:
<idea-plugin>
<id>catviewer</id>
<name>CatViewer</name>
<version>1.0</version>
<description><![CDATA[
Your employee track every minute spent in the browser? Use CatViewer in your IDE, and browse funny cats along your code!
]]></description>
<idea-version since-build="192.5118"/>
<depends>com.intellij.modules.platform</depends>
<extensions defaultExtensionNs="com.intellij">
<toolWindow id="CatViewer" anchor="right" factoryClass="com.catviewer.WindowFactory"/>
</extensions>
</idea-plugin>
It should be located in src/main/resources/META-INF/plugin.xml
.
What has happened above? It is a file with some general information about the plugin, like version, name, or id. An important part of it is extensions
- there are declared services or tool windows, like it is in our case. I.e. Projects
is one of the default tool windows in IntelliJ.
Let’s look closer to it:
<toolWindow id="CatViewer" anchor="right" factoryClass="com.catviewer.WindowFactory"/>
Tool window has a few properties. Some of them- like id
or factoryClass
are obligatory, but others, like icon
is optional. Keep in mind, that id has in this case two functions- it is the name displayed on the button, but also this tool window is identified by it. So if later in code, you want to access this tool window, you can do it only by that name. And factory class- it must contain a classpath to class in your project, which implements ToolWindowFactory
with its method createToolWindowContent
. This method is invoked by IDE, when the tool window button is pressed.
The next step would be creating a factory class mentioned above.
package com.catviewer
import com.intellij.openapi.components.ServiceManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.{ ToolWindow, ToolWindowFactory }
class WindowFactory extends ToolWindowFactory {
override def createToolWindowContent(project: Project, toolWindow: ToolWindow): Unit = {
val catViewerWindow = ServiceManager.getService(project, classOf[CatViewerWindowService]).catViewerWindow
val component = toolWindow.getComponent
component.getParent.add(catViewerWindow.content)
}
}
Save it to src/main/scala/com/catviewer/WindowFactory.scala
. Surprised about language? Yes, it is possible to develop plugins for IntelliJ also using Scala! Unfortunately, it also hides some types, so I need to describe it a bit.
In the above method, the WebView window is injected into the ToolWindow component. As IntelliJ UI is written using Swing, it must be passed as a compatible component. But what about the ServiceManager
? It is a typical way of implementing singleton in plugins- create service, which would create and manage access to object. In this case it is a WebView window.
In the next step let's create service and window class itself, so everything should be clear:
package com.catviewer
import com.intellij.openapi.project.Project
import com.catviewer.CatViewerWindow
class CatViewerWindowService(val project: Project) {
val catViewerWindow = CatViewerWindow(project)
}
Save it to src/main/scala/com/catviewer/CatViewerWindowService.scala
.
Also add it as project service by adding line to extensions
field in plugin.xml
:
<extensions defaultExtensionNs="com.intellij">
<toolWindow id="CatViewer" anchor="right" factoryClass="com.catviewer.WindowFactory"/>
<projectService id="CatViewerWindowService" serviceImplementation="com.catviewer.CatViewerWindowService"/>
</extensions>
Generally services in IntelliJ are initialized lazily, so if you want to make sure that your service is running, remember to access it (as it is done in WindowFactory
).
And CatViewerWindow
(src/main/scala/com/catviewer/CatViewerWindow.scala
):
package com.catviewer
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.TimeoutException
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.ui.jcef.JBCefBrowser
import com.catviewer.CustomSchemeHandlerFactory
import javax.swing.JComponent
import org.cef.CefApp
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.Duration
import scala.concurrent.{ Await, Future, Promise }
case class CatViewerWindow(val project: Project) {
private lazy val webView: JBCefBrowser = {
createAndSetUpWebView()
}
def content: JComponent = webView.getComponent
private def createAndSetUpWebView(): JBCefBrowser = {
val webView = new JBCefBrowser()
registerAppSchemeHandler()
webView.loadURL("http://myapp/index.html")
Disposer.register(project, webView)
webView
}
private def registerAppSchemeHandler(): Unit = {
CefApp
.getInstance()
.registerSchemeHandlerFactory(
"http",
"myapp",
new CustomSchemeHandlerFactory
)
}
}
As you can see, it is almost the same, as in the Jetbrains documentation. There are a few places worth looking. Notice the "myapp"
domain registered in WebView - it allows us to use files from the resources
folder in our jar
archive. To do this, we need to implement CustomResourceHandler
and CustomSchemeHandlerFactory
(this is the next part). Another not obvious point is registering webview in disposer- it helps IDE with memory management (and is a must to avoid “Memory leak detected” in IntelliJ logs).
src/main/scala/com/catviewer/CustomResourceHandler
:
package com.catviewer
import java.io.{IOException, InputStream}
import java.net.JarURLConnection
import org.cef.callback.CefCallback
import org.cef.handler.{CefLoadHandler, CefResourceHandler}
import org.cef.misc.{IntRef, StringRef}
import org.cef.network.{CefRequest, CefResponse}
class CustomResourceHandler extends CefResourceHandler {
var connectionOption: Option[JarURLConnection] = None
var inputStreamOption: Option[InputStream] = _
override def processRequest(cefRequest: CefRequest, cefCallback: CefCallback): Boolean = {
val urlOption = Option(cefRequest.getURL)
urlOption match {
case Some(processedUrl) =>
val pathToResource = processedUrl.replace("http://myapp", "webview/")
val newUrl = getClass.getClassLoader.getResource(pathToResource)
connectionOption = Some(newUrl.openConnection().asInstanceOf[JarURLConnection])
cefCallback.Continue()
true
case None => false
}
}
override def getResponseHeaders(cefResponse: CefResponse, responseLength: IntRef, redirectUrl: StringRef): Unit = {
if (connectionOption.isDefined) {
try {
val connection = connectionOption.get
cefResponse.setMimeType(connection.getContentType)
if (connectionOption.get.getURL.toString.contains("css")) {
cefResponse.setMimeType("text/css")
} else {
if (connectionOption.get.getURL.toString.contains("js")) {
cefResponse.setMimeType("text/javascript")
}
}
inputStreamOption = Some(connection.getInputStream)
responseLength.set(inputStreamOption.get.available())
cefResponse.setStatus(200)
} catch {
case e: IOException =>
cefResponse.setError(CefLoadHandler.ErrorCode.ERR_FILE_NOT_FOUND)
cefResponse.setStatusText(e.getLocalizedMessage)
cefResponse.setStatus(404)
}
} else {
cefResponse.setStatus(404)
}
}
override def readResponse(
dataOut: Array[Byte],
designedBytesToRead: Int,
bytesRead: IntRef,
callback: CefCallback
): Boolean = {
if (inputStreamOption.isDefined) {
val inputStream = inputStreamOption.get
val availableSize = inputStream.available()
if (availableSize > 0) {
val bytesToRead = Math.min(availableSize, designedBytesToRead)
val realNumberOfReadBytes = inputStream.read(dataOut, 0, bytesToRead)
bytesRead.set(realNumberOfReadBytes)
true
} else {
inputStreamOption.get.close()
connectionOption = None
inputStreamOption = None
false
}
} else {
false
}
}
override def cancel(): Unit = {
inputStreamOption = None
connectionOption = None
}
}
In terms of logic - it is quite straightforward. We need to implement the logic for opening files, returning headers, sending data, and handling cases of cancelation.
Page sources are loaded from the webview
folder in sources. And CustomSchemeHandlerFactory
:
package com.catviewer
import org.cef.browser.{ CefBrowser, CefFrame }
import org.cef.callback.CefSchemeHandlerFactory
import org.cef.handler.CefResourceHandler
import org.cef.network.CefRequest
class CustomSchemeHandlerFactory extends CefSchemeHandlerFactory {
override def create(
cefBrowser: CefBrowser,
cefFrame: CefFrame,
s: String,
cefRequest: CefRequest
): CefResourceHandler = {
new CustomResourceHandler()
}
}
And finally, we can create WebView folder in out resources
and insert there our website files.
index.html
(src/main/resources/webview/index.html
)
<html>
<body>
<div>
<img src="cat.png"/>
</div>
</body>
</html>
Insert there also your favorite image with the name cat.png
(src/main/resources/webview/cat.png
).
To build everything we would use gradle
.
build.gradle
:
buildscript {
repositories {
jcenter()
mavenCentral()
maven { url 'https://plugins.gradle.org/m2/' }
maven { url 'https://dl.bintray.com/jetbrains/intellij-plugin-service' }
maven { url 'https://dl.bintray.com/jetbrains/intellij-third-party-dependencies/' }
}
dependencies {
classpath "org.jetbrains.intellij.plugins:gradle-intellij-plugin:0.5.0-SNAPSHOT"
classpath "cz.alenkacz:gradle-scalafmt:1.13.0"
}
}
apply plugin: 'idea'
apply plugin: 'scala'
apply plugin: 'org.jetbrains.intellij'
apply plugin: 'scalafmt'
ext {
scalaVersion = "2.13.1"
}
sourceSets {
main.scala.srcDirs = ['src/main/scala']
main.java.srcDirs = []
}
dependencies {
implementation group: "org.scala-lang", name: "scala-library", version: scalaVersion
implementation group: 'org.scala-lang', name: 'scala-reflect', version: scalaVersion
}
sourceCompatibility = 11
targetCompatibility = 11
Place it in the root of the project.
And finally - to run IntelliJ with our plugin:
$ gradle runIde
On the right toolbar, you should see the CatViewer
button. After clicking on it, a tool window with your photo should appear.
Of course, WebView can show much more. My colleagues develop quite a heavy app for browsing code like a graph - and for showing it in IntelliJ, they use JCEF. Project has the name GraphBuddy
- please check it, even to see how complex things can be presented using WebView.
Ok, that's all. Thank you for reading! Happy coding & keep safe!
More materials: