mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-14 07:25:55 +08:00
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:
parent
c28eb6979b
commit
f0606b109d
12 changed files with 166 additions and 32 deletions
|
@ -144,6 +144,10 @@ Livebook if said token is supplied as part of the URL.
|
||||||
|
|
||||||
The following environment variables configure Livebook:
|
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.
|
* LIVEBOOK_COOKIE - sets the cookie for running Livebook in a cluster.
|
||||||
Defaults to a random string that is generated on boot.
|
Defaults to a random string that is generated on boot.
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
|
|
||||||
.button-base {
|
.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 {
|
.button-blue {
|
||||||
|
|
|
@ -21,6 +21,7 @@ const FocusOnUpdate = {
|
||||||
|
|
||||||
this.el.focus();
|
this.el.focus();
|
||||||
this.el.selectionStart = this.el.selectionEnd = this.el.value.length;
|
this.el.selectionStart = this.el.selectionEnd = this.el.value.length;
|
||||||
|
this.el.scrollLeft = this.el.scrollWidth;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -308,11 +308,12 @@ function handleDocumentKeyDown(hook, event) {
|
||||||
|
|
||||||
const editor = event.target.closest(".monaco-editor.focused");
|
const editor = event.target.closest(".monaco-editor.focused");
|
||||||
|
|
||||||
const completionBoxOpen = !!editor.querySelector(
|
const completionBoxOpen = !!(
|
||||||
".editor-widget.parameter-hints-widget.visible"
|
editor &&
|
||||||
|
editor.querySelector(".editor-widget.parameter-hints-widget.visible")
|
||||||
);
|
);
|
||||||
const signatureDetailsOpen = !!editor.querySelector(
|
const signatureDetailsOpen = !!(
|
||||||
".editor-widget.suggest-widget.visible"
|
editor && editor.querySelector(".editor-widget.suggest-widget.visible")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ignore Escape if it's supposed to close some Monaco input
|
// Ignore Escape if it's supposed to close some Monaco input
|
||||||
|
|
|
@ -60,6 +60,15 @@ defmodule Livebook do
|
||||||
configured_file_systems = Livebook.Config.file_systems!("LIVEBOOK_FILE_SYSTEM_")
|
configured_file_systems = Livebook.Config.file_systems!("LIVEBOOK_FILE_SYSTEM_")
|
||||||
|
|
||||||
config :livebook, :file_systems, [local_file_system | configured_file_systems]
|
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
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
@ -71,6 +71,14 @@ defmodule Livebook.Config do
|
||||||
FileSystem.File.new(file_system)
|
FileSystem.File.new(file_system)
|
||||||
end
|
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
|
## Parsing
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
@ -78,7 +86,7 @@ defmodule Livebook.Config do
|
||||||
"""
|
"""
|
||||||
def root_path!(env) do
|
def root_path!(env) do
|
||||||
if root_path = System.get_env(env) do
|
if root_path = System.get_env(env) do
|
||||||
root_path!("LIVEBOOK_ROOT_PATH", root_path)
|
root_path!(env, root_path)
|
||||||
else
|
else
|
||||||
File.cwd!()
|
File.cwd!()
|
||||||
end
|
end
|
||||||
|
@ -89,13 +97,62 @@ defmodule Livebook.Config do
|
||||||
"""
|
"""
|
||||||
def root_path!(context, root_path) do
|
def root_path!(context, root_path) do
|
||||||
if File.dir?(root_path) do
|
if File.dir?(root_path) do
|
||||||
root_path
|
Path.expand(root_path)
|
||||||
else
|
else
|
||||||
IO.warn("ignoring #{context} because it doesn't point to a directory: #{root_path}")
|
IO.warn("ignoring #{context} because it doesn't point to a directory: #{root_path}")
|
||||||
File.cwd!()
|
File.cwd!()
|
||||||
end
|
end
|
||||||
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 """
|
@doc """
|
||||||
Parses and validates the secret from env.
|
Parses and validates the secret from env.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -973,9 +973,10 @@ defmodule Livebook.Session do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_save_notebook_async(state) do
|
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()
|
pid = self()
|
||||||
file = state.data.file
|
|
||||||
content = LiveMarkdown.Export.notebook_to_markdown(state.data.notebook)
|
content = LiveMarkdown.Export.notebook_to_markdown(state.data.notebook)
|
||||||
|
|
||||||
{:ok, pid} =
|
{:ok, pid} =
|
||||||
|
@ -991,9 +992,11 @@ defmodule Livebook.Session do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_save_notebook_sync(state) do
|
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)
|
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)
|
handle_save_finished(state, result)
|
||||||
else
|
else
|
||||||
state
|
state
|
||||||
|
@ -1001,7 +1004,34 @@ defmodule Livebook.Session do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp should_save_notebook?(state) do
|
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
|
end
|
||||||
|
|
||||||
defp handle_save_finished(state, result) do
|
defp handle_save_finished(state, result) do
|
||||||
|
|
|
@ -19,6 +19,9 @@ defmodule LivebookCLI.Server do
|
||||||
|
|
||||||
Available options:
|
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
|
--cookie Sets a cookie for the app distributed node
|
||||||
--default-runtime Sets the runtime type that is used by default when none is started
|
--default-runtime Sets the runtime type that is used by default when none is started
|
||||||
explicitly for the given notebook, defaults to standalone
|
explicitly for the given notebook, defaults to standalone
|
||||||
|
@ -126,6 +129,7 @@ defmodule LivebookCLI.Server do
|
||||||
end
|
end
|
||||||
|
|
||||||
@switches [
|
@switches [
|
||||||
|
autosave_path: :string,
|
||||||
cookie: :string,
|
cookie: :string,
|
||||||
default_runtime: :string,
|
default_runtime: :string,
|
||||||
ip: :string,
|
ip: :string,
|
||||||
|
@ -202,6 +206,11 @@ defmodule LivebookCLI.Server do
|
||||||
opts_to_config(opts, [{:livebook, :default_runtime, default_runtime} | config])
|
opts_to_config(opts, [{:livebook, :default_runtime, default_runtime} | config])
|
||||||
end
|
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 opts_to_config([_opt | opts], config), do: opts_to_config(opts, config)
|
||||||
|
|
||||||
defp browser_open(url) do
|
defp browser_open(url) do
|
||||||
|
|
|
@ -273,7 +273,7 @@ defmodule LivebookWeb.FileSelectComponent do
|
||||||
</span>
|
</span>
|
||||||
<span class={"flex font-medium overflow-hidden whitespace-nowrap #{if(@file_info.is_running, do: "text-green-300", else: "text-gray-500")}"}>
|
<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 %>
|
<%= 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 %>
|
<%= @file_info.highlighted %>
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -242,17 +242,19 @@ defmodule LivebookWeb.HomeLive do
|
||||||
)}
|
)}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_info({:set_file, file, info}, socket) do
|
def handle_info({:set_file, file, info}, socket) do
|
||||||
file_info = %{
|
file_info = %{exists: info.exists, access: file_access(file)}
|
||||||
exists: info.exists,
|
|
||||||
access:
|
|
||||||
case FileSystem.File.access(file) do
|
|
||||||
{:ok, access} -> access
|
|
||||||
{:error, _} -> :none
|
|
||||||
end
|
|
||||||
}
|
|
||||||
|
|
||||||
{:noreply, assign(socket, file: file, file_info: file_info)}
|
{:noreply, assign(socket, file: file, file_info: file_info)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -336,4 +338,11 @@ defmodule LivebookWeb.HomeLive do
|
||||||
session_opts = Keyword.merge(session_opts, notebook: notebook)
|
session_opts = Keyword.merge(session_opts, notebook: notebook)
|
||||||
create_session(socket, session_opts)
|
create_session(socket, session_opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp file_access(file) do
|
||||||
|
case FileSystem.File.access(file) do
|
||||||
|
{:ok, access} -> access
|
||||||
|
{:error, _} -> :none
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -12,10 +12,16 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
|
||||||
|
|
||||||
sessions = sort_sessions(sessions, socket.assigns.order_by)
|
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 =
|
||||||
socket
|
socket
|
||||||
|> assign(assigns)
|
|> assign(assigns)
|
||||||
|> assign(:sessions, sessions)
|
|> assign(sessions: sessions, show_autosave_note?: show_autosave_note?)
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
@ -47,21 +53,29 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
|
||||||
</:content>
|
</:content>
|
||||||
</.menu>
|
</.menu>
|
||||||
</div>
|
</div>
|
||||||
<.session_list sessions={@sessions} socket={@socket} />
|
<.session_list sessions={@sessions} socket={@socket} show_autosave_note?={@show_autosave_note?} />
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp session_list(%{sessions: []} = assigns) do
|
defp session_list(%{sessions: []} = assigns) do
|
||||||
~H"""
|
~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>
|
<div>
|
||||||
<.remix_icon icon="windy-line" class="text-gray-400 text-xl" />
|
<.remix_icon icon="windy-line" class="text-gray-400 text-xl" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600">
|
<div class="flex-grow flex items-center justify-between">
|
||||||
You do not have any running sessions.
|
<div class="text-gray-600">
|
||||||
<br>
|
You do not have any running sessions.
|
||||||
Please create a new one by clicking <span class="font-semibold">“New notebook”</span>
|
<%= 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>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -48,7 +48,7 @@ defmodule LivebookWeb.SessionLive.PersistenceLive do
|
||||||
</h3>
|
</h3>
|
||||||
<div class="w-full flex-col space-y-8">
|
<div class="w-full flex-col space-y-8">
|
||||||
<div class="flex">
|
<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">
|
<div class="flex flex-col space-y-4">
|
||||||
<.switch_checkbox
|
<.switch_checkbox
|
||||||
name="persist_outputs"
|
name="persist_outputs"
|
||||||
|
@ -68,7 +68,7 @@ defmodule LivebookWeb.SessionLive.PersistenceLive do
|
||||||
]} />
|
]} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<span class="text-gray-700 whitespace-nowrap">File:</span>
|
||||||
<%= if @new_attrs.file do %>
|
<%= if @new_attrs.file do %>
|
||||||
<span class="tooltip right" data-tooltip={file_system_label(@new_attrs.file.file_system)}>
|
<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} />]
|
[<.file_system_icon file_system={@new_attrs.file.file_system} />]
|
||||||
</span>
|
</span>
|
||||||
</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 %>
|
<%= @new_attrs.file.path %>
|
||||||
</span>
|
</span>
|
||||||
<button class="button-base button-gray button-small"
|
<button class="button-base button-gray button-small"
|
||||||
|
|
Loading…
Add table
Reference in a new issue