Skip to content

Commit

Permalink
Automatically back up notebooks without a file (#736)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
jonatanklosko committed Dec 4, 2021
1 parent c28eb69 commit f0606b1
Show file tree
Hide file tree
Showing 12 changed files with 166 additions and 32 deletions.
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
40 changes: 35 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,46 @@ 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()
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
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.
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_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

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
28 changes: 21 additions & 7 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) != []
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,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>
"""
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

0 comments on commit f0606b1

Please sign in to comment.