diff --git a/CODESMELL.md b/CODESMELL.md index d297bc8..6e22d23 100644 --- a/CODESMELL.md +++ b/CODESMELL.md @@ -436,7 +436,22 @@ Total: 2245 lines now live in focused submodules; the remaining 647 in `BDS.Gene **Remaining work in this priority:** - ✅ `BDS.Generation` — done (76% reduction, 647 lines remaining is acceptable for a coordinator). -- ⏳ `BDS.Desktop.ShellLive` (2607) — next target. +- 🔄 `BDS.Desktop.ShellLive` (2607 → 1545, 41% reduction). Submodules extracted under `lib/bds/desktop/shell_live/`: + + | Module | Lines | Responsibility | + |---|---|---| + | `TitlebarMenu` | 181 | Menu group definition, dropdown items, open/close/hover/keydown | + | `CliSync` | 133 | CLI watcher entity-change application + tab refresh | + | `PanelRenderer` | 290 | Tasks/output/post-links/git-log panel rendering + editor toolbar | + | `TabHelpers` | 99 | Tab title/subtitle/icon, route atom mapping, post/media labels | + | `TaskLocalization` | 80 | Task status localization, editor-meta translation | + | `ChatSurface` | 233 | Chat-surface action dispatch, assistant message helpers | + | `SidebarCreate` | 131 | New post/media/script/template/import sidebar creation | + | `Layout` | 53 | Sync-layout, resize-panel, parse-width, ignore-shortcut | + | `ShellCommandRunner` | 95 | `apply_shell_command` + `apply_result` dispatch | + | `SessionUtil` | 49 | Workbench-session restore, project-name picker, task-result tracking | + + Coordinator (`shell_live.ex`) now 1545 lines containing only `mount/3`, `render/1`, `handle_event/3`, `handle_info/2` clauses, plus thin dispatchers and small editor-assign helpers. - ⏳ `BDS.Posts` (1781). - ⏳ `BDS.AI` (1711). - ⏳ `BDS.MCP` (677). diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 5e81dda..f30b542 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -7,27 +7,34 @@ defmodule BDS.Desktop.ShellLive do alias BDS.AI alias BDS.CliSync.Watcher - alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData} + alias BDS.Desktop.{FolderPicker, Overlay, ShellData} alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, ImportEditor, MediaEditor, MenuEditor, MiscEditor, SettingsEditor, TagsEditor} alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents alias BDS.Desktop.ShellLive.PostEditor alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents alias BDS.Desktop.ShellLive.SidebarState, as: ShellSidebarState - alias BDS.Desktop.MenuBar, as: DesktopMenuBar - alias BDS.Git - alias BDS.ImportDefinitions - alias BDS.Media.Media - alias BDS.PostLinks - alias BDS.Posts.Post + alias BDS.Desktop.ShellLive.{ChatSurface, CliSync, Layout, SessionUtil, ShellCommandRunner, SidebarCreate, TabHelpers, TaskLocalization, TitlebarMenu} + import TaskLocalization, + only: [ + localize_task_status: 2, + translate_for_socket: 2 + ] + import TabHelpers, + only: [ + tab_title: 2, + tab_subtitle: 2, + tab_id_for_route: 2, + tab_intent: 2, + sidebar_route_atom: 1, + parse_integer: 1 + ] alias BDS.Projects alias BDS.Repo - alias BDS.Scripts alias BDS.Templates - alias BDS.UI.{Commands, MenuBar, Registry, Session, Workbench} + alias BDS.UI.{Commands, MenuBar, Session, Workbench} @refresh_interval 1_500 @output_entry_limit 20 - @default_new_project_name "New Blog" @local_menu_actions MapSet.new([ :toggle_sidebar, :toggle_panel, @@ -60,11 +67,11 @@ defmodule BDS.Desktop.ShellLive do |> assign(:page_language, ShellData.ui_language()) |> assign(:client_shortcuts, Commands.client_shortcuts()) |> assign(:offline_mode, if(connected, do: AI.airplane_mode?(true), else: true)) - |> assign(:handled_task_results, initial_handled_task_results()) + |> assign(:handled_task_results, SessionUtil.initial_handled_task_results()) |> assign(:assistant_prompt, "") |> assign(:assistant_messages, []) |> assign(:is_mac_ui, mac_ui?()) - |> assign(:menu_groups, titlebar_menu_groups()) + |> assign(:menu_groups, TitlebarMenu.groups()) |> assign(:titlebar_menu_group, nil) |> assign(:titlebar_menu_item_index, nil) |> assign(:tab_meta, %{}) @@ -158,11 +165,11 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("sync_layout", params, socket) do - {:noreply, reload_shell(socket, sync_layout(socket.assigns.workbench, params))} + {:noreply, reload_shell(socket, Layout.sync(socket.assigns.workbench, params))} end def handle_event("resize_panel", %{"target" => target, "width" => width}, socket) do - {:noreply, reload_shell(socket, resize_panel(socket.assigns.workbench, target, width))} + {:noreply, reload_shell(socket, Layout.resize(socket.assigns.workbench, target, width))} end def handle_event("toggle_sidebar_filters", _params, socket) do @@ -313,7 +320,7 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("shortcut", params, socket) do - if ignore_shortcut?(params) do + if Layout.ignore_shortcut?(params) do {:noreply, socket} else {:noreply, reload_shell(socket, Commands.handle_shortcut(socket.assigns.workbench, params))} @@ -388,7 +395,7 @@ defmodule BDS.Desktop.ShellLive do else socket |> assign(:assistant_prompt, "") - |> assign(:assistant_messages, socket.assigns.assistant_messages ++ assistant_turn(prompt, socket)) + |> assign(:assistant_messages, socket.assigns.assistant_messages ++ ChatSurface.assistant_turn(prompt, socket)) end {:noreply, socket} @@ -759,7 +766,7 @@ defmodule BDS.Desktop.ShellLive do def handle_event("open_chat_settings", _params, socket) do {:noreply, socket - |> clear_chat_action_error() + |> ChatSurface.clear_action_error() |> open_sidebar_item(%{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"}, :pin)} end @@ -772,7 +779,11 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("chat_surface_action", params, socket) do - {:noreply, handle_chat_surface_action(socket, params)} + {:noreply, + ChatSurface.handle_action(socket, params, %{ + reload: &reload_shell/2, + open_sidebar: &open_sidebar_item/3 + })} end def handle_event("change_import_editor_definition", %{"import_definition" => params}, socket) do @@ -1115,7 +1126,7 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("create_project", _params, socket) do - attrs = %{name: next_project_name(socket.assigns.projects.projects)} + attrs = %{name: SessionUtil.next_project_name(socket.assigns.projects.projects)} socket = case Projects.create_project(attrs) do @@ -1156,7 +1167,7 @@ defmodule BDS.Desktop.ShellLive do 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))} + {:noreply, reload_shell(socket, SessionUtil.restore_workbench_session(session_payload))} end def handle_event("native_menu_action", %{"action" => action}, socket) do @@ -1164,36 +1175,25 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("titlebar_menu_keydown", %{"key" => key}, socket) do - {:noreply, handle_titlebar_menu_keydown(socket, key)} + {:noreply, TitlebarMenu.handle_keydown(socket, key, &handle_native_menu_action/2)} end def handle_event("toggle_titlebar_menu", %{"group" => group}, socket) do - {:noreply, - if(socket.assigns.titlebar_menu_group == group, - do: close_titlebar_menu(socket), - else: open_titlebar_menu(socket, group) - )} + {:noreply, TitlebarMenu.toggle(socket, group)} end def handle_event("hover_titlebar_menu", %{"group" => group}, socket) do - socket = - if socket.assigns.titlebar_menu_group do - open_titlebar_menu(socket, group) - else - socket - end - - {:noreply, socket} + {:noreply, TitlebarMenu.hover(socket, group)} end def handle_event("close_titlebar_menu", _params, socket) do - {:noreply, close_titlebar_menu(socket)} + {:noreply, TitlebarMenu.close(socket)} end def handle_event("titlebar_menu_action", %{"action" => action}, socket) do {:noreply, socket - |> close_titlebar_menu() + |> TitlebarMenu.close() |> handle_native_menu_action(action)} end @@ -1253,13 +1253,13 @@ defmodule BDS.Desktop.ShellLive do end def handle_info({:entity_changed, payload}, socket) when is_map(payload) do - {:noreply, apply_cli_entity_change(socket, payload)} + {:noreply, CliSync.apply_entity_change(socket, payload, &reload_shell/2)} end def handle_info(:refresh_task_status, socket) do raw_task_status = BDS.Tasks.status_snapshot() - case next_completed_task_result(socket, raw_task_status) do + case SessionUtil.next_completed_task_result(socket, raw_task_status) do nil -> task_status = localize_task_status(raw_task_status, socket.assigns.page_language) @@ -1278,7 +1278,7 @@ defmodule BDS.Desktop.ShellLive do task -> {:noreply, socket - |> mark_task_result_handled(task.id) + |> SessionUtil.mark_task_result_handled(task.id) |> apply_shell_command_result(task.result)} end end @@ -1332,7 +1332,7 @@ defmodule BDS.Desktop.ShellLive do |> assign(:activity_buttons, activity_buttons) |> assign(:panel_tabs, ShellData.panel_tabs(workbench)) |> assign(:supported_ui_languages, ShellData.supported_ui_languages()) - |> assign(:menu_groups, socket.assigns[:menu_groups] || titlebar_menu_groups()) + |> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups()) |> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index]) |> assign(:current_tab, current_tab(workbench)) |> assign_post_editor() @@ -1346,298 +1346,6 @@ defmodule BDS.Desktop.ShellLive do |> assign_misc_editor() end - defp apply_cli_entity_change(socket, payload) do - entity = Map.get(payload, :entity) || Map.get(payload, "entity") || Map.get(payload, :entity_type) || Map.get(payload, "entity_type") - entity_id = Map.get(payload, :entity_id) || Map.get(payload, "entity_id") || Map.get(payload, :entityId) || Map.get(payload, "entityId") - action = normalize_cli_entity_action(Map.get(payload, :action) || Map.get(payload, "action")) - - if is_binary(entity) and entity != "" and is_binary(entity_id) and entity_id != "" and action in [:created, :updated, :deleted] do - {socket, workbench} = maybe_close_deleted_cli_tab(socket, entity, entity_id, action) - - socket - |> maybe_refresh_cli_tab_meta(entity, entity_id, action) - |> reload_shell(workbench) - else - socket - end - end - - defp maybe_close_deleted_cli_tab(socket, "post", post_id, :deleted) do - workbench = Workbench.close_tab(socket.assigns.workbench, :post, post_id) - - socket = - socket - |> assign(:workbench, workbench) - |> assign(:shell_overlay, nil) - |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id})) - |> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id)) - |> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id)) - |> assign(:post_editor_tag_queries, Map.delete(socket.assigns.post_editor_tag_queries, post_id)) - |> assign(:post_editor_category_queries, Map.delete(socket.assigns.post_editor_category_queries, post_id)) - |> assign(:post_editor_quick_actions_open, Map.delete(socket.assigns.post_editor_quick_actions_open, post_id)) - |> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id)) - |> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id)) - |> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id)) - - {socket, workbench} - end - - defp maybe_close_deleted_cli_tab(socket, "media", media_id, :deleted) do - workbench = Workbench.close_tab(socket.assigns.workbench, :media, media_id) - - socket = - socket - |> assign(:workbench, workbench) - |> assign(:shell_overlay, nil) - |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id})) - |> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id)) - |> assign(:media_editor_quick_actions_open, Map.delete(socket.assigns.media_editor_quick_actions_open, media_id)) - |> assign(:media_editor_post_pickers_open, Map.delete(socket.assigns.media_editor_post_pickers_open, media_id)) - |> assign(:media_editor_post_picker_queries, Map.delete(socket.assigns.media_editor_post_picker_queries, media_id)) - |> assign(:media_editor_save_states, Map.delete(socket.assigns.media_editor_save_states, media_id)) - |> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) - - {socket, workbench} - end - - defp maybe_close_deleted_cli_tab(socket, _entity, _entity_id, _action), do: {socket, socket.assigns.workbench} - - defp maybe_refresh_cli_tab_meta(socket, "post", post_id, action) when action in [:created, :updated] do - maybe_put_cli_tab_meta(socket, :post, post_id, fn -> - case Repo.get(Post, post_id) do - %Post{} = post -> %{title: post.title || post.slug || post.id, subtitle: Atom.to_string(post.status || :draft)} - _other -> nil - end - end) - end - - defp maybe_refresh_cli_tab_meta(socket, "media", media_id, action) when action in [:created, :updated] do - maybe_put_cli_tab_meta(socket, :media, media_id, fn -> - case Repo.get(Media, media_id) do - %Media{} = media -> %{title: media.title || media.filename || media.id, subtitle: media.filename || media.mime_type || "media"} - _other -> nil - end - end) - end - - defp maybe_refresh_cli_tab_meta(socket, _entity, _entity_id, _action), do: socket - - defp maybe_put_cli_tab_meta(socket, route, entity_id, meta_fun) do - key = {route, entity_id} - - if cli_tab_present?(socket.assigns.workbench, key) or Map.has_key?(socket.assigns.tab_meta, key) do - case meta_fun.() do - %{} = fresh_meta -> - updated_meta = Map.update(socket.assigns.tab_meta, key, fresh_meta, &Map.merge(&1, fresh_meta)) - assign(socket, :tab_meta, updated_meta) - - _other -> - socket - end - else - socket - end - end - - defp cli_tab_present?(%{tabs: tabs}, {route, entity_id}) do - Enum.any?(tabs, &(&1.type == route and &1.id == entity_id)) - end - - defp normalize_cli_entity_action(action) when action in [:created, :updated, :deleted], do: action - - defp normalize_cli_entity_action(action) do - action - |> to_string() - |> String.downcase() - |> case do - "created" -> :created - "updated" -> :updated - "deleted" -> :deleted - _other -> :unknown - end - end - - defp render_panel_body(assigns) do - case assigns.workbench.panel.active_tab do - :tasks -> render_task_entries(assigns) - :output -> render_output_entries(assigns) - :post_links -> render_post_links(assigns) - :git_log -> render_git_log(assigns) - other -> render_generic_panel(assigns, other) - end - end - - defp render_editor_toolbar(assigns) do - buttons = editor_toolbar_buttons(assigns.current_tab) - assigns = assign(assigns, :editor_toolbar_buttons, buttons) - - ~H""" - <%= if Enum.any?(@editor_toolbar_buttons) do %> -
- <%= for button <- @editor_toolbar_buttons do %> - - <% end %> -
- <% end %> - """ - end - - defp render_task_entries(assigns) do - ~H""" - <%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %> -
- <%= translated("Tasks") %> - <%= translated("No background tasks running") %> -
- <% else %> -
- <%= for task <- Map.get(@task_status, :tasks, []) do %> -
-
- <%= task.name %> - <%= Map.get(task, :status_label, task.status |> to_string() |> String.capitalize()) %> -
- <%= task.message || task.group_name || "" %> - <%= if is_number(task.progress) do %> -
- - <%= Map.get(task, :progress_label, progress_percent(task.progress)) %> -
- <% end %> -
- <% end %> -
- <% end %> - """ - end - - defp render_output_entries(assigns) do - ~H""" - <%= if Enum.empty?(@output_entries) do %> -
- <%= translated("Output") %> - <%= translated("No shell output yet") %> -
- <% else %> -
- <%= for entry <- @output_entries do %> -
- <%= entry.title %> - <%= entry.message %> - <%= if present?(entry.details) do %> - <%= entry.details %> - <% end %> -
- <% end %> -
- <% end %> - """ - end - - defp render_post_links(assigns) do - links = post_link_entries(assigns) - - assigns = - assigns - |> assign(:backlinks, Map.get(links, :backlinks, [])) - |> assign(:outlinks, Map.get(links, :outlinks, [])) - - ~H""" - <%= if Enum.empty?(@backlinks) and Enum.empty?(@outlinks) do %> -
- <%= translated("Post Links") %> - <%= translated("No post links yet") %> -
- <% else %> -
- <%= if Enum.any?(@backlinks) do %> -
<%= translated("Backlinks") %>
- <%= for entry <- @backlinks do %> - - <% end %> - <% end %> - - <%= if Enum.any?(@outlinks) do %> -
<%= translated("Links To") %>
- <%= for entry <- @outlinks do %> - - <% end %> - <% end %> -
- <% end %> - """ - end - - defp render_git_log(assigns) do - entries = git_log_entries(assigns) - assigns = assign(assigns, :git_entries, entries) - - ~H""" - <%= if Enum.empty?(@git_entries) do %> -
-
- <%= translated("Git Log") %> - <%= translated("No git history yet") %> -
-
- <% else %> -
- <%= for entry <- @git_entries do %> -
- <%= short_commit_hash(entry.hash) %> <%= entry.subject || translated("No commit subject") %> - <%= entry.hash %> -
- <% end %> -
- <% end %> - """ - end - - defp render_generic_panel(assigns, tab) do - assigns = assign(assigns, :panel_label, ShellData.route_label(tab)) - - ~H""" -
- <%= @panel_label %> - <%= translated("The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.") %> -
- """ - end - defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts) @@ -1661,8 +1369,6 @@ defmodule BDS.Desktop.ShellLive do defp sidebar_header_label(label), do: translated(label) - defp present?(value), do: value not in [nil, ""] - defp timeline_height(entry, entries) do max_count = entries @@ -1715,143 +1421,17 @@ defmodule BDS.Desktop.ShellLive do end - defp sync_layout(workbench, params) do - workbench - |> maybe_set_sidebar_width(Map.get(params, "sidebar_width")) - |> maybe_set_assistant_width(Map.get(params, "assistant_sidebar_width")) + defp create_sidebar_item(socket, kind), + do: SidebarCreate.create(socket, kind, sidebar_create_callbacks()) + + defp sidebar_create_callbacks do + %{ + reload: &reload_shell/2, + open_sidebar: &open_sidebar_item/3, + append_output: &append_output_entry/5 + } end - defp resize_panel(workbench, "sidebar", width) do - workbench - |> Workbench.set_sidebar_width(parse_width(width)) - |> Map.put(:sidebar_visible, true) - end - - defp resize_panel(workbench, "assistant", width) do - workbench - |> Workbench.set_assistant_sidebar_width(parse_width(width)) - |> Map.put(:assistant_sidebar_visible, true) - end - - defp resize_panel(workbench, _target, _width), do: workbench - - defp maybe_set_sidebar_width(workbench, nil), do: workbench - defp maybe_set_sidebar_width(workbench, width), do: Workbench.set_sidebar_width(workbench, parse_width(width)) - - defp maybe_set_assistant_width(workbench, nil), do: workbench - - defp maybe_set_assistant_width(workbench, width) do - Workbench.set_assistant_sidebar_width(workbench, parse_width(width)) - end - - defp parse_width(width) when is_integer(width), do: width - - defp parse_width(width) when is_binary(width) do - case Integer.parse(width) do - {parsed, _rest} -> parsed - :error -> 0 - end - end - - defp ignore_shortcut?(params) do - Map.get(params, "alt", false) or - Map.get(params, "contentEditable", false) or - Map.get(params, "content_editable", false) or - Map.get(params, "tag") in ["INPUT", "TEXTAREA", "SELECT"] or - Map.get(params, :tag) in ["INPUT", "TEXTAREA", "SELECT"] - end - - defp create_sidebar_item(socket, kind) do - case socket.assigns.projects.active_project_id do - project_id when is_binary(project_id) -> create_sidebar_item(socket, project_id, kind) - _other -> reload_shell(socket, socket.assigns.workbench) - end - end - - defp create_sidebar_item(socket, project_id, "post") do - case BDS.Posts.create_post(%{project_id: project_id, title: "", content: "", tags: [], categories: []}) do - {:ok, _post} -> reload_shell(socket, socket.assigns.workbench) - - {:error, reason} -> - socket - |> append_output_entry(translated("sidebar.newPost"), inspect(reason), nil, "error") - |> reload_shell(socket.assigns.workbench) - end - end - - defp create_sidebar_item(socket, project_id, "media") do - case FilePicker.choose_file(translated("sidebar.importMedia")) do - {:ok, source_path} -> - case BDS.Media.import_media(%{project_id: project_id, source_path: source_path}) do - {:ok, _media} -> reload_shell(socket, socket.assigns.workbench) - - {:error, reason} -> - socket - |> append_output_entry(translated("sidebar.importMedia"), inspect(reason), nil, "error") - |> reload_shell(socket.assigns.workbench) - end - - :cancel -> - reload_shell(socket, socket.assigns.workbench) - - {:error, %{message: message}} -> - socket - |> append_output_entry(translated("sidebar.importMedia"), message, nil, "error") - |> reload_shell(socket.assigns.workbench) - end - end - - defp create_sidebar_item(socket, project_id, "script") do - case Scripts.create_script(%{ - project_id: project_id, - title: translated("sidebar.scripts.newScript"), - kind: :utility, - content: "print(\"new script\")", - entrypoint: "main", - enabled: true - }) do - {:ok, script} -> - open_sidebar_item(socket, %{"route" => "scripts", "id" => script.id, "title" => script.title, "subtitle" => "Automation helpers"}, :pin) - - {:error, reason} -> - socket - |> append_output_entry(translated("sidebar.scripts.newScript"), inspect(reason), nil, "error") - |> reload_shell(socket.assigns.workbench) - end - end - - defp create_sidebar_item(socket, project_id, "template") do - case Templates.create_template(%{ - project_id: project_id, - title: translated("sidebar.templates.newTemplate"), - kind: :post, - content: "", - enabled: true - }) do - {:ok, template} -> - open_sidebar_item(socket, %{"route" => "templates", "id" => template.id, "title" => template.title, "subtitle" => "Site rendering"}, :pin) - - {:error, reason} -> - socket - |> append_output_entry(translated("sidebar.templates.newTemplate"), inspect(reason), nil, "error") - |> reload_shell(socket.assigns.workbench) - end - end - - defp create_sidebar_item(socket, project_id, "import") do - case ImportDefinitions.create_definition(%{project_id: project_id, name: translated("sidebar.import.newDefinition")}) do - {:ok, definition} -> - open_sidebar_item(socket, %{"route" => "import", "id" => definition.id, "title" => definition.name, "subtitle" => "Import definitions"}, :pin) - - {:error, reason} -> - socket - |> append_output_entry(translated("sidebar.import.newDefinition"), inspect(reason), nil, "error") - |> reload_shell(socket.assigns.workbench) - end - end - - defp create_sidebar_item(socket, _project_id, _kind), do: reload_shell(socket, socket.assigns.workbench) - defp open_sidebar_item(socket, params, intent) do route_atom = sidebar_route_atom(Map.fetch!(params, "route")) tab_id = tab_id_for_route(route_atom, Map.fetch!(params, "id")) @@ -1871,12 +1451,7 @@ defmodule BDS.Desktop.ShellLive do |> reload_shell(workbench) end - defp sidebar_create_action(:posts), do: %{kind: "post", label: "sidebar.newPost"} - defp sidebar_create_action(:media), do: %{kind: "media", label: "sidebar.importMedia"} - defp sidebar_create_action(:scripts), do: %{kind: "script", label: "sidebar.scripts.newScript"} - defp sidebar_create_action(:templates), do: %{kind: "template", label: "sidebar.templates.newTemplate"} - defp sidebar_create_action(:import), do: %{kind: "import", label: "sidebar.import.newDefinition"} - defp sidebar_create_action(_view), do: nil + defp sidebar_create_action(view), do: SidebarCreate.action(view) defp set_page_language(socket, language) do codes = Enum.map(socket.assigns[:supported_ui_languages] || ShellData.supported_ui_languages(), & &1.code) @@ -1924,16 +1499,6 @@ defmodule BDS.Desktop.ShellLive do assign(socket, :output_entries, entries) end - defp next_project_name(projects) do - existing_names = MapSet.new(Enum.map(projects, & &1.name)) - - Stream.iterate(1, &(&1 + 1)) - |> Enum.find_value(fn index -> - candidate = if index == 1, do: @default_new_project_name, else: "#{@default_new_project_name} #{index}" - if MapSet.member?(existing_names, candidate), do: nil, else: candidate - end) - end - defp handle_native_menu_action(socket, action) do with action_atom when not is_nil(action_atom) <- safe_existing_atom(action) do if MapSet.member?(@local_menu_actions, action_atom) do @@ -1946,322 +1511,20 @@ defmodule BDS.Desktop.ShellLive do end end - defp restore_workbench_session(session_payload) do - Session.restore(session_payload) - rescue - _error -> Workbench.new() + defp apply_shell_command(socket, action, params \\ %{}), + do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks()) + + defp apply_shell_command_result(socket, result), + do: ShellCommandRunner.apply_result(socket, result, shell_command_callbacks()) + + defp shell_command_callbacks do + %{ + reload: &reload_shell/2, + append_output: &append_output_entry/5 + } end - defp safe_existing_atom(action) when is_binary(action) do - String.to_existing_atom(action) - rescue - ArgumentError -> nil - end - - defp apply_shell_command(socket, action, params \\ %{}) do - case ShellCommands.execute(action, params) do - {:ok, result} -> apply_shell_command_result(socket, result) - {:error, %{message: message}} -> append_output_entry(socket, command_title(action), message, nil, "error") - {:error, reason} -> append_output_entry(socket, command_title(action), inspect(reason), nil, "error") - end - end - - defp apply_shell_command_result(socket, %{kind: "task_queued", title: title, message: message, panel_tab: panel_tab}) do - workbench = - socket.assigns.workbench - |> Workbench.set_panel_visible(true) - |> Workbench.set_panel_tab(String.to_existing_atom(panel_tab)) - - socket - |> append_output_entry(translate_for_socket(socket, title), translate_for_socket(socket, message)) - |> reload_shell(workbench) - end - - defp apply_shell_command_result(socket, %{kind: "output", title: title, message: message} = result) do - socket - |> append_output_entry(translate_for_socket(socket, title), translate_for_socket(socket, message), Map.get(result, :details), Map.get(result, :level, "info")) - end - - defp apply_shell_command_result(socket, %{kind: "open_url", title: title, message: message, url: url}) do - append_output_entry(socket, translate_for_socket(socket, title), translate_for_socket(socket, message), url) - end - - defp apply_shell_command_result(socket, %{kind: "open_editor", route: route, title: title, subtitle: subtitle} = result) do - route_atom = String.to_existing_atom(route) - tab_id = tab_id_for_route(route_atom, route) - workbench = Workbench.open_tab(socket.assigns.workbench, route_atom, tab_id, :pin) - - tab_meta = - Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{ - title: translate_for_socket(socket, title), - subtitle: translate_for_socket(socket, subtitle), - action: Map.get(result, :action), - payload: Map.get(result, :payload), - project_id: Map.get(result, :project_id), - editor_meta: translate_editor_meta(Map.get(result, :editorMeta, []), socket.assigns.page_language) - }) - - socket - |> assign(:tab_meta, tab_meta) - |> reload_shell(workbench) - end - - defp apply_shell_command_result(socket, _result), do: socket - - defp initial_handled_task_results do - BDS.Tasks.status_snapshot() - |> Map.get(:tasks, []) - |> Enum.filter(fn task -> task.status == :completed and is_map(task.result) end) - |> Enum.map(& &1.id) - |> MapSet.new() - end - - defp next_completed_task_result(socket, task_status) do - handled = Map.get(socket.assigns, :handled_task_results, MapSet.new()) - - Enum.find(Map.get(task_status, :tasks, []), fn task -> - task.status == :completed and is_map(task.result) and not MapSet.member?(handled, task.id) - end) - end - - defp mark_task_result_handled(socket, task_id) do - handled = Map.get(socket.assigns, :handled_task_results, MapSet.new()) - assign(socket, :handled_task_results, MapSet.put(handled, task_id)) - end - - defp localize_task_status(task_status, locale) do - tasks = Enum.map(Map.get(task_status, :tasks, []), &localize_task(&1, locale)) - active = Enum.filter(tasks, &(&1.status in [:running, :pending])) - - task_status - |> Map.put(:tasks, tasks) - |> Map.put(:running_task_message, localized_running_task_message(active, locale)) - end - - defp localize_task(task, locale) do - progress = Map.get(task, :progress) - - task - |> Map.put(:name, ShellData.translate(task.name, %{}, locale)) - |> Map.put(:message, localize_task_message(Map.get(task, :message), locale)) - |> Map.put(:group_name, localize_task_group(Map.get(task, :group_name), locale)) - |> Map.put(:status_label, localize_task_status_label(task.status, locale)) - |> Map.put(:progress_label, if(is_number(progress), do: progress_percent(progress), else: nil)) - end - - defp localize_task_message(nil, _locale), do: nil - defp localize_task_message("", _locale), do: "" - defp localize_task_message(message, locale), do: ShellData.translate(message, %{}, locale) - - defp localize_task_group(nil, _locale), do: nil - defp localize_task_group(group, locale), do: ShellData.translate(group, %{}, locale) - - defp localize_task_status_label(status, locale) do - status - |> to_string() - |> String.capitalize() - |> ShellData.translate(%{}, locale) - end - - defp localized_running_task_message([], _locale), do: nil - - defp localized_running_task_message([task | _rest], locale) do - cond do - task.status == :pending -> ShellData.translate("Queued", %{}, locale) <> ": " <> task.name - is_binary(task.message) and task.message != "" -> task.name <> ": " <> task.message - true -> task.name - end - end - - defp translate_editor_meta(items, locale) do - Enum.map(items, fn item -> - item - |> Map.update(:label, nil, &ShellData.translate(&1, %{}, locale)) - |> Map.update(:value, nil, &translate_editor_meta_value(&1, locale)) - end) - end - - defp translate_editor_meta_value(value, locale) when is_binary(value), do: ShellData.translate(value, %{}, locale) - defp translate_editor_meta_value(value, _locale), do: value - - defp translate_for_socket(socket, text) when is_binary(text), do: ShellData.translate(text, %{}, socket.assigns.page_language) - defp translate_for_socket(_socket, text), do: text - - defp progress_percent(progress) when is_number(progress) do - percentage = progress |> Kernel.*(100) |> round() - Integer.to_string(percentage) <> "%" - end - - defp command_title(action) do - action - |> to_string() - |> String.replace("_", " ") - |> String.split() - |> Enum.map_join(" ", &String.capitalize/1) - end - - defp titlebar_menu_groups do - DesktopMenuBar.groups(dev_mode?: Application.get_env(:bds, :dev_routes, false)) - end - - defp titlebar_menu_dropdown_items(group) do - group.items - |> Enum.map_reduce(0, fn item, keyboard_index -> - if Map.get(item, :separator, false) do - {%{separator: true}, keyboard_index} - else - {Map.put(item, :keyboard_index, keyboard_index), keyboard_index + 1} - end - end) - |> elem(0) - end - - defp titlebar_menu_item_active?(group, item, current_index) do - cond do - is_nil(current_index) -> - false - - Map.get(item, :separator, false) -> - false - - true -> - group.items - |> Enum.reject(&Map.get(&1, :separator, false)) - |> Enum.find_index(&(&1.id == item.id)) - |> Kernel.==(current_index) - end - end - - defp active_titlebar_menu_group(assigns) do - Enum.find(assigns.menu_groups || [], fn group -> Atom.to_string(group.id) == assigns.titlebar_menu_group end) - end - - defp active_titlebar_menu_items(assigns) do - assigns - |> active_titlebar_menu_group() - |> case do - nil -> [] - group -> Enum.reject(group.items, &Map.get(&1, :separator, false)) - end - end - - defp open_titlebar_menu(socket, group) do - socket - |> assign(:titlebar_menu_group, group) - |> assign(:titlebar_menu_item_index, nil) - end - - defp close_titlebar_menu(socket) do - socket - |> assign(:titlebar_menu_group, nil) - |> assign(:titlebar_menu_item_index, nil) - end - - defp handle_titlebar_menu_keydown(socket, key) do - if socket.assigns.titlebar_menu_group do - case key do - "Escape" -> - close_titlebar_menu(socket) - - "ArrowRight" -> - rotate_titlebar_menu_group(socket, 1) - - "ArrowLeft" -> - rotate_titlebar_menu_group(socket, -1) - - "ArrowDown" -> - advance_titlebar_menu_item_index(socket, 1) - - "ArrowUp" -> - advance_titlebar_menu_item_index(socket, -1) - - "Home" -> - set_first_titlebar_menu_item_index(socket) - - "End" -> - set_last_titlebar_menu_item_index(socket) - - "Enter" -> - invoke_active_titlebar_menu_item(socket) - - " " -> - invoke_active_titlebar_menu_item(socket) - - _other -> - socket - end - else - socket - end - end - - defp rotate_titlebar_menu_group(socket, offset) do - groups = socket.assigns.menu_groups || [] - current_group = socket.assigns.titlebar_menu_group - current_index = Enum.find_index(groups, fn group -> Atom.to_string(group.id) == current_group end) - - if is_nil(current_index) or groups == [] do - socket - else - next_index = rem(current_index + offset + length(groups), length(groups)) - next_group = Enum.at(groups, next_index) - open_titlebar_menu(socket, Atom.to_string(next_group.id)) - end - end - - defp advance_titlebar_menu_item_index(socket, offset) do - items = active_titlebar_menu_items(socket.assigns) - current_index = socket.assigns[:titlebar_menu_item_index] - - cond do - items == [] -> - socket - - current_index == nil and offset > 0 -> - assign(socket, :titlebar_menu_item_index, 0) - - current_index == nil and offset < 0 -> - assign(socket, :titlebar_menu_item_index, length(items) - 1) - - true -> - next_index = rem(current_index + offset + length(items), length(items)) - assign(socket, :titlebar_menu_item_index, next_index) - end - end - - defp set_last_titlebar_menu_item_index(socket) do - items = active_titlebar_menu_items(socket.assigns) - - if items == [] do - socket - else - assign(socket, :titlebar_menu_item_index, length(items) - 1) - end - end - - defp set_first_titlebar_menu_item_index(socket) do - items = active_titlebar_menu_items(socket.assigns) - - if items == [] do - socket - else - assign(socket, :titlebar_menu_item_index, 0) - end - end - - defp invoke_active_titlebar_menu_item(socket) do - items = active_titlebar_menu_items(socket.assigns) - - case Enum.at(items, socket.assigns[:titlebar_menu_item_index]) do - %{id: id} -> - socket - |> close_titlebar_menu() - |> handle_native_menu_action(Atom.to_string(id)) - - _other -> - socket - end - end + defp safe_existing_atom(action), do: ShellCommandRunner.safe_existing_atom(action) defp mac_ui? do case Application.get_env(:bds, :shell_platform) do @@ -2270,333 +1533,8 @@ defmodule BDS.Desktop.ShellLive do end end - defp post_link_entries(assigns) do - case assigns.current_tab do - %{type: :post, id: post_id} -> - %{ - backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id), - outlinks: related_posts(PostLinks.list_outgoing_links(post_id), :target_post_id) - } - - _other -> - %{backlinks: [], outlinks: []} - end - end - - defp related_posts(links, key) do - Enum.map(links, fn link -> - case Repo.get(Post, Map.fetch!(link, key)) do - %Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id} - _other -> nil - end - end) - |> Enum.reject(&is_nil/1) - end - - defp git_log_entries(assigns) do - case git_history_target(assigns.current_tab) do - nil -> [] - {project_id, file_path} -> - case Git.file_history(project_id, file_path) do - {:ok, %{commits: commits}} -> commits - _other -> [] - end - end - end - - defp git_history_target(%{type: :post, id: post_id}) do - case Repo.get(Post, post_id) do - %Post{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] -> {project_id, file_path} - _other -> nil - end - end - - defp git_history_target(%{type: :media, id: media_id}) do - case Repo.get(Media, media_id) do - %Media{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] -> {project_id, file_path} - _other -> nil - end - end - - defp git_history_target(_tab), do: nil - - defp handle_chat_surface_action(socket, params) do - surface_id = Map.get(params, "surface-id", "") - - payload = - params - |> Map.get("payload") - |> decode_chat_surface_payload() - |> maybe_put_chat_surface_form_data(socket, surface_id) - - case normalize_chat_action(Map.get(params, "action", "")) do - :open_post -> - case Map.get(payload, "postId") || Map.get(payload, "post_id") do - post_id when is_binary(post_id) and post_id != "" -> - socket - |> clear_chat_action_error() - |> open_sidebar_item(%{"route" => "post", "id" => post_id, "title" => post_title(post_id), "subtitle" => post_subtitle(post_id)}, :pin) - - _other -> - ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Invalid payload for openPost action", &reload_shell/2) - end - - :open_media -> - case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do - media_id when is_binary(media_id) and media_id != "" -> - socket - |> clear_chat_action_error() - |> open_sidebar_item(%{"route" => "media", "id" => media_id, "title" => media_title(media_id), "subtitle" => media_subtitle(media_id)}, :pin) - - _other -> - ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Invalid payload for openMedia action", &reload_shell/2) - end - - :open_settings -> - socket - |> clear_chat_action_error() - |> open_sidebar_item(%{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"}, :pin) - - :open_chat -> - chat_id = Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") || socket.assigns.current_tab.id - - socket - |> clear_chat_action_error() - |> open_sidebar_item(%{"route" => "chat", "id" => chat_id, "title" => Map.get(payload, "title", "Chat"), "subtitle" => Map.get(payload, "subtitle", "")}, :pin) - - :switch_view -> - case safe_existing_atom(Map.get(payload, "view")) do - nil -> ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Invalid payload for switchView action", &reload_shell/2) - view -> - socket - |> clear_chat_action_error() - |> reload_shell(Workbench.click_activity(socket.assigns.workbench, view)) - end - - :toggle_sidebar -> - socket - |> clear_chat_action_error() - |> reload_shell(Workbench.toggle_sidebar(socket.assigns.workbench)) - - :toggle_panel -> - socket - |> clear_chat_action_error() - |> reload_shell(Workbench.toggle_panel(socket.assigns.workbench)) - - :toggle_assistant_sidebar -> - socket - |> clear_chat_action_error() - |> reload_shell(Workbench.toggle_assistant_sidebar(socket.assigns.workbench)) - - :unknown -> - ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Unsupported assistant action", &reload_shell/2) - end - end - - defp clear_chat_action_error(%{assigns: %{current_tab: %{type: :chat, id: conversation_id}}} = socket) do - assign(socket, :chat_editor_action_errors, Map.delete(socket.assigns.chat_editor_action_errors, conversation_id)) - end - - defp clear_chat_action_error(socket), do: socket - - defp decode_chat_surface_payload(nil), do: %{} - defp decode_chat_surface_payload(""), do: %{} - - defp decode_chat_surface_payload(payload) when is_binary(payload) do - case Jason.decode(payload) do - {:ok, decoded} when is_map(decoded) -> decoded - _other -> %{} - end - end - - defp decode_chat_surface_payload(_payload), do: %{} - - defp maybe_put_chat_surface_form_data(payload, socket, surface_id) when is_binary(surface_id) and surface_id != "" do - form_data = ChatEditor.current_surface_data(socket, surface_id) - - if form_data == %{} do - payload - else - Map.put(payload, "formData", form_data) - end - end - - defp maybe_put_chat_surface_form_data(payload, _socket, _surface_id), do: payload - - defp normalize_chat_action(action) do - action - |> to_string() - |> String.replace("_", "") - |> String.downcase() - |> case do - "openpost" -> :open_post - "openmedia" -> :open_media - "opensettings" -> :open_settings - "openchat" -> :open_chat - "switchview" -> :switch_view - "setactiveview" -> :switch_view - "togglesidebar" -> :toggle_sidebar - "togglepanel" -> :toggle_panel - "openpanel" -> :toggle_panel - "toggleassistantsidebar" -> :toggle_assistant_sidebar - _other -> :unknown - end - end - - defp post_title(post_id) do - case Repo.get(Post, post_id) do - %Post{} = post -> post.title || post.slug || post.id - _other -> "Post" - end - end - - defp post_subtitle(post_id) do - case Repo.get(Post, post_id) do - %Post{} = post -> post.slug || "draft" - _other -> "draft" - end - end - - defp media_title(media_id) do - case Repo.get(Media, media_id) do - %Media{} = media -> media.title || media.filename || media.id - _other -> "Media" - end - end - - defp media_subtitle(media_id) do - case Repo.get(Media, media_id) do - %Media{} = media -> media.filename || media.mime_type || "media" - _other -> "media" - end - end - - defp parse_integer(value) when is_integer(value), do: value - - defp parse_integer(value) do - case Integer.parse(to_string(value || "0")) do - {parsed, _rest} -> parsed - :error -> 0 - end - end - - defp short_commit_hash(hash) when is_binary(hash), do: String.slice(hash, 0, 7) - defp short_commit_hash(_hash), do: "-------" - - defp sidebar_route_atom(route) when is_atom(route), do: route - defp sidebar_route_atom(route) when is_binary(route), do: String.to_existing_atom(route) - - defp tab_id_for_route(route, id) do - case Registry.editor_route(route) do - %{singleton: true} -> Atom.to_string(route) - _other -> id - end - end - - defp tab_intent(route, requested_intent) do - case Registry.editor_route(route) do - %{singleton: true} -> :pin - _other -> requested_intent - end - end - - defp tab_title(nil, _tab_meta), do: translated("Dashboard") - - defp tab_title(tab, tab_meta) do - case Map.get(tab_meta, {tab.type, tab.id}) do - %{title: title} when is_binary(title) and title != "" -> title - _other -> default_tab_title(tab) - end - end - - defp tab_subtitle(nil, _tab_meta), do: translated("dashboard.subtitle") - - defp tab_subtitle(tab, tab_meta) do - case Map.get(tab_meta, {tab.type, tab.id}) do - %{subtitle: subtitle} when is_binary(subtitle) and subtitle != "" -> subtitle - _other -> "Desktop workbench content routed through the Elixir shell." - end - end - - defp default_tab_title(%{type: type, id: id}) do - case Registry.editor_route(type) do - %{singleton: true} -> ShellData.route_label(type) - _other -> id - end - end - - defp tab_route_label(nil), do: translated("Dashboard") - defp tab_route_label(%{type: type}), do: ShellData.route_label(type) - - defp editor_toolbar_buttons(nil), do: [] - - defp editor_toolbar_buttons(%{type: :post}) do - [ - %{kind: "ai_suggestions", label: "AI Suggestions", destructive: false}, - %{kind: "insert_link", label: "Insert Link", destructive: false}, - %{kind: "insert_media", label: "Insert Media", destructive: false}, - %{kind: "language_picker", label: "Translate", destructive: false}, - %{kind: "gallery", label: "Gallery", destructive: false} - ] - end - - defp editor_toolbar_buttons(%{type: :media}) do - [ - %{kind: "ai_suggestions", label: "AI Suggestions", destructive: false}, - %{kind: "language_picker", label: "Translate", destructive: false}, - %{kind: "confirm_delete", label: "Delete Media", destructive: true} - ] - end - - defp editor_toolbar_buttons(%{type: :tags}) do - [ - %{kind: "confirm_merge", label: "Merge Tags", destructive: false}, - %{kind: "confirm_delete", label: "Delete Tag", destructive: true} - ] - end - - defp editor_toolbar_buttons(_tab), do: [] - - defp tab_icon_id(nil), do: "posts" - defp tab_icon_id(%{type: :post}), do: "posts" - defp tab_icon_id(%{type: :git_diff}), do: "git" - defp tab_icon_id(%{type: :style}), do: "settings" - defp tab_icon_id(%{type: type}), do: Atom.to_string(type) - - defp assistant_turn(prompt, socket) do - [ - %{role: "user", content: prompt}, - %{role: "assistant", content: assistant_reply(socket)} - ] - end - - defp assistant_reply(socket) do - if socket.assigns.offline_mode do - ShellData.translate("Automatic AI actions stay gated by airplane mode.", %{}, socket.assigns.page_language) - else - ShellData.translate( - "The assistant sidebar chat surface is ready, but model execution is not connected yet.", - %{}, - socket.assigns.page_language - ) - end - end - - defp assistant_project_name(nil), do: translated("Projects") - defp assistant_project_name(project), do: project.name - - defp assistant_message_label("assistant"), do: translated("Assistant") - defp assistant_message_label("user"), do: translated("You") - defp assistant_message_label(_role), do: translated("Assistant") - - defp assistant_message_testid(role), do: "assistant-message-#{role}" - - defp update_shell_overlay(socket, updater) do - case socket.assigns[:shell_overlay] do - nil -> socket - overlay -> assign(socket, :shell_overlay, updater.(overlay)) - end - end + defp update_shell_overlay(socket, updater), + do: ChatSurface.update_shell_overlay(socket, updater) defp close_overlay_with_output(socket, title, details) do socket diff --git a/lib/bds/desktop/shell_live/chat_surface.ex b/lib/bds/desktop/shell_live/chat_surface.ex new file mode 100644 index 0000000..2fe9773 --- /dev/null +++ b/lib/bds/desktop/shell_live/chat_surface.ex @@ -0,0 +1,233 @@ +defmodule BDS.Desktop.ShellLive.ChatSurface do + @moduledoc false + + import Phoenix.Component, only: [assign: 3] + + alias BDS.Desktop.ShellData + alias BDS.Desktop.ShellLive.{ChatEditor, TabHelpers} + alias BDS.UI.Workbench + + @doc """ + Handle a chat-surface action from a chat message. Receives callbacks for + `reload_shell/2` and `open_sidebar_item/3` to remain decoupled from + `BDS.Desktop.ShellLive` private state. + """ + def handle_action(socket, params, callbacks) do + surface_id = Map.get(params, "surface-id", "") + + payload = + params + |> Map.get("payload") + |> decode_payload() + |> maybe_put_form_data(socket, surface_id) + + case normalize_action(Map.get(params, "action", "")) do + :open_post -> + case Map.get(payload, "postId") || Map.get(payload, "post_id") do + post_id when is_binary(post_id) and post_id != "" -> + socket + |> clear_action_error() + |> callbacks.open_sidebar.( + %{ + "route" => "post", + "id" => post_id, + "title" => TabHelpers.post_title(post_id), + "subtitle" => TabHelpers.post_subtitle(post_id) + }, + :pin + ) + + _other -> + ChatEditor.set_action_error( + socket, + socket.assigns.current_tab.id, + "Invalid payload for openPost action", + callbacks.reload + ) + end + + :open_media -> + case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do + media_id when is_binary(media_id) and media_id != "" -> + socket + |> clear_action_error() + |> callbacks.open_sidebar.( + %{ + "route" => "media", + "id" => media_id, + "title" => TabHelpers.media_title(media_id), + "subtitle" => TabHelpers.media_subtitle(media_id) + }, + :pin + ) + + _other -> + ChatEditor.set_action_error( + socket, + socket.assigns.current_tab.id, + "Invalid payload for openMedia action", + callbacks.reload + ) + end + + :open_settings -> + socket + |> clear_action_error() + |> callbacks.open_sidebar.( + %{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"}, + :pin + ) + + :open_chat -> + chat_id = + Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") || + socket.assigns.current_tab.id + + socket + |> clear_action_error() + |> callbacks.open_sidebar.( + %{ + "route" => "chat", + "id" => chat_id, + "title" => Map.get(payload, "title", "Chat"), + "subtitle" => Map.get(payload, "subtitle", "") + }, + :pin + ) + + :switch_view -> + case safe_existing_atom(Map.get(payload, "view")) do + nil -> + ChatEditor.set_action_error( + socket, + socket.assigns.current_tab.id, + "Invalid payload for switchView action", + callbacks.reload + ) + + view -> + socket + |> clear_action_error() + |> callbacks.reload.(Workbench.click_activity(socket.assigns.workbench, view)) + end + + :toggle_sidebar -> + socket + |> clear_action_error() + |> callbacks.reload.(Workbench.toggle_sidebar(socket.assigns.workbench)) + + :toggle_panel -> + socket + |> clear_action_error() + |> callbacks.reload.(Workbench.toggle_panel(socket.assigns.workbench)) + + :toggle_assistant_sidebar -> + socket + |> clear_action_error() + |> callbacks.reload.(Workbench.toggle_assistant_sidebar(socket.assigns.workbench)) + + :unknown -> + ChatEditor.set_action_error( + socket, + socket.assigns.current_tab.id, + "Unsupported assistant action", + callbacks.reload + ) + end + end + + def assistant_turn(prompt, socket) do + [ + %{role: "user", content: prompt}, + %{role: "assistant", content: assistant_reply(socket)} + ] + end + + def assistant_project_name(nil), do: translated("Projects") + def assistant_project_name(project), do: project.name + + def assistant_message_label("assistant"), do: translated("Assistant") + def assistant_message_label("user"), do: translated("You") + def assistant_message_label(_role), do: translated("Assistant") + + def assistant_message_testid(role), do: "assistant-message-#{role}" + + def update_shell_overlay(socket, updater) do + case socket.assigns[:shell_overlay] do + nil -> socket + overlay -> assign(socket, :shell_overlay, updater.(overlay)) + end + end + + def clear_action_error(%{assigns: %{current_tab: %{type: :chat, id: conversation_id}}} = socket) do + assign(socket, :chat_editor_action_errors, Map.delete(socket.assigns.chat_editor_action_errors, conversation_id)) + end + + def clear_action_error(socket), do: socket + + defp decode_payload(nil), do: %{} + defp decode_payload(""), do: %{} + + defp decode_payload(payload) when is_binary(payload) do + case Jason.decode(payload) do + {:ok, decoded} when is_map(decoded) -> decoded + _other -> %{} + end + end + + defp decode_payload(_payload), do: %{} + + defp maybe_put_form_data(payload, socket, surface_id) when is_binary(surface_id) and surface_id != "" do + form_data = ChatEditor.current_surface_data(socket, surface_id) + + if form_data == %{} do + payload + else + Map.put(payload, "formData", form_data) + end + end + + defp maybe_put_form_data(payload, _socket, _surface_id), do: payload + + defp normalize_action(action) do + action + |> to_string() + |> String.replace("_", "") + |> String.downcase() + |> case do + "openpost" -> :open_post + "openmedia" -> :open_media + "opensettings" -> :open_settings + "openchat" -> :open_chat + "switchview" -> :switch_view + "setactiveview" -> :switch_view + "togglesidebar" -> :toggle_sidebar + "togglepanel" -> :toggle_panel + "openpanel" -> :toggle_panel + "toggleassistantsidebar" -> :toggle_assistant_sidebar + _other -> :unknown + end + end + + defp safe_existing_atom(action) when is_binary(action) do + String.to_existing_atom(action) + rescue + ArgumentError -> nil + end + + defp safe_existing_atom(_), do: nil + + defp assistant_reply(socket) do + if socket.assigns.offline_mode do + ShellData.translate("Automatic AI actions stay gated by airplane mode.", %{}, socket.assigns.page_language) + else + ShellData.translate( + "The assistant sidebar chat surface is ready, but model execution is not connected yet.", + %{}, + socket.assigns.page_language + ) + end + end + + defp translated(text), do: ShellData.translate(text, %{}, Process.get(:bds_ui_locale)) +end diff --git a/lib/bds/desktop/shell_live/cli_sync.ex b/lib/bds/desktop/shell_live/cli_sync.ex new file mode 100644 index 0000000..5c857ce --- /dev/null +++ b/lib/bds/desktop/shell_live/cli_sync.ex @@ -0,0 +1,133 @@ +defmodule BDS.Desktop.ShellLive.CliSync do + @moduledoc false + + import Phoenix.Component, only: [assign: 3] + + alias BDS.Media.Media + alias BDS.Posts.Post + alias BDS.Repo + alias BDS.UI.Workbench + + @doc """ + Apply a CLI entity change payload to the shell socket. `reload_fun` is + called with `(socket, workbench)` to refresh derived data. + """ + @spec apply_entity_change(Phoenix.LiveView.Socket.t(), map(), + (Phoenix.LiveView.Socket.t(), map() -> Phoenix.LiveView.Socket.t())) :: + Phoenix.LiveView.Socket.t() + def apply_entity_change(socket, payload, reload_fun) do + entity = Map.get(payload, :entity) || Map.get(payload, "entity") || Map.get(payload, :entity_type) || Map.get(payload, "entity_type") + + entity_id = + Map.get(payload, :entity_id) || Map.get(payload, "entity_id") || Map.get(payload, :entityId) || + Map.get(payload, "entityId") + + action = normalize_action(Map.get(payload, :action) || Map.get(payload, "action")) + + if is_binary(entity) and entity != "" and is_binary(entity_id) and entity_id != "" and + action in [:created, :updated, :deleted] do + {socket, workbench} = maybe_close_deleted_tab(socket, entity, entity_id, action) + + socket + |> maybe_refresh_tab_meta(entity, entity_id, action) + |> reload_fun.(workbench) + else + socket + end + end + + defp maybe_close_deleted_tab(socket, "post", post_id, :deleted) do + workbench = Workbench.close_tab(socket.assigns.workbench, :post, post_id) + + socket = + socket + |> assign(:workbench, workbench) + |> assign(:shell_overlay, nil) + |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id})) + |> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id)) + |> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id)) + |> assign(:post_editor_tag_queries, Map.delete(socket.assigns.post_editor_tag_queries, post_id)) + |> assign(:post_editor_category_queries, Map.delete(socket.assigns.post_editor_category_queries, post_id)) + |> assign(:post_editor_quick_actions_open, Map.delete(socket.assigns.post_editor_quick_actions_open, post_id)) + |> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id)) + |> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id)) + |> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id)) + + {socket, workbench} + end + + defp maybe_close_deleted_tab(socket, "media", media_id, :deleted) do + workbench = Workbench.close_tab(socket.assigns.workbench, :media, media_id) + + socket = + socket + |> assign(:workbench, workbench) + |> assign(:shell_overlay, nil) + |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id})) + |> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id)) + |> assign(:media_editor_quick_actions_open, Map.delete(socket.assigns.media_editor_quick_actions_open, media_id)) + |> assign(:media_editor_post_pickers_open, Map.delete(socket.assigns.media_editor_post_pickers_open, media_id)) + |> assign(:media_editor_post_picker_queries, Map.delete(socket.assigns.media_editor_post_picker_queries, media_id)) + |> assign(:media_editor_save_states, Map.delete(socket.assigns.media_editor_save_states, media_id)) + |> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) + + {socket, workbench} + end + + defp maybe_close_deleted_tab(socket, _entity, _entity_id, _action), do: {socket, socket.assigns.workbench} + + defp maybe_refresh_tab_meta(socket, "post", post_id, action) when action in [:created, :updated] do + maybe_put_tab_meta(socket, :post, post_id, fn -> + case Repo.get(Post, post_id) do + %Post{} = post -> %{title: post.title || post.slug || post.id, subtitle: Atom.to_string(post.status || :draft)} + _other -> nil + end + end) + end + + defp maybe_refresh_tab_meta(socket, "media", media_id, action) when action in [:created, :updated] do + maybe_put_tab_meta(socket, :media, media_id, fn -> + case Repo.get(Media, media_id) do + %Media{} = media -> %{title: media.title || media.filename || media.id, subtitle: media.filename || media.mime_type || "media"} + _other -> nil + end + end) + end + + defp maybe_refresh_tab_meta(socket, _entity, _entity_id, _action), do: socket + + defp maybe_put_tab_meta(socket, route, entity_id, meta_fun) do + key = {route, entity_id} + + if tab_present?(socket.assigns.workbench, key) or Map.has_key?(socket.assigns.tab_meta, key) do + case meta_fun.() do + %{} = fresh_meta -> + updated_meta = Map.update(socket.assigns.tab_meta, key, fresh_meta, &Map.merge(&1, fresh_meta)) + assign(socket, :tab_meta, updated_meta) + + _other -> + socket + end + else + socket + end + end + + defp tab_present?(%{tabs: tabs}, {route, entity_id}) do + Enum.any?(tabs, &(&1.type == route and &1.id == entity_id)) + end + + defp normalize_action(action) when action in [:created, :updated, :deleted], do: action + + defp normalize_action(action) do + action + |> to_string() + |> String.downcase() + |> case do + "created" -> :created + "updated" -> :updated + "deleted" -> :deleted + _other -> :unknown + end + end +end diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex index 5c651a2..f6b6de0 100644 --- a/lib/bds/desktop/shell_live/index.html.heex +++ b/lib/bds/desktop/shell_live/index.html.heex @@ -41,14 +41,14 @@ data-testid="window-titlebar-menu-dropdown" phx-click-away="close_titlebar_menu" > - <%= for item <- titlebar_menu_dropdown_items(group) do %> + <%= for item <- BDS.Desktop.ShellLive.TitlebarMenu.dropdown_items(group) do %> <%= if item.separator do %> <% else %>
<%= if Workbench.dirty?(@workbench, tab.type, tab.id) do %> @@ -383,7 +383,7 @@ <% else %> <%= cond do %> <% @current_tab.type == :post and @post_editor -> %> - + <% @current_tab.type == :media and @media_editor -> %> @@ -418,14 +418,14 @@ <% true -> %>
-
<%= tab_route_label(@current_tab) %>
-

<%= tab_title(@current_tab, @tab_meta) %>

-

<%= tab_subtitle(@current_tab, @tab_meta) %>

+
<%= BDS.Desktop.ShellLive.TabHelpers.tab_route_label(@current_tab) %>
+

<%= BDS.Desktop.ShellLive.TabHelpers.tab_title(@current_tab, @tab_meta) %>

+

<%= BDS.Desktop.ShellLive.TabHelpers.tab_subtitle(@current_tab, @tab_meta) %>

- <%= render_editor_toolbar(assigns) %> + <%= BDS.Desktop.ShellLive.PanelRenderer.render_editor_toolbar(assigns) %>
-

<%= tab_title(@current_tab, @tab_meta) %>

+

<%= BDS.Desktop.ShellLive.TabHelpers.tab_title(@current_tab, @tab_meta) %>

Desktop workbench content routed through the Elixir shell.

@@ -469,7 +469,7 @@
- <%= render_panel_body(assigns) %> + <%= BDS.Desktop.ShellLive.PanelRenderer.render_panel_body(assigns) %>
@@ -498,13 +498,13 @@
<%= translated("Project") %> - <%= assistant_project_name(@current_project) %> + <%= BDS.Desktop.ShellLive.ChatSurface.assistant_project_name(@current_project) %>
<%= translated("Editor") %> - <%= tab_title(@current_tab, @tab_meta) %> + <%= BDS.Desktop.ShellLive.TabHelpers.tab_title(@current_tab, @tab_meta) %>
-

<%= tab_subtitle(@current_tab, @tab_meta) %>

+

<%= BDS.Desktop.ShellLive.TabHelpers.tab_subtitle(@current_tab, @tab_meta) %>

- <%= assistant_message_label(message.role) %> + <%= BDS.Desktop.ShellLive.ChatSurface.assistant_message_label(message.role) %>

<%= message.content %>

<% end %> diff --git a/lib/bds/desktop/shell_live/layout.ex b/lib/bds/desktop/shell_live/layout.ex new file mode 100644 index 0000000..86b07ec --- /dev/null +++ b/lib/bds/desktop/shell_live/layout.ex @@ -0,0 +1,53 @@ +defmodule BDS.Desktop.ShellLive.Layout do + @moduledoc false + + alias BDS.UI.Workbench + + def sync(workbench, params) do + workbench + |> maybe_set_sidebar_width(Map.get(params, "sidebar_width")) + |> maybe_set_assistant_width(Map.get(params, "assistant_sidebar_width")) + end + + def resize(workbench, "sidebar", width) do + workbench + |> Workbench.set_sidebar_width(parse_width(width)) + |> Map.put(:sidebar_visible, true) + end + + def resize(workbench, "assistant", width) do + workbench + |> Workbench.set_assistant_sidebar_width(parse_width(width)) + |> Map.put(:assistant_sidebar_visible, true) + end + + def resize(workbench, _target, _width), do: workbench + + def ignore_shortcut?(params) do + Map.get(params, "alt", false) or + Map.get(params, "contentEditable", false) or + Map.get(params, "content_editable", false) or + Map.get(params, "tag") in ["INPUT", "TEXTAREA", "SELECT"] or + Map.get(params, :tag) in ["INPUT", "TEXTAREA", "SELECT"] + end + + defp maybe_set_sidebar_width(workbench, nil), do: workbench + defp maybe_set_sidebar_width(workbench, width), + do: Workbench.set_sidebar_width(workbench, parse_width(width)) + + defp maybe_set_assistant_width(workbench, nil), do: workbench + + defp maybe_set_assistant_width(workbench, width), + do: Workbench.set_assistant_sidebar_width(workbench, parse_width(width)) + + defp parse_width(width) when is_integer(width), do: width + + defp parse_width(width) when is_binary(width) do + case Integer.parse(width) do + {parsed, _rest} -> parsed + :error -> 0 + end + end + + defp parse_width(_), do: 0 +end diff --git a/lib/bds/desktop/shell_live/panel_renderer.ex b/lib/bds/desktop/shell_live/panel_renderer.ex new file mode 100644 index 0000000..5d07631 --- /dev/null +++ b/lib/bds/desktop/shell_live/panel_renderer.ex @@ -0,0 +1,290 @@ +defmodule BDS.Desktop.ShellLive.PanelRenderer do + @moduledoc false + + use Phoenix.Component + + alias BDS.Desktop.ShellData + alias BDS.Git + alias BDS.Media.Media + alias BDS.PostLinks + alias BDS.Posts.Post + alias BDS.Repo + + @doc "Render the active panel tab body." + def render_panel_body(assigns) do + case assigns.workbench.panel.active_tab do + :tasks -> render_task_entries(assigns) + :output -> render_output_entries(assigns) + :post_links -> render_post_links(assigns) + :git_log -> render_git_log(assigns) + other -> render_generic_panel(assigns, other) + end + end + + @doc "Render the editor toolbar for the current tab." + def render_editor_toolbar(assigns) do + buttons = editor_toolbar_buttons(assigns.current_tab) + assigns = assign(assigns, :editor_toolbar_buttons, buttons) + + ~H""" + <%= if Enum.any?(@editor_toolbar_buttons) do %> +
+ <%= for button <- @editor_toolbar_buttons do %> + + <% end %> +
+ <% end %> + """ + end + + defp render_task_entries(assigns) do + ~H""" + <%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %> +
+ <%= translated("Tasks") %> + <%= translated("No background tasks running") %> +
+ <% else %> +
+ <%= for task <- Map.get(@task_status, :tasks, []) do %> +
+
+ <%= task.name %> + <%= Map.get(task, :status_label, task.status |> to_string() |> String.capitalize()) %> +
+ <%= task.message || task.group_name || "" %> + <%= if is_number(task.progress) do %> +
+ + <%= Map.get(task, :progress_label, progress_percent(task.progress)) %> +
+ <% end %> +
+ <% end %> +
+ <% end %> + """ + end + + defp render_output_entries(assigns) do + ~H""" + <%= if Enum.empty?(@output_entries) do %> +
+ <%= translated("Output") %> + <%= translated("No shell output yet") %> +
+ <% else %> +
+ <%= for entry <- @output_entries do %> +
+ <%= entry.title %> + <%= entry.message %> + <%= if present?(entry.details) do %> + <%= entry.details %> + <% end %> +
+ <% end %> +
+ <% end %> + """ + end + + defp render_post_links(assigns) do + links = post_link_entries(assigns) + + assigns = + assigns + |> assign(:backlinks, Map.get(links, :backlinks, [])) + |> assign(:outlinks, Map.get(links, :outlinks, [])) + + ~H""" + <%= if Enum.empty?(@backlinks) and Enum.empty?(@outlinks) do %> +
+ <%= translated("Post Links") %> + <%= translated("No post links yet") %> +
+ <% else %> +
+ <%= if Enum.any?(@backlinks) do %> +
<%= translated("Backlinks") %>
+ <%= for entry <- @backlinks do %> + + <% end %> + <% end %> + + <%= if Enum.any?(@outlinks) do %> +
<%= translated("Links To") %>
+ <%= for entry <- @outlinks do %> + + <% end %> + <% end %> +
+ <% end %> + """ + end + + defp render_git_log(assigns) do + entries = git_log_entries(assigns) + assigns = assign(assigns, :git_entries, entries) + + ~H""" + <%= if Enum.empty?(@git_entries) do %> +
+
+ <%= translated("Git Log") %> + <%= translated("No git history yet") %> +
+
+ <% else %> +
+ <%= for entry <- @git_entries do %> +
+ <%= short_commit_hash(entry.hash) %> <%= entry.subject || translated("No commit subject") %> + <%= entry.hash %> +
+ <% end %> +
+ <% end %> + """ + end + + defp render_generic_panel(assigns, tab) do + assigns = assign(assigns, :panel_label, ShellData.route_label(tab)) + + ~H""" +
+ <%= @panel_label %> + <%= translated("The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.") %> +
+ """ + end + + defp post_link_entries(assigns) do + case assigns.current_tab do + %{type: :post, id: post_id} -> + %{ + backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id), + outlinks: related_posts(PostLinks.list_outgoing_links(post_id), :target_post_id) + } + + _other -> + %{backlinks: [], outlinks: []} + end + end + + defp related_posts(links, key) do + Enum.map(links, fn link -> + case Repo.get(Post, Map.fetch!(link, key)) do + %Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id} + _other -> nil + end + end) + |> Enum.reject(&is_nil/1) + end + + defp git_log_entries(assigns) do + case git_history_target(assigns.current_tab) do + nil -> + [] + + {project_id, file_path} -> + case Git.file_history(project_id, file_path) do + {:ok, %{commits: commits}} -> commits + _other -> [] + end + end + end + + defp git_history_target(%{type: :post, id: post_id}) do + case Repo.get(Post, post_id) do + %Post{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] -> {project_id, file_path} + _other -> nil + end + end + + defp git_history_target(%{type: :media, id: media_id}) do + case Repo.get(Media, media_id) do + %Media{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] -> {project_id, file_path} + _other -> nil + end + end + + defp git_history_target(_tab), do: nil + + def editor_toolbar_buttons(nil), do: [] + + def editor_toolbar_buttons(%{type: :post}) do + [ + %{kind: "ai_suggestions", label: "AI Suggestions", destructive: false}, + %{kind: "insert_link", label: "Insert Link", destructive: false}, + %{kind: "insert_media", label: "Insert Media", destructive: false}, + %{kind: "language_picker", label: "Translate", destructive: false}, + %{kind: "gallery", label: "Gallery", destructive: false} + ] + end + + def editor_toolbar_buttons(%{type: :media}) do + [ + %{kind: "ai_suggestions", label: "AI Suggestions", destructive: false}, + %{kind: "language_picker", label: "Translate", destructive: false}, + %{kind: "confirm_delete", label: "Delete Media", destructive: true} + ] + end + + def editor_toolbar_buttons(%{type: :tags}) do + [ + %{kind: "confirm_merge", label: "Merge Tags", destructive: false}, + %{kind: "confirm_delete", label: "Delete Tag", destructive: true} + ] + end + + def editor_toolbar_buttons(_tab), do: [] + + defp short_commit_hash(hash) when is_binary(hash), do: String.slice(hash, 0, 7) + defp short_commit_hash(_hash), do: "-------" + + defp progress_percent(progress) when is_number(progress) do + rounded = progress |> Kernel.*(100) |> Float.round(0) |> trunc() + "#{rounded}%" + end + + defp progress_percent(_), do: "" + + defp present?(value), do: value not in [nil, ""] + + defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) +end diff --git a/lib/bds/desktop/shell_live/session_util.ex b/lib/bds/desktop/shell_live/session_util.ex new file mode 100644 index 0000000..f48bceb --- /dev/null +++ b/lib/bds/desktop/shell_live/session_util.ex @@ -0,0 +1,48 @@ +defmodule BDS.Desktop.ShellLive.SessionUtil do + @moduledoc false + + import Phoenix.Component, only: [assign: 3] + + alias BDS.UI.{Session, Workbench} + + @default_new_project_name "New Blog" + + def restore_workbench_session(session_payload) do + Session.restore(session_payload) + rescue + _error -> Workbench.new() + end + + def next_project_name(projects) do + existing_names = MapSet.new(Enum.map(projects, & &1.name)) + + Stream.iterate(1, &(&1 + 1)) + |> Enum.find_value(fn index -> + candidate = + if index == 1, do: @default_new_project_name, else: "#{@default_new_project_name} #{index}" + + if MapSet.member?(existing_names, candidate), do: nil, else: candidate + end) + end + + def initial_handled_task_results do + BDS.Tasks.status_snapshot() + |> Map.get(:tasks, []) + |> Enum.filter(fn task -> task.status == :completed and is_map(task.result) end) + |> Enum.map(& &1.id) + |> MapSet.new() + end + + def next_completed_task_result(socket, task_status) do + handled = Map.get(socket.assigns, :handled_task_results, MapSet.new()) + + Enum.find(Map.get(task_status, :tasks, []), fn task -> + task.status == :completed and is_map(task.result) and not MapSet.member?(handled, task.id) + end) + end + + def mark_task_result_handled(socket, task_id) do + handled = Map.get(socket.assigns, :handled_task_results, MapSet.new()) + assign(socket, :handled_task_results, MapSet.put(handled, task_id)) + end +end diff --git a/lib/bds/desktop/shell_live/shell_command_runner.ex b/lib/bds/desktop/shell_live/shell_command_runner.ex new file mode 100644 index 0000000..6cb7556 --- /dev/null +++ b/lib/bds/desktop/shell_live/shell_command_runner.ex @@ -0,0 +1,95 @@ +defmodule BDS.Desktop.ShellLive.ShellCommandRunner do + @moduledoc false + + import Phoenix.Component, only: [assign: 3] + + alias BDS.Desktop.ShellCommands + alias BDS.Desktop.ShellLive.{TabHelpers, TaskLocalization} + alias BDS.UI.Workbench + + @doc """ + Execute a shell command and apply its result to the socket. + + `callbacks` requires: + * `:reload` — `(socket, workbench -> socket)` + * `:append_output` — `(socket, title, message, details, level -> socket)` + """ + def execute(socket, action, params \\ %{}, callbacks) do + case ShellCommands.execute(action, params) do + {:ok, result} -> + apply_result(socket, result, callbacks) + + {:error, %{message: message}} -> + callbacks.append_output.(socket, TaskLocalization.command_title(action), message, nil, "error") + + {:error, reason} -> + callbacks.append_output.(socket, TaskLocalization.command_title(action), inspect(reason), nil, "error") + end + end + + def apply_result(socket, %{kind: "task_queued", title: title, message: message, panel_tab: panel_tab}, callbacks) do + workbench = + socket.assigns.workbench + |> Workbench.set_panel_visible(true) + |> Workbench.set_panel_tab(String.to_existing_atom(panel_tab)) + + socket + |> callbacks.append_output.( + TaskLocalization.translate_for_socket(socket, title), + TaskLocalization.translate_for_socket(socket, message), + nil, + "info" + ) + |> callbacks.reload.(workbench) + end + + def apply_result(socket, %{kind: "output", title: title, message: message} = result, callbacks) do + callbacks.append_output.( + socket, + TaskLocalization.translate_for_socket(socket, title), + TaskLocalization.translate_for_socket(socket, message), + Map.get(result, :details), + Map.get(result, :level, "info") + ) + end + + def apply_result(socket, %{kind: "open_url", title: title, message: message, url: url}, callbacks) do + callbacks.append_output.( + socket, + TaskLocalization.translate_for_socket(socket, title), + TaskLocalization.translate_for_socket(socket, message), + url, + "info" + ) + end + + def apply_result(socket, %{kind: "open_editor", route: route, title: title, subtitle: subtitle} = result, callbacks) do + route_atom = String.to_existing_atom(route) + tab_id = TabHelpers.tab_id_for_route(route_atom, route) + workbench = Workbench.open_tab(socket.assigns.workbench, route_atom, tab_id, :pin) + + tab_meta = + Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{ + title: TaskLocalization.translate_for_socket(socket, title), + subtitle: TaskLocalization.translate_for_socket(socket, subtitle), + action: Map.get(result, :action), + payload: Map.get(result, :payload), + project_id: Map.get(result, :project_id), + editor_meta: TaskLocalization.translate_editor_meta(Map.get(result, :editorMeta, []), socket.assigns.page_language) + }) + + socket + |> assign(:tab_meta, tab_meta) + |> callbacks.reload.(workbench) + end + + def apply_result(socket, _result, _callbacks), do: socket + + def safe_existing_atom(action) when is_binary(action) do + String.to_existing_atom(action) + rescue + ArgumentError -> nil + end + + def safe_existing_atom(_), do: nil +end diff --git a/lib/bds/desktop/shell_live/sidebar_create.ex b/lib/bds/desktop/shell_live/sidebar_create.ex new file mode 100644 index 0000000..2ef1abc --- /dev/null +++ b/lib/bds/desktop/shell_live/sidebar_create.ex @@ -0,0 +1,131 @@ +defmodule BDS.Desktop.ShellLive.SidebarCreate do + @moduledoc false + + alias BDS.Desktop.{FilePicker, ShellData} + alias BDS.ImportDefinitions + alias BDS.Scripts + alias BDS.Templates + + @doc """ + Create a new sidebar item of the given kind for the active project. + + `callbacks` must contain: + * `:reload` — `(socket, workbench -> socket)` + * `:open_sidebar` — `(socket, params, intent -> socket)` + * `:append_output` — `(socket, title, message, details, level -> socket)` + """ + def create(socket, kind, callbacks) do + case socket.assigns.projects.active_project_id do + project_id when is_binary(project_id) -> create(socket, project_id, kind, callbacks) + _other -> callbacks.reload.(socket, socket.assigns.workbench) + end + end + + def create(socket, project_id, "post", callbacks) do + case BDS.Posts.create_post(%{project_id: project_id, title: "", content: "", tags: [], categories: []}) do + {:ok, _post} -> + callbacks.reload.(socket, socket.assigns.workbench) + + {:error, reason} -> + socket + |> callbacks.append_output.(translated("sidebar.newPost"), inspect(reason), nil, "error") + |> callbacks.reload.(socket.assigns.workbench) + end + end + + def create(socket, project_id, "media", callbacks) do + case FilePicker.choose_file(translated("sidebar.importMedia")) do + {:ok, source_path} -> + case BDS.Media.import_media(%{project_id: project_id, source_path: source_path}) do + {:ok, _media} -> + callbacks.reload.(socket, socket.assigns.workbench) + + {:error, reason} -> + socket + |> callbacks.append_output.(translated("sidebar.importMedia"), inspect(reason), nil, "error") + |> callbacks.reload.(socket.assigns.workbench) + end + + :cancel -> + callbacks.reload.(socket, socket.assigns.workbench) + + {:error, %{message: message}} -> + socket + |> callbacks.append_output.(translated("sidebar.importMedia"), message, nil, "error") + |> callbacks.reload.(socket.assigns.workbench) + end + end + + def create(socket, project_id, "script", callbacks) do + case Scripts.create_script(%{ + project_id: project_id, + title: translated("sidebar.scripts.newScript"), + kind: :utility, + content: "print(\"new script\")", + entrypoint: "main", + enabled: true + }) do + {:ok, script} -> + callbacks.open_sidebar.( + socket, + %{"route" => "scripts", "id" => script.id, "title" => script.title, "subtitle" => "Automation helpers"}, + :pin + ) + + {:error, reason} -> + socket + |> callbacks.append_output.(translated("sidebar.scripts.newScript"), inspect(reason), nil, "error") + |> callbacks.reload.(socket.assigns.workbench) + end + end + + def create(socket, project_id, "template", callbacks) do + case Templates.create_template(%{ + project_id: project_id, + title: translated("sidebar.templates.newTemplate"), + kind: :post, + content: "", + enabled: true + }) do + {:ok, template} -> + callbacks.open_sidebar.( + socket, + %{"route" => "templates", "id" => template.id, "title" => template.title, "subtitle" => "Site rendering"}, + :pin + ) + + {:error, reason} -> + socket + |> callbacks.append_output.(translated("sidebar.templates.newTemplate"), inspect(reason), nil, "error") + |> callbacks.reload.(socket.assigns.workbench) + end + end + + def create(socket, project_id, "import", callbacks) do + case ImportDefinitions.create_definition(%{project_id: project_id, name: translated("sidebar.import.newDefinition")}) do + {:ok, definition} -> + callbacks.open_sidebar.( + socket, + %{"route" => "import", "id" => definition.id, "title" => definition.name, "subtitle" => "Import definitions"}, + :pin + ) + + {:error, reason} -> + socket + |> callbacks.append_output.(translated("sidebar.import.newDefinition"), inspect(reason), nil, "error") + |> callbacks.reload.(socket.assigns.workbench) + end + end + + def create(socket, _project_id, _kind, callbacks), + do: callbacks.reload.(socket, socket.assigns.workbench) + + def action(:posts), do: %{kind: "post", label: "sidebar.newPost"} + def action(:media), do: %{kind: "media", label: "sidebar.importMedia"} + def action(:scripts), do: %{kind: "script", label: "sidebar.scripts.newScript"} + def action(:templates), do: %{kind: "template", label: "sidebar.templates.newTemplate"} + def action(:import), do: %{kind: "import", label: "sidebar.import.newDefinition"} + def action(_view), do: nil + + defp translated(text), do: ShellData.translate(text, %{}, Process.get(:bds_ui_locale)) +end diff --git a/lib/bds/desktop/shell_live/tab_helpers.ex b/lib/bds/desktop/shell_live/tab_helpers.ex new file mode 100644 index 0000000..4cc854f --- /dev/null +++ b/lib/bds/desktop/shell_live/tab_helpers.ex @@ -0,0 +1,99 @@ +defmodule BDS.Desktop.ShellLive.TabHelpers do + @moduledoc false + + alias BDS.Desktop.ShellData + alias BDS.Media.Media + alias BDS.Posts.Post + alias BDS.Repo + alias BDS.UI.Registry + + def tab_title(nil, _tab_meta), do: translated("Dashboard") + + def tab_title(tab, tab_meta) do + case Map.get(tab_meta, {tab.type, tab.id}) do + %{title: title} when is_binary(title) and title != "" -> title + _other -> default_tab_title(tab) + end + end + + def tab_subtitle(nil, _tab_meta), do: translated("dashboard.subtitle") + + def tab_subtitle(tab, tab_meta) do + case Map.get(tab_meta, {tab.type, tab.id}) do + %{subtitle: subtitle} when is_binary(subtitle) and subtitle != "" -> subtitle + _other -> "Desktop workbench content routed through the Elixir shell." + end + end + + def default_tab_title(%{type: type, id: id}) do + case Registry.editor_route(type) do + %{singleton: true} -> ShellData.route_label(type) + _other -> id + end + end + + def tab_route_label(nil), do: translated("Dashboard") + def tab_route_label(%{type: type}), do: ShellData.route_label(type) + + def tab_icon_id(nil), do: "posts" + def tab_icon_id(%{type: :post}), do: "posts" + def tab_icon_id(%{type: :git_diff}), do: "git" + def tab_icon_id(%{type: :style}), do: "settings" + def tab_icon_id(%{type: type}), do: Atom.to_string(type) + + def sidebar_route_atom(route) when is_atom(route), do: route + def sidebar_route_atom(route) when is_binary(route), do: String.to_existing_atom(route) + + def tab_id_for_route(route, id) do + case Registry.editor_route(route) do + %{singleton: true} -> Atom.to_string(route) + _other -> id + end + end + + def tab_intent(route, requested_intent) do + case Registry.editor_route(route) do + %{singleton: true} -> :pin + _other -> requested_intent + end + end + + def post_title(post_id) do + case Repo.get(Post, post_id) do + %Post{} = post -> post.title || post.slug || post.id + _other -> "Post" + end + end + + def post_subtitle(post_id) do + case Repo.get(Post, post_id) do + %Post{} = post -> post.slug || "draft" + _other -> "draft" + end + end + + def media_title(media_id) do + case Repo.get(Media, media_id) do + %Media{} = media -> media.title || media.filename || media.id + _other -> "Media" + end + end + + def media_subtitle(media_id) do + case Repo.get(Media, media_id) do + %Media{} = media -> media.filename || media.mime_type || "media" + _other -> "media" + end + end + + def parse_integer(value) when is_integer(value), do: value + + def parse_integer(value) do + case Integer.parse(to_string(value || "0")) do + {parsed, _rest} -> parsed + :error -> 0 + end + end + + defp translated(text), do: ShellData.translate(text, %{}, Process.get(:bds_ui_locale)) +end diff --git a/lib/bds/desktop/shell_live/task_localization.ex b/lib/bds/desktop/shell_live/task_localization.ex new file mode 100644 index 0000000..6599667 --- /dev/null +++ b/lib/bds/desktop/shell_live/task_localization.ex @@ -0,0 +1,80 @@ +defmodule BDS.Desktop.ShellLive.TaskLocalization do + @moduledoc false + + alias BDS.Desktop.ShellData + + def localize_task_status(task_status, locale) do + tasks = Enum.map(Map.get(task_status, :tasks, []), &localize_task(&1, locale)) + active = Enum.filter(tasks, &(&1.status in [:running, :pending])) + + task_status + |> Map.put(:tasks, tasks) + |> Map.put(:running_task_message, localized_running_task_message(active, locale)) + end + + def translate_editor_meta(items, locale) do + Enum.map(items, fn item -> + item + |> Map.update(:label, nil, &ShellData.translate(&1, %{}, locale)) + |> Map.update(:value, nil, &translate_editor_meta_value(&1, locale)) + end) + end + + def translate_for_socket(socket, text) when is_binary(text), + do: ShellData.translate(text, %{}, socket.assigns.page_language) + + def translate_for_socket(_socket, text), do: text + + def progress_percent(progress) when is_number(progress) do + percentage = progress |> Kernel.*(100) |> round() + Integer.to_string(percentage) <> "%" + end + + def command_title(action) do + action + |> to_string() + |> String.replace("_", " ") + |> String.split() + |> Enum.map_join(" ", &String.capitalize/1) + end + + defp localize_task(task, locale) do + progress = Map.get(task, :progress) + + task + |> Map.put(:name, ShellData.translate(task.name, %{}, locale)) + |> Map.put(:message, localize_task_message(Map.get(task, :message), locale)) + |> Map.put(:group_name, localize_task_group(Map.get(task, :group_name), locale)) + |> Map.put(:status_label, localize_task_status_label(task.status, locale)) + |> Map.put(:progress_label, if(is_number(progress), do: progress_percent(progress), else: nil)) + end + + defp localize_task_message(nil, _locale), do: nil + defp localize_task_message("", _locale), do: "" + defp localize_task_message(message, locale), do: ShellData.translate(message, %{}, locale) + + defp localize_task_group(nil, _locale), do: nil + defp localize_task_group(group, locale), do: ShellData.translate(group, %{}, locale) + + defp localize_task_status_label(status, locale) do + status + |> to_string() + |> String.capitalize() + |> ShellData.translate(%{}, locale) + end + + defp localized_running_task_message([], _locale), do: nil + + defp localized_running_task_message([task | _rest], locale) do + cond do + task.status == :pending -> ShellData.translate("Queued", %{}, locale) <> ": " <> task.name + is_binary(task.message) and task.message != "" -> task.name <> ": " <> task.message + true -> task.name + end + end + + defp translate_editor_meta_value(value, locale) when is_binary(value), + do: ShellData.translate(value, %{}, locale) + + defp translate_editor_meta_value(value, _locale), do: value +end diff --git a/lib/bds/desktop/shell_live/titlebar_menu.ex b/lib/bds/desktop/shell_live/titlebar_menu.ex new file mode 100644 index 0000000..8def69e --- /dev/null +++ b/lib/bds/desktop/shell_live/titlebar_menu.ex @@ -0,0 +1,181 @@ +defmodule BDS.Desktop.ShellLive.TitlebarMenu do + @moduledoc false + + use Phoenix.Component + alias BDS.Desktop.MenuBar, as: DesktopMenuBar + + @spec groups() :: [map()] + def groups do + DesktopMenuBar.groups(dev_mode?: Application.get_env(:bds, :dev_routes, false)) + end + + @spec dropdown_items(map()) :: [map()] + def dropdown_items(group) do + group.items + |> Enum.map_reduce(0, fn item, keyboard_index -> + if Map.get(item, :separator, false) do + {%{separator: true}, keyboard_index} + else + {Map.put(item, :keyboard_index, keyboard_index), keyboard_index + 1} + end + end) + |> elem(0) + end + + @spec item_active?(map(), map(), non_neg_integer() | nil) :: boolean() + def item_active?(group, item, current_index) do + cond do + is_nil(current_index) -> + false + + Map.get(item, :separator, false) -> + false + + true -> + group.items + |> Enum.reject(&Map.get(&1, :separator, false)) + |> Enum.find_index(&(&1.id == item.id)) + |> Kernel.==(current_index) + end + end + + @spec active_group(map()) :: map() | nil + def active_group(assigns) do + Enum.find(assigns.menu_groups || [], fn group -> Atom.to_string(group.id) == assigns.titlebar_menu_group end) + end + + @spec active_items(map()) :: [map()] + def active_items(assigns) do + assigns + |> active_group() + |> case do + nil -> [] + group -> Enum.reject(group.items, &Map.get(&1, :separator, false)) + end + end + + @spec open(Phoenix.LiveView.Socket.t(), String.t()) :: Phoenix.LiveView.Socket.t() + def open(socket, group) do + socket + |> assign(:titlebar_menu_group, group) + |> assign(:titlebar_menu_item_index, nil) + end + + @spec close(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() + def close(socket) do + socket + |> assign(:titlebar_menu_group, nil) + |> assign(:titlebar_menu_item_index, nil) + end + + @spec toggle(Phoenix.LiveView.Socket.t(), String.t()) :: Phoenix.LiveView.Socket.t() + def toggle(socket, group) do + if socket.assigns.titlebar_menu_group == group do + close(socket) + else + open(socket, group) + end + end + + @spec hover(Phoenix.LiveView.Socket.t(), String.t()) :: Phoenix.LiveView.Socket.t() + def hover(socket, group) do + if socket.assigns.titlebar_menu_group do + open(socket, group) + else + socket + end + end + + @doc """ + Handle a keydown event on an open titlebar menu. `invoke_fun` is called + with the action id (string) when the user activates an item. + """ + @spec handle_keydown(Phoenix.LiveView.Socket.t(), String.t(), (Phoenix.LiveView.Socket.t(), String.t() -> Phoenix.LiveView.Socket.t())) :: + Phoenix.LiveView.Socket.t() + def handle_keydown(socket, key, invoke_fun) do + if socket.assigns.titlebar_menu_group do + case key do + "Escape" -> close(socket) + "ArrowRight" -> rotate_group(socket, 1) + "ArrowLeft" -> rotate_group(socket, -1) + "ArrowDown" -> advance_item_index(socket, 1) + "ArrowUp" -> advance_item_index(socket, -1) + "Home" -> set_first_item_index(socket) + "End" -> set_last_item_index(socket) + "Enter" -> invoke_active_item(socket, invoke_fun) + " " -> invoke_active_item(socket, invoke_fun) + _other -> socket + end + else + socket + end + end + + defp rotate_group(socket, offset) do + groups = socket.assigns.menu_groups || [] + current_group = socket.assigns.titlebar_menu_group + current_index = Enum.find_index(groups, fn group -> Atom.to_string(group.id) == current_group end) + + if is_nil(current_index) or groups == [] do + socket + else + next_index = rem(current_index + offset + length(groups), length(groups)) + next_group = Enum.at(groups, next_index) + open(socket, Atom.to_string(next_group.id)) + end + end + + defp advance_item_index(socket, offset) do + items = active_items(socket.assigns) + current_index = socket.assigns[:titlebar_menu_item_index] + + cond do + items == [] -> + socket + + current_index == nil and offset > 0 -> + assign(socket, :titlebar_menu_item_index, 0) + + current_index == nil and offset < 0 -> + assign(socket, :titlebar_menu_item_index, length(items) - 1) + + true -> + next_index = rem(current_index + offset + length(items), length(items)) + assign(socket, :titlebar_menu_item_index, next_index) + end + end + + defp set_last_item_index(socket) do + items = active_items(socket.assigns) + + if items == [] do + socket + else + assign(socket, :titlebar_menu_item_index, length(items) - 1) + end + end + + defp set_first_item_index(socket) do + items = active_items(socket.assigns) + + if items == [] do + socket + else + assign(socket, :titlebar_menu_item_index, 0) + end + end + + defp invoke_active_item(socket, invoke_fun) do + items = active_items(socket.assigns) + + case Enum.at(items, socket.assigns[:titlebar_menu_item_index]) do + %{id: id} -> + socket + |> close() + |> invoke_fun.(Atom.to_string(id)) + + _other -> + socket + end + end +end diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index c7573a2..c3f65b3 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -121,12 +121,14 @@ defmodule BDS.UI.ShellTest do 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") + session_util_ex = + File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/session_util.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 session_util_ex =~ "Session.restore" assert live_ex =~ "encoded_workbench_session" end