diff --git a/README.md b/README.md index f3f60c836..1cb430d10 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,10 @@ Livebook if said token is supplied as part of the URL. The following environment variables configure Livebook: + * LIVEBOOK_AUTOSAVE_PATH - sets the directory where notebooks with no file are + saved. Defaults to livebook/notebooks/ under the default user cache location. + You can pass "none" to disable this behaviour. + * LIVEBOOK_COOKIE - sets the cookie for running Livebook in a cluster. Defaults to a random string that is generated on boot. diff --git a/assets/css/components.css b/assets/css/components.css index 942ab8020..0b0074f9e 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -2,7 +2,7 @@ /* Buttons */ .button-base { - @apply px-5 py-2 rounded-lg border border-transparent font-medium text-sm; + @apply px-5 py-2 rounded-lg border border-transparent font-medium text-sm whitespace-nowrap; } .button-blue { diff --git a/assets/js/focus_on_update/index.js b/assets/js/focus_on_update/index.js index 87a7db4af..61f5ce85e 100644 --- a/assets/js/focus_on_update/index.js +++ b/assets/js/focus_on_update/index.js @@ -21,6 +21,7 @@ const FocusOnUpdate = { this.el.focus(); this.el.selectionStart = this.el.selectionEnd = this.el.value.length; + this.el.scrollLeft = this.el.scrollWidth; }, }; diff --git a/assets/js/session/index.js b/assets/js/session/index.js index c4cdb06fd..8c6e10cbe 100644 --- a/assets/js/session/index.js +++ b/assets/js/session/index.js @@ -308,11 +308,12 @@ function handleDocumentKeyDown(hook, event) { const editor = event.target.closest(".monaco-editor.focused"); - const completionBoxOpen = !!editor.querySelector( - ".editor-widget.parameter-hints-widget.visible" + const completionBoxOpen = !!( + editor && + editor.querySelector(".editor-widget.parameter-hints-widget.visible") ); - const signatureDetailsOpen = !!editor.querySelector( - ".editor-widget.suggest-widget.visible" + const signatureDetailsOpen = !!( + editor && editor.querySelector(".editor-widget.suggest-widget.visible") ); // Ignore Escape if it's supposed to close some Monaco input diff --git a/lib/livebook.ex b/lib/livebook.ex index 40d1a26ec..264cc3eaf 100644 --- a/lib/livebook.ex +++ b/lib/livebook.ex @@ -60,6 +60,15 @@ defmodule Livebook do configured_file_systems = Livebook.Config.file_systems!("LIVEBOOK_FILE_SYSTEM_") config :livebook, :file_systems, [local_file_system | configured_file_systems] + + autosave_path = + if config_env() == :test do + nil + else + Livebook.Config.autosave_path!("LIVEBOOK_AUTOSAVE_PATH") + end + + config :livebook, :autosave_path, autosave_path end @doc """ diff --git a/lib/livebook/config.ex b/lib/livebook/config.ex index 0edd25866..9cb238680 100644 --- a/lib/livebook/config.ex +++ b/lib/livebook/config.ex @@ -71,6 +71,14 @@ defmodule Livebook.Config do FileSystem.File.new(file_system) end + @doc """ + Returns the directory where notebooks with no file should be persisted. + """ + @spec autosave_path() :: String.t() | nil + def autosave_path() do + Application.fetch_env!(:livebook, :autosave_path) + end + ## Parsing @doc """ @@ -78,7 +86,7 @@ defmodule Livebook.Config do """ def root_path!(env) do if root_path = System.get_env(env) do - root_path!("LIVEBOOK_ROOT_PATH", root_path) + root_path!(env, root_path) else File.cwd!() end @@ -89,13 +97,62 @@ defmodule Livebook.Config do """ def root_path!(context, root_path) do if File.dir?(root_path) do - root_path + Path.expand(root_path) else IO.warn("ignoring #{context} because it doesn't point to a directory: #{root_path}") File.cwd!() end end + @doc """ + Parses and validates the autosave directory from env. + """ + def autosave_path!(env) do + if path = System.get_env(env) do + autosave_path!(env, path) + else + default_autosave_path!() + end + end + + @doc """ + Validates `autosave_path` within context. + """ + def autosave_path!(context, path) + + def autosave_path!(_context, "none"), do: nil + + def autosave_path!(context, path) do + if writable_directory?(path) do + Path.expand(path) + else + IO.warn("ignoring #{context} because it doesn't point to a writable directory: #{path}") + default_autosave_path!() + end + end + + defp default_autosave_path!() do + cache_path = :filename.basedir(:user_cache, "livebook") + + path = + if writable_directory?(cache_path) do + cache_path + else + System.tmp_dir!() |> Path.join("livebook") + end + + notebooks_path = Path.join(path, "notebooks") + File.mkdir_p!(notebooks_path) + notebooks_path + end + + defp writable_directory?(path) do + case File.stat(path) do + {:ok, %{type: :directory, access: access}} when access in [:read_write, :write] -> true + _ -> false + end + end + @doc """ Parses and validates the secret from env. """ diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index e574b7f86..5020fcffd 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -973,9 +973,10 @@ defmodule Livebook.Session do end defp maybe_save_notebook_async(state) do - if should_save_notebook?(state) do + file = notebook_autosave_file(state) + + if file && should_save_notebook?(state) do pid = self() - file = state.data.file content = LiveMarkdown.Export.notebook_to_markdown(state.data.notebook) {:ok, pid} = @@ -991,9 +992,11 @@ defmodule Livebook.Session do end defp maybe_save_notebook_sync(state) do - if should_save_notebook?(state) do + file = notebook_autosave_file(state) + + if file && should_save_notebook?(state) do content = LiveMarkdown.Export.notebook_to_markdown(state.data.notebook) - result = FileSystem.File.write(state.data.file, content) + result = FileSystem.File.write(file, content) handle_save_finished(state, result) else state @@ -1001,7 +1004,34 @@ defmodule Livebook.Session do end defp should_save_notebook?(state) do - state.data.file != nil and state.data.dirty and state.save_task_pid == nil + state.data.dirty and state.save_task_pid == nil + end + + defp notebook_autosave_file(state) do + state.data.file || default_notebook_file(state) + end + + defp default_notebook_file(session) do + if path = Livebook.Config.autosave_path() do + dir = path |> FileSystem.Utils.ensure_dir_path() |> FileSystem.File.local() + notebook_rel_path = path_with_timestamp(session.session_id, session.created_at) + FileSystem.File.resolve(dir, notebook_rel_path) + end + end + + defp path_with_timestamp(session_id, date_time) do + # We want a random, but deterministic part, so we + # use a few characters from the session id, which + # is random already + random_str = String.slice(session_id, 0..3) + + [date_str, time_str, _] = + date_time + |> DateTime.to_iso8601() + |> String.replace(["-", ":"], "_") + |> String.split(["T", "."]) + + "#{date_str}/#{time_str}_#{random_str}.livemd" end defp handle_save_finished(state, result) do diff --git a/lib/livebook_cli/server.ex b/lib/livebook_cli/server.ex index af247e98c..2e7cf0445 100644 --- a/lib/livebook_cli/server.ex +++ b/lib/livebook_cli/server.ex @@ -19,6 +19,9 @@ defmodule LivebookCLI.Server do Available options: + --autosave-path The directory where notebooks with no file are persisted. + Defaults to livebook/notebooks/ under the default user cache + location. You can pass "none" to disable this behaviour --cookie Sets a cookie for the app distributed node --default-runtime Sets the runtime type that is used by default when none is started explicitly for the given notebook, defaults to standalone @@ -126,6 +129,7 @@ defmodule LivebookCLI.Server do end @switches [ + autosave_path: :string, cookie: :string, default_runtime: :string, ip: :string, @@ -202,6 +206,11 @@ defmodule LivebookCLI.Server do opts_to_config(opts, [{:livebook, :default_runtime, default_runtime} | config]) end + defp opts_to_config([{:autosave_path, path} | opts], config) do + autosave_path = Livebook.Config.autosave_path!("--autosave-path", path) + opts_to_config(opts, [{:livebook, :autosave_path, autosave_path} | config]) + end + defp opts_to_config([_opt | opts], config), do: opts_to_config(opts, config) defp browser_open(url) do diff --git a/lib/livebook_web/live/file_select_component.ex b/lib/livebook_web/live/file_select_component.ex index acacff624..394fbafad 100644 --- a/lib/livebook_web/live/file_select_component.ex +++ b/lib/livebook_web/live/file_select_component.ex @@ -273,7 +273,7 @@ defmodule LivebookWeb.FileSelectComponent do <%= if @file_info.highlighted != "" do %> - + <%= @file_info.highlighted %> <% end %> diff --git a/lib/livebook_web/live/home_live.ex b/lib/livebook_web/live/home_live.ex index 561f1619f..df089f1c9 100644 --- a/lib/livebook_web/live/home_live.ex +++ b/lib/livebook_web/live/home_live.ex @@ -242,17 +242,19 @@ defmodule LivebookWeb.HomeLive do )} end + def handle_event("open_autosave_directory", %{}, socket) do + file = + Livebook.Config.autosave_path() + |> FileSystem.Utils.ensure_dir_path() + |> FileSystem.File.local() + + file_info = %{exists: true, access: file_access(file)} + {:noreply, assign(socket, file: file, file_info: file_info)} + end + @impl true def handle_info({:set_file, file, info}, socket) do - file_info = %{ - exists: info.exists, - access: - case FileSystem.File.access(file) do - {:ok, access} -> access - {:error, _} -> :none - end - } - + file_info = %{exists: info.exists, access: file_access(file)} {:noreply, assign(socket, file: file, file_info: file_info)} end @@ -336,4 +338,11 @@ defmodule LivebookWeb.HomeLive do session_opts = Keyword.merge(session_opts, notebook: notebook) create_session(socket, session_opts) end + + defp file_access(file) do + case FileSystem.File.access(file) do + {:ok, access} -> access + {:error, _} -> :none + end + end end diff --git a/lib/livebook_web/live/home_live/session_list_component.ex b/lib/livebook_web/live/home_live/session_list_component.ex index df9780733..7f503320d 100644 --- a/lib/livebook_web/live/home_live/session_list_component.ex +++ b/lib/livebook_web/live/home_live/session_list_component.ex @@ -12,10 +12,16 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do sessions = sort_sessions(sessions, socket.assigns.order_by) + show_autosave_note? = + case Livebook.Config.autosave_path() do + nil -> false + path -> File.ls!(path) != [] + end + socket = socket |> assign(assigns) - |> assign(:sessions, sessions) + |> assign(sessions: sessions, show_autosave_note?: show_autosave_note?) {:ok, socket} end @@ -47,21 +53,29 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do - <.session_list sessions={@sessions} socket={@socket} /> + <.session_list sessions={@sessions} socket={@socket} show_autosave_note?={@show_autosave_note?} /> """ end defp session_list(%{sessions: []} = assigns) do ~H""" -
+
<.remix_icon icon="windy-line" class="text-gray-400 text-xl" />
-
- You do not have any running sessions. -
- Please create a new one by clicking “New notebook” +
+
+ You do not have any running sessions. + <%= if @show_autosave_note? do %> +
+ Looking for unsaved notebooks? + Browse them here. + <% end %> +
+
""" diff --git a/lib/livebook_web/live/session_live/persistence_live.ex b/lib/livebook_web/live/session_live/persistence_live.ex index 8aecdbf9c..baa680c0c 100644 --- a/lib/livebook_web/live/session_live/persistence_live.ex +++ b/lib/livebook_web/live/session_live/persistence_live.ex @@ -48,7 +48,7 @@ defmodule LivebookWeb.SessionLive.PersistenceLive do
-
+
<.switch_checkbox name="persist_outputs" @@ -68,7 +68,7 @@ defmodule LivebookWeb.SessionLive.PersistenceLive do ]} />
-
+
File: <%= if @new_attrs.file do %> @@ -76,7 +76,7 @@ defmodule LivebookWeb.SessionLive.PersistenceLive do [<.file_system_icon file_system={@new_attrs.file.file_system} />] - + <%= @new_attrs.file.path %>