mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-11 14:06:20 +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:
|
||||
|
||||
* 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.
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 """
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
"""
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue