Files
bDS2/lib/bds/desktop/shell_live.ex

1268 lines
39 KiB
Elixir

defmodule BDS.Desktop.ShellLive do
@moduledoc false
use Phoenix.LiveView
import Phoenix.HTML
alias BDS.{AI, BoundedAtoms, Metadata}
alias BDS.CliSync.Watcher
alias BDS.Desktop.{ExternalLinks, FilePicker, FolderPicker, ShellData, UILocale}
alias BDS.Desktop.ShellLive.{
Bridges,
ChatEditor,
GalleryImport,
ImportEditor,
MediaEditor,
MenuEditor,
MiscEditor,
OverlayManager,
ScriptEditor,
SettingsEditor,
SidebarDelete,
TagsEditor,
TemplateEditor
}
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
alias BDS.Desktop.ShellLive.PostEditor
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
alias BDS.Desktop.ShellLive.SidebarEvents
alias BDS.Desktop.ShellLive.SidebarState, as: ShellSidebarState
alias BDS.Desktop.ShellLive.{
ChatSurface,
Layout,
PanelRenderer,
SessionUtil,
ShellCommandRunner,
SidebarCreate,
TabHelpers,
TaskLocalization,
TitlebarMenu
}
import TaskLocalization,
only: [
localize_task_status: 2
]
import TabHelpers,
only: [
tab_id_for_route: 2,
tab_intent: 2,
sidebar_route_atom: 1
]
alias BDS.Projects
alias BDS.UI.{Commands, MenuBar, Session, Workbench}
alias Desktop.OS
alias BDS.Desktop.Shutdown
use Gettext, backend: BDS.Gettext
@refresh_interval 1_500
def refresh_interval, do: @refresh_interval
@output_entry_limit 20
@sidebar_filter_events [
"toggle_sidebar_filters",
"toggle_sidebar_archive",
"toggle_sidebar_tags",
"toggle_sidebar_categories",
"update_sidebar_search",
"clear_sidebar_search",
"clear_sidebar_tags",
"clear_sidebar_categories",
"toggle_sidebar_tag",
"toggle_sidebar_category",
"select_sidebar_year",
"select_sidebar_month",
"clear_sidebar_month",
"clear_sidebar_filters",
"load_more_sidebar"
]
@layout_menu_actions MapSet.new([
:toggle_sidebar,
:toggle_panel,
:toggle_assistant_sidebar,
:close_tab
])
@sidebar_menu_actions MapSet.new([
:view_posts,
:view_media,
:edit_preferences,
:edit_menu,
:documentation,
:api_documentation
])
@local_menu_actions MapSet.union(@layout_menu_actions, @sidebar_menu_actions)
@socket_menu_actions MapSet.new([
:new_post,
:import_media,
:save,
:publish_selected,
:quit,
:view_on_github,
:report_issue,
:about
])
@runtime_menu_actions MapSet.new([
:undo,
:redo,
:cut,
:copy,
:paste,
:delete,
:select_all,
:find,
:replace,
:reload,
:force_reload,
:reset_zoom,
:zoom_in,
:zoom_out,
:toggle_full_screen
])
def supported_menu_actions do
@local_menu_actions
|> MapSet.union(@socket_menu_actions)
|> MapSet.union(@runtime_menu_actions)
|> MapSet.union(MapSet.new([:open_in_browser, :open_data_folder]))
|> MapSet.union(MapSet.new([:preview_post, :rebuild_database, :reindex_text]))
|> MapSet.union(MapSet.new([:rebuild_embedding_index, :metadata_diff, :regenerate_calendar]))
|> MapSet.union(
MapSet.new([:validate_translations, :fill_missing_translations, :find_duplicates])
)
|> MapSet.union(MapSet.new([:generate_sitemap, :validate_site, :upload_site]))
end
embed_templates("shell_live/*")
@impl true
def mount(params, _session, socket) do
connected = connected?(socket)
if connected do
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
Process.send_after(self(), :refresh_task_status, @refresh_interval)
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, SessionUtil.initial_handled_task_results())
|> assign(:assistant_prompt, "")
|> assign(:assistant_messages, [])
|> assign(:is_mac_ui, mac_ui?())
|> assign(:menu_groups, TitlebarMenu.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(:chat_editor_request_refs, %{})
|> assign(:file_picker_task, nil)
|> assign(:shell_overlay, nil)
|> assign(:output_entries, [])
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|> assign(:panel_git_entries, [])
|> assign(:auto_save_timers, %{})
|> reload_shell(workbench)
|> apply_url_params(params)
|> tap(&sync_menu_bar_locale/1)}
end
@impl true
def handle_event("toggle_sidebar", _params, socket) do
{:noreply, refresh_layout(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
end
def handle_event("toggle_panel", _params, socket) do
{:noreply, refresh_layout(socket, Workbench.toggle_panel(socket.assigns.workbench))}
end
def handle_event("toggle_assistant_sidebar", _params, socket) do
{:noreply, refresh_layout(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,
BoundedAtoms.sidebar_view(view_id, :posts)
)
{:noreply,
socket
|> refresh_sidebar(workbench)
|> push_url_state()}
end
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
workbench =
socket.assigns.workbench
|> Workbench.set_panel_visible(true)
|> Workbench.set_panel_tab(BoundedAtoms.panel_tab(tab, :tasks))
{:noreply, refresh_layout(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, refresh_layout(socket, Layout.sync(socket.assigns.workbench, params))}
end
def handle_event("resize_panel", %{"target" => target, "width" => width}, socket) do
{:noreply, refresh_layout(socket, Layout.resize(socket.assigns.workbench, target, width))}
end
def handle_event(event, params, socket) when event in @sidebar_filter_events do
SidebarEvents.handle(socket, event, params, &refresh_sidebar/2)
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 Layout.ignore_shortcut?(params) do
{:noreply, socket}
else
case Commands.command_for_shortcut(params) do
nil -> {:noreply, socket}
action -> {:noreply, handle_menu_action(socket, action)}
end
end
end
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
socket = auto_save_current_post(socket)
workbench =
Workbench.open_tab(
socket.assigns.workbench,
BoundedAtoms.editor_route(type, :post),
id,
:preview
)
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
{:noreply,
socket
|> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench)
|> push_url_state()}
end
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
socket = auto_save_current_post(socket)
type_atom = BoundedAtoms.editor_route(type, :post)
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)
|> refresh_layout(workbench)
|> push_url_state()}
end
def handle_event(
"confirm_sidebar_delete",
%{"route" => route, "id" => id} = params,
socket
) do
{:noreply,
SidebarDelete.request_delete(
socket,
route,
id,
Map.get(params, "title"),
sidebar_delete_callbacks()
)}
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, refresh_layout(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 ++ ChatSurface.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, refresh_layout(socket, workbench)}
end
def handle_event("settings_shell_command", %{"action" => action}, socket) do
{:noreply, apply_shell_command(socket, action)}
end
def handle_event("open_overlay", params, socket),
do: OverlayManager.handle_event("open_overlay", params, socket, overlay_callbacks())
def handle_event("close_overlay", params, socket),
do: OverlayManager.handle_event("close_overlay", params, socket, overlay_callbacks())
def handle_event("overlay_keydown", params, socket),
do: OverlayManager.handle_event("overlay_keydown", params, socket, overlay_callbacks())
def handle_event("overlay_toggle_ai_field", params, socket),
do:
OverlayManager.handle_event("overlay_toggle_ai_field", params, socket, overlay_callbacks())
def handle_event("overlay_set_search", params, socket),
do: OverlayManager.handle_event("overlay_set_search", params, socket, overlay_callbacks())
def handle_event("overlay_set_tab", params, socket),
do: OverlayManager.handle_event("overlay_set_tab", params, socket, overlay_callbacks())
def handle_event("overlay_update_form", params, socket),
do: OverlayManager.handle_event("overlay_update_form", params, socket, overlay_callbacks())
def handle_event("overlay_select_result", params, socket),
do: OverlayManager.handle_event("overlay_select_result", params, socket, overlay_callbacks())
def handle_event("overlay_insert_external", params, socket),
do:
OverlayManager.handle_event("overlay_insert_external", params, socket, overlay_callbacks())
def handle_event("overlay_select_language", params, socket),
do:
OverlayManager.handle_event("overlay_select_language", params, socket, overlay_callbacks())
def handle_event("overlay_confirm", params, socket),
do: OverlayManager.handle_event("overlay_confirm", params, socket, overlay_callbacks())
def handle_event("overlay_select_gallery_image", params, socket),
do:
OverlayManager.handle_event(
"overlay_select_gallery_image",
params,
socket,
overlay_callbacks()
)
def handle_event("overlay_close_lightbox", params, socket),
do: OverlayManager.handle_event("overlay_close_lightbox", params, socket, overlay_callbacks())
def handle_event("overlay_lightbox_previous", params, socket),
do:
OverlayManager.handle_event(
"overlay_lightbox_previous",
params,
socket,
overlay_callbacks()
)
def handle_event("overlay_lightbox_next", params, socket),
do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks())
def handle_event("add_gallery_images", %{"post-id" => post_id}, socket) do
if socket.assigns.offline_mode do
{:noreply,
append_output_entry(
socket,
dgettext("ui", "Add Gallery Images"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)}
else
project_id = socket.assigns.projects.active_project_id
{:ok, metadata} = Metadata.get_project_metadata(project_id)
concurrency_limit = metadata.image_import_concurrency
language = metadata.main_language || "en"
parent = self()
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
case FilePicker.choose_files(dgettext("ui", "Add Gallery Images"),
image_only: true, multiple: true) do
{:ok, paths} when is_list(paths) and paths != [] ->
GalleryImport.start(paths, project_id, post_id, language, concurrency_limit, parent)
:cancel ->
send(parent, {:add_images_cancelled})
{:error, reason} ->
send(parent, {:add_images_error, reason})
end
end)
{:noreply, assign(socket, :gallery_import_post_id, post_id)}
end
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: SessionUtil.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, SessionUtil.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, TitlebarMenu.handle_keydown(socket, key, &handle_native_menu_action/2)}
end
def handle_event("toggle_titlebar_menu", %{"group" => group}, socket) do
{:noreply, TitlebarMenu.toggle(socket, group)}
end
def handle_event("hover_titlebar_menu", %{"group" => group}, socket) do
{:noreply, TitlebarMenu.hover(socket, group)}
end
def handle_event("close_titlebar_menu", _params, socket) do
{:noreply, TitlebarMenu.close(socket)}
end
def handle_event("titlebar_menu_action", %{"action" => action}, socket) do
{:noreply,
socket
|> TitlebarMenu.close()
|> handle_native_menu_action(action)}
end
@impl true
def handle_info({ref, result}, socket) when is_reference(ref) do
Process.demonitor(ref, [:flush])
cond do
socket.assigns.file_picker_task == ref ->
{:noreply,
socket
|> assign(:file_picker_task, nil)
|> handle_file_picker_result(result)}
Map.has_key?(socket.assigns.chat_editor_request_refs, ref) ->
{conversation_id, remaining_refs} = Map.pop(socket.assigns.chat_editor_request_refs, ref)
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :finish_request,
result: result
)
{:noreply, assign(socket, :chat_editor_request_refs, remaining_refs)}
true ->
{:noreply, socket}
end
end
def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) do
next_socket =
cond do
socket.assigns.file_picker_task == ref ->
if reason == :normal do
assign(socket, :file_picker_task, nil)
else
socket
|> assign(:file_picker_task, nil)
|> append_output_entry(
dgettext("ui", "Import media"),
inspect(reason),
nil,
"error"
)
|> refresh_content(socket.assigns.workbench)
end
Map.has_key?(socket.assigns.chat_editor_request_refs, ref) ->
{conversation_id, remaining_refs} =
Map.pop(socket.assigns.chat_editor_request_refs, ref)
if reason == :normal do
assign(socket, :chat_editor_request_refs, remaining_refs)
else
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :finish_request,
result: {:error, :cancelled}
)
assign(socket, :chat_editor_request_refs, remaining_refs)
end
true ->
socket
end
{:noreply, next_socket}
end
def handle_info({:ai_suggestions_result, type, id, result}, socket) do
OverlayManager.handle_info({:ai_suggestions_result, type, id, result}, socket)
end
def handle_info({:ai_suggestions_error, type, id, reason}, socket) do
OverlayManager.handle_info({:ai_suggestions_error, type, id, reason}, socket)
end
def handle_info({:add_image_processed, title}, socket) do
{:noreply,
append_output_entry(socket, dgettext("ui", "Add Gallery Images"), dgettext("ui", "Added %{title}", title: title), nil, "info")}
end
def handle_info({:add_images_complete, count}, socket) do
post_id = socket.assigns[:gallery_import_post_id]
socket =
if is_binary(post_id) do
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :insert_content,
content: "\n[[gallery]]\n"
)
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :refresh
)
socket
|> assign(:gallery_import_post_id, nil)
else
socket
end
{:noreply,
socket
|> append_output_entry(
dgettext("ui", "Add Gallery Images"),
dgettext("ui", "Added %{count} images to post", count: count),
nil,
"info"
)}
end
def handle_info({:add_images_error, reason}, socket) do
{:noreply,
append_output_entry(socket, dgettext("ui", "Add Gallery Images"), inspect(reason), nil, "error")}
end
def handle_info({:add_image_error, path, reason}, socket) do
{:noreply,
append_output_entry(
socket,
dgettext("ui", "Add Gallery Images"),
dgettext("ui", "Failed to process %{path}: %{reason}", path: Path.basename(path), reason: inspect(reason)),
nil,
"error"
)}
end
def handle_info({:add_images_cancelled}, socket) do
{:noreply, socket}
end
def handle_info({:test_ping, caller, ref}, socket) do
send(caller, {:test_pong, ref})
{:noreply, socket}
end
def handle_info(message, socket) do
Bridges.handle_info(message, socket, bridges_callbacks())
end
@impl true
def render(assigns) do
UILocale.put(assigns.page_language)
index(assigns)
end
defp refresh_layout(socket, workbench) do
git_badge_count = socket.assigns[:git_badge_count] || 0
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
task_status = socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
page_language = socket.assigns[:page_language] || ShellData.ui_language()
offline_mode = Map.get(socket.assigns, :offline_mode, true)
sidebar_data = socket.assigns[:sidebar_data] || %{}
current_tab = current_tab(workbench)
prev_tab = socket.assigns[:current_tab]
prev_panel_tab =
case socket.assigns[:workbench] do
%Workbench{panel: %{active_tab: tab}} -> tab
_ -> nil
end
socket =
socket
|> assign(:workbench, workbench)
|> assign(:activity_buttons, activity_buttons)
|> assign(
:sidebar_header,
active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data)
)
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|> assign(:current_tab, current_tab)
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|> assign(
:status,
ShellData.status_bar(workbench, task_status, dashboard,
ui_language: page_language,
offline_mode: offline_mode
)
)
if panel_data_stale?(current_tab, prev_tab, workbench.panel.active_tab, prev_panel_tab) do
refresh_panel_data(socket)
else
socket
end
end
defp panel_data_stale?(current_tab, prev_tab, panel_tab, prev_panel_tab) do
current_tab != prev_tab or panel_tab != prev_panel_tab
end
defp refresh_panel_data(socket) do
panel_tab = socket.assigns.workbench.panel.active_tab
socket
|> assign(
:panel_post_links,
if(panel_tab == :post_links,
do: PanelRenderer.fetch_post_link_entries(socket.assigns),
else: socket.assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
)
)
|> assign(
:panel_git_entries,
if(panel_tab == :git_log,
do: PanelRenderer.fetch_git_log_entries(socket.assigns),
else: socket.assigns[:panel_git_entries] || []
)
)
end
defp push_url_state(socket) do
workbench = socket.assigns.workbench
params =
%{}
|> put_url_view(workbench.active_view)
|> put_url_tab(workbench.active_tab)
query = URI.encode_query(params)
path = if query == "", do: "/", else: "/?" <> query
push_event(socket, "url-state", %{path: path})
end
defp put_url_view(params, :posts), do: params
defp put_url_view(params, view), do: Map.put(params, "view", Atom.to_string(view))
defp put_url_tab(params, nil), do: params
defp put_url_tab(params, {type, id}),
do: Map.put(params, "tab", Atom.to_string(type) <> ":" <> id)
defp apply_url_params(socket, params) when is_map(params) and map_size(params) > 0 do
workbench = socket.assigns.workbench
workbench = apply_url_view(workbench, Map.get(params, "view"))
workbench = apply_url_tab(workbench, Map.get(params, "tab"))
if workbench == socket.assigns.workbench do
socket
else
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
socket
|> assign(:tab_meta, tab_meta)
|> refresh_sidebar(workbench)
end
end
defp apply_url_params(socket, _params), do: socket
defp apply_url_view(workbench, nil), do: workbench
defp apply_url_view(workbench, view_str) do
view = BoundedAtoms.sidebar_view(view_str, nil)
if view && view != workbench.active_view do
Workbench.click_activity(workbench, view)
else
workbench
end
end
defp apply_url_tab(workbench, nil), do: workbench
defp apply_url_tab(workbench, tab_str) do
case String.split(tab_str, ":", parts: 2) do
[type_str, id] ->
type = BoundedAtoms.editor_route(type_str, nil)
if type && workbench.active_tab != {type, id} do
Workbench.open_tab(workbench, type, id, :preview)
else
workbench
end
_ ->
workbench
end
end
defp refresh_sidebar(socket, workbench) do
project_id = (socket.assigns[:projects] || %{})[:active_project_id]
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data =
case ShellData.sidebar_view(
project_id,
active_view_id,
ShellSidebarState.current_filters(socket, active_view_id)
) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
end
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
socket
|> assign(:sidebar_data, sidebar_data)
|> refresh_layout(workbench)
end
defp refresh_content(socket, workbench) do
projects =
case ShellData.project_snapshot() do
{:ok, data} -> data
{:error, :not_ready} -> ShellData.default_project_snapshot()
end
dashboard =
case ShellData.dashboard(projects.active_project_id) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot()
end
git_badge_count =
case ShellData.git_badge_count(projects.active_project_id) do
{:ok, count} -> count
{:error, :not_ready} -> 0
end
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data =
case ShellData.sidebar_view(
projects.active_project_id,
active_view_id,
ShellSidebarState.current_filters(socket, active_view_id)
) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
end
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
socket
|> 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(:git_badge_count, git_badge_count)
|> assign(:sidebar_data, sidebar_data)
|> refresh_layout(workbench)
end
defp reload_shell(socket, workbench) do
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
raw_task_status = BDS.Tasks.status_snapshot()
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(:tab_meta, tab_meta)
|> assign(:task_status, task_status)
|> assign(:offline_mode, offline_mode)
|> assign(:assistant_cards, ShellData.assistant_cards())
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|> refresh_content(workbench)
end
defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)
defp encoded_workbench_session(workbench), do: Jason.encode!(Session.serialize(workbench))
defp panel_tab_label(:tasks), do: dgettext("ui", "Tasks")
defp panel_tab_label(:output), do: dgettext("ui", "Output")
defp panel_tab_label(:git_log), do: dgettext("ui", "Git Log")
defp panel_tab_label(tab), do: ShellData.route_label(tab)
defp activity_label("AI Assistant"), do: dgettext("ui", "Chat")
defp activity_label("Source Control"), do: dgettext("ui", "Git")
defp activity_label(label), do: label
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
Enum.find_value(activity_buttons, Map.get(sidebar_data, :title, ""), fn button ->
if button.id == active_view, do: activity_label(button.label), else: nil
end)
end
defp sidebar_header_label(label), do: label
defp timeline_height(entry, entries) do
max_count =
entries
|> Enum.map(&(&1.count || 0))
|> Enum.max(fn -> 1 end)
max(4, (entry.count || 0) / max_count * 100)
end
defp current_tab(%{active_tab: nil}), do: nil
defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do
Enum.find(tabs, &(&1.type == type and &1.id == id))
end
defp create_sidebar_item(socket, kind),
do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())
defp handle_file_picker_result(socket, {:ok, _media}),
do: refresh_content(socket, socket.assigns.workbench)
defp handle_file_picker_result(socket, :cancel), do: socket
defp handle_file_picker_result(socket, {:error, %{message: message}}),
do:
socket
|> append_output_entry(dgettext("ui", "Import media"), message, nil, "error")
|> refresh_content(socket.assigns.workbench)
defp handle_file_picker_result(socket, {:error, reason}),
do:
socket
|> append_output_entry(dgettext("ui", "Import media"), inspect(reason), nil, "error")
|> refresh_content(socket.assigns.workbench)
defp sidebar_create_callbacks do
%{
reload: &reload_shell/2,
refresh_content: &refresh_content/2,
open_sidebar: &open_sidebar_item/3,
append_output: &append_output_entry/5
}
end
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"))
workbench =
Workbench.open_tab(
socket.assigns.workbench,
route_atom,
tab_id,
tab_intent(route_atom, intent)
)
tab_meta =
Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{
sidebar_item_id: Map.get(params, "id"),
title: Map.get(params, "title", ""),
subtitle: Map.get(params, "subtitle", "")
})
tab_meta = TabHelpers.sync_tab_meta(workbench, tab_meta)
socket
|> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench)
|> push_url_state()
end
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
)
normalized =
language
|> to_string()
|> String.trim()
|> case do
value -> if(value in codes, do: value, else: socket.assigns.page_language)
end
if normalized == socket.assigns.page_language do
socket
else
UILocale.put(normalized)
socket
|> assign(:page_language, normalized)
|> reload_shell(socket.assigns.workbench)
|> tap(&sync_menu_bar_locale/1)
end
end
defp activate_project(socket, project_id, title, message_fun) do
cond do
project_id == socket.assigns.projects.active_project_id ->
assign(socket, :project_menu_open, false)
true ->
case Projects.set_active_project(project_id) do
{:ok, project} ->
socket
|> assign(:project_menu_open, false)
|> assign(:sidebar_filters_by_view, %{})
|> append_output_entry(title, message_fun.(project))
|> reload_shell(Workbench.new())
|> push_url_state()
{:error, reason} ->
socket
|> assign(:project_menu_open, false)
|> append_output_entry(title, inspect(reason), nil, "error")
end
end
end
defp append_output_entry(socket, title, message, details \\ nil, level \\ "info") do
entry = %{title: title, message: message, details: details, level: level}
entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
assign(socket, :output_entries, entries)
end
defp handle_native_menu_action(socket, action) do
case BoundedAtoms.menu_action(action) do
nil -> append_output_entry(socket, "Menu", "Unsupported shell command", action, "error")
action_atom -> handle_menu_action(socket, action_atom)
end
end
defp handle_menu_action(socket, action) when is_atom(action) do
cond do
MapSet.member?(@layout_menu_actions, action) ->
refresh_layout(socket, MenuBar.execute(socket.assigns.workbench, action))
MapSet.member?(@sidebar_menu_actions, action) ->
workbench = MenuBar.execute(socket.assigns.workbench, action)
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
socket
|> assign(:tab_meta, tab_meta)
|> refresh_sidebar(workbench)
|> push_url_state()
MapSet.member?(@socket_menu_actions, action) ->
handle_socket_menu_action(socket, action)
MapSet.member?(@runtime_menu_actions, action) ->
push_event(socket, "menu-runtime-command", %{action: Atom.to_string(action)})
shell_command?(action) ->
apply_shell_command(socket, Atom.to_string(action))
true ->
append_output_entry(
socket,
"Menu",
"Unsupported shell command",
Atom.to_string(action),
"error"
)
end
end
defp handle_socket_menu_action(socket, :new_post), do: create_sidebar_item(socket, "post")
defp handle_socket_menu_action(socket, :import_media), do: create_sidebar_item(socket, "media")
defp handle_socket_menu_action(socket, :save), do: save_current_tab(socket)
defp handle_socket_menu_action(socket, :publish_selected), do: publish_current_tab(socket)
defp handle_socket_menu_action(socket, :quit) do
Shutdown.request_quit()
socket
end
defp handle_socket_menu_action(socket, :view_on_github) do
OS.launch_default_browser(ExternalLinks.github_url())
socket
end
defp handle_socket_menu_action(socket, :report_issue) do
OS.launch_default_browser(ExternalLinks.github_issues_url())
socket
end
defp handle_socket_menu_action(socket, :about) do
append_output_entry(
socket,
"About",
"Blogging Desktop Server",
"Version #{Application.spec(:bds, :vsn) |> to_string()}",
"info"
)
end
defp shell_command?(action), do: not is_nil(shell_command_atom(action))
defp auto_save_current_post(
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
) do
if Workbench.dirty?(workbench, :post, post_id) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
end
socket
end
defp auto_save_current_post(socket), do: socket
defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do
send_update(MediaEditor, id: "media-editor-#{media_id}", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do
send_update(SettingsEditor, id: "settings-editor", action: :save_project)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :menu_editor}}} = socket) do
send_update(MenuEditor, id: "menu-editor", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :tags}}} = socket) do
send_update(TagsEditor, id: "tags-editor", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :scripts, id: script_id}}} = socket) do
send_update(ScriptEditor, id: "script-editor-#{script_id}", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :templates, id: template_id}}} = socket) do
send_update(TemplateEditor, id: "template-editor-#{template_id}", action: :save)
socket
end
defp save_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
defp publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :publish)
socket
end
defp publish_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
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,
refresh_content: &refresh_content/2,
append_output: &append_output_entry/5
}
end
defp shell_command_atom(action), do: ShellCommandRunner.shell_command_atom(action)
defp mac_ui? do
case Application.get_env(:bds, :shell_platform) do
nil -> match?({:unix, :darwin}, :os.type())
platform -> match?({:unix, :darwin}, platform)
end
end
defp overlay_callbacks,
do: %{
reload: &reload_shell/2,
refresh_content: &refresh_content/2,
append_output: &append_output_entry/5,
execute_sidebar_delete: fn socket, route, id ->
SidebarDelete.execute_delete(socket, route, id, sidebar_delete_callbacks())
end
}
defp sidebar_delete_callbacks,
do: %{
reload: &reload_shell/2,
refresh_content: &refresh_content/2,
append_output: &append_output_entry/5
}
defp bridges_callbacks,
do: %{
reload: &reload_shell/2,
refresh_layout: &refresh_layout/2,
refresh_sidebar: &refresh_sidebar/2,
refresh_content: &refresh_content/2,
append_output: &append_output_entry/5,
open_sidebar: &open_sidebar_item/3,
apply_shell_command: &apply_shell_command/3,
apply_shell_command_result: &apply_shell_command_result/2
}
defp sync_menu_bar_locale(socket) do
locale = socket.assigns.page_language
case Process.whereis(BDS.Desktop.MenuBar) do
nil -> :ok
pid -> send(pid, {:set_ui_locale, locale})
end
end
end