defmodule BDS.Desktop.ShellLive do @moduledoc false use Phoenix.LiveView import Phoenix.HTML alias BDS.AI alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData} alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, 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.Projects alias BDS.Repo alias BDS.Scripts alias BDS.Templates alias BDS.UI.{Commands, MenuBar, Registry, 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, :toggle_assistant_sidebar, :view_posts, :view_media, :edit_preferences, :edit_menu, :documentation, :api_documentation, :close_tab ]) embed_templates "shell_live/*" @impl true def mount(_params, _session, socket) do connected = connected?(socket) if connected do :timer.send_interval(@refresh_interval, :refresh_task_status) end workbench = Workbench.new() {:ok, socket |> assign(:page_title, ShellData.title()) |> 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(:assistant_prompt, "") |> assign(:assistant_messages, []) |> assign(:is_mac_ui, mac_ui?()) |> assign(:menu_groups, titlebar_menu_groups()) |> assign(:titlebar_menu_group, nil) |> assign(:titlebar_menu_item_index, nil) |> assign(:tab_meta, %{}) |> assign(:project_menu_open, false) |> assign(:sidebar_filters_by_view, %{}) |> assign(:sidebar_filter_panels, %{}) |> assign(:post_editor_drafts, %{}) |> assign(:post_editor_active_languages, %{}) |> assign(:post_editor_tag_queries, %{}) |> assign(:post_editor_category_queries, %{}) |> assign(:post_editor_quick_actions_open, %{}) |> assign(:post_editor_modes, %{}) |> assign(:post_editor_expanded, %{}) |> assign(:post_editor_save_states, %{}) |> assign(:media_editor_drafts, %{}) |> assign(:media_editor_quick_actions_open, %{}) |> assign(:media_editor_post_pickers_open, %{}) |> assign(:media_editor_post_picker_queries, %{}) |> assign(:media_editor_save_states, %{}) |> assign(:media_editor_translation_forms, %{}) |> assign(:settings_editor_search, "") |> assign(:settings_editor_project_draft, %{}) |> assign(:settings_editor_endpoint_models, %{}) |> assign(:settings_editor_publishing_draft, %{}) |> assign(:settings_editor_new_category, "") |> assign(:style_editor_theme, nil) |> assign(:style_editor_preview_mode, "auto") |> assign(:tags_editor_selected, []) |> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""}) |> assign(:tags_editor_edit_draft, %{}) |> assign(:tags_editor_merge_target, "") |> assign(:script_editor_drafts, %{}) |> assign(:template_editor_drafts, %{}) |> assign(:chat_editor_inputs, %{}) |> assign(:misc_editor_selected_pairs, %{}) |> assign(:shell_overlay, nil) |> assign(:output_entries, []) |> reload_shell(workbench)} end @impl true def handle_event("toggle_sidebar", _params, socket) do {:noreply, reload_shell(socket, Workbench.toggle_sidebar(socket.assigns.workbench))} end def handle_event("toggle_panel", _params, socket) do {:noreply, reload_shell(socket, Workbench.toggle_panel(socket.assigns.workbench))} end def handle_event("toggle_assistant_sidebar", _params, socket) do {:noreply, reload_shell(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))} end def handle_event("select_view", %{"view" => view_id}, socket) do workbench = Workbench.click_activity(socket.assigns.workbench, String.to_existing_atom(view_id)) {:noreply, reload_shell(socket, workbench)} end def handle_event("select_panel_tab", %{"tab" => tab}, socket) do workbench = socket.assigns.workbench |> Workbench.set_panel_visible(true) |> Workbench.set_panel_tab(String.to_existing_atom(tab)) {:noreply, reload_shell(socket, workbench)} end def handle_event("open_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do {:noreply, open_sidebar_item(socket, params, :preview)} end def handle_event("pin_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do {:noreply, open_sidebar_item(socket, params, :pin)} end def handle_event("sync_layout", params, socket) do {:noreply, reload_shell(socket, sync_layout(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))} end def handle_event("toggle_sidebar_filters", _params, socket) do socket = ShellSidebarState.put_filter_panel_state(socket, fn state -> if state.visible do %{state | visible: false} else %{visible: true, archive_collapsed: true, tags_collapsed: true, categories_collapsed: true, expanded_year: nil} end end) {:noreply, socket |> reload_shell(socket.assigns.workbench)} end def handle_event("toggle_sidebar_archive", _params, socket) do {:noreply, socket |> ShellSidebarState.put_filter_panel_state(fn state -> %{state | archive_collapsed: not state.archive_collapsed} end) |> reload_shell(socket.assigns.workbench)} end def handle_event("toggle_sidebar_tags", _params, socket) do {:noreply, socket |> ShellSidebarState.put_filter_panel_state(fn state -> %{state | tags_collapsed: not state.tags_collapsed} end) |> reload_shell(socket.assigns.workbench)} end def handle_event("toggle_sidebar_categories", _params, socket) do {:noreply, socket |> ShellSidebarState.put_filter_panel_state(fn state -> %{state | categories_collapsed: not state.categories_collapsed} end) |> reload_shell(socket.assigns.workbench)} end def handle_event("update_sidebar_search", %{"sidebar_filters" => params}, socket) do {:noreply, socket |> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :search, ShellSidebarState.normalize_filter_string(Map.get(params, "search"))) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("clear_sidebar_search", _params, socket) do {:noreply, socket |> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :search, nil) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("clear_sidebar_tags", _params, socket) do {:noreply, socket |> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :tags, []) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("clear_sidebar_categories", _params, socket) do {:noreply, socket |> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :categories, []) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("toggle_sidebar_tag", %{"tag" => tag}, socket) do {:noreply, socket |> ShellSidebarState.put_filters(fn filters -> ShellSidebarState.toggle_filter_value(filters, :tags, tag) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("toggle_sidebar_category", %{"category" => category}, socket) do {:noreply, socket |> ShellSidebarState.put_filters(fn filters -> ShellSidebarState.toggle_filter_value(filters, :categories, category) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("select_sidebar_year", %{"year" => year}, socket) do parsed_year = ShellSidebarState.parse_optional_integer(year) {:noreply, socket |> ShellSidebarState.put_filter_panel_state(fn state -> %{state | archive_collapsed: false, expanded_year: if(state.expanded_year == parsed_year, do: nil, else: parsed_year) } end) |> ShellSidebarState.put_filters(fn filters -> filters |> Map.put(:year, parsed_year) |> Map.put(:month, nil) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("select_sidebar_month", %{"year" => year, "month" => month}, socket) do {:noreply, socket |> ShellSidebarState.put_filter_panel_state(fn state -> %{state | archive_collapsed: false, expanded_year: ShellSidebarState.parse_optional_integer(year)} end) |> ShellSidebarState.put_filters(fn filters -> filters |> Map.put(:year, ShellSidebarState.parse_optional_integer(year)) |> Map.put(:month, ShellSidebarState.parse_optional_integer(month)) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("clear_sidebar_month", _params, socket) do {:noreply, socket |> ShellSidebarState.put_filter_panel_state(fn state -> %{state | archive_collapsed: false} end) |> ShellSidebarState.put_filters(fn filters -> filters |> Map.put(:year, nil) |> Map.put(:month, nil) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("clear_sidebar_filters", _params, socket) do {:noreply, socket |> ShellSidebarState.put_filters(fn filters -> filters |> Map.put(:search, nil) |> Map.put(:year, nil) |> Map.put(:month, nil) |> Map.put(:tags, []) |> Map.put(:categories, []) |> Map.put(:display_limit, ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data)) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("load_more_sidebar", _params, socket) do {:noreply, socket |> ShellSidebarState.put_filters(fn filters -> Map.update(filters, :display_limit, ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data), &(&1 + ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data))) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do {:noreply, create_sidebar_item(socket, kind)} end def handle_event("shortcut", params, socket) do if ignore_shortcut?(params) do {:noreply, socket} else {:noreply, reload_shell(socket, Commands.handle_shortcut(socket.assigns.workbench, params))} end end def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do workbench = Workbench.open_tab(socket.assigns.workbench, String.to_existing_atom(type), id, :preview) {:noreply, reload_shell(socket, workbench)} end def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do type_atom = String.to_existing_atom(type) workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id) tab_meta = Map.delete(socket.assigns.tab_meta, {type_atom, id}) {:noreply, socket |> assign(:tab_meta, tab_meta) |> reload_shell(workbench)} end def handle_event("delete_sidebar_template", %{"id" => template_id}, socket) do case Repo.get(Templates.Template, template_id) do %Templates.Template{project_id: project_id} when project_id == socket.assigns.projects.active_project_id -> case Templates.delete_template(template_id) do {:ok, :deleted} -> workbench = Workbench.close_tab(socket.assigns.workbench, :templates, template_id) tab_meta = Map.delete(socket.assigns.tab_meta, {:templates, template_id}) {:noreply, socket |> assign(:tab_meta, tab_meta) |> reload_shell(workbench)} {:error, reason} -> {:noreply, socket |> append_output_entry(translated("Delete") <> " " <> translated("Template"), inspect(reason), nil, "error") |> reload_shell(socket.assigns.workbench)} end _other -> {:noreply, socket |> append_output_entry(translated("Delete") <> " " <> translated("Template"), inspect(:not_found), nil, "error") |> reload_shell(socket.assigns.workbench)} end end def handle_event("toggle_offline_mode", _params, socket) do next_mode = not socket.assigns.offline_mode :ok = AI.set_airplane_mode(next_mode) socket = assign(socket, :offline_mode, next_mode) {:noreply, reload_shell(socket, socket.assigns.workbench)} end def handle_event("update_assistant_prompt", %{"assistant" => %{"prompt" => prompt}}, socket) do {:noreply, assign(socket, :assistant_prompt, prompt)} end def handle_event("submit_assistant_prompt", %{"assistant" => %{"prompt" => prompt}}, socket) do prompt = prompt |> to_string() |> String.trim() socket = if prompt == "" do assign(socket, :assistant_prompt, "") else socket |> assign(:assistant_prompt, "") |> assign(:assistant_messages, socket.assigns.assistant_messages ++ assistant_turn(prompt, socket)) end {:noreply, socket} end def handle_event("open_tasks_panel", _params, socket) do workbench = socket.assigns.workbench |> Workbench.set_panel_visible(true) |> Workbench.set_panel_tab(:tasks) {:noreply, reload_shell(socket, workbench)} end def handle_event("change_post_editor", %{"post_editor" => params}, socket) do {:noreply, PostEditor.update(socket, params, &reload_shell/2)} end def handle_event("save_post_editor", %{"id" => post_id}, socket) do {:noreply, PostEditor.persist_socket(socket, post_id, :save, &reload_shell/2, &append_output_entry/5)} end def handle_event("publish_post_editor", %{"id" => post_id}, socket) do {:noreply, PostEditor.persist_socket(socket, post_id, :publish, &reload_shell/2, &append_output_entry/5)} end def handle_event("discard_post_editor", %{"id" => post_id}, socket) do {:noreply, PostEditor.discard_socket(socket, post_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("delete_post_editor", %{"id" => post_id}, socket) do {:noreply, PostEditor.delete_socket(socket, post_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("set_post_editor_mode", %{"id" => post_id, "mode" => mode}, socket) do {:noreply, PostEditor.set_mode(socket, post_id, mode, &reload_shell/2)} end def handle_event("toggle_post_metadata", %{"id" => post_id}, socket) do {:noreply, PostEditor.toggle_section(socket, post_id, :metadata, &reload_shell/2)} end def handle_event("toggle_post_excerpt", %{"id" => post_id}, socket) do {:noreply, PostEditor.toggle_section(socket, post_id, :excerpt, &reload_shell/2)} end def handle_event("select_post_editor_language", %{"id" => post_id, "language" => language}, socket) do {:noreply, PostEditor.select_language(socket, post_id, language, &reload_shell/2)} end def handle_event("toggle_post_editor_quick_actions", %{"id" => post_id}, socket) do {:noreply, PostEditor.toggle_quick_actions(socket, post_id, &reload_shell/2)} end def handle_event("detect_post_editor_language", %{"id" => post_id}, socket) do {:noreply, PostEditor.detect_language(socket, post_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("add_post_editor_tag", %{"id" => post_id, "tag" => tag}, socket) do {:noreply, PostEditor.add_list_value(socket, post_id, :tags, tag, &reload_shell/2)} end def handle_event("remove_post_editor_tag", %{"id" => post_id, "tag" => tag}, socket) do {:noreply, PostEditor.remove_list_value(socket, post_id, :tags, tag, &reload_shell/2)} end def handle_event("add_post_editor_category", %{"id" => post_id, "category" => category}, socket) do {:noreply, PostEditor.add_list_value(socket, post_id, :categories, category, &reload_shell/2)} end def handle_event("remove_post_editor_category", %{"id" => post_id, "category" => category}, socket) do {:noreply, PostEditor.remove_list_value(socket, post_id, :categories, category, &reload_shell/2)} end def handle_event("change_media_editor", %{"media_editor" => params}, socket) do {:noreply, MediaEditor.update(socket, params, &reload_shell/2)} end def handle_event("save_media_editor", %{"id" => media_id}, socket) do {:noreply, MediaEditor.persist_socket(socket, media_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("toggle_media_editor_quick_actions", %{"id" => media_id}, socket) do {:noreply, MediaEditor.toggle_quick_actions(socket, media_id, &reload_shell/2)} end def handle_event("replace_media_editor_file", %{"id" => media_id}, socket) do {:noreply, MediaEditor.replace_file(socket, media_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("detect_media_editor_language", %{"id" => media_id}, socket) do {:noreply, MediaEditor.detect_language(socket, media_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("toggle_media_post_picker", %{"id" => media_id}, socket) do {:noreply, MediaEditor.toggle_post_picker(socket, media_id, &reload_shell/2)} end def handle_event("change_media_post_picker", %{"id" => media_id, "media_post_picker" => %{"query" => query}}, socket) do {:noreply, MediaEditor.set_post_picker_query(socket, media_id, query, &reload_shell/2)} end def handle_event("link_media_to_post", %{"id" => media_id, "post-id" => post_id}, socket) do {:noreply, MediaEditor.link_post(socket, media_id, post_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("unlink_media_from_post", %{"id" => media_id, "post-id" => post_id}, socket) do {:noreply, MediaEditor.unlink_post(socket, media_id, post_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("edit_media_translation", %{"id" => media_id, "language" => language}, socket) do {:noreply, MediaEditor.edit_translation(socket, media_id, language, &reload_shell/2)} end def handle_event("change_media_translation", %{"media_translation" => params}, socket) do case socket.assigns.current_tab do %{type: :media, id: media_id} -> {:noreply, MediaEditor.update_translation(socket, media_id, params, &reload_shell/2)} _other -> {:noreply, socket} end end def handle_event("save_media_translation", %{"id" => media_id}, socket) do {:noreply, MediaEditor.save_translation(socket, media_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("refresh_media_translation", %{"id" => media_id, "language" => language}, socket) do {:noreply, MediaEditor.refresh_translation(socket, media_id, language, &reload_shell/2, &append_output_entry/5)} end def handle_event("delete_media_translation", %{"id" => media_id, "language" => language}, socket) do {:noreply, MediaEditor.delete_translation(socket, media_id, language, &reload_shell/2, &append_output_entry/5)} end def handle_event("close_media_translation_editor", _params, socket) do case socket.assigns.current_tab do %{type: :media, id: media_id} -> {:noreply, socket |> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) |> reload_shell(socket.assigns.workbench)} _other -> {:noreply, socket} end end def handle_event("change_settings_search", %{"query" => query}, socket) do {:noreply, SettingsEditor.update_search(socket, query, &reload_shell/2)} end def handle_event("change_settings_project", %{"settings_project" => params}, socket) do {:noreply, SettingsEditor.update_project_draft(socket, params, &reload_shell/2)} end def handle_event("change_settings_editor", %{"settings_editor" => params}, socket) do {:noreply, SettingsEditor.update_editor_draft(socket, params, &reload_shell/2)} end def handle_event("save_settings_editor", _params, socket) do {:noreply, SettingsEditor.save_editor(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("save_settings_project", _params, socket) do {:noreply, SettingsEditor.save_project(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("change_settings_publishing", %{"settings_publishing" => params}, socket) do {:noreply, SettingsEditor.update_publishing_draft(socket, params, &reload_shell/2)} end def handle_event("change_settings_ai", %{"settings_ai" => params}, socket) do {:noreply, SettingsEditor.update_ai_draft(socket, params, &reload_shell/2)} end def handle_event("refresh_settings_ai_models", %{"endpoint" => endpoint}, socket) do endpoint_key = String.to_existing_atom(endpoint) {:noreply, SettingsEditor.refresh_ai_models(socket, endpoint_key, &reload_shell/2, &append_output_entry/5)} end def handle_event("save_settings_ai", _params, socket) do {:noreply, SettingsEditor.save_ai(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("reset_settings_ai_prompt", _params, socket) do {:noreply, SettingsEditor.reset_ai_prompt(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("save_settings_publishing", _params, socket) do {:noreply, SettingsEditor.save_publishing(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("clear_settings_publishing", _params, socket) do {:noreply, SettingsEditor.clear_publishing(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("change_settings_new_category", %{"name" => name}, socket) do {:noreply, SettingsEditor.update_new_category(socket, name, &reload_shell/2)} end def handle_event("add_settings_category", _params, socket) do {:noreply, SettingsEditor.add_category(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("reset_settings_categories", _params, socket) do {:noreply, SettingsEditor.reset_categories(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("save_settings_category", %{"category_settings" => params}, socket) do {:noreply, SettingsEditor.save_category(socket, params, &reload_shell/2, &append_output_entry/5)} end def handle_event("remove_settings_category", %{"category" => category}, socket) do {:noreply, SettingsEditor.remove_category(socket, category, &reload_shell/2, &append_output_entry/5)} end def handle_event("settings_shell_command", %{"action" => action}, socket) do {:noreply, apply_shell_command(socket, action)} end def handle_event("toggle_settings_mcp_agent", %{"agent" => agent}, socket) do {:noreply, SettingsEditor.toggle_mcp_agent(socket, agent, &reload_shell/2, &append_output_entry/5)} end def handle_event("select_style_theme", %{"theme" => theme}, socket) do {:noreply, SettingsEditor.select_style_theme(socket, theme, &reload_shell/2)} end def handle_event("change_style_preview_mode", %{"mode" => mode}, socket) do {:noreply, SettingsEditor.change_style_preview_mode(socket, mode, &reload_shell/2)} end def handle_event("apply_style_theme", _params, socket) do {:noreply, SettingsEditor.apply_style_theme(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("toggle_tag_selection", %{"name" => tag_name}, socket) do {:noreply, TagsEditor.toggle_selection(socket, tag_name, &reload_shell/2)} end def handle_event("change_new_tag_editor", %{"new_tag" => params}, socket) do {:noreply, TagsEditor.update_new_tag(socket, params, &reload_shell/2)} end def handle_event("create_tag_editor", _params, socket) do {:noreply, TagsEditor.create_tag(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("change_edit_tag_editor", %{"edit_tag" => params}, socket) do {:noreply, TagsEditor.update_edit_tag(socket, params, &reload_shell/2)} end def handle_event("save_tag_editor", _params, socket) do {:noreply, TagsEditor.save_tag(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("delete_tag_editor", _params, socket) do {:noreply, TagsEditor.delete_selected(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("change_merge_target", %{"target" => target}, socket) do {:noreply, TagsEditor.update_merge_target(socket, target, &reload_shell/2)} end def handle_event("merge_tags_editor", _params, socket) do {:noreply, TagsEditor.merge_selected(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("sync_tags_editor", _params, socket) do {:noreply, TagsEditor.sync(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("change_script_editor", %{"script_editor" => params}, socket) do {:noreply, CodeEntityEditor.update_script(socket, params, &reload_shell/2)} end def handle_event("save_script_editor", _params, socket) do {:noreply, CodeEntityEditor.save_script(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("run_script_editor", _params, socket) do {:noreply, CodeEntityEditor.run_script(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("check_script_editor", _params, socket) do {:noreply, CodeEntityEditor.check_script(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("delete_script_editor", _params, socket) do {:noreply, CodeEntityEditor.delete_script(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("change_template_editor", %{"template_editor" => params}, socket) do {:noreply, CodeEntityEditor.update_template(socket, params, &reload_shell/2)} end def handle_event("save_template_editor", _params, socket) do {:noreply, CodeEntityEditor.save_template(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("validate_template_editor", _params, socket) do {:noreply, CodeEntityEditor.validate_template(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("delete_template_editor", _params, socket) do {:noreply, CodeEntityEditor.delete_template(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("change_chat_editor_input", %{"message" => message}, socket) do {:noreply, ChatEditor.update_input(socket, message, &reload_shell/2)} end def handle_event("send_chat_editor_message", _params, socket) do {:noreply, ChatEditor.send_message(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("rerun_misc_editor", _params, socket) do case MiscEditor.rerun(socket) do {:command, action} -> {:noreply, apply_shell_command(socket, action)} {:noop, next_socket} -> {:noreply, next_socket} end end def handle_event("apply_site_validation", _params, socket) do case MiscEditor.apply_site_validation(socket, &append_output_entry/5) do {:rerun, next_socket} -> {:noreply, apply_shell_command(next_socket, "validate_site")} {:socket, next_socket} -> {:noreply, next_socket} end end def handle_event("toggle_duplicate_pair", %{"pair-id" => pair_id}, socket) do {:noreply, MiscEditor.toggle_duplicate(socket, pair_id, &reload_shell/2)} end def handle_event("dismiss_duplicate_pair", %{"post-id-a" => post_id_a, "post-id-b" => post_id_b}, socket) do {:noreply, MiscEditor.dismiss_duplicate(socket, post_id_a, post_id_b, &reload_shell/2, &append_output_entry/5)} end def handle_event("dismiss_selected_duplicates", _params, socket) do {:noreply, MiscEditor.dismiss_selected(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("open_duplicate_post", %{"id" => id, "title" => title}, socket) do {:noreply, open_sidebar_item(socket, %{"route" => "post", "id" => id, "title" => title, "subtitle" => "draft"}, :preview)} end def handle_event("open_overlay", %{"kind" => kind}, socket) do socket = case socket.assigns[:current_tab] do %{type: :post, id: post_id} when kind in ["ai_suggestions", "language_picker"] -> assign(socket, :post_editor_quick_actions_open, Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false)) %{type: :media, id: media_id} when kind in ["ai_suggestions", "language_picker", "confirm_delete"] -> assign(socket, :media_editor_quick_actions_open, Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false)) _other -> socket end overlay = with overlay_kind when not is_nil(overlay_kind) <- ShellOverlayComponents.kind(kind), %{type: route} <- socket.assigns[:current_tab] do tab = socket.assigns.current_tab title = tab_title(tab, socket.assigns.tab_meta) subtitle = tab_subtitle(tab, socket.assigns.tab_meta) Overlay.open(route, overlay_kind, ShellOverlayComponents.context(socket.assigns, title, subtitle)) end {:noreply, assign(socket, :shell_overlay, overlay)} end def handle_event("close_overlay", _params, socket) do {:noreply, assign(socket, :shell_overlay, nil)} end def handle_event("overlay_keydown", %{"key" => key}, socket) do socket = case {socket.assigns[:shell_overlay], key} do {nil, _other} -> socket {_overlay, "Escape"} -> assign(socket, :shell_overlay, nil) {%{kind: :gallery} = overlay, "ArrowLeft"} -> assign(socket, :shell_overlay, Overlay.lightbox_previous(overlay)) {%{kind: :gallery} = overlay, "ArrowRight"} -> assign(socket, :shell_overlay, Overlay.lightbox_next(overlay)) _other -> socket end {:noreply, socket} end def handle_event("overlay_toggle_ai_field", %{"key" => key}, socket) do {:noreply, update_shell_overlay(socket, &Overlay.toggle_ai_field(&1, key))} end def handle_event("overlay_set_search", %{"overlay" => %{"query" => query}}, socket) do {:noreply, update_shell_overlay(socket, &Overlay.set_search_query(&1, query))} end def handle_event("overlay_set_tab", %{"tab" => tab}, socket) do {:noreply, update_shell_overlay(socket, &Overlay.set_active_tab(&1, ShellOverlayComponents.tab(tab)))} end def handle_event("overlay_update_form", %{"overlay" => params}, socket) do socket = socket |> update_shell_overlay(&Overlay.update_form_value(&1, :external_url, Map.get(params, "url", ""))) |> update_shell_overlay(&Overlay.update_form_value(&1, :external_text, Map.get(params, "text", ""))) {:noreply, socket} end def handle_event("overlay_select_result", %{"id" => id}, socket) do overlay = socket.assigns[:shell_overlay] current_tab = socket.assigns[:current_tab] socket = case {overlay, current_tab} do {%{kind: :insert_link}, %{type: :post, id: post_id}} -> case Overlay.insert_link_result(overlay, id) do nil -> socket result -> PostEditor.insert_content(socket, post_id, ShellOverlayComponents.markdown_link(result.title, result.canonical_url), &reload_shell/2) end {%{kind: :insert_media}, %{type: :post, id: post_id}} -> case Overlay.insert_media_result(overlay, id) do nil -> socket result -> syntax = if result.is_image do "" else "[#{result.original_name}](bds-media://#{result.media_id})" end PostEditor.insert_content(socket, post_id, syntax, &reload_shell/2) end _other -> socket end {:noreply, socket} end def handle_event("overlay_insert_external", _params, socket) do current_tab = socket.assigns[:current_tab] socket = case {socket.assigns[:shell_overlay], current_tab} do {%{kind: :insert_link} = overlay, %{type: :post, id: post_id}} -> details = case {overlay.external_url, String.trim(overlay.external_text || "")} do {"", _text} -> nil {url, ""} -> url {url, text} -> ShellOverlayComponents.markdown_link(text, url) end if details do PostEditor.insert_content(socket, post_id, details, &reload_shell/2) else socket end _other -> socket end {:noreply, socket} end def handle_event("overlay_select_language", %{"code" => code}, socket) do current_tab = socket.assigns[:current_tab] socket = case {socket.assigns[:shell_overlay], current_tab} do {%{kind: :language_picker}, %{type: :post, id: post_id}} -> PostEditor.translate(socket, post_id, code, &reload_shell/2, &append_output_entry/5) {%{kind: :language_picker}, %{type: :media, id: media_id}} -> MediaEditor.translate(socket, media_id, code, &reload_shell/2, &append_output_entry/5) _other -> socket end {:noreply, socket} end def handle_event("overlay_confirm", _params, socket) do current_tab = socket.assigns[:current_tab] socket = case {socket.assigns[:shell_overlay], current_tab} do {%{kind: :ai_suggestions} = overlay, %{type: :post, id: post_id}} -> PostEditor.apply_ai_suggestions( socket, post_id, Overlay.selected_ai_fields(overlay), &reload_shell/2, &append_output_entry/5 ) {%{kind: :ai_suggestions} = overlay, %{type: :media, id: media_id}} -> MediaEditor.apply_ai_suggestions( socket, media_id, Overlay.selected_ai_fields(overlay), &reload_shell/2, &append_output_entry/5 ) {%{kind: :confirm_delete}, %{type: :media, id: media_id}} -> MediaEditor.delete_socket(socket, media_id, &reload_shell/2, &append_output_entry/5) {%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} -> close_overlay_with_output(socket, title, entity_name) {%{kind: :confirm_dialog, title: title, message: message}, _tab} -> close_overlay_with_output(socket, title, message) _other -> socket end {:noreply, socket} end def handle_event("overlay_select_gallery_image", %{"id" => id}, socket) do {:noreply, update_shell_overlay(socket, &Overlay.select_gallery_image(&1, id))} end def handle_event("overlay_close_lightbox", _params, socket) do {:noreply, update_shell_overlay(socket, &Overlay.close_lightbox/1)} end def handle_event("overlay_lightbox_previous", _params, socket) do {:noreply, update_shell_overlay(socket, &Overlay.lightbox_previous/1)} end def handle_event("overlay_lightbox_next", _params, socket) do {:noreply, update_shell_overlay(socket, &Overlay.lightbox_next/1)} end def handle_event("toggle_project_menu", _params, socket) do {:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)} end def handle_event("close_project_menu", _params, socket) do {:noreply, assign(socket, :project_menu_open, false)} end def handle_event("select_project", %{"project_id" => project_id}, socket) do {:noreply, activate_project(socket, project_id, "Select Project", fn project -> "Activated #{project.name}" end)} end def handle_event("create_project", _params, socket) do attrs = %{name: next_project_name(socket.assigns.projects.projects)} socket = case Projects.create_project(attrs) do {:ok, project} -> activate_project(socket, project.id, "New Project", fn created -> "Activated #{created.name}" end) {:error, reason} -> append_output_entry(socket, "New Project", inspect(reason), nil, "error") end {:noreply, socket} end def handle_event("import_project", _params, socket) do socket = case FolderPicker.choose_directory("Open Existing Blog") do {:ok, path} -> name = path |> Path.basename() |> String.trim() |> case do "" -> "Imported Blog" value -> value end case Projects.create_project(%{name: name, data_path: path}) do {:ok, project} -> activate_project(socket, project.id, "Open Existing Blog", fn imported -> "Activated #{imported.name}" end) {:error, reason} -> append_output_entry(socket, "Open Existing Blog", inspect(reason), nil, "error") end :cancel -> assign(socket, :project_menu_open, false) {:error, %{message: message}} -> append_output_entry(socket, "Open Existing Blog", message, nil, "error") end {:noreply, socket} end def handle_event("change_ui_language", %{"ui_language" => language}, socket) do {:noreply, set_page_language(socket, language)} end def handle_event("sync_ui_language", %{"language" => language}, socket) do {:noreply, set_page_language(socket, language)} end def handle_event("restore_workbench_session", %{"session" => session_payload}, socket) when is_map(session_payload) do {:noreply, reload_shell(socket, restore_workbench_session(session_payload))} end def handle_event("native_menu_action", %{"action" => action}, socket) do {:noreply, handle_native_menu_action(socket, action)} end def handle_event("titlebar_menu_keydown", %{"key" => key}, socket) do {:noreply, handle_titlebar_menu_keydown(socket, key)} 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) )} 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} end def handle_event("close_titlebar_menu", _params, socket) do {:noreply, close_titlebar_menu(socket)} end def handle_event("titlebar_menu_action", %{"action" => action}, socket) do {:noreply, socket |> close_titlebar_menu() |> handle_native_menu_action(action)} end @impl true 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 nil -> task_status = localize_task_status(raw_task_status, socket.assigns.page_language) {:noreply, socket |> assign(:task_status, task_status) |> assign(:editor_meta, ShellData.editor_meta(task_status)) |> assign( :status, ShellData.status_bar(socket.assigns.workbench, task_status, socket.assigns.dashboard, ui_language: socket.assigns.page_language, offline_mode: socket.assigns.offline_mode ) )} task -> {:noreply, socket |> mark_task_result_handled(task.id) |> apply_shell_command_result(task.result)} end end @impl true def render(assigns) do Process.put(:bds_ui_locale, assigns.page_language) index(assigns) end defp reload_shell(socket, workbench) do projects = ShellData.project_snapshot() 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) sidebar_data = ShellData.sidebar_view(projects.active_project_id, active_view_id, ShellSidebarState.current_filters(socket, active_view_id)) sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data) raw_task_status = BDS.Tasks.status_snapshot() activity_buttons = Workbench.activity_buttons(workbench, git_badge_count) page_language = socket.assigns[:page_language] || ShellData.ui_language() offline_mode = if connected?(socket) do Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true)) else Map.get(socket.assigns, :offline_mode, true) end task_status = localize_task_status(raw_task_status, page_language) socket |> assign(:workbench, workbench) |> assign(:projects, projects) |> assign(:current_project, ShellData.current_project(projects)) |> assign(:dashboard, dashboard) |> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, [])) |> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, [])) |> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, [])) |> assign(:dashboard_tag_cloud_items, ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))) |> assign(:sidebar_data, sidebar_data) |> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data)) |> assign(:assistant_cards, ShellData.assistant_cards()) |> assign(:editor_meta, ShellData.editor_meta(task_status)) |> assign(:task_status, task_status) |> assign( :status, ShellData.status_bar(workbench, task_status, dashboard, ui_language: page_language, offline_mode: offline_mode ) ) |> 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(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index]) |> assign(:current_tab, current_tab(workbench)) |> assign_post_editor() |> assign_media_editor() |> assign_settings_editor() |> assign_tags_editor() |> assign_code_entity_editor() |> assign_chat_editor() |> assign_misc_editor() 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 %>
<% end %> """ end defp render_task_entries(assigns) do ~H""" <%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %>