├── test ├── test_helper.exs └── kino_livereload_test.exs ├── .formatter.exs ├── .gitignore ├── mix.exs ├── mix.lock ├── lib └── kino_livereload.ex └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/kino_livereload_test.exs: -------------------------------------------------------------------------------- 1 | defmodule KinoLivereloadTest do 2 | use ExUnit.Case 3 | doctest KinoLivereload 4 | 5 | test "greets the world" do 6 | assert KinoLivereload.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | kino_livereload-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule KinoLivereload.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :kino_livereload, 7 | version: "0.1.0", 8 | elixir: "~> 1.15", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | package: package(), 12 | description: "Live Reload for Livebook cells" 13 | ] 14 | end 15 | 16 | # Run "mix help compile.app" to learn about applications. 17 | def application do 18 | [ 19 | extra_applications: [:logger] 20 | ] 21 | end 22 | 23 | def package do 24 | [ 25 | maintainers: ["Thomas Millar"], 26 | licenses: ["Apache-2.0"], 27 | links: %{"GitHub" => "https://github.com/thmsmlr/kino_livereload"} 28 | ] 29 | end 30 | 31 | # Run "mix help deps" to learn about dependencies. 32 | defp deps do 33 | [ 34 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 35 | {:file_system, "~> 1.0"}, 36 | {:kino, "~> 0.12.2"} 37 | ] 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 3 | "ex_doc": {:hex, :ex_doc, "0.31.1", "8a2355ac42b1cc7b2379da9e40243f2670143721dd50748bf6c3b1184dae2089", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3178c3a407c557d8343479e1ff117a96fd31bafe52a039079593fb0524ef61b0"}, 4 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 5 | "fss": {:hex, :fss, "0.1.1", "9db2344dbbb5d555ce442ac7c2f82dd975b605b50d169314a20f08ed21e08642", [:mix], [], "hexpm", "78ad5955c7919c3764065b21144913df7515d52e228c09427a004afe9c1a16b0"}, 6 | "kino": {:hex, :kino, "0.12.3", "a5f48a243c60a7ac18ba23869f697b1c775fc7794e8cd55dd248ba33c6fe9445", [:mix], [{:fss, "~> 0.1.0", [hex: :fss, repo: "hexpm", optional: false]}, {:nx, "~> 0.1", [hex: :nx, repo: "hexpm", optional: true]}, {:table, "~> 0.1.2", [hex: :table, repo: "hexpm", optional: false]}], "hexpm", "a6dfa3d54ba0edec9ca6e5940154916b381901001f171c85a2d8c67869dbc2d8"}, 7 | "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, 8 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 9 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.4", "29563475afa9b8a2add1b7a9c8fb68d06ca7737648f28398e04461f008b69521", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f4ed47ecda66de70dd817698a703f8816daa91272e7e45812469498614ae8b29"}, 10 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 11 | "table": {:hex, :table, "0.1.2", "87ad1125f5b70c5dea0307aa633194083eb5182ec537efc94e96af08937e14a8", [:mix], [], "hexpm", "7e99bc7efef806315c7e65640724bf165c3061cdc5d854060f74468367065029"}, 12 | } 13 | -------------------------------------------------------------------------------- /lib/kino_livereload.ex: -------------------------------------------------------------------------------- 1 | defmodule KinoLiveReload do 2 | @external_resource "README.md" 3 | 4 | [_, readme_docs, _] = 5 | "README.md" 6 | |> File.read!() 7 | |> String.split("") 8 | 9 | @moduledoc """ 10 | #{readme_docs} 11 | """ 12 | 13 | use GenServer 14 | 15 | def start_link(args) do 16 | GenServer.start_link(__MODULE__, args) 17 | end 18 | 19 | def child_spec(args) do 20 | %{ 21 | id: __MODULE__, 22 | start: {__MODULE__, :start_link, [args]} 23 | } 24 | end 25 | 26 | def init(args) do 27 | modules = Keyword.get(args, :modules) 28 | cell_id = Keyword.fetch!(args, :cell_id) 29 | 30 | path_to_module = 31 | for mod <- modules, into: %{} do 32 | {mod.module_info(:compile)[:source] |> to_string(), mod} 33 | end 34 | 35 | paths = Map.keys(path_to_module) 36 | 37 | {:ok, watcher_pid} = FileSystem.start_link(dirs: paths) 38 | FileSystem.subscribe(watcher_pid) 39 | 40 | {:ok, %{watcher_pid: watcher_pid, path_to_module: path_to_module, cell_id: cell_id}} 41 | end 42 | 43 | def handle_info( 44 | {:file_event, watcher_pid, {path, events}}, 45 | %{watcher_pid: watcher_pid} = state 46 | ) do 47 | module = Map.get(state.path_to_module, path, nil) 48 | modified? = :modified in events 49 | 50 | if module != nil and modified? do 51 | :code.purge(module) 52 | IEx.Helpers.r(module) 53 | 54 | cell_id = state.cell_id 55 | 56 | livebook_pids = 57 | Node.list(:connected) 58 | |> Enum.flat_map(fn n -> 59 | :rpc.call(n, :erlang, :processes, []) 60 | |> Enum.map(fn pid -> 61 | info = :rpc.call(n, Process, :info, [pid]) 62 | {pid, info} 63 | end) 64 | |> Enum.filter(fn {_pid, info} -> 65 | case info[:dictionary][:"$initial_call"] do 66 | {Livebook.Session, _, _} -> true 67 | _ -> false 68 | end 69 | end) 70 | |> Enum.map(fn {pid, _} -> pid end) 71 | end) 72 | 73 | livebook_pid = 74 | livebook_pids 75 | |> Enum.find(fn pid -> 76 | :sys.get_state(pid) 77 | |> get_in( 78 | [:data, :cell_infos] 79 | |> Enum.map(&Access.key!(&1)) 80 | ) 81 | |> Map.get(cell_id, false) 82 | end) 83 | 84 | GenServer.cast(livebook_pid, {:queue_cell_evaluation, "LiveReloader", cell_id}) 85 | 86 | {:noreply, state} 87 | else 88 | {:noreply, state} 89 | end 90 | end 91 | 92 | def handle_info({:file_event, watcher_pid, :stop}, %{watcher_pid: watcher_pid} = state) do 93 | # Your own logic when monitor stop 94 | {:noreply, state} 95 | end 96 | 97 | def handle_info(:subscribe, state) do 98 | {:noreply, state} 99 | end 100 | 101 | defmacro __using__(opts) do 102 | mods = Keyword.fetch!(opts, :watch) 103 | "#cell:" <> cell_id = __CALLER__.file 104 | mods = List.wrap(mods) 105 | 106 | quote do 107 | mods = unquote(mods) 108 | cell_id = unquote(cell_id) 109 | child_spec = KinoLiveReload.child_spec(modules: mods, cell_id: cell_id) 110 | {:ok, pid} = Kino.start_child(child_spec) 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KinoLiveReload 2 | 3 | [![KinoLiveReload version](https://img.shields.io/hexpm/v/kino_livereload.svg)](https://hex.pm/packages/kino_livereload) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/kino_livereload/) 5 | [![Hex Downloads](https://img.shields.io/hexpm/dt/kino_livereload)](https://hex.pm/packages/kino_livereload) 6 | [![GitHub stars](https://img.shields.io/github/stars/thmsmlr/kino_livereload.svg)](https://github.com/thmsmlr/kino_livereload/stargazers) 7 | [![Twitter Follow](https://img.shields.io/twitter/follow/thmsmlr?style=social)](https://twitter.com/thmsmlr) 8 | 9 | 10 | 11 | kino_livereload brings the developer experience of a pheonix application to Livebook. 12 | 13 | Simply mark which cell you want to livereload with `use KinoLiveReload, watch: MyModule` to 14 | rerun the cell anytime the source code for MyModule changes. You can provide a single module or a 15 | list of modules to the `watch:` paramater. 16 | 17 | For example, 18 | 19 | ```elixir 20 | use KinoLiveReload, watch: MyLocalModule 21 | 22 | MyLocalModule.hello() 23 | 24 | # => "world" 25 | 26 | # Edit to /path/to/my-local-module.ex 27 | 28 | # => "thomas!" 29 | ``` 30 | 31 | Any time you make changes to the source code of MyLocalModule the cell with the aforementioned code will automatically recompile, and rerun. 32 | 33 | 34 | 35 | ## Motivation 36 | 37 | Livebook is a godsend for productivity. 38 | When you are working on code that requires a long boot up time, whether it be waiting for resources, or doing some long computation, Livebook lets you implicitly cache previous results. 39 | It's like a repl, but more premenant as you can rerun cells, save notbooks and rerun them later. 40 | However, ultimately we know that our code ultimately ends in a Mix projects. 41 | That's where a gap still existis. 42 | While Livebook is better than IEX, there is still a jump between Livebook and a Mix project. 43 | That's why I built `:kino_livereload`. 44 | Let me explain. 45 | 46 | Frequently you'll start writing an adhoc script in Livebook because you get a REPL like experience with visual, rerunnable feedback. 47 | However, at some point your Livebooks in a project become too large and you want to refactor helper functions into a library. 48 | This is where the first problem begins. 49 | Since instally dependencies happesn in the first cell as part of the `Mix.install(...)` if you need to fix and edge case in a refactored helper function, you have to rerun the entire notebook, nullifying the benefist of the REPL. 50 | At which point you are no better off than moving the Livebook into a test and running `mix test`. 51 | 52 | Now that's just in the refactoring usecases, imagine you wanted to co-develop a library with a livebook. 53 | At least when you're fixing edge cases in a refactor, maybe the hit to developer productivity is tolerably to continue using Livebook (but then again why?). 54 | Whereas in a co-develop situation as you'd constantly be rerunning the entire Livebook. 55 | 56 | That's where `:kino_livereload` comes in. 57 | This library brings Pheonix LiveReload functionality to Livebook. 58 | When you have a local library that you're actively working on, upon which a specific Livebook cell depends, you can mark the cell and just rerun that cell anytime the underlying source code changes. 59 | 60 | This is particularly useful if you have a usecase where you are actively iterating on helper functions that operate on some expensive data to initial load/compute. 61 | For example, in my usecase, I built this because I was working on a webscraper. 62 | I had many functions which I was iterating that on extracting data from the HTML. 63 | The Livebook would initialize the browser, set the cookies, and do the actions that would get the browser into the appropriate state before I could test my extract. 64 | This could take up to 5-10 seconds, just to test a CSS Selector change. 65 | 66 | With this library, I was able to keep the Browser state cached within the Livebook and just recompile/reload the extraction code as I was updating it in the shared library. 67 | 68 | This lead to a much smoother path between prototype and production. 69 | I was able to continue to leverage the visual / repl nature of Livebook, while still working on my code in it's final location. 70 | 71 | ## Installation 72 | 73 | In your Livebook, 74 | 75 | ```elixir 76 | Mix.install([ 77 | {:kino_livereload, "~> 0.1.0"} 78 | ]) 79 | ``` 80 | 81 | 82 | --------------------------------------------------------------------------------