IntelliJプラグインでAnnotate機能を作った

はじめに

これは Ubie Advent Calendar 2019 - Qiita の21日目の記事です。

想定読者

(開発方法セクションより前)

(開発方法セクション以降)

本題

まずはこれを見て欲しい。

https://raw.githubusercontent.com/shiraji/find-pull-request/master/website/images/list-pr.gif

以前から欲しいなーって思っていたのだけど、プライベートの時間の捻出が出来ず、主に眠気に勝てないのが原因で、なかなか腰が上がらなかったんですが、息子くんがすんなり寝てくれて2時間くらい時間が出来たので、さくっと作ってみた。その後Twitterに成果報告して寝た。

翌朝起きたら中々の反応がもらえてたので、ちょっとやる気が出て、そのまま勢いで作り切りました。余談ですが、自分の場合、こんな機能作った!ってツイートした場合、反応あるとすごくモチベーションが上がるので、欲しいな!って思ったらぜひいいねしてもらえると!

これ単体でプラグインとして出しても良いのですが、切り出すメリットがあまり見えなかったため、Find Pull Request プラグインのv1.7.0の機能として出しています。Repositoryはこちら

この機能により、Find pull requestプラグインは3つの機能を持つことになりました。

  1. 右クリックでpull requestページに飛ぶ
  2. 右クリックでpull requestページのURLをコピーする
  3. pull requestの一覧をエディタに記載する

まだまだ他にも欲しい機能があるので、ちょくちょく作っていこうと思います。他にも欲しい機能あればぜひissueにあげてください。ちなみにGitHub, GitHub Enterprise, GitLab, Bitbucketで動作します。他にも欲しいサービスあれば、issueテンプレート埋めていただければ対応します。

開発方法

せっかく作ったので、調べた知見を置いておきます。

ここから想定読者は 「IntelliJプラグインをがっつり開発したことがある・Annotate機能を作りたい人」とします。つまり基本的なプラグイン開発の説明は省略します。

参考ソース

普段プラグイン開発するなら他の似たようなプラグインソースコード読め。で終わるのですが、アイコンつけるのはいくつかありましたが、Annotate機能を使ったプラグインは誰も作ったことがないのではないか?と言うくらい見つけることが出来ませんでした。

その為、みなさんお馴染みのintellij-communityのrepoから仕様を把握することにしました。

github.com

その中でも唯一Annotate機能を提供している、 AnnotateToggleAction を見て開発を行ないました。

https://github.com/JetBrains/intellij-community/blob/master/platform/vcs-impl/src/com/intellij/openapi/vcs/actions/AnnotateToggleAction.java

Find pull requestプラグインは開発当時2019.1をサポートしているので、masterブランチではなく、2019.1のソースを見て開発していました。後述するけど、これが後に問題になります。

Annotateを出すには

色々読んだ結果、Annotateを出すには editor.gutter.registerTextAnnotation() を呼び出すことで実装が可能のようです。

  /**
   * Adds a provider for drawing custom text annotations in the editor gutter, with the
   * possibility to execute an action when the annotation is clicked.
   *
   * @param provider the provider instance.
   * @param action the action to execute when the annotation is clicked.
   */
  void registerTextAnnotation(@NotNull TextAnnotationGutterProvider provider, @NotNull EditorGutterAction action);

引数は二つ、一つ目の TextAnnotationGutterProvider はどんなテキストを出すのか?もう一つの EditorGutterAction はそのテキストに対してクリックした時どんなアクションを起こすのか?です。クリックなどのアクションが必要ない場合、二つめの引数は省略可です。

TextAnnotationGutterProvider

TextAnnotationGutterProviderで一番重要なメソッドはこの getLineText メソッドです。

/**
   * Returns the text which should be drawn for the line with the specified number in the specified editor.
   *
   * @param line   the line for which the text is requested.
   * @param editor the editor in which the text will be drawn.
   * @return the text to draw, or null if no text should be drawn.
   */
  @Nullable
  String getLineText(int line, Editor editor);

このメソッドは引数に行番号と表示するEditorが渡されます。戻り値にはその行に表示するStringです。

簡単なメソッドなのですが、一つ問題があります。それはこのメソッドが呼び出されるタイミングはAnnotateがレンダーされ得るたびと言うことです。

その何が問題なのかを説明する前に、Annotateの機能についておさらいします。AnnotateとはEditorの左横の部分を右クリックし、Annotateを選択したら、その行の最後のコミット(正確には少し違う)を表示します。

