feat: persisted tabs
This commit is contained in:
@@ -13,7 +13,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.UI.{Commands, MenuBar, Registry, Workbench}
|
alias BDS.UI.{Commands, MenuBar, Registry, Session, Workbench}
|
||||||
|
|
||||||
@refresh_interval 1_500
|
@refresh_interval 1_500
|
||||||
@output_entry_limit 20
|
@output_entry_limit 20
|
||||||
@@ -359,6 +359,10 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:noreply, set_page_language(socket, language)}
|
{:noreply, set_page_language(socket, language)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("restore_workbench_session", %{"session" => session_payload}, socket) when is_map(session_payload) do
|
||||||
|
{:noreply, reload_shell(socket, restore_workbench_session(session_payload))}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("native_menu_action", %{"action" => action}, socket) do
|
def handle_event("native_menu_action", %{"action" => action}, socket) do
|
||||||
{:noreply, handle_native_menu_action(socket, action)}
|
{:noreply, handle_native_menu_action(socket, action)}
|
||||||
end
|
end
|
||||||
@@ -1006,6 +1010,8 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)
|
defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)
|
||||||
|
|
||||||
|
defp encoded_workbench_session(workbench), do: Jason.encode!(Session.serialize(workbench))
|
||||||
|
|
||||||
defp panel_tab_label(:tasks), do: translated("Tasks")
|
defp panel_tab_label(:tasks), do: translated("Tasks")
|
||||||
defp panel_tab_label(:output), do: translated("Output")
|
defp panel_tab_label(:output), do: translated("Output")
|
||||||
defp panel_tab_label(:git_log), do: translated("Git Log")
|
defp panel_tab_label(:git_log), do: translated("Git Log")
|
||||||
@@ -1319,7 +1325,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> assign(:project_menu_open, false)
|
|> assign(:project_menu_open, false)
|
||||||
|> assign(:sidebar_filters_by_view, %{})
|
|> assign(:sidebar_filters_by_view, %{})
|
||||||
|> append_output_entry(title, message_fun.(project))
|
|> append_output_entry(title, message_fun.(project))
|
||||||
|> reload_shell(Workbench.clear_tabs(socket.assigns.workbench))
|
|> reload_shell(Workbench.new())
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
socket
|
socket
|
||||||
@@ -1357,6 +1363,12 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp restore_workbench_session(session_payload) do
|
||||||
|
Session.restore(session_payload)
|
||||||
|
rescue
|
||||||
|
_error -> Workbench.new()
|
||||||
|
end
|
||||||
|
|
||||||
defp safe_existing_atom(action) when is_binary(action) do
|
defp safe_existing_atom(action) when is_binary(action) do
|
||||||
String.to_existing_atom(action)
|
String.to_existing_atom(action)
|
||||||
rescue
|
rescue
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
<div class="app" id="bds-shell-app" phx-hook="AppShell" data-shortcuts={encoded_shortcuts(@client_shortcuts)}>
|
<div
|
||||||
|
class="app"
|
||||||
|
id="bds-shell-app"
|
||||||
|
phx-hook="AppShell"
|
||||||
|
data-shortcuts={encoded_shortcuts(@client_shortcuts)}
|
||||||
|
data-project-id={@projects.active_project_id || ""}
|
||||||
|
data-workbench-session={encoded_workbench_session(@workbench)}
|
||||||
|
>
|
||||||
<%= if @is_mac_ui do %>
|
<%= if @is_mac_ui do %>
|
||||||
<span data-testid="window-title" hidden><%= @page_title %></span>
|
<span data-testid="window-title" hidden><%= @page_title %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -32,18 +32,18 @@ defmodule BDS.UI.Session do
|
|||||||
Workbench.new(
|
Workbench.new(
|
||||||
sidebar_visible: Map.get(payload, "sidebar_visible", true),
|
sidebar_visible: Map.get(payload, "sidebar_visible", true),
|
||||||
sidebar_width: Map.get(payload, "sidebar_width", 280),
|
sidebar_width: Map.get(payload, "sidebar_width", 280),
|
||||||
active_view: Map.get(payload, "active_view", "posts"),
|
active_view: atomize(Map.get(payload, "active_view"), :posts),
|
||||||
assistant_sidebar_visible: Map.get(payload, "assistant_sidebar_visible", false),
|
assistant_sidebar_visible: Map.get(payload, "assistant_sidebar_visible", false),
|
||||||
assistant_sidebar_width: Map.get(payload, "assistant_sidebar_width", 360),
|
assistant_sidebar_width: Map.get(payload, "assistant_sidebar_width", 360),
|
||||||
panel_visible: get_in(payload, ["panel", "visible"]) || false,
|
panel_visible: get_in(payload, ["panel", "visible"]) || false,
|
||||||
panel_tab: atomize(get_in(payload, ["panel", "active_tab"]) || "tasks"),
|
panel_tab: atomize(get_in(payload, ["panel", "active_tab"]) || "tasks", :tasks),
|
||||||
dirty_tabs: Enum.map(Map.get(payload, "dirty_tabs", []), &decode_tab_ref/1)
|
dirty_tabs: Enum.map(Map.get(payload, "dirty_tabs", []), &decode_tab_ref/1)
|
||||||
)
|
)
|
||||||
|
|
||||||
tabs =
|
tabs =
|
||||||
Enum.map(Map.get(payload, "tabs", []), fn tab ->
|
Enum.map(Map.get(payload, "tabs", []), fn tab ->
|
||||||
%{
|
%{
|
||||||
type: atomize(Map.get(tab, "type", "post")),
|
type: atomize(Map.get(tab, "type", "post"), :post),
|
||||||
id: Map.get(tab, "id"),
|
id: Map.get(tab, "id"),
|
||||||
is_transient: Map.get(tab, "is_transient", false)
|
is_transient: Map.get(tab, "is_transient", false)
|
||||||
}
|
}
|
||||||
@@ -58,10 +58,15 @@ defmodule BDS.UI.Session do
|
|||||||
defp encode_tab_ref({type, id}), do: %{"type" => Atom.to_string(type), "id" => id}
|
defp encode_tab_ref({type, id}), do: %{"type" => Atom.to_string(type), "id" => id}
|
||||||
|
|
||||||
defp decode_tab_ref(nil), do: nil
|
defp decode_tab_ref(nil), do: nil
|
||||||
defp decode_tab_ref(%{"type" => type, "id" => id}), do: {atomize(type), id}
|
defp decode_tab_ref(%{"type" => type, "id" => id}), do: {atomize(type, :post), id}
|
||||||
|
|
||||||
defp atomize(value) when is_atom(value), do: value
|
defp atomize(value, _fallback) when is_atom(value), do: value
|
||||||
defp atomize(value) when is_binary(value), do: String.to_atom(value)
|
|
||||||
|
defp atomize(value, fallback) when is_binary(value) do
|
||||||
|
String.to_existing_atom(value)
|
||||||
|
rescue
|
||||||
|
ArgumentError -> fallback
|
||||||
|
end
|
||||||
|
|
||||||
defp active_route(nil), do: :dashboard
|
defp active_route(nil), do: :dashboard
|
||||||
defp active_route({type, _id}), do: type
|
defp active_route({type, _id}), do: type
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar";
|
const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar";
|
||||||
const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar";
|
const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar";
|
||||||
const UI_LANGUAGE_STORAGE_KEY = "bds-ui-language";
|
const UI_LANGUAGE_STORAGE_KEY = "bds-ui-language";
|
||||||
|
const WORKBENCH_SESSION_STORAGE_KEY_PREFIX = "bds-workbench-";
|
||||||
|
|
||||||
const parseShortcutConfig = (value) => {
|
const parseShortcutConfig = (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -20,6 +21,19 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseJsonObject = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeShortcutKey = (key) => String(key || "").toLowerCase();
|
const normalizeShortcutKey = (key) => String(key || "").toLowerCase();
|
||||||
|
|
||||||
const shortcutTargetIsEditable = (event) => {
|
const shortcutTargetIsEditable = (event) => {
|
||||||
@@ -125,10 +139,44 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
AppShell: {
|
AppShell: {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.shortcuts = parseShortcutConfig(this.el.dataset.shortcuts);
|
this.shortcuts = parseShortcutConfig(this.el.dataset.shortcuts);
|
||||||
|
this.currentProjectId = this.el.dataset.projectId || "";
|
||||||
this.syncStoredLayout();
|
this.syncStoredLayout();
|
||||||
this.syncStoredUiLanguage();
|
this.syncStoredUiLanguage();
|
||||||
this.destroyOverlaySync = syncTitlebarOverlayInsets();
|
this.destroyOverlaySync = syncTitlebarOverlayInsets();
|
||||||
|
|
||||||
|
this.workbenchStorageKey = (projectId) =>
|
||||||
|
projectId ? `${WORKBENCH_SESSION_STORAGE_KEY_PREFIX}${projectId}` : null;
|
||||||
|
|
||||||
|
this.restoreStoredWorkbenchSession = () => {
|
||||||
|
const projectId = this.el.dataset.projectId || "";
|
||||||
|
const storageKey = this.workbenchStorageKey(projectId);
|
||||||
|
|
||||||
|
if (!storageKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = parseJsonObject(window.localStorage.getItem(storageKey));
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pushEvent("restore_workbench_session", { session });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.persistWorkbenchSession = () => {
|
||||||
|
const projectId = this.el.dataset.projectId || "";
|
||||||
|
const storageKey = this.workbenchStorageKey(projectId);
|
||||||
|
const session = this.el.dataset.workbenchSession;
|
||||||
|
|
||||||
|
if (!storageKey || !session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(storageKey, session);
|
||||||
|
};
|
||||||
|
|
||||||
this.handleMouseDown = (event) => {
|
this.handleMouseDown = (event) => {
|
||||||
const handle = event.target.closest("[data-role='resize-handle']");
|
const handle = event.target.closest("[data-role='resize-handle']");
|
||||||
|
|
||||||
@@ -219,6 +267,21 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
|
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
|
||||||
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
|
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
|
||||||
this.el.addEventListener("change", this.handleChange);
|
this.el.addEventListener("change", this.handleChange);
|
||||||
|
this.restoreStoredWorkbenchSession();
|
||||||
|
},
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
const nextProjectId = this.el.dataset.projectId || "";
|
||||||
|
|
||||||
|
if (nextProjectId !== this.currentProjectId) {
|
||||||
|
this.currentProjectId = nextProjectId;
|
||||||
|
|
||||||
|
if (this.restoreStoredWorkbenchSession()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persistWorkbenchSession();
|
||||||
},
|
},
|
||||||
|
|
||||||
destroyed() {
|
destroyed() {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.Tags
|
alias BDS.Tags
|
||||||
|
alias BDS.UI.{Session, Workbench}
|
||||||
|
|
||||||
@endpoint BDS.Desktop.Endpoint
|
@endpoint BDS.Desktop.Endpoint
|
||||||
|
|
||||||
@@ -205,6 +206,27 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
|
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "workbench session restore reopens permanent and transient tabs and selected activity" do
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
session_payload =
|
||||||
|
Workbench.new()
|
||||||
|
|> Workbench.click_activity(:media)
|
||||||
|
|> Workbench.open_tab(:post, "post-1", :pin)
|
||||||
|
|> Workbench.open_tab(:media, "media-1", :preview)
|
||||||
|
|> Session.serialize()
|
||||||
|
|
||||||
|
html = render_hook(view, "restore_workbench_session", %{"session" => session_payload})
|
||||||
|
|
||||||
|
assert html =~ ~s(data-view="media")
|
||||||
|
assert html =~ ~s(data-active="true")
|
||||||
|
assert html =~ ~s(data-tab-type="post")
|
||||||
|
assert html =~ ~s(data-tab-id="post-1")
|
||||||
|
assert html =~ ~s(data-tab-type="media")
|
||||||
|
assert html =~ ~s(data-tab-id="media-1")
|
||||||
|
assert html =~ ~s(class="tab active transient")
|
||||||
|
end
|
||||||
|
|
||||||
test "shell live renders the legacy git activity badge from remote behind count" do
|
test "shell live renders the legacy git activity badge from remote behind count" do
|
||||||
Application.put_env(:bds, :git_remote_state_provider, fn _project_id, _opts ->
|
Application.put_env(:bds, :git_remote_state_provider, fn _project_id, _opts ->
|
||||||
{:ok, %{local_branch: "main", upstream_branch: "origin/main", has_upstream: true, ahead: 0, behind: 7}}
|
{:ok, %{local_branch: "main", upstream_branch: "origin/main", has_upstream: true, ahead: 0, behind: 7}}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ defmodule BDS.UI.ShellTest do
|
|||||||
test "desktop shell keeps the compact frame metrics and live bootstrap assets" do
|
test "desktop shell keeps the compact frame metrics and live bootstrap assets" do
|
||||||
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
||||||
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js")
|
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js")
|
||||||
|
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
|
||||||
|
|
||||||
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
||||||
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/live.js")
|
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/live.js")
|
||||||
@@ -113,6 +114,20 @@ defmodule BDS.UI.ShellTest do
|
|||||||
assert css =~ "height: 22px"
|
assert css =~ "height: 22px"
|
||||||
assert live_js =~ "LiveView.LiveSocket"
|
assert live_js =~ "LiveView.LiveSocket"
|
||||||
assert live_js =~ "Phoenix.Socket"
|
assert live_js =~ "Phoenix.Socket"
|
||||||
|
assert template =~ "data-project-id={@projects.active_project_id || \"\"}"
|
||||||
|
assert template =~ "data-workbench-session={encoded_workbench_session(@workbench)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "desktop shell assets persist workbench layout per project" do
|
||||||
|
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js")
|
||||||
|
live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex")
|
||||||
|
|
||||||
|
assert live_js =~ "bds-workbench-"
|
||||||
|
assert live_js =~ "restore_workbench_session"
|
||||||
|
assert live_js =~ "dataset.workbenchSession"
|
||||||
|
assert live_ex =~ ~s(def handle_event("restore_workbench_session")
|
||||||
|
assert live_ex =~ "Session.restore"
|
||||||
|
assert live_ex =~ "encoded_workbench_session"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "desktop shell css keeps the status bar and hidden menu alignment rules" do
|
test "desktop shell css keeps the status bar and hidden menu alignment rules" do
|
||||||
|
|||||||
Reference in New Issue
Block a user