From 89859684eb584d67cdd3e1734d87775170cdb4f4 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 8 Jul 2026 18:58:06 +0200 Subject: [PATCH] fix: issue #19 opening post editor on blogmark --- assets/js/hooks/app_shell.js | 3 ++ lib/bds/desktop/shell_live.ex | 31 ++++++++++++- specs/script.allium | 10 +++-- test/bds/desktop/shell_live_test.exs | 67 ++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 5 deletions(-) diff --git a/assets/js/hooks/app_shell.js b/assets/js/hooks/app_shell.js index 1b4f543..fbc01e2 100644 --- a/assets/js/hooks/app_shell.js +++ b/assets/js/hooks/app_shell.js @@ -186,6 +186,9 @@ export const AppShell = { this.el.addEventListener("change", this.handleChange); syncMediaThumbnailState(this.el); this.restoreStoredWorkbenchSession(); + // Rehydration is done (any stored-session push is ordered before this): + // tell the server it may now deliver queued deep links. + this.pushEvent("shell_ready", {}); }, updated() { diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index f5ba436..265dec4 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -152,7 +152,6 @@ defmodule BDS.Desktop.ShellLive do if connected do Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic()) - attach_deep_link() Process.send_after(self(), :refresh_task_status, @refresh_interval) end @@ -546,7 +545,20 @@ defmodule BDS.Desktop.ShellLive do def handle_event("restore_workbench_session", %{"session" => session_payload}, socket) when is_map(session_payload) do - {:noreply, reload_shell(socket, SessionUtil.restore_workbench_session(session_payload))} + restored = + session_payload + |> SessionUtil.restore_workbench_session() + |> preserve_active_tab(socket.assigns.workbench) + + {:noreply, reload_shell(socket, restored)} + end + + # The client signals readiness after mounting its hooks and pushing any + # stored workbench session. Deep links attach only now, so cold-start + # blogmark links run after rehydration and the editor they open survives. + def handle_event("shell_ready", _params, socket) do + attach_deep_link() + {:noreply, socket} end def handle_event(event, params, socket) when event in @native_menu_events do @@ -923,6 +935,21 @@ defmodule BDS.Desktop.ShellLive do |> UrlState.push() end + # A tab opened before the client's stored session arrives (e.g. a blogmark + # deep link that switched projects) must survive the restore, so re-open it + # on the restored workbench with its original transient/pinned intent. + defp preserve_active_tab(restored, %{active_tab: nil}), do: restored + + defp preserve_active_tab(restored, %{active_tab: {type, id}} = previous) do + intent = + case Enum.find(previous.tabs, &(&1.type == type and &1.id == id)) do + %{is_transient: true} -> :preview + _ -> :pin + end + + Workbench.open_tab(restored, type, id, intent) + end + defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id] # Register this shell with the deep-link router and drain any links queued diff --git a/specs/script.allium b/specs/script.allium index 33ea035..91e6660 100644 --- a/specs/script.allium +++ b/specs/script.allium @@ -271,9 +271,13 @@ rule ExecuteTransform { -- project_id and no active project the import is refused with a warning. @guarantee BlogmarkColdStartDelivery - -- A deep link that arrives before the shell is connected (e.g. the - -- bookmarklet launching the app) is queued and replayed once a shell - -- attaches, so launching via a bookmarklet still creates the post. + -- A deep link that arrives before the shell is ready (e.g. the + -- bookmarklet launching the app) is queued and replayed only after the + -- client has finished initialising and rehydrating its stored + -- workbench session, so the post is created and the editor it opens is + -- not overwritten by the session restore. A session restore arriving + -- after a deep link (e.g. when the link switched projects) preserves + -- the newly opened editor tab as the active tab. @guarantee TransformTrigger -- Transform scripts are triggered automatically by blogmark import. diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 1faea9e..3ea850e 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -960,6 +960,73 @@ defmodule BDS.Desktop.ShellLiveTest do end) end + test "cold-start deep links replay only after the client signals shell_ready", %{ + project: project + } do + start_supervised!({BDS.Desktop.DeepLink, []}) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + url = + "bds2://new-post?title=" <> + URI.encode_www_form("Cold Start Post") <> + "&url=" <> URI.encode_www_form("https://example.com/cold") + + send(BDS.Desktop.DeepLink, {:open_url, String.to_charlist(url)}) + + # The link stays queued while the client is still rehydrating. + _html = render(view) + refute Repo.get_by(Post, title: "Cold Start Post") + + # The client restores its stored session, then signals readiness. + session_payload = + Workbench.new() + |> Workbench.open_tab(:post, "stored-post", :pin) + |> Session.serialize() + + _html = render_hook(view, "restore_workbench_session", %{"session" => session_payload}) + _html = render_hook(view, "shell_ready", %{}) + html = render(view) + + created_post = Repo.get_by!(Post, title: "Cold Start Post") + assert created_post.project_id == project.id + + # The blogmark post is open and active; the restored tab survived. + assert html =~ ~s(data-tab-id="#{created_post.id}") + assert html =~ ~s(data-tab-id="stored-post") + + assert :sys.get_state(view.pid).socket.assigns.workbench.active_tab == + {:post, created_post.id} + end + + test "workbench session restore preserves a blogmark tab opened before the restore arrives" do + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + url = + "bds2://new-post?title=" <> + URI.encode_www_form("Survives Restore") <> + "&url=" <> URI.encode_www_form("https://example.com/survives") + + send(view.pid, {:blogmark_deep_link, url}) + _html = render(view) + created_post = Repo.get_by!(Post, title: "Survives Restore") + + # A late session restore (e.g. after the blogmark switched projects) must + # not discard the editor tab the blogmark just opened. + session_payload = + Workbench.new() + |> Workbench.open_tab(:post, "stored-post", :pin) + |> Session.serialize() + + html = render_hook(view, "restore_workbench_session", %{"session" => session_payload}) + + assert html =~ ~s(data-tab-id="#{created_post.id}") + assert html =~ ~s(data-tab-id="stored-post") + + assert :sys.get_state(view.pid).socket.assigns.workbench.active_tab == + {:post, created_post.id} + end + test "settings sidebar selections expose a scroll target for the preferences editor" do {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)