f:id:shiraji:20191215165849p:plain
Annotate

ここまではよく知られています。問題はエディタでの表示と言うことはAnnotate表示中も開発者がコードの更新をすることが可能です。つまりコード編集してもAnnotateを閉じず、正しい行に正しい情報を表示する必要があるということです。

f:id:shiraji:20191215170639g:plain
コードの変更・改行・行削除などにも対応する必要がある

registerTextAnnotation するのはアクションがcheckになった時のみです。そのため、registerTextAnnotation された時と getLineText が呼び出された時の表示されているエディタの状態は異なる可能性があることになります。

これを踏まえて、 getLineText の実装をします。最初に問題になるのが、 line の意味です。the line for which the text is requested. と記述されていますが、説明が圧倒的に足りていません。というか、説明通りに受け取ってはいけません。この lineAnnotate が表示された時の行番号に当たります。今まさにEditorで表示されている行番号ではありません。受け取った line をそのまま利用するのは危険です。 line が今表示されているエディターの行番号としてどこに当たるのかを確認し、その確認した行番号に対して情報を返さないといけません。(ここを読むような訓練されたプラグイン開発者なら「あーよくあるやつね」となるいつものやつです。)

line が今表示されているエディターの行番号のどこに当たるのか」は UpToDateLineNumberProviderImpl を利用して取得します。

        val upToDateLineNumberProvider = UpToDateLineNumberProviderImpl(editor?.document, editor?.project)
        val currentLine = upToDateLineNumberProvider.getLineNumber(line)

UpToDateLineNumberProviderImpl#getLineNumber にはドキュメントはありませんが、0未満の時には現在表示されているエディタにはその行は存在していないことを表しています。そのため今回は、0未満の値だった場合、表示されることはないため、空文字即returnにしてあります。

ここが呼び出される回数は思った以上に頻度が高いです。しかも各行呼び出されます。このプラグインではgitのハッシュ値からpull requestの番号を取得しますが、都度その処理を行うことは危険です。そのため、ハッシュ値とPR番号のマッピングregisterTextAnnotation する前に計算しておき、このメソッド内ではそのマッピングから表示する文字列を生成する簡単な処理に留めてあります。

EditorGutterAction

EditorGutterAction重要なのがdoAnnotateメソッドです。

/**
   * Processes the click on the specified line.
   *
   * @param lineNum the number of line in the document the annotation for which was clicked.
   */
  void doAction(int lineNum);

このメソッドのlineNumは現在表示されている行番号になります。 UpToDateLineNumberProviderImpl は利用する必要はありません。Editorは受け取れないことに注意してください。

TextAnnotationGutterProvider と同じようなデータやロジックを使います。そのため、今回の機能では、一つのクラスで二つとも実装することにしています。

FileAnnotation

AnnotateのようにFileAnnotationに依存するような情報を表示する場合、注意しなくてはならないのが、IntelliJのエディタ以外の部分からもファイルの変更が可能になる点です。何を言っているのかわからないと思います。例えばですが、ファイルの変更をして、コンソール上でコミットをするようなケースを考えてください。エディタの変更だけではなく、IntelliJ外部からのFileの変更(FileAnnotationの変更)などを検知し最新の情報を表示する必要があります。ちなみにAnnotateを開いたまま、改行し、コンソールでコミットしてみてください。Annotateが自動で閉じます。今回の機能もFileAnnotationに依存しているため、その変更を検知する必要があります。あまり深く仕様を考えるのは大変であるため、Annotateと同じようにFileAnnotationの変更が発生した場合、Annotateを閉じるようにします。

FileAnnotationの変更を検知するメソッドは二つあります。

  /**
   * Notify that annotations should be closed
   */
  public synchronized final void close() {
    myIsClosed = true;
    if (myCloser != null) {
      myCloser.run();

      myCloser = null;
      myReloader = null;
    }
  }

  /**
   * Notify that annotation information has changed, and should be updated.
   * If `this` is visible, hide it and show new one instead.
   * If `this` is not visible, do nothing.
   *
   * @param newFileAnnotation annotations to be shown or `null` to load annotations again
   */
  @CalledInAwt
  public synchronized final void reload(@Nullable FileAnnotation newFileAnnotation) {
    if (myReloader != null) myReloader.consume(newFileAnnotation);
  }

  /**
   * @see #close()
   */
  public synchronized final void setCloser(@NotNull Runnable closer) {
    if (myIsClosed) return;
    myCloser = closer;
  }

  /**
   * @see #reload(FileAnnotation)
   */
  public synchronized final void setReloader(@Nullable Consumer<? super FileAnnotation> reloader) {
    if (myIsClosed) return;
    myReloader = reloader;
  }

