こんにちは、こんばんは、kitakkunです。
筆者が把握する限り、IntelliJ IDEA Plugin(以後簡単にIntelliJ Plugin)開発に関連する記事の多くは公式テンプレートの使用にとどまっています。
しかし、公式テンプレートはCIやテストも含め網羅的にセットアップされていて考慮事項が多く、何をどう作っていったらいいか把握しにくいのが難点です。
本記事では、公式テンプレートに頼らず、自分の手で理解しながらIntelliJ Pluginを実装していきます。
重要なエッセンスのみを抽出した内容となっているので、プラグイン開発の第一歩として活用していただければと思います。
本記事でカバーする内容
本記事では、以下の内容をカバーします。
- IDEA Pluginの最小限のプロジェクトセットアップ
- 簡単なカウンタ機能を持つシンプルなGUIツール拡張の作り方
- ランタイム状態保持のベストプラクティス
- データ永続化のベストプラクティス
- プラグインのjarをzip形式で配布する or JetBrains Marketplaceで公開する
プロジェクトセットアップ
最初にGradleプロジェクトのセットアップをします。
適当なディレクトリを作成し、プロジェクト全体の依存関係の解決方法などを定義するsettings.gradle.kts
を追加しましょう。
rootProject.name = "intellij-plugin-from-scratch"
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
続いて、IntelliJ Pluginのビルド設定を記述した build.gradle.kts
も追加します。
plugins {
kotlin("jvm") version "2.1.20"
id("org.jetbrains.intellij.platform") version "2.5.0"
}
repositories {
intellijPlatform {
defaultRepositories()
}
mavenCentral()
}
dependencies {
intellijPlatform {
intellijIdeaCommunity("2025.1")
}
}
最後に、プラグインの識別情報や拡張機能を定義するための plugin.xml
を追加します。
<idea-plugin>
<id>greeting</id>
<name>Greeting</name>
<vendor>Your name</vendor>
<depends>com.intellij.modules.platform</depends>
<extensions defaultExtensionNs="com.intellij">
<!-- Add your extensions here -->
</extensions>
</idea-plugin>
プラグイン開発に使用している IntelliJ に以下のプラグインを入れておくと xml に補完が効くようになるので、入れておくことをおすすめします。
ここまでやったら、以下の手順で正しく設定できているか確認してみましょう。
-
./gradlew runIde
を実行してテスト用IDEを起動(開発中のプラグインがインストールされた状態で起動します) - 適当なプロジェクトを開く
- Settings(
⌘
+,
)->Plugins->Installedを確認して「Greeting」が追加されていればOK
最低限の設定は以上となります。次のセクションからは簡単な例としてカウンタ機能のみを持つGUIツールを作成します。
ToolWindowの実装
IntelliJ プラットフォームにおいて、GUI 拡張は ToolWindow と呼ばれます。
ToolWindow の UI 開発には様々な方法が使えますが、本記事では Jetpack Compose と Jewel というIntelliJプラットフォーム用のコンポーネントライブラリを使用して開発します。
依存設定
まず最初に Compose のGradle Pluginを適用します。build.gradle.kts
の plugins ブロックに以下の2つのプラグインを追加します。
plugins {
id("org.jetbrains.compose") version "1.7.3"
id("org.jetbrains.kotlin.plugin.compose") version "2.1.20"
}
続いて、build.gradle.kts
の repositories
に以下を追加します。それぞれ Jetpack Compose の依存と Jewel の依存を解決するために必要な設定です。
repositories {
google() // jetpack compose
maven("https://packages.jetbrains.team/maven/p/kpm/public/") // jewel
}
dependenciesに以下を追加します。jewelはlaf-bridgeというサフィックスがついたバージョンを使用し、composeの依存を追加する際はmaterialおよびkotlinxの依存を除外しておきます。
dependencies {
implementation("org.jetbrains.jewel:jewel-ide-laf-bridge-243:0.27.0")
api(compose.desktop.currentOs) {
exclude(group = "org.jetbrains.compose.material")
exclude(group = "org.jetbrains.kotlinx")
}
}
なぜ一部の依存を exclude する必要があるか?
Jetpack Compose の依存を追加する際に、org.jetbrains.compose.material
および org.jetbrains.kotlinx
への依存を除外していることに疑問を持つ方も多いと思います。
org.jetbrains.compose.material
について
UI コンポーネントの開発には Jewel 側で定義されているコンポーネントを使用するため、不要です。
org.jetbrains.kotlinx
について
IntelliJ 側でロードされる kotlinx-coroutines
ライブラリとの衝突を回避するためです。
開発するプラグインが kotlinx-coroutines
への依存を直接間接問わず持っていると、ランタイムのクラスロードエラーを引き起こすため注意が必要です。
クラスローダー関連のエラーに直面した際は、まずはここを疑ってみましょう。
ToolWindowFactory の実装
さて、依存の追加も済んだところで、ToolWindow 拡張の実装を進めていきます。
ToolWindow 拡張の実装には、ToolWindowFactory の実装が必要です。ToolWindowFactory
は、以下のようなインタフェースを持ち、最低限実装が必要なメソッドは一つのみです。
package com.intellij.openapi.wm
public interface ToolWindowFactory : com.intellij.openapi.project.PossiblyDumbAware {
public abstract fun createToolWindowContent(project: com.intellij.openapi.project.Project, toolWindow: com.intellij.openapi.wm.ToolWindow): kotlin.Unit
// 省略
}
ToolWindowFactory の実装と plugin.xml
への登録
Jewel に定義されている ToolWindow.addComposeTab
という拡張関数を用いて、簡単に Compose UI を ToolWindow に追加することができます。以下の GreetingToolWindowFactory を実装します。
package greeting
class GreetingToolWindowFactory : ToolWindowFactory {
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
toolWindow.addComposeTab {
Text("Hello, IntelliJ IDEA Plugin!!")
}
}
}
実装した ToolWindow 拡張は、plugin.xml
の方にも定義が必要です。plugin.xml
に以下の行を追加します。
<idea-plugin>
<id>greeting</id>
<name>Greeting</name>
<vendor>Your name</vendor>
<depends>com.intellij.modules.platform</depends>
<extensions defaultExtensionNs="com.intellij">
<!-- 以下を追加 -->
<toolWindow factoryClass="greeting.GreetingToolWindowFactory" id="Greeting" />
</extensions>
</idea-plugin>
この時点で ./gradlew runIde
を実行しテスト用IDE環境を立ち上げて適当なプロジェクトを開くと、左側に新しく Greeting というタイトルの ToolWindow が追加されていることを確認できます。
簡単なカウンタ機能を作成する
次に、ToolWindowFactory を改造して簡単なカウンタを実装してみます。
class GreetingToolWindowFactory : ToolWindowFactory {
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
toolWindow.addComposeTab {
var counter by remember { mutableIntStateOf(0) }
DefaultButton(onClick = { counter++ }) {
Text("Click me!! $counter")
}
}
}
}
これで簡単なカウンタ機能を持つ ToolWindow ができました!
しかし、ToolWindow を閉じると状態が消し飛んでしまう問題を抱えていることがわかります。
ランタイム状態を適切に保持する
ToolWindow を閉じても状態が保持したい場合もあると思います。
本セクションでは、IntelliJ Platform の中で正しくプラグインのランタイム状態を保持する方法を説明します。
状態保持のアンチパターン
通常、アプリケーションのライフサイクルに合わせて状態を保持する場合、以下のやり方を思いつくと思います。
- シングルトンパターンの使用
IDEA Plugin の開発において、シングルトンの使用はアンチパターンとなります。なぜなら、シングルトンは大元のアプリケーション(プラグイン動かす側のIDE)のスコープで生存し、必要のない時でもメモリを占有してしまうためです。
実際に、IntelliJ Platform公式のドキュメントには以下のような記述があります。
Do not use object but class
Plugins may use Kotlin classes (class keyword) to implement declarations in the plugin configuration file. When registering an extension, the platform uses a dependency injection framework to instantiate these classes at runtime. For this reason, plugins must not use Kotlin objects (object keyword) to implement any plugin.xml declarations. Managing the lifecycle of extensions is the platform's responsibility, and instantiating these classes as Kotlin singletons may cause issues.
https://plugins.jetbrains.com/docs/intellij/using-kotlin.html#object-vs-class
上記で述べられているのは、概ね以下の内容です。
- IntelliJ は内部のDIフレームワークを用いてプラグインクラスをランタイムにインスタンス化して使用している
- プラグインのライフサイクルの管理をするのは IntelliJ Platform 側の責任であり、Kotlin のシングルトンパターンの使用は問題を起こす可能性がある
companion object や object を使ってランタイム状態を保持することもできるものの、生存スコープがプラグインのライフサイクルを超えてしまうため控えましょう、ということです(控えましょうというよりかは使うなというレベルみたい)。
サービスを使ってランタイム状態を保持する(推奨)
IntelliJ Platform では、プロジェクト単位もしくはアプリ単位のスコープで常駐するサービスを作成することができます。こちらを使用することで、プラグインのランタイム状態を適切に保持することが可能です。
サービスを作るには、専用のアノテーションを使用します。簡単なカウンタのみを持つ GreetingCounterStateService
を実装してみましょう。
package greeting
@Service(Service.Level.PROJECT) // まずはProjectレベルのサービスを作ってみる
class GreetingCounterStateService {
var counter by mutableIntStateOf(0)
}
あとは、createToolWindowContent
に渡ってくる project
を使用してサービスのインスタンスを取得し、サービスが持つ counter
を参照するように変更します。
class GreetingToolWindowFactory : ToolWindowFactory {
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
val counterStateService = project.service<GreetingCounterStateService>()
toolWindow.addComposeTab {
DefaultButton(onClick = { counterStateService.counter++ }) {
Text("Click me!! ${counterStateService.counter}")
}
}
}
}
すると、ToolWindow を閉じたり開いたりしても、カウンターの状態は残存しリセットされなくなります!
また、今作成したサービスはプロジェクトレベルのスコープなので、別のプロジェクトを開き ToolWindow を開くと、状態がプロジェクト別に管理されていることを確認できると思います。
APPレベルのサービスに変更してみる
もう大体どうなるかはわかっていると思いますが、余裕があればServiceアノテーションの引数を Project.Level.APP
に変更してみましょう。
@Service(Project.Level.APP)
class GreetingCounterStateService {
var counter by mutableIntStateOf(0)
}
APPレベルのサービスは、ApplicationManager
を経由してそのインスタンスへアクセスします。
val counterStateService = ApplicationManager.getApplication().service<GreetingCounterStateService>()
複数のプロジェクトウィンドウを跨いで状態が共通管理されていることを確認しましょう!
状態を永続化する
通常のServiceクラスが生きているのは、プロジェクトウィンドウまたはIDE自体が生きている間のみです。
プラグインの設定情報など、一部永続化したい状態もあると思います。
ここでは、例としてカウンタのインクリメント値を変更できるようにして、なおかつその設定値を永続化してみましょう。
PersistentStateComponent で状態を永続化する
PersistentStateComponent を使用すると、JVMオブジェクトをシリアライズ、デシリアライズして状態を永続化することができます。
ドキュメントでは最も単純な方法として、SimplePersistentStateComponentの使用が紹介されていますが、Jetpack ComposeのUI実装視点での使いやすさも考慮して、自分でPersistentStateComponentのインタフェースを実装する手法を紹介します。
PersistentStateComponent は、次の2つのメソッド宣言が必要です。
public interface PersistentStateComponent<T> {
@Nullable T getState();
void loadState(@NotNull T var1);
}
カウンタのインクリメント値を永続化する
まずは一番シンプルな実装として、Serviceクラスそのものを状態管理クラスとしてしまうパターンを紹介します。PersistentStateComponentの型引数がServiceクラス自身になっていることに注意してください。
package greeting
@Service(Service.Level.APP)
@State(name = "GreetingConfigurationState", storages = [Storage("Greeting.xml")])
class GreetingConfigurationState : PersistentStateComponent<GreetingConfigurationState> {
var incrementBy: Int by mutableIntStateOf(1)
override fun getState(): GreetingConfigurationState {
return this
}
override fun loadState(state: GreetingConfigurationState) {
XmlSerializerUtil.copyBean(state, this)
// XmlSerializerUtil を使うサンプルが多いが必ずしもそれを使う必要はない。
// 例えば以下のように書いても同じ。
//
// incrementBy = state.incrementBy
//
// ただしフィールドが増える度に追加しなきゃいけなくなるので、XmlSerializerUtilを使うと少しだけ楽
}
}
IntelliJプラットフォームは永続化された状態をサービスに読み込む際に loadState を呼び出します。そのため、loadState の中で適切にフィールドを初期化し、getState で状態を返すような実装を行います。
Jetpack Composeからアクセスする際は、フィールドを MutableState 系のクラスに委譲すればコンパイラーが自動的に監視対象として認識してコンパイルしてくれるので、簡潔な実装でUIに設定値を公開することができます。
次に、GreetingToolWindowFactoryを以下のように変更します。ざっくり変更点は以下の3つです。
- TextFieldがボタンの下に追加
- TextFieldの値をInt型に変換して設定値を更新するLaunchedEffectを追加
- counter++ だったところを incrementBy だけ増やすように変更
class GreetingToolWindowFactory : ToolWindowFactory {
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
val counterStateService = ApplicationManager.getApplication().service<GreetingCounterStateService>()
val configurationState = ApplicationManager.getApplication().service<GreetingConfigurationState>()
toolWindow.addComposeTab {
val incrementByTextFieldState = rememberTextFieldState(configurationState.incrementBy.toString())
LaunchedEffect(Unit) {
snapshotFlow { incrementByTextFieldState.text }
.distinctUntilChanged()
.collect { text ->
configurationState.incrementBy = text.toString().toIntOrNull() ?: return@collect
}
}
Column {
DefaultButton(
onClick = {
counterStateService.counter += configurationState.incrementBy
}
) {
Text("Click me!! ${counterStateService.counter}")
}
TextField(
state = incrementByTextFieldState,
placeholder = { Text("Increment by...") },
// 数値だけ受け付ける
inputTransformation = {
if (this.asCharSequence().any { !it.isDigit() }) {
this.revertAllChanges()
}
},
)
}
}
}
}
これで起動して incrementBy が正しく動いているか、また runIde タスクを再起動しても設定が維持されているかを確認します。
問題なく永続化できていますね!
PersistentStateComponent のハマりどころ
筆者自身この記事を整理する中で、うまく永続化されずハマった点があるので軽く紹介します。
PersistentStateComponentは、型引数に指定されたクラスをほぼ自動的にシリアライズしてくれます。しかし、デフォルトでシリアライズの対象となるのはプリミティブな型のvarフィールドのみです。
例えば、前節で紹介した incrementBy を別のデータクラスの内部に定義したとします。
data class GreetingConfiguration(
val incrementBy: Int = 1,
)
そして、GreetingConfigurationState クラスを以下のように変更したとします。
@Service(Service.Level.APP)
@State(name = "GreetingConfigurationState", storages = [Storage("Greeting.xml")])
class GreetingConfigurationState : PersistentStateComponent<GreetingConfiguration> {
var configuration by mutableStateOf(GreetingConfiguration())
override fun getState(): GreetingConfiguration {
return configuration
}
override fun loadState(state: GreetingConfiguration) {
configuration = state
}
}
そして適宜インタフェースの変更をUIにも反映してビルドしてみると・・
きっと状態がうまく永続化されないという事象にぶち当たります。
GreetingConfigurationのincrementByがvarではなくvalになっているためデフォルトではシリアライズの対象とならないのです。incrementBy を var に変更して再度試してみると、うまくいくはずです。
ただ、せっかくイミュータブルな data class なのに、事故の元にもなる var は使いたくないはずです。その場合は @Tag("タグ名")
のアノテーションをフィールドに付与してあげることでもシリアライズの対象とできるようです。
data class GreetingConfiguration(
@Tag("incrementBy")
val incrementBy: Int = 1,
)
若干トリッキーな挙動をしており、扱いが難しいため結構な人がハマると思います。
また、紹介した方法はあくまでプリミティブな型に限定した話です。独自の型がフィールドに含まれる場合は、公式ドキュメントのこちらを参考に Converter を実装し @OptionTag
を付与してあげる必要があります。
プラグインを配布する
お疲れ様でした。最後に、プラグインの配布方法について紹介します。
IntelliJプラットフォームのGradleプラグインによってbuildPlugin
タスクが追加されています。こちらを実行すると build/distributions の配下に zip ファイルができます。
./gradlew buildPlugin
あとはIDEの設定を開いてインストールするだけです。
Settings -> Plugins -> タブ右端の歯車アイコン -> Install Plugin from Disk... で zip ファイルを選択
また、JetBrains Marketplace で配布する際は、buildPluginでできるzipファイルを手動でアップロードすることで公開することができます。CI上で配布する手法については、話が肥大化してしまうため、本記事ではここまでの説明にとどめたいと思います。
また、JetBrains Marketplace で配布する際はplugin.xmlのdescriptionの指定が必須なため注意してください。
まとめ
本記事では、IntelliJ IDEA Pluginの開発に興味はあれど、なかなか参考資料が少なく手を出せていない方に向けて、確実な理解を持ちながら実装していけるように1から実装していく方法を説明しました。
記事の説明用に作成したプロジェクトは GitHub にて公開しておりますので、適宜そちらも参照しつつ進めていただけるとわかりやすいかと思います(コミットを小分けにしているので、コミット単位でみるとわかりやすいはず)。
最後にいつも通り宣伝ですが、発展的な事例として back-in-time-plugin の IntelliJ Plugin を紹介して終わりにしたいと思います。なかなかに規模が大きいため、マルチモジュールでプロジェクトを構成しています。
つい最近アルファ版をリリースしてようやく自由に触れる状態になっているので、興味があればぜひ触ってみてください。
(現状IntelliJプラグインはリリースページにzipを添付する運用になっていますが、後ほどCIに組み込んでJetBrains Marketplaceから落とせるようにする予定)
長くなりましたが、ここまで読んでいただきありがとうございました!!!
ぜひ思い思いの IntelliJ Plugin を作っていだたければと思います。