🦁

Livebook 自作のKinoでセッションをリロードする

に公開

Livebookのエディタ機能はショートカットも一通り備わっていて高機能です。一方で、がっつり作るときは自分のエディタを使いたいことがあります、たぶん🤔

今現在そんなときは、手元でファイルを編集するたびに「Livebookでの動作確認のためにWebでのセッション=ノートを一度閉じて、また開いてをブラウザUI上で行う」ことがあります。これはわりと面倒です(...想定している使い方ではないので当然です)。

本記事は、Kinoでこの操作を簡略化したい、というものです(ニッチかつ見てて不穏な動きをします笑)。

Kinoの導入

Livebookは、セル単位の実行をKinoというツールに任せて描画することができます。

Kino.Markdown.new("# テスト")

上記は、elixirコードからマークダウンHTML表示を出力するためのKinoライブラリです。このように、Kinoのルールに則ってライブラリを自作すれば、セル内容=>単なるコード結果、という出力だけでなく、自由な出力を作ることができます。

ということで、Kinoの仕組みを使って、リロードするためのボタンを出力し、ボタンを押した時にリロードするものを自作します。

実装

実装です。留意点としては、セルがLivebookと同じノードではなく専用のノードであることです。また出力されたUIはiframe上となります。

setup
Mix.install([{:kino, "~> 0.14.0"}])
defmodule Kino.ReloadButton do
  use Kino.JS
  use Kino.JS.Live

  def new(env_file) do
    file_path = env_file |> URI.parse() |> Map.get(:path)
    livebook_node = get_livebook_node()
    session_id = get_session_id(livebook_node, file_path)
    Kino.JS.Live.new(__MODULE__, %{file_path: file_path, livebook_node: livebook_node, session_id: session_id})
  end

  defp get_livebook_node do
    Node.list(:connected) |> hd()
  end

  defp get_session_id(livebook_node, file_path) do
    livebook_node
    |> :rpc.call(Livebook.Sessions, :list_sessions, [])
    |> Enum.find(& &1.file && &1.file.path == file_path)
    |> Map.get(:id)
  end

  @impl true
  def init(data, ctx), do: {:ok, assign(ctx, data: data)}

  @impl true
  def handle_connect(ctx), do: {:ok, %{}, ctx}

  @impl true
  def handle_event("reload", _params, ctx) do
    data = ctx.assigns.data

    # 新しいセッションを用意
    {:ok, content} = File.read(data.file_path)
    {notebook, _} = :rpc.call(data.livebook_node, Livebook.LiveMarkdown, :notebook_from_livemd, [content])

    {:ok, new_session} =
      :rpc.call(data.livebook_node, Livebook.Sessions, :create_session, [
        [notebook: notebook, mode: :default]
      ])

    # 現セッションを閉じる処理の予約
    # この場で閉じると、現状ではまだ繋いでいるこのクライアント(ブラウザ)が強制的に別画面に遷移してしまう
    code = """
    spawn(fn ->
      Process.sleep(1000)
      {:ok, old_session} = Livebook.Sessions.fetch_session("#{data.session_id}")
      Livebook.Session.close(old_session.pid)
      {:ok, new_session} = Livebook.Sessions.fetch_session("#{new_session.id}")
      file = Livebook.FileSystem.File.local("#{data.file_path}")
      Livebook.Session.set_file(new_session.pid, file)
    end)
    """

    :rpc.call(data.livebook_node, Code, :eval_string, [code])

    # 新しいセッション(URL)への移動指示
    broadcast_event(ctx, "navigate", %{url: "/sessions/#{new_session.id}"})

    {:noreply, ctx}
  end

  asset "main.js" do
    """
    export function init(ctx, data) {
      ctx.root.innerHTML = `
        <div style="padding: 16px; background: #f5f5f5; border-radius: 8px;">
          <button id="reload-btn" style="padding: 10px 20px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">
            🔄 Reload Notebook
          </button>
        </div>
      `;

      const btn = ctx.root.querySelector("#reload-btn");
      btn.addEventListener("click", () => {
        ctx.pushEvent("reload", {});
      });

      ctx.handleEvent("navigate", ({ url }) => {
        window.top.location.href = url;
      });
    }
    """
  end
end

これで下記を実行すると、セルの実行結果としてボタンが表示されます。

Kino.ReloadButton.new(__ENV__.file)

そしてボタンを押すと、ファイル内容から生成された新しいセッションに遷移します。その後、その新しいセッションがもともとのファイルと紐づけられるようになっています。結果として、最新のファイル内容(=手元のエディタで保存した内容)をスムーズにLivebook上で確認できます。

デメリットといいますが、仕方のない仕様として、Mix.install()からやり直しです。

おまけ:アプリにしておく

毎回Kino.ReloadButtonをノートに定義するのは現実的ではないので、各ノートで簡単にボタンを出せるように、共通利用可能にしておきます。

https://zenn.dev/ta_to/articles/35bc17d24efc98

↑を実施すると、各ファイルには下記の記述だけで簡単に(?)ボタンを設置できます。

:rpc.call(livebook, LibStore, :load, [Node.self(), Kino.ReloadButton])
Kino.ReloadButton.new(__ENV__.file)

Discussion