上記のようにコンソールでコミットする場合、FileAnnotateのcloseが呼び出されるため、setCloserメソッド内でAnnotateを閉じる処理を記述します。

        fileAnnotation.setCloser {
            UIUtil.invokeLaterIfNeeded {
                if (!project.isDisposed) editor.gutter.closeAllAnnotations()
            }
        }

同じようにsetReloaderメソッドを実装しようとしたのですが、思いつく限りの行動をしても、残念ながらreloadメソッドが呼び出されることがありませんでした。そのため、setReloaderの実装することができませんでした。AnnotationToggleActionから引数のFileAnnotationが最新のFileAnnotationのようですが、ここは謎です。。。誰か教えて。

2019.1 vs 2019.2

さてここまで実装して、よっしゃリリースだ!って思ったのですが、 closeAllAnnotations メソッドが気になっていました。このメソッド呼び出されるとAnnotateの部分に表示されている情報全てを閉じます。つまり、Annotateを表示し、List pull requestを表示し、 close annotationを選択するとAnnotateとList pull requestが両方とも閉じてしまします。片方だけ閉じることが出来ません。

f:id:shiraji:20191220231636g:plain

2019.1の場合、個々のAnnotateを閉じる方法が提供されておらず、さらに設定されているTextAnnotationの取得が行えません。残念だなーと思っていたのですが、ふとした拍子にこんなissueを見つけてしまいました。

Annotate action behave wrong if other annotations added https://youtrack.jetbrains.com/issue/IDEA-209722

このissueによると、192.2300から個々のAnnotateを閉じることができるようになったみたいです。つまり2019.1では出来ないけど、2019.2では出来ると。2019.1のソースコードばかり追っていたため、最新のコードでは出来る事を出来ないと諦めてしまっていました。

古いバージョンに引っ張られて、しょぼい機能を提供するのはなんだかなーって思ったので、2019.2での開発に切り替えることにしました。

ちなみにこのissueにはこんなことが書かれています。

We've run into that in the students' project, but it also can be actual for 3rd-party plugins developers.

要約すると「学生のプロジェクトやってる時に発覚した。3rdパーティのプラグイン開発者にも発生する可能性あるよ。」とのことです。3rdパーティのプラグイン開発者でAnnotate作って公開まで持っていったの、ひょっとすると相当少ないようです。どうりで似たようなプラグインが見つからない訳ですね・・・

2019.2対応

2019.2(ここでは192.5728以上とします)では新たに以下の二つのメソッドが提供されました。

  @NotNull
  List<TextAnnotationGutterProvider> getTextAnnotations();

  void closeTextAnnotations(@NotNull Collection<? extends TextAnnotationGutterProvider> annotations);

getTextAnnotations はそのEditorに設定されている全てのTextAnnotationsを取得し、 closeTextAnnotations は引数に渡された複数のTextAnnotationを閉じるメソッドです。

これを使えばOK!ということでさくっと実装してみました。上記の問題が解消されると思ったのですが、Exceptionが出ます。

java.lang.Throwable: Synchronous execution on EDT: /usr/bin/git -c ...
    at com.intellij.openapi.diagnostic.Logger.error(Logger.java:145)
    at com.intellij.execution.process.OSProcessHandler.checkEdtAndReadAction(OSProcessHandler.java:117)
    at com.intellij.execution.process.OSProcessHandler.waitFor(OSProcessHandler.java:62)
    at git4idea.commands.GitTextHandler.waitForProcess(GitTextHandler.java:145)
    at git4idea.commands.GitHandler.runInCurrentThread(GitHandler.java:383)
    at git4idea.commands.GitImplBase.doRun(GitImplBase.java:172)
    at git4idea.commands.GitImplBase.run(GitImplBase.java:148)
    at git4idea.commands.GitImplBase.runCommand(GitImplBase.java:46)
    at git4idea.commands.GitImpl.runCommand(GitImpl.java:41)

これは何かというと、UIスレッド時にgitのような外部コマンド叩くとエラーになるということです。IntelliJ 2019.2からこのような仕様に変わったようです。

