Add autosave path configuration (#1019)

* Added autosave path configuration

* Update settings.ex

* Adjusted to review

* Apply suggestions from code review

Co-authored-by: José Valim <jose.valim@gmail.com>

* Adjusted to review

* Adjusted to review

Co-authored-by: José Valim <jose.valim@gmail.com>
This commit is contained in:
Jakub Perżyło 2022-03-13 16:22:52 +01:00 committed by GitHub
parent baccc964db
commit 4061aa150d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 179 additions and 13 deletions

View file

@ -11,18 +11,40 @@ defmodule Livebook.Settings do
@type file_system_id :: :local | String.t()
@doc """
Returns the autosave path.
TODO: Make this configurable in the UI.
Returns the current autosave path.
"""
@spec autosave_path() :: String.t() | nil
def autosave_path() do
case storage().fetch_key(:settings, "global", :autosave_path) do
{:ok, value} -> value
:error -> Path.join(Livebook.Config.data_path(), "autosaved")
:error -> default_autosave_path()
end
end
@doc """
Returns the default autosave path.
"""
@spec default_autosave_path() :: String.t()
def default_autosave_path() do
Path.join(Livebook.Config.data_path(), "autosaved")
end
@doc """
Sets the current autosave path.
"""
@spec set_autosave_path(String.t()) :: :ok
def set_autosave_path(autosave_path) do
storage().insert(:settings, "global", autosave_path: autosave_path)
end
@doc """
Restores the default autosave path.
"""
@spec reset_autosave_path() :: :ok
def reset_autosave_path() do
storage().delete_key(:settings, "global", :autosave_path)
end
@doc """
Returns all known filesystems with their associated ids.

View file

@ -49,6 +49,11 @@ defmodule Livebook.Storage do
"""
@callback delete(namespace(), entity_id()) :: :ok
@doc """
Deletes an attribute from given entity.
"""
@callback delete_key(namespace(), entity_id(), attribute()) :: :ok
@spec current() :: module()
def current(), do: Application.fetch_env!(:livebook, :storage)
end

View file

@ -75,6 +75,11 @@ defmodule Livebook.Storage.Ets do
GenServer.call(__MODULE__, {:delete, namespace, entity_id})
end
@impl Livebook.Storage
def delete_key(namespace, entity_id, key) do
GenServer.call(__MODULE__, {:delete_key, namespace, entity_id, key})
end
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@ -99,14 +104,9 @@ defmodule Livebook.Storage.Ets do
@impl GenServer
def handle_call({:insert, namespace, entity_id, attributes}, _from, %{table: table} = state) do
match_head = {{namespace, entity_id}, :"$1", :_, :_}
keys_to_delete = Enum.map(attributes, fn {key, _val} -> key end)
guards =
Enum.map(attributes, fn {key, _val} ->
{:==, :"$1", key}
end)
:ets.select_delete(table, [{match_head, guards, [true]}])
delete_keys(table, namespace, entity_id, keys_to_delete)
timestamp = System.os_time(:millisecond)
@ -125,6 +125,12 @@ defmodule Livebook.Storage.Ets do
{:reply, :ok, state, {:continue, :save_to_file}}
end
@impl GenServer
def handle_call({:delete_key, namespace, entity_id, key}, _from, %{table: table} = state) do
delete_keys(table, namespace, entity_id, [key])
{:reply, :ok, state, {:continue, :save_to_file}}
end
@impl GenServer
def handle_continue(:save_to_file, %{table: table} = state) do
file_path = String.to_charlist(config_file_path())
@ -151,4 +157,12 @@ defmodule Livebook.Storage.Ets do
:ets.new(__MODULE__, [:protected, :duplicate_bag])
end
end
defp delete_keys(table, namespace, entity_id, keys) do
match_head = {{namespace, entity_id}, :"$1", :_, :_}
guards = Enum.map(keys, &{:==, :"$1", &1})
:ets.select_delete(table, [{match_head, guards, [true]}])
end
end

View file

@ -14,6 +14,10 @@ defmodule LivebookWeb.SettingsLive do
|> SidebarHelpers.shared_home_handlers()
|> assign(
file_systems: file_systems,
autosave_path_state: %{
file: autosave_dir(),
dialog_opened?: false
},
page_title: "Livebook - Settings"
)}
end
@ -34,8 +38,8 @@ defmodule LivebookWeb.SettingsLive do
<PageHelpers.title text="System settings" socket={@socket} />
<p class="mt-4 text-gray-700">
Here you can change global Livebook configuration. Keep in mind
that this configuration is not persisted and gets discarded as
soon as you stop the application.
that this configuration gets persisted and will be restored on application
launch.
</p>
</div>
@ -57,6 +61,18 @@ defmodule LivebookWeb.SettingsLive do
<% end %>
</div>
</div>
<!-- Autosave path configuration -->
<div class="flex flex-col space-y-4">
<div>
<h2 class="text-xl text-gray-800 font-semibold">
Autosave location
</h2>
<p class="mt-4 text-gray-700">
A directory to temporarily keep notebooks until they are persisted.
</p>
</div>
<.autosave_path_select state={@autosave_path_state} />
</div>
<!-- File systems configuration -->
<div class="flex flex-col space-y-4">
<div class="flex justify-between items-center">
@ -124,6 +140,50 @@ defmodule LivebookWeb.SettingsLive do
"""
end
defp autosave_path_select(%{state: %{dialog_opened?: true}} = assigns) do
~H"""
<div class="w-full h-52">
<.live_component module={LivebookWeb.FileSelectComponent}
id="autosave-path-component"
file={@state.file}
extnames={[]}
running_files={[]}
submit_event={:set_autosave_path}
file_system_select_disabled={true}
>
<button class="button-base button-gray"
phx-click="cancel_autosave_path"
tabindex="-1">
Cancel
</button>
<button class="button-base button-gray"
phx-click="reset_autosave_path"
tabindex="-1">
Reset
</button>
<button class="button-base button-blue"
phx-click="set_autosave_path"
disabled={not Livebook.FileSystem.File.dir?(@state.file)}
tabindex="-1">
Save
</button>
</.live_component>
</div>
"""
end
defp autosave_path_select(assigns) do
~H"""
<div class="flex">
<input class="input mr-2" readonly value={@state.file.path}/>
<button class="button-base button-gray button-small"
phx-click="open_autosave_path_select">
Change
</button>
</div>
"""
end
@impl true
def handle_params(%{"file_system_id" => file_system_id}, _url, socket) do
{:noreply, assign(socket, file_system_id: file_system_id)}
@ -132,6 +192,42 @@ defmodule LivebookWeb.SettingsLive do
def handle_params(_params, _url, socket), do: {:noreply, socket}
@impl true
def handle_event("cancel_autosave_path", %{}, socket) do
{:noreply,
update(
socket,
:autosave_path_state,
&%{&1 | dialog_opened?: false, file: autosave_dir()}
)}
end
def handle_event("set_autosave_path", %{}, socket) do
path = socket.assigns.autosave_path_state.file.path
Livebook.Settings.set_autosave_path(path)
{:noreply,
update(
socket,
:autosave_path_state,
&%{&1 | dialog_opened?: false, file: autosave_dir()}
)}
end
@impl true
def handle_event("reset_autosave_path", %{}, socket) do
{:noreply,
update(
socket,
:autosave_path_state,
&%{&1 | file: default_autosave_dir()}
)}
end
def handle_event("open_autosave_path_select", %{}, socket) do
{:noreply, update(socket, :autosave_path_state, &%{&1 | dialog_opened?: true})}
end
def handle_event("detach_file_system", %{"id" => file_system_id}, socket) do
Livebook.Settings.remove_file_system(file_system_id)
file_systems = Livebook.Settings.file_systems()
@ -143,5 +239,25 @@ defmodule LivebookWeb.SettingsLive do
{:noreply, assign(socket, file_systems: file_systems)}
end
def handle_info({:set_file, file, _info}, socket) do
{:noreply, update(socket, :autosave_path_state, &%{&1 | file: file})}
end
def handle_info(:set_autosave_path, socket) do
handle_event("set_autosave_path", %{}, socket)
end
def handle_info(_message, socket), do: {:noreply, socket}
defp autosave_dir() do
Livebook.Settings.autosave_path()
|> Livebook.FileSystem.Utils.ensure_dir_path()
|> Livebook.FileSystem.File.local()
end
defp default_autosave_dir() do
Livebook.Settings.default_autosave_path()
|> Livebook.FileSystem.Utils.ensure_dir_path()
|> Livebook.FileSystem.File.local()
end
end

View file

@ -72,6 +72,15 @@ defmodule Livebook.Storage.EtsTest do
assert :error = Ets.fetch(:delete, "test")
end
test "delete_key/3" do
:ok = Ets.insert(:delete_key, "test", key1: "val1", key2: "val2")
assert :ok = Ets.delete_key(:delete_key, "test", :key2)
assert {:ok, "val1"} = Ets.fetch_key(:delete_key, "test", :key1)
assert :error = Ets.fetch_key(:delete_key, "test", :key2)
end
describe "all/1" do
test "returns all inserted entities for given namespace" do
:ok = Ets.insert(:all, "test1", key1: "val1")