Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatically back up notebooks without a file #736

Merged
merged 12 commits into from Dec 4, 2021
4 changes: 4 additions & 0 deletions README.md
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion assets/css/components.css
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions assets/js/focus_on_update/index.js
Expand Up @@ -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;
},
};

Expand Down
9 changes: 5 additions & 4 deletions assets/js/session/index.js
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions lib/livebook.ex
Expand Up @@ -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 """
Expand Down
61 changes: 59 additions & 2 deletions lib/livebook/config.ex
Expand Up @@ -71,14 +71,22 @@ 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 """
Parses and validates the root path from env.
"""
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
Expand All @@ -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.
"""
Expand Down
32 changes: 27 additions & 5 deletions lib/livebook/session.ex
Expand Up @@ -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} =
Expand All @@ -991,17 +992,38 @@ 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
end
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()
filename = name_with_timestamp(session.created_at) <> ".livemd"
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved
FileSystem.File.resolve(dir, filename)
end
end

defp name_with_timestamp(date_time) do
date_time
|> DateTime.to_iso8601()
|> String.replace(["-", ":"], "_")
|> String.replace(["T", "."], "__")
end

defp handle_save_finished(state, result) do
Expand Down
9 changes: 9 additions & 0 deletions lib/livebook_cli/server.ex
Expand Up @@ -19,6 +19,9 @@ defmodule LivebookCLI.Server do

Available options:

--autosave-path The directory where notebooks with no file are persisted.
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down Expand Up @@ -126,6 +129,7 @@ defmodule LivebookCLI.Server do
end

@switches [
autosave_path: :string,
cookie: :string,
default_runtime: :string,
ip: :string,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/livebook_web/live/file_select_component.ex
Expand Up @@ -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 %>
Expand Down
27 changes: 18 additions & 9 deletions lib/livebook_web/live/home_live.ex
Expand Up @@ -242,17 +242,19 @@ defmodule LivebookWeb.HomeLive do
)}
end

def handle_event("open_autosave_pathectory", %{}, socket) do
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved
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

Expand Down Expand Up @@ -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
27 changes: 24 additions & 3 deletions lib/livebook_web/live/home_live/session_list_component.ex
Expand Up @@ -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) != []
josevalim marked this conversation as resolved.
Show resolved Hide resolved
end

socket =
socket
|> assign(assigns)
|> assign(:sessions, sessions)
|> assign(sessions: sessions, show_autosave_note?: show_autosave_note?)

{:ok, socket}
end
Expand Down Expand Up @@ -47,14 +53,14 @@ 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>
Expand All @@ -64,6 +70,21 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
Please create a new one by clicking <span class="font-semibold">“New notebook”</span>
</div>
</div>
<%= if @show_autosave_note? do %>
<div class="mt-4 p-5 flex space-x-4 items-center border border-gray-200 rounded-lg">
<div>
<.remix_icon icon="history-line" class="text-gray-400 text-xl" />
</div>
<div class="text-gray-600">
We automatically persist all notebooks that you forget to save manually.
<br>
To restore one of those
<a class="font-semibold" href="#" phx-click="open_autosave_pathectory">
go to backups
</a>
jonatanklosko marked this conversation as resolved.
Show resolved Hide resolved
</div>
</div>
<% end %>
"""
end

Expand Down
6 changes: 3 additions & 3 deletions lib/livebook_web/live/session_live/persistence_live.ex
Expand Up @@ -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"
Expand All @@ -68,15 +68,15 @@ 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)}>
<span class="flex items-center">
[<.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"
Expand Down