diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 613fa8c..a4b56f9 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -13,7 +13,7 @@ defmodule BDS.Desktop.ShellLive do alias BDS.Posts.Post alias BDS.Projects alias BDS.Repo - alias BDS.UI.{Commands, MenuBar, Registry, Workbench} + alias BDS.UI.{Commands, MenuBar, Registry, Session, Workbench} @refresh_interval 1_500 @output_entry_limit 20 @@ -359,6 +359,10 @@ defmodule BDS.Desktop.ShellLive do {:noreply, set_page_language(socket, language)} 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 {:noreply, handle_native_menu_action(socket, action)} end @@ -1006,6 +1010,8 @@ defmodule BDS.Desktop.ShellLive do 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(:output), do: translated("Output") 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(:sidebar_filters_by_view, %{}) |> append_output_entry(title, message_fun.(project)) - |> reload_shell(Workbench.clear_tabs(socket.assigns.workbench)) + |> reload_shell(Workbench.new()) {:error, reason} -> socket @@ -1357,6 +1363,12 @@ defmodule BDS.Desktop.ShellLive do 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 String.to_existing_atom(action) rescue diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex index 968e4f0..c958450 100644 --- a/lib/bds/desktop/shell_live/index.html.heex +++ b/lib/bds/desktop/shell_live/index.html.heex @@ -1,4 +1,11 @@ -
+
<%= if @is_mac_ui do %> <% end %> diff --git a/lib/bds/ui/session.ex b/lib/bds/ui/session.ex index a6f2394..3a1a034 100644 --- a/lib/bds/ui/session.ex +++ b/lib/bds/ui/session.ex @@ -32,18 +32,18 @@ defmodule BDS.UI.Session do Workbench.new( sidebar_visible: Map.get(payload, "sidebar_visible", true), 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_width: Map.get(payload, "assistant_sidebar_width", 360), 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) ) tabs = 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"), 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 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) when is_binary(value), do: String.to_atom(value) + defp atomize(value, _fallback) when is_atom(value), do: 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({type, _id}), do: type diff --git a/priv/ui/live.js b/priv/ui/live.js index 43c90f3..414ea4b 100644 --- a/priv/ui/live.js +++ b/priv/ui/live.js @@ -6,6 +6,7 @@ document.addEventListener("DOMContentLoaded", () => { const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar"; const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar"; const UI_LANGUAGE_STORAGE_KEY = "bds-ui-language"; + const WORKBENCH_SESSION_STORAGE_KEY_PREFIX = "bds-workbench-"; const parseShortcutConfig = (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 shortcutTargetIsEditable = (event) => { @@ -125,10 +139,44 @@ document.addEventListener("DOMContentLoaded", () => { AppShell: { mounted() { this.shortcuts = parseShortcutConfig(this.el.dataset.shortcuts); + this.currentProjectId = this.el.dataset.projectId || ""; this.syncStoredLayout(); this.syncStoredUiLanguage(); 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) => { 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("keydown", this.handleShortcutKeyDown, true); 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() { diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 11d1e62..3120352 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -10,6 +10,7 @@ defmodule BDS.Desktop.ShellLiveTest do alias BDS.Projects alias BDS.Repo alias BDS.Tags + alias BDS.UI.{Session, Workbench} @endpoint BDS.Desktop.Endpoint @@ -205,6 +206,27 @@ defmodule BDS.Desktop.ShellLiveTest do refute html =~ ~s(data-testid="window-titlebar-menu-dropdown") 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 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}} diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index e208495..b7bcdf4 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -103,6 +103,7 @@ defmodule BDS.UI.ShellTest 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") 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/live.js") @@ -113,6 +114,20 @@ defmodule BDS.UI.ShellTest do assert css =~ "height: 22px" assert live_js =~ "LiveView.LiveSocket" 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 test "desktop shell css keeps the status bar and hidden menu alignment rules" do