Automatically back up notebooks without a file (#736)

* Improve file select layout on long paths

* Automatically back up notebooks without a file

* Run formatter

* Add margin when there are no sections

* Add an informative note about autosave directory

* Store autosave path instead of file in the config

* Rename autosave dir to autosave path

* Fix insert mode escape on section headlines

* Show ellipsis on selected file too

* Always create the default directory

* Apply review comments
This commit is contained in:
Jonatan Kłosko 2021-12-04 16:29:14 +01:00 committed by GitHub
parent c28eb6979b
commit f0606b109d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 166 additions and 32 deletions

View file

@ -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.

View file

@ -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 {

View file

@ -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;
},
};

View file

@ -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

View file

@ -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 """

View file

@ -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.
"""

View file

@ -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

View file

@ -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

View file

@ -273,7 +273,7 @@ defmodule LivebookWeb.FileSelectComponent do
</span>
<span class={"flex font-medium overflow-hidden whitespace-nowrap #{if(@file_info.is_running, do: "text-green-300", else: "text-gray-500")}"}>
<%= if @file_info.highlighted != "" do %>
<span class={"font-medium #{if(@file_info.is_running, do: "text-green-400", else: "text-gray-900")}"}>
<span class={"font-medium overflow-hidden overflow-ellipsis #{if(@file_info.is_running, do: "text-green-400", else: "text-gray-900")}"}>
<%= @file_info.highlighted %>
</span>
<% end %>

View file

@ -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

View file

@ -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
</:content>
</.menu>
</div>
<.session_list sessions={@sessions} socket={@socket} />
<.session_list sessions={@sessions} socket={@socket} show_autosave_note?={@show_autosave_note?} />
</div>
"""
end
defp session_list(%{sessions: []} = assigns) do
~H"""
<div class="p-5 flex space-x-4 items-center border border-gray-200 rounded-lg">
<div class="mt-4 p-5 flex space-x-4 items-center border border-gray-200 rounded-lg">
<div>
<.remix_icon icon="windy-line" class="text-gray-400 text-xl" />
</div>
<div class="text-gray-600">
You do not have any running sessions.
<br>
Please create a new one by clicking <span class="font-semibold">New notebook</span>
<div class="flex-grow flex items-center justify-between">
<div class="text-gray-600">
You do not have any running sessions.
<%= if @show_autosave_note? do %>
<br>
Looking for unsaved notebooks?
<a class="font-semibold" href="#" phx-click="open_autosave_directory">Browse them here</a>.
<% end %>
</div>
<button class="button-base button-blue" phx-click="new">
New notebook
</button>
</div>
</div>
"""

View file

@ -48,7 +48,7 @@ defmodule LivebookWeb.SessionLive.PersistenceLive do
</h3>
<div class="w-full flex-col space-y-8">
<div class="flex">
<form phx-change="set_options" onsubmit="return false;" class="flex flex-col space-y-4 items-start">
<form phx-change="set_options" onsubmit="return false;" class="flex flex-col space-y-4 items-start max-w-full">
<div class="flex flex-col space-y-4">
<.switch_checkbox
name="persist_outputs"
@ -68,7 +68,7 @@ defmodule LivebookWeb.SessionLive.PersistenceLive do
]} />
</div>
</div>
<div class="flex space-x-2 items-center">
<div class="flex space-x-2 items-center max-w-full">
<span class="text-gray-700 whitespace-nowrap">File:</span>
<%= if @new_attrs.file do %>
<span class="tooltip right" data-tooltip={file_system_label(@new_attrs.file.file_system)}>
@ -76,7 +76,7 @@ defmodule LivebookWeb.SessionLive.PersistenceLive do
[<.file_system_icon file_system={@new_attrs.file.file_system} />]
</span>
</span>
<span class="text-gray-700 whitespace-no-wrap font-medium">
<span class="text-gray-700 whitespace-no-wrap font-medium overflow-ellipsis overflow-hidden">
<%= @new_attrs.file.path %>
</span>
<button class="button-base button-gray button-small"