From c118412f56ed39daf2f2e5c6e60d52922468e2b8 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 2 May 2026 09:03:03 +0200 Subject: [PATCH] fix: handling of tab titles on restore --- lib/bds/desktop/shell_live.ex | 2 + lib/bds/desktop/shell_live/tab_helpers.ex | 148 +++++++++++++++++++--- test/bds/desktop/shell_live_test.exs | 109 ++++++++++++++++ 3 files changed, 244 insertions(+), 15 deletions(-) diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 582e936..497be34 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -1652,6 +1652,7 @@ defmodule BDS.Desktop.ShellLive do dashboard = ShellData.dashboard(projects.active_project_id) git_badge_count = ShellData.git_badge_count(projects.active_project_id) active_view_id = Atom.to_string(workbench.active_view) + tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{}) sidebar_data = ShellData.sidebar_view( @@ -1675,6 +1676,7 @@ defmodule BDS.Desktop.ShellLive do task_status = localize_task_status(raw_task_status, page_language) socket + |> assign(:tab_meta, tab_meta) |> assign(:workbench, workbench) |> assign(:projects, projects) |> assign(:current_project, ShellData.current_project(projects)) diff --git a/lib/bds/desktop/shell_live/tab_helpers.ex b/lib/bds/desktop/shell_live/tab_helpers.ex index 5b36ce4..ea3496c 100644 --- a/lib/bds/desktop/shell_live/tab_helpers.ex +++ b/lib/bds/desktop/shell_live/tab_helpers.ex @@ -2,7 +2,7 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do @moduledoc false alias BDS.Desktop.ShellData - alias BDS.{AI, BoundedAtoms, Media, Posts} + alias BDS.{AI, BoundedAtoms, ImportDefinitions, Media, Posts, Scripts, Templates} alias BDS.Media.Media, as: MediaRecord alias BDS.Posts.Post alias BDS.UI.Registry @@ -25,7 +25,21 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do end end - def default_tab_title(%{type: :chat, id: conversation_id}), do: chat_title(conversation_id) + def sync_tab_meta(%{tabs: tabs}, tab_meta) when is_list(tabs) and is_map(tab_meta) do + Enum.reduce(tabs, %{}, fn tab, acc -> + key = {tab.type, tab.id} + existing_meta = Map.get(tab_meta, key, %{}) + synced_meta = merge_missing_meta(existing_meta, derived_tab_meta(tab)) + + if map_size(synced_meta) == 0 do + acc + else + Map.put(acc, key, synced_meta) + end + end) + end + + def sync_tab_meta(_workbench, tab_meta) when is_map(tab_meta), do: tab_meta def default_tab_title(%{type: type, id: id}) do case Registry.editor_route(type) do @@ -34,7 +48,6 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do end end - defp default_tab_subtitle(%{type: :chat}), do: translated("AI conversations") defp default_tab_subtitle(_tab), do: "Desktop workbench content routed through the Elixir shell." def tab_route_label(nil), do: translated("Dashboard") @@ -67,40 +80,32 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do def post_title(post_id) do case Posts.get_post(post_id) do - %Post{} = post -> post.title || post.slug || post.id + %Post{} = post -> post_record_title(post) _other -> "Post" end end def post_subtitle(post_id) do case Posts.get_post(post_id) do - %Post{} = post -> post.slug || "draft" + %Post{} = post -> post_record_subtitle(post) _other -> "draft" end end def media_title(media_id) do case Media.get_media(media_id) do - %MediaRecord{} = media -> media.title || media.filename || media.id + %MediaRecord{} = media -> media_record_title(media) _other -> "Media" end end def media_subtitle(media_id) do case Media.get_media(media_id) do - %MediaRecord{} = media -> media.filename || media.mime_type || "media" + %MediaRecord{} = media -> media_record_subtitle(media) _other -> "media" end end - def chat_title(conversation_id) do - case AI.get_chat_conversation(conversation_id) do - %{title: title} when is_binary(title) and title != "" -> title - %{id: id} when is_binary(id) and id != "" -> id - _other -> "Chat" - end - end - def parse_integer(value) when is_integer(value), do: value def parse_integer(value) do @@ -110,5 +115,118 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do end end + defp derived_tab_meta(%{type: :post, id: post_id}) do + case Posts.get_post(post_id) do + %Post{} = post -> %{title: post_record_title(post), subtitle: post_record_subtitle(post)} + _other -> %{} + end + end + + defp derived_tab_meta(%{type: :media, id: media_id}) do + case Media.get_media(media_id) do + %MediaRecord{} = media -> + %{title: media_record_title(media), subtitle: media_record_subtitle(media)} + + _other -> + %{} + end + end + + defp derived_tab_meta(%{type: :scripts, id: script_id}) do + case Scripts.get_script(script_id) do + %{title: title, id: id} -> + %{title: blank_to_nil(title) || id, subtitle: translated("Automation helpers")} + + _other -> + %{} + end + end + + defp derived_tab_meta(%{type: :templates, id: template_id}) do + case Templates.get_template(template_id) do + %{title: title, id: id} -> + %{title: blank_to_nil(title) || id, subtitle: translated("Site rendering")} + + _other -> + %{} + end + end + + defp derived_tab_meta(%{type: :chat, id: conversation_id}) do + case AI.get_chat_conversation(conversation_id) do + conversation when is_map(conversation) -> + %{title: chat_record_title(conversation), subtitle: translated("AI conversations")} + + _other -> + %{} + end + end + + defp derived_tab_meta(%{type: :import, id: definition_id}) do + case ImportDefinitions.get_definition(definition_id) do + %{name: name} -> + %{ + title: blank_to_nil(name) || translated("importAnalysis.untitledImport"), + subtitle: translated("importAnalysis.headerDescription") + } + + _other -> + %{} + end + end + + defp derived_tab_meta(%{type: :git_diff, id: "git-working-tree"}) do + %{title: translated("Working tree"), subtitle: translated("Working tree and history")} + end + + defp derived_tab_meta(_tab), do: %{} + + defp merge_missing_meta(existing_meta, fresh_meta) do + existing_meta + |> maybe_put_missing(:title, Map.get(fresh_meta, :title)) + |> maybe_put_missing(:subtitle, Map.get(fresh_meta, :subtitle)) + end + + defp maybe_put_missing(meta, key, value) do + cond do + blank_to_nil(value) == nil -> + meta + + existing_value_present?(Map.get(meta, key)) -> + meta + + true -> + Map.put(meta, key, value) + end + end + + defp existing_value_present?(value) do + if is_binary(value), do: String.trim(value) != "", else: false + end + + defp post_record_title(%Post{} = post), do: blank_to_nil(post.title) || blank_to_nil(post.slug) || post.id + + defp post_record_subtitle(%Post{} = post), do: Atom.to_string(post.status) + + defp media_record_title(%MediaRecord{} = media) do + blank_to_nil(media.title) || blank_to_nil(media.original_name) || media.id + end + + defp media_record_subtitle(%MediaRecord{} = media) do + blank_to_nil(media.original_name) || blank_to_nil(media.mime_type) || "media" + end + + defp chat_record_title(%{title: title, id: id}), do: blank_to_nil(title) || id + + defp blank_to_nil(value) do + value + |> to_string() + |> String.trim() + |> case do + "" -> nil + trimmed -> trimmed + end + end + defp translated(text), do: ShellData.translate(text, %{}, BDS.Desktop.UILocale.current()) end diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 4ca71c7..2e30e4e 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -728,6 +728,115 @@ defmodule BDS.Desktop.ShellLiveTest do assert has_element?(view, ".chat-panel-title-main", "Editorial Plan") end + test "workbench session restore rehydrates entity tab titles from backing records", %{ + project: project, + temp_dir: temp_dir + } do + assert {:ok, post} = Posts.create_post(%{project_id: project.id, title: "Restored Post"}) + + source_path = Path.join(temp_dir, "restored-media.txt") + File.write!(source_path, "media body") + + assert {:ok, media} = + Media.import_media(%{ + project_id: project.id, + source_path: source_path, + title: "Restored Media" + }) + + assert {:ok, script} = + Scripts.create_script(%{ + project_id: project.id, + title: "Restored Script", + kind: :utility, + content: "print(\"ok\")", + entrypoint: "main", + enabled: true + }) + + assert {:ok, template} = + Templates.create_template(%{ + project_id: project.id, + title: "Restored Template", + kind: :post, + content: "", + enabled: true + }) + + assert {:ok, definition} = + ImportDefinitions.create_definition(%{ + project_id: project.id, + name: "Restored Import" + }) + + assert {:ok, conversation} = AI.start_chat(%{title: "Restored Chat"}) + + posts_dir = Path.join(temp_dir, "posts") + File.mkdir_p!(posts_dir) + + git_file_path = Path.join(posts_dir, "restore.md") + File.write!(git_file_path, "Old content\n") + init_git_repo!(temp_dir, "initial") + File.write!(git_file_path, "New content\n") + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + session_payload = + Workbench.new() + |> Workbench.open_tab(:post, post.id, :pin) + |> Workbench.open_tab(:media, media.id, :pin) + |> Workbench.open_tab(:scripts, script.id, :pin) + |> Workbench.open_tab(:templates, template.id, :pin) + |> Workbench.open_tab(:import, definition.id, :pin) + |> Workbench.open_tab(:chat, conversation.id, :pin) + |> Workbench.open_tab(:git_diff, "git-working-tree", :pin) + |> Session.serialize() + + _html = render_hook(view, "restore_workbench_session", %{"session" => session_payload}) + + assert has_element?( + view, + ".tab[data-tab-type='post'][data-tab-id='#{post.id}'] .tab-title", + "Restored Post" + ) + + assert has_element?( + view, + ".tab[data-tab-type='media'][data-tab-id='#{media.id}'] .tab-title", + "Restored Media" + ) + + assert has_element?( + view, + ".tab[data-tab-type='scripts'][data-tab-id='#{script.id}'] .tab-title", + "Restored Script" + ) + + assert has_element?( + view, + ".tab[data-tab-type='templates'][data-tab-id='#{template.id}'] .tab-title", + "Restored Template" + ) + + assert has_element?( + view, + ".tab[data-tab-type='import'][data-tab-id='#{definition.id}'] .tab-title", + "Restored Import" + ) + + assert has_element?( + view, + ".tab[data-tab-type='chat'][data-tab-id='#{conversation.id}'] .tab-title", + "Restored Chat" + ) + + assert has_element?( + view, + ".tab[data-tab-type='git_diff'][data-tab-id='git-working-tree'] .tab-title", + "Working tree" + ) + end + test "metadata diff refresh reruns after workbench session restore", %{project: project} do :ok = BDS.Tasks.clear_finished()