Find Pull Requestプラグインは各所でgitコマンドを叩いているため、これの対応が必要になります。

外部コマンドを叩く場合、一番簡単な対処方法はバックグラウンドスレッドでの処理にしてしまうことです。バックグラウンドスレッドでの実行方法はいくつかあるのですが、簡単な方法として、Task.Backgroundableを使ってみます。

        object : Task.Backgroundable(project, "Listing Pull Request...") {
            override fun run(indicator: ProgressIndicator) {
                // 外部コマンドを叩くなどの処理+その後続処理
            }
        }.queue()

queue()の呼び出しを忘れがちになるので、注意してください。

さぁ対応終わったぞ!って思ったのですが、今度は別のエラーが出ます。

java.lang.Throwable: Read access is allowed from event dispatch thread or inside read-action only (see com.intellij.openapi.application.Application.runReadAction())
    at com.intellij.openapi.diagnostic.Logger.error(Logger.java:162)
    at com.intellij.openapi.application.impl.ApplicationImpl.assertReadAccessAllowed(ApplicationImpl.java:1027)
    at com.intellij.openapi.editor.impl.EditorImpl.assertReadAccess(EditorImpl.java:3254)
    at com.intellij.openapi.editor.impl.EditorImpl.getSettings(EditorImpl.java:920)

今度は何かっていうと、上でやったバックグラウンドスレッドはEditorへのRead権限がないため、あかんよってことです。上のバックグラウンド処理は必須であるため、read権限あるスレッドでEditorへのアクセスをするように修正します。

以下の公式ドキュメントにスレッド周りの話が書いてありますので、興味ある人は読んで下さい。 www.jetbrains.org

ドキュメントによると今回の場合は、invokeLaterを呼び出せば対処可能になります。つまりコードとしてはこんな感じに。

        object : Task.Backgroundable(project, "Listing Pull Request...") {
            override fun run(indicator: ProgressIndicator) {
                // 外部コマンドを叩くなどの処理+その後続処理
                ApplicationManager.getApplication().invokeLater {
                    // バックグラウンドスレッド内でeditorへアクセスするなどread権限が必要な処理を実行
                }
            }
        }.queue()

ようやく全てのコードがエラーなしで2019.2で動くようになりました。ほっと一安心。

さぁリリースです。

はいリリース後にユーザが多い、Android Studioの最新版3.5系(2019.1ベース)のサポートを勢い余って切ったことが発覚しました。最近Android開発してなかったし、完全に忘れてました。。。

複数バージョン対応

かと言って、2019.1のサポートに戻すと、問題解消されている2019.2のユーザにも問題ある挙動を強制する事になってしまいます。そこで、複数のバージョンのプラグインをリリースすることにしました。

  • 1.7.0 -> 2019.2以降のユーザ
  • 1.7.0-2019.1 -> 2019.1.xのユーザ(Android Studio含む)

対処方法は簡単で、plugin.xmlの idea-version のuntil-buildを設定し <idea-version since-build="191" until-build="192.5727"/>プラグインのバージョンを新しく 1.7.0-2019.1 として、リリースするだけです。(until-buildなどはplugin.xmlじゃなくて、gradle-intellij-pluginで設定可能になっているはずです。未検証) そうするとこんな感じで、IntelliJのバージョンによって、インストールできるバージョンが変わるようになります。

f:id:shiraji:20191221002340p:plain
複数バージョンがホストされる

まとめ

先日のUbieアドベントカレンダーLukas (@cvguy84) | Twitter さんのIntelliJプラグインの記事では

qiita.com

抽象的で考えるとIntelliJプラグインの開発は簡単です。

どこがやー!って思うのですが、

IntelliJプラグインは誰でも作ることができます。 Documentationを慎重に読んで、実装するしかないです。でもそれがOSS開発の一つの楽しみであると思います。

本当にこれです。ドキュメント(という名のソースコード)を読んで、うまく動いた時、すっごく楽しいし、それが人に使われると思うとやめられませんなーってなります。

ちなみに年内に出したいと思ったので、コピペの嵐でやり過ごしました。そのため、2019/12/21現在のrepositoryのコードは参考にしない方が良いです。。。時間とってがっつりフルスクラッチします。誰にも何も言われず、費用対効果ガン無視で思いっきり俺の考えた最強のコードがかけるのもOSSの醍醐味だと思います。