feat: first take at UI app
This commit is contained in:
@@ -3,6 +3,31 @@ defmodule BDS.Application do
|
||||
|
||||
use Application
|
||||
|
||||
def desktop_children(env \\ nil)
|
||||
|
||||
def desktop_children(:test), do: []
|
||||
|
||||
def desktop_children(_env) do
|
||||
if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do
|
||||
[
|
||||
{BDS.Desktop.Server, []},
|
||||
{Desktop.Window,
|
||||
[
|
||||
app: :bds,
|
||||
id: BDS.Desktop.MainWindow,
|
||||
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
|
||||
size: Application.get_env(:bds, :desktop)[:window_size] || {1440, 900},
|
||||
min_size: Application.get_env(:bds, :desktop)[:window_min_size] || {1100, 700},
|
||||
menubar: BDS.Desktop.MenuBar,
|
||||
icon_menu: BDS.Desktop.Menu,
|
||||
url: &BDS.Desktop.url/0
|
||||
]}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
@@ -14,9 +39,15 @@ defmodule BDS.Application do
|
||||
BDS.Scripting.JobStore,
|
||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||
BDS.Scripting.JobSupervisor
|
||||
| desktop_children(current_env())
|
||||
]
|
||||
|
||||
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
|
||||
defp current_env do
|
||||
Application.get_env(:bds, :current_env_override) ||
|
||||
if(Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod)
|
||||
end
|
||||
end
|
||||
|
||||
12
lib/bds/desktop.ex
Normal file
12
lib/bds/desktop.ex
Normal file
@@ -0,0 +1,12 @@
|
||||
defmodule BDS.Desktop do
|
||||
@moduledoc false
|
||||
|
||||
def url do
|
||||
Application.get_env(:bds, :desktop)[:port]
|
||||
|> url()
|
||||
end
|
||||
|
||||
def url(port) when is_integer(port) do
|
||||
"http://127.0.0.1:#{port}/"
|
||||
end
|
||||
end
|
||||
3
lib/bds/desktop/main_window.ex
Normal file
3
lib/bds/desktop/main_window.ex
Normal file
@@ -0,0 +1,3 @@
|
||||
defmodule BDS.Desktop.MainWindow do
|
||||
@moduledoc false
|
||||
end
|
||||
42
lib/bds/desktop/menu.ex
Normal file
42
lib/bds/desktop/menu.ex
Normal file
@@ -0,0 +1,42 @@
|
||||
defmodule BDS.Desktop.Menu do
|
||||
@moduledoc false
|
||||
|
||||
use Desktop.Menu
|
||||
alias Desktop.Window
|
||||
|
||||
@impl true
|
||||
def mount(menu) do
|
||||
{:ok, menu}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<menu>
|
||||
<item onclick="open">Open</item>
|
||||
<hr />
|
||||
<item onclick="quit">Quit</item>
|
||||
</menu>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open", menu) do
|
||||
Window.show(BDS.Desktop.MainWindow)
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
def handle_event("quit", menu) do
|
||||
Window.quit()
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
def handle_event(_, menu) do
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(_, menu) do
|
||||
{:noreply, menu}
|
||||
end
|
||||
end
|
||||
73
lib/bds/desktop/menu_bar.ex
Normal file
73
lib/bds/desktop/menu_bar.ex
Normal file
@@ -0,0 +1,73 @@
|
||||
defmodule BDS.Desktop.MenuBar do
|
||||
@moduledoc false
|
||||
|
||||
use Desktop.Menu
|
||||
alias Desktop.Window
|
||||
|
||||
def groups(opts \\ []) do
|
||||
dev_mode? = Keyword.get(opts, :dev_mode?, false)
|
||||
|
||||
[
|
||||
%{id: :app, label: "App", items: [%{id: :about, label: "About"}]},
|
||||
%{id: :file, label: "File", items: [%{id: :new_post, label: "New Post"}, %{id: :close_tab, label: "Close Tab"}]},
|
||||
%{id: :edit, label: "Edit", items: [%{id: :undo, label: "Undo"}, %{id: :redo, label: "Redo"}]},
|
||||
%{id: :view, label: "View", items: view_items(dev_mode?)},
|
||||
%{id: :window, label: "Window", items: [%{id: :minimize, label: "Minimize"}]},
|
||||
%{id: :help, label: "Help", items: [%{id: :documentation, label: "Documentation"}]}
|
||||
]
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(menu) do
|
||||
{:ok,
|
||||
Desktop.Menu.assign(
|
||||
menu,
|
||||
:groups,
|
||||
groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<menubar>
|
||||
<%= for group <- @groups do %>
|
||||
<menu label={group.label}>
|
||||
<%= for item <- group.items do %>
|
||||
<item onclick={Atom.to_string(item.id)}>{item.label}</item>
|
||||
<% end %>
|
||||
</menu>
|
||||
<% end %>
|
||||
</menubar>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("quit", menu) do
|
||||
Window.quit()
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
def handle_event(_, menu) do
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(_, menu) do
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
defp view_items(dev_mode?) do
|
||||
items = [
|
||||
%{id: :toggle_sidebar, label: "Toggle Sidebar"},
|
||||
%{id: :toggle_panel, label: "Toggle Panel"},
|
||||
%{id: :toggle_assistant_sidebar, label: "Toggle Assistant Sidebar"}
|
||||
]
|
||||
|
||||
if dev_mode? do
|
||||
items ++ [%{id: :toggle_dev_tools, label: "Toggle Dev Tools"}]
|
||||
else
|
||||
items
|
||||
end
|
||||
end
|
||||
end
|
||||
49
lib/bds/desktop/router.ex
Normal file
49
lib/bds/desktop/router.ex
Normal file
@@ -0,0 +1,49 @@
|
||||
defmodule BDS.Desktop.Router do
|
||||
@moduledoc false
|
||||
|
||||
use Plug.Router
|
||||
|
||||
plug :put_secret_key_base
|
||||
|
||||
plug Plug.Session,
|
||||
store: :cookie,
|
||||
key: "_bds_desktop_key",
|
||||
signing_salt: "desktop-shell"
|
||||
|
||||
plug :match
|
||||
plug Desktop.Auth
|
||||
|
||||
plug Plug.Static,
|
||||
at: "/assets",
|
||||
from: {:bds, "priv/ui"},
|
||||
only: ["app.css", "app.js"]
|
||||
|
||||
plug :dispatch
|
||||
|
||||
get "/" do
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("text/html")
|
||||
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.index_html())
|
||||
end
|
||||
|
||||
get "/health" do
|
||||
Plug.Conn.send_resp(conn, 200, "ok")
|
||||
end
|
||||
|
||||
match _ do
|
||||
Plug.Conn.send_resp(conn, 404, "not found")
|
||||
end
|
||||
|
||||
defp put_secret_key_base(conn, _opts) do
|
||||
if conn.secret_key_base do
|
||||
conn
|
||||
else
|
||||
%{conn | secret_key_base: desktop_secret_key_base()}
|
||||
end
|
||||
end
|
||||
|
||||
defp desktop_secret_key_base do
|
||||
Application.get_env(:bds, :desktop)[:secret_key_base] ||
|
||||
raise "missing :desktop secret_key_base configuration"
|
||||
end
|
||||
end
|
||||
38
lib/bds/desktop/server.ex
Normal file
38
lib/bds/desktop/server.ex
Normal file
@@ -0,0 +1,38 @@
|
||||
defmodule BDS.Desktop.Server do
|
||||
@moduledoc false
|
||||
|
||||
use GenServer
|
||||
|
||||
def child_spec(opts) do
|
||||
%{
|
||||
id: __MODULE__,
|
||||
start: {__MODULE__, :start_link, [opts]}
|
||||
}
|
||||
end
|
||||
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
def url do
|
||||
BDS.Desktop.url(port())
|
||||
end
|
||||
|
||||
def port do
|
||||
Application.get_env(:bds, :desktop)[:port] || 4010
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
{:ok, bandit_pid} =
|
||||
Bandit.start_link(
|
||||
plug: BDS.Desktop.Router,
|
||||
scheme: :http,
|
||||
ip: {127, 0, 0, 1},
|
||||
port: port(),
|
||||
startup_log: false
|
||||
)
|
||||
|
||||
{:ok, %{bandit_pid: bandit_pid}}
|
||||
end
|
||||
end
|
||||
7
lib/bds/desktop/shell_controller.ex
Normal file
7
lib/bds/desktop/shell_controller.ex
Normal file
@@ -0,0 +1,7 @@
|
||||
defmodule BDS.Desktop.ShellController do
|
||||
@moduledoc false
|
||||
|
||||
def index_html do
|
||||
File.read!(Application.app_dir(:bds, ["priv", "ui", "index.html"]))
|
||||
end
|
||||
end
|
||||
16
lib/bds/ui/commands.ex
Normal file
16
lib/bds/ui/commands.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule BDS.UI.Commands do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.MenuBar
|
||||
|
||||
def handle_shortcut(state, shortcut) when is_map(shortcut) do
|
||||
key = shortcut |> Map.get(:key, Map.get(shortcut, "key", "")) |> String.downcase()
|
||||
primary = Map.get(shortcut, :meta, false) or Map.get(shortcut, :ctrl, false)
|
||||
|
||||
cond do
|
||||
primary and key == "b" -> MenuBar.execute(state, :toggle_sidebar)
|
||||
primary and key == "w" -> MenuBar.execute(state, :close_tab)
|
||||
true -> state
|
||||
end
|
||||
end
|
||||
end
|
||||
41
lib/bds/ui/menu_bar.ex
Normal file
41
lib/bds/ui/menu_bar.ex
Normal file
@@ -0,0 +1,41 @@
|
||||
defmodule BDS.UI.MenuBar do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
def default_groups(opts \\ []) do
|
||||
dev_mode? = Keyword.get(opts, :dev_mode?, false)
|
||||
|
||||
[
|
||||
%{id: :app, items: [%{id: :about}, %{id: :settings}]},
|
||||
%{id: :file, items: [%{id: :new_post}, %{id: :new_page}, %{id: :close_tab}]},
|
||||
%{id: :edit, items: [%{id: :undo}, %{id: :redo}]},
|
||||
%{id: :view, items: view_items(dev_mode?)},
|
||||
%{id: :window, items: [%{id: :minimize}, %{id: :zoom}]},
|
||||
%{id: :help, items: [%{id: :documentation}, %{id: :api_documentation}]}
|
||||
]
|
||||
end
|
||||
|
||||
def execute(state, :toggle_sidebar), do: Workbench.toggle_sidebar(state)
|
||||
def execute(state, :toggle_panel), do: Workbench.toggle_panel(state)
|
||||
def execute(state, :toggle_assistant_sidebar), do: Workbench.toggle_assistant_sidebar(state)
|
||||
|
||||
def execute(state, :close_tab) do
|
||||
case state.active_tab do
|
||||
{type, id} -> Workbench.close_tab(state, type, id)
|
||||
nil -> state
|
||||
end
|
||||
end
|
||||
|
||||
def execute(state, _command_id), do: state
|
||||
|
||||
defp view_items(dev_mode?) do
|
||||
base = [
|
||||
%{id: :toggle_sidebar},
|
||||
%{id: :toggle_panel},
|
||||
%{id: :toggle_assistant_sidebar}
|
||||
]
|
||||
|
||||
if dev_mode?, do: base ++ [%{id: :toggle_dev_tools}], else: base
|
||||
end
|
||||
end
|
||||
44
lib/bds/ui/registry.ex
Normal file
44
lib/bds/ui/registry.ex
Normal file
@@ -0,0 +1,44 @@
|
||||
defmodule BDS.UI.Registry do
|
||||
@moduledoc false
|
||||
|
||||
@sidebar_views [
|
||||
%{id: :posts, label: "Posts", activity_group: :top, editor_route: :post, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :pages, label: "Pages", activity_group: :top, editor_route: :post, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :media, label: "Media", activity_group: :top, editor_route: :media, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :scripts, label: "Scripts", activity_group: :top, editor_route: :scripts, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :templates, label: "Templates", activity_group: :top, editor_route: :templates, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :tags, label: "Tags", activity_group: :top, editor_route: :tags, singleton: true, demo_kind: :singleton},
|
||||
%{id: :chat, label: "AI Assistant", activity_group: :top, editor_route: :chat, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :import, label: "Import", activity_group: :top, editor_route: :import, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :git, label: "Source Control", activity_group: :bottom, editor_route: :git_diff, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :settings, label: "Settings", activity_group: :bottom, editor_route: :settings, singleton: true, demo_kind: :singleton}
|
||||
]
|
||||
|
||||
@editor_routes [
|
||||
%{id: :dashboard, singleton: true, entity_tab: false, title: "Dashboard"},
|
||||
%{id: :post, singleton: false, entity_tab: true, title: "Post"},
|
||||
%{id: :media, singleton: false, entity_tab: true, title: "Media"},
|
||||
%{id: :settings, singleton: true, entity_tab: false, title: "Settings"},
|
||||
%{id: :style, singleton: true, entity_tab: false, title: "Style"},
|
||||
%{id: :tags, singleton: true, entity_tab: false, title: "Tags"},
|
||||
%{id: :chat, singleton: false, entity_tab: true, title: "Chat"},
|
||||
%{id: :import, singleton: false, entity_tab: true, title: "Import"},
|
||||
%{id: :menu_editor, singleton: true, entity_tab: false, title: "Menu"},
|
||||
%{id: :metadata_diff, singleton: true, entity_tab: false, title: "Metadata Diff"},
|
||||
%{id: :git_diff, singleton: false, entity_tab: true, title: "Git Diff"},
|
||||
%{id: :documentation, singleton: true, entity_tab: false, title: "Documentation"},
|
||||
%{id: :api_documentation, singleton: true, entity_tab: false, title: "API"},
|
||||
%{id: :site_validation, singleton: true, entity_tab: false, title: "Site Validation"},
|
||||
%{id: :translation_validation, singleton: true, entity_tab: false, title: "Translations"},
|
||||
%{id: :scripts, singleton: false, entity_tab: true, title: "Script"},
|
||||
%{id: :templates, singleton: false, entity_tab: true, title: "Template"},
|
||||
%{id: :find_duplicates, singleton: true, entity_tab: false, title: "Find Duplicates"}
|
||||
]
|
||||
|
||||
def default_sidebar_view, do: :posts
|
||||
def sidebar_views, do: @sidebar_views
|
||||
def editor_routes, do: @editor_routes
|
||||
|
||||
def sidebar_view(id) when is_atom(id), do: Enum.find(@sidebar_views, &(&1.id == id))
|
||||
def editor_route(id) when is_atom(id), do: Enum.find(@editor_routes, &(&1.id == id))
|
||||
end
|
||||
68
lib/bds/ui/session.ex
Normal file
68
lib/bds/ui/session.ex
Normal file
@@ -0,0 +1,68 @@
|
||||
defmodule BDS.UI.Session do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
def serialize(state) do
|
||||
%{
|
||||
"sidebar_visible" => state.sidebar_visible,
|
||||
"sidebar_width" => state.sidebar_width,
|
||||
"active_view" => Atom.to_string(state.active_view),
|
||||
"assistant_sidebar_visible" => state.assistant_sidebar_visible,
|
||||
"assistant_sidebar_width" => state.assistant_sidebar_width,
|
||||
"panel" => %{
|
||||
"visible" => state.panel.visible,
|
||||
"active_tab" => Atom.to_string(state.panel.active_tab)
|
||||
},
|
||||
"tabs" =>
|
||||
Enum.map(state.tabs, fn tab ->
|
||||
%{
|
||||
"type" => Atom.to_string(tab.type),
|
||||
"id" => tab.id,
|
||||
"is_transient" => tab.is_transient
|
||||
}
|
||||
end),
|
||||
"active_tab" => encode_tab_ref(state.active_tab),
|
||||
"dirty_tabs" => Enum.map(state.dirty_tabs, &encode_tab_ref/1)
|
||||
}
|
||||
end
|
||||
|
||||
def restore(payload) when is_map(payload) do
|
||||
state =
|
||||
Workbench.new(
|
||||
sidebar_visible: Map.get(payload, "sidebar_visible", true),
|
||||
sidebar_width: Map.get(payload, "sidebar_width", 280),
|
||||
active_view: Map.get(payload, "active_view", "posts"),
|
||||
assistant_sidebar_visible: Map.get(payload, "assistant_sidebar_visible", false),
|
||||
assistant_sidebar_width: Map.get(payload, "assistant_sidebar_width", 360),
|
||||
panel_visible: get_in(payload, ["panel", "visible"]) || false,
|
||||
panel_tab: atomize(get_in(payload, ["panel", "active_tab"]) || "tasks"),
|
||||
dirty_tabs: Enum.map(Map.get(payload, "dirty_tabs", []), &decode_tab_ref/1)
|
||||
)
|
||||
|
||||
tabs =
|
||||
Enum.map(Map.get(payload, "tabs", []), fn tab ->
|
||||
%{
|
||||
type: atomize(Map.get(tab, "type", "post")),
|
||||
id: Map.get(tab, "id"),
|
||||
is_transient: Map.get(tab, "is_transient", false)
|
||||
}
|
||||
end)
|
||||
|
||||
active_tab = decode_tab_ref(Map.get(payload, "active_tab"))
|
||||
|
||||
%{state | tabs: tabs, active_tab: active_tab, editor_route: active_route(active_tab)}
|
||||
end
|
||||
|
||||
defp encode_tab_ref(nil), do: nil
|
||||
defp encode_tab_ref({type, id}), do: %{"type" => Atom.to_string(type), "id" => id}
|
||||
|
||||
defp decode_tab_ref(nil), do: nil
|
||||
defp decode_tab_ref(%{"type" => type, "id" => id}), do: {atomize(type), id}
|
||||
|
||||
defp atomize(value) when is_atom(value), do: value
|
||||
defp atomize(value) when is_binary(value), do: String.to_atom(value)
|
||||
|
||||
defp active_route(nil), do: :dashboard
|
||||
defp active_route({type, _id}), do: type
|
||||
end
|
||||
62
lib/bds/ui/shell_page.ex
Normal file
62
lib/bds/ui/shell_page.ex
Normal file
@@ -0,0 +1,62 @@
|
||||
defmodule BDS.UI.ShellPage do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.MenuBar
|
||||
alias BDS.UI.Registry
|
||||
alias BDS.UI.Session
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
def render do
|
||||
bootstrap =
|
||||
%{
|
||||
registry: %{
|
||||
sidebar_views: Registry.sidebar_views(),
|
||||
editor_routes: Registry.editor_routes(),
|
||||
default_sidebar_view: Registry.default_sidebar_view()
|
||||
},
|
||||
menu_groups: MenuBar.default_groups(),
|
||||
session: Session.serialize(Workbench.new(panel_visible: true)),
|
||||
status: %{
|
||||
post_count: 12,
|
||||
media_count: 34,
|
||||
theme_badge: "zinc",
|
||||
ui_language: "en",
|
||||
offline_mode: true,
|
||||
running_task_message: "Building starter shell",
|
||||
running_task_overflow: 1,
|
||||
git_badge_count: 7
|
||||
}
|
||||
}
|
||||
|
||||
[
|
||||
"<!DOCTYPE html>",
|
||||
"<html lang=\"en\">",
|
||||
"<head>",
|
||||
" <meta charset=\"utf-8\">",
|
||||
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
|
||||
" <title>bDS Shell</title>",
|
||||
" <link rel=\"stylesheet\" href=\"./app.css\">",
|
||||
"</head>",
|
||||
"<body>",
|
||||
" <div id=\"bds-shell-app\">",
|
||||
" <header data-region=\"title-bar\"></header>",
|
||||
" <div data-region=\"activity-bar\"></div>",
|
||||
" <aside data-region=\"sidebar\"></aside>",
|
||||
" <div data-role=\"resize-handle\" data-target=\"sidebar\"></div>",
|
||||
" <main data-region=\"content\">",
|
||||
" <div data-region=\"tab-bar\"></div>",
|
||||
" <section data-region=\"editor\"></section>",
|
||||
" <section data-region=\"panel\"></section>",
|
||||
" </main>",
|
||||
" <div data-role=\"resize-handle\" data-target=\"assistant\"></div>",
|
||||
" <aside data-region=\"assistant-sidebar\"></aside>",
|
||||
" <footer data-region=\"status-bar\"></footer>",
|
||||
" </div>",
|
||||
" <script id=\"bds-shell-bootstrap\" type=\"application/json\">#{Jason.encode!(bootstrap)}</script>",
|
||||
" <script src=\"./app.js\"></script>",
|
||||
"</body>",
|
||||
"</html>"
|
||||
]
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
end
|
||||
268
lib/bds/ui/workbench.ex
Normal file
268
lib/bds/ui/workbench.ex
Normal file
@@ -0,0 +1,268 @@
|
||||
defmodule BDS.UI.Workbench do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.Registry
|
||||
|
||||
@singleton_tabs MapSet.new([
|
||||
:settings,
|
||||
:tags,
|
||||
:style,
|
||||
:scripts,
|
||||
:menu_editor,
|
||||
:documentation,
|
||||
:api_documentation,
|
||||
:metadata_diff,
|
||||
:site_validation,
|
||||
:translation_validation,
|
||||
:find_duplicates
|
||||
])
|
||||
|
||||
defstruct sidebar_visible: true,
|
||||
sidebar_width: 280,
|
||||
active_view: :posts,
|
||||
assistant_sidebar_visible: false,
|
||||
assistant_sidebar_width: 360,
|
||||
panel: %{visible: false, active_tab: :tasks},
|
||||
tabs: [],
|
||||
active_tab: nil,
|
||||
editor_route: :dashboard,
|
||||
dirty_tabs: MapSet.new()
|
||||
|
||||
def new(opts \\ []) do
|
||||
%__MODULE__{
|
||||
sidebar_visible: Keyword.get(opts, :sidebar_visible, true),
|
||||
sidebar_width: clamp_sidebar_width(Keyword.get(opts, :sidebar_width, 280)),
|
||||
active_view: normalize_type(Keyword.get(opts, :active_view, :posts)),
|
||||
assistant_sidebar_visible: Keyword.get(opts, :assistant_sidebar_visible, false),
|
||||
assistant_sidebar_width:
|
||||
clamp_assistant_sidebar_width(Keyword.get(opts, :assistant_sidebar_width, 360)),
|
||||
panel: %{
|
||||
visible: Keyword.get(opts, :panel_visible, false),
|
||||
active_tab: Keyword.get(opts, :panel_tab, :tasks)
|
||||
},
|
||||
dirty_tabs: MapSet.new(Keyword.get(opts, :dirty_tabs, []))
|
||||
}
|
||||
|> sync_editor_route()
|
||||
|> normalize_panel()
|
||||
end
|
||||
|
||||
def set_sidebar_width(state, width) when is_integer(width) do
|
||||
%{state | sidebar_width: clamp_sidebar_width(width)}
|
||||
end
|
||||
|
||||
def set_assistant_sidebar_width(state, width) when is_integer(width) do
|
||||
%{state | assistant_sidebar_width: clamp_assistant_sidebar_width(width)}
|
||||
end
|
||||
|
||||
def open_tab(state, type, id, intent) do
|
||||
{tabs, opened_tab} = upsert_tab(state.tabs, normalize_type(type), id, intent)
|
||||
|
||||
state
|
||||
|> Map.put(:tabs, tabs)
|
||||
|> Map.put(:active_tab, tab_ref(opened_tab))
|
||||
|> sync_editor_route()
|
||||
|> normalize_panel()
|
||||
end
|
||||
|
||||
def open_tab_in_background(state, type, id, intent) do
|
||||
current_active = state.active_tab
|
||||
{tabs, _opened_tab} = upsert_tab(state.tabs, normalize_type(type), id, intent)
|
||||
|
||||
state
|
||||
|> Map.put(:tabs, tabs)
|
||||
|> Map.put(:active_tab, current_active)
|
||||
|> sync_editor_route()
|
||||
|> normalize_panel()
|
||||
end
|
||||
|
||||
def close_tab(state, type, id) do
|
||||
type = normalize_type(type)
|
||||
target = {type, id}
|
||||
index = Enum.find_index(state.tabs, &(tab_ref(&1) == target))
|
||||
|
||||
if is_nil(index) do
|
||||
state
|
||||
else
|
||||
tabs = List.delete_at(state.tabs, index)
|
||||
|
||||
next_active =
|
||||
cond do
|
||||
state.active_tab != target -> state.active_tab
|
||||
tabs == [] -> nil
|
||||
index < length(tabs) -> tab_ref(Enum.at(tabs, index))
|
||||
true -> tab_ref(List.last(tabs))
|
||||
end
|
||||
|
||||
state
|
||||
|> Map.put(:tabs, tabs)
|
||||
|> Map.put(:active_tab, next_active)
|
||||
|> sync_editor_route()
|
||||
|> normalize_panel()
|
||||
end
|
||||
end
|
||||
|
||||
def pin_tab(state, type, id) do
|
||||
type = normalize_type(type)
|
||||
|
||||
tabs =
|
||||
Enum.map(state.tabs, fn tab ->
|
||||
if tab_ref(tab) == {type, id}, do: %{tab | is_transient: false}, else: tab
|
||||
end)
|
||||
|
||||
%{state | tabs: tabs}
|
||||
end
|
||||
|
||||
def clear_tabs(state) do
|
||||
%{state | tabs: [], active_tab: nil, editor_route: :dashboard, dirty_tabs: MapSet.new()}
|
||||
|> normalize_panel()
|
||||
end
|
||||
|
||||
def mark_dirty(state, type, id) do
|
||||
if normalize_type(type) == :post do
|
||||
%{state | dirty_tabs: MapSet.put(state.dirty_tabs, {normalize_type(type), id})}
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
def clear_dirty(state, type, id) do
|
||||
%{state | dirty_tabs: MapSet.delete(state.dirty_tabs, {normalize_type(type), id})}
|
||||
end
|
||||
|
||||
def dirty?(state, type, id) do
|
||||
MapSet.member?(state.dirty_tabs, {normalize_type(type), id})
|
||||
end
|
||||
|
||||
def toggle_sidebar(state), do: %{state | sidebar_visible: not state.sidebar_visible}
|
||||
|
||||
def set_panel_visible(state, visible) when is_boolean(visible) do
|
||||
%{state | panel: %{state.panel | visible: visible}}
|
||||
end
|
||||
|
||||
def toggle_panel(state) do
|
||||
set_panel_visible(state, not state.panel.visible)
|
||||
end
|
||||
|
||||
def toggle_assistant_sidebar(state) do
|
||||
%{state | assistant_sidebar_visible: not state.assistant_sidebar_visible}
|
||||
end
|
||||
|
||||
def set_panel_tab(state, tab) when tab in [:tasks, :output, :post_links, :git_log] do
|
||||
%{state | panel: %{state.panel | active_tab: tab}}
|
||||
end
|
||||
|
||||
def click_activity(state, activity_id) do
|
||||
activity_id = normalize_type(activity_id)
|
||||
|
||||
cond do
|
||||
state.active_view == activity_id -> toggle_sidebar(state)
|
||||
state.sidebar_visible -> %{state | active_view: activity_id}
|
||||
true -> %{state | active_view: activity_id, sidebar_visible: true}
|
||||
end
|
||||
end
|
||||
|
||||
def activity_buttons(state, git_badge_count \\ 0) do
|
||||
Registry.sidebar_views()
|
||||
|> Enum.map(fn view ->
|
||||
%{
|
||||
id: view.id,
|
||||
label: view.label,
|
||||
activity_group: view.activity_group,
|
||||
active: state.sidebar_visible and state.active_view == view.id,
|
||||
badge: activity_badge(view.id, git_badge_count)
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
def status_bar(state, opts) do
|
||||
%{
|
||||
left: %{
|
||||
running_task_message: Keyword.get(opts, :running_task_message),
|
||||
running_task_overflow: Keyword.get(opts, :running_task_overflow)
|
||||
},
|
||||
right: %{
|
||||
post_status: post_status(state, Keyword.get(opts, :active_post_status)),
|
||||
post_count: "#{Keyword.get(opts, :post_count, 0)} posts",
|
||||
media_count: "#{Keyword.get(opts, :media_count, 0)} media",
|
||||
token_usage: token_usage(state, Keyword.get(opts, :token_usage)),
|
||||
theme_badge: Keyword.get(opts, :theme_badge, "default"),
|
||||
offline_mode: Keyword.get(opts, :offline_mode, false),
|
||||
ui_language: Keyword.get(opts, :ui_language, "en"),
|
||||
brand: "bDS"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp upsert_tab(tabs, type, id, intent) do
|
||||
transient? = transient_tab?(type, intent)
|
||||
|
||||
case Enum.find_index(tabs, &(tab_ref(&1) == {type, id})) do
|
||||
nil ->
|
||||
new_tab = %{type: type, id: id, is_transient: transient?}
|
||||
|
||||
tabs =
|
||||
cond do
|
||||
transient? -> replace_transient_tab(tabs, type, new_tab)
|
||||
true -> tabs ++ [new_tab]
|
||||
end
|
||||
|
||||
{tabs, new_tab}
|
||||
|
||||
index ->
|
||||
existing = Enum.at(tabs, index)
|
||||
updated = if intent == :pin, do: %{existing | is_transient: false}, else: existing
|
||||
{List.replace_at(tabs, index, updated), updated}
|
||||
end
|
||||
end
|
||||
|
||||
defp replace_transient_tab(tabs, type, new_tab) do
|
||||
case Enum.find_index(tabs, &(&1.type == type and &1.is_transient)) do
|
||||
nil -> tabs ++ [new_tab]
|
||||
index -> List.replace_at(tabs, index, new_tab)
|
||||
end
|
||||
end
|
||||
|
||||
defp transient_tab?(type, _intent) when type in [:chat, :import], do: false
|
||||
defp transient_tab?(type, intent) do
|
||||
if MapSet.member?(@singleton_tabs, type), do: false, else: transient_from_intent(intent)
|
||||
end
|
||||
|
||||
defp transient_from_intent(:preview), do: true
|
||||
defp transient_from_intent(_intent), do: false
|
||||
|
||||
defp sync_editor_route(%{active_tab: nil} = state), do: %{state | editor_route: :dashboard}
|
||||
defp sync_editor_route(%{active_tab: {type, _id}} = state), do: %{state | editor_route: type}
|
||||
|
||||
defp normalize_panel(state) do
|
||||
if panel_tab_available?(state.editor_route, state.panel.active_tab) do
|
||||
state
|
||||
else
|
||||
%{state | panel: %{state.panel | active_tab: :tasks}}
|
||||
end
|
||||
end
|
||||
|
||||
defp panel_tab_available?(_route, tab) when tab in [:tasks, :output], do: true
|
||||
defp panel_tab_available?(:post, :post_links), do: true
|
||||
defp panel_tab_available?(route, :git_log) when route in [:post, :media], do: true
|
||||
defp panel_tab_available?(_route, _tab), do: false
|
||||
|
||||
defp activity_badge(:git, count) when is_integer(count) and count > 0 do
|
||||
%{count: count, display: if(count > 99, do: "99+", else: Integer.to_string(count))}
|
||||
end
|
||||
|
||||
defp activity_badge(_id, _count), do: nil
|
||||
|
||||
defp post_status(%{editor_route: :post}, status) when not is_nil(status), do: to_string(status)
|
||||
defp post_status(_state, _status), do: nil
|
||||
|
||||
defp token_usage(%{editor_route: :chat}, usage), do: usage
|
||||
defp token_usage(_state, _usage), do: nil
|
||||
|
||||
defp normalize_type(type) when is_atom(type), do: type
|
||||
defp normalize_type(type) when is_binary(type), do: String.to_atom(type)
|
||||
|
||||
defp tab_ref(tab), do: {tab.type, tab.id}
|
||||
|
||||
defp clamp_sidebar_width(width), do: max(200, min(width, 500))
|
||||
defp clamp_assistant_sidebar_width(width), do: max(280, min(width, 640))
|
||||
end
|
||||
Reference in New Issue
Block a user