diff --git a/config/config.exs b/config/config.exs
index 0a12341..31313bb 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -9,7 +9,14 @@ config :bds, BDS.Repo,
stacktrace: true,
show_sensitive_data_on_connection_error: true
-config :bds, BDS.Application, desktop_adapter: :pending_selection
+config :bds, BDS.Application, desktop_adapter: :desktop
+
+config :bds, :desktop,
+ port: 4010,
+ window_size: {1440, 900},
+ window_min_size: {1100, 700},
+ title: "Blogging Desktop Server",
+ secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001"
config :bds, :scripting,
runtime: BDS.Scripting.Lua,
diff --git a/config/runtime.exs b/config/runtime.exs
index 60260a8..4e61208 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -7,5 +7,5 @@ if config_env() == :prod do
config :bds, BDS.Repo,
database: database_path,
- pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
+ pool_size: String.to_integer(System.get_env("POOL_SIZE") || "1")
end
diff --git a/lib/bds/application.ex b/lib/bds/application.ex
index afda2fb..34bf1d6 100644
--- a/lib/bds/application.ex
+++ b/lib/bds/application.ex
@@ -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
diff --git a/lib/bds/desktop.ex b/lib/bds/desktop.ex
new file mode 100644
index 0000000..1c141ba
--- /dev/null
+++ b/lib/bds/desktop.ex
@@ -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
diff --git a/lib/bds/desktop/main_window.ex b/lib/bds/desktop/main_window.ex
new file mode 100644
index 0000000..25c6c9d
--- /dev/null
+++ b/lib/bds/desktop/main_window.ex
@@ -0,0 +1,3 @@
+defmodule BDS.Desktop.MainWindow do
+ @moduledoc false
+end
diff --git a/lib/bds/desktop/menu.ex b/lib/bds/desktop/menu.ex
new file mode 100644
index 0000000..c98635a
--- /dev/null
+++ b/lib/bds/desktop/menu.ex
@@ -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"""
+
+ - Open
+
+ - Quit
+
+ """
+ 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
diff --git a/lib/bds/desktop/menu_bar.ex b/lib/bds/desktop/menu_bar.ex
new file mode 100644
index 0000000..73ce56a
--- /dev/null
+++ b/lib/bds/desktop/menu_bar.ex
@@ -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"""
+
+ <%= for group <- @groups do %>
+
+ <%= for item <- group.items do %>
+ - {item.label}
+ <% end %>
+
+ <% end %>
+
+ """
+ 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
diff --git a/lib/bds/desktop/router.ex b/lib/bds/desktop/router.ex
new file mode 100644
index 0000000..1742d2d
--- /dev/null
+++ b/lib/bds/desktop/router.ex
@@ -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
diff --git a/lib/bds/desktop/server.ex b/lib/bds/desktop/server.ex
new file mode 100644
index 0000000..a92c081
--- /dev/null
+++ b/lib/bds/desktop/server.ex
@@ -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
diff --git a/lib/bds/desktop/shell_controller.ex b/lib/bds/desktop/shell_controller.ex
new file mode 100644
index 0000000..3e16646
--- /dev/null
+++ b/lib/bds/desktop/shell_controller.ex
@@ -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
diff --git a/lib/bds/ui/commands.ex b/lib/bds/ui/commands.ex
new file mode 100644
index 0000000..53f924c
--- /dev/null
+++ b/lib/bds/ui/commands.ex
@@ -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
\ No newline at end of file
diff --git a/lib/bds/ui/menu_bar.ex b/lib/bds/ui/menu_bar.ex
new file mode 100644
index 0000000..3d2f259
--- /dev/null
+++ b/lib/bds/ui/menu_bar.ex
@@ -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
\ No newline at end of file
diff --git a/lib/bds/ui/registry.ex b/lib/bds/ui/registry.ex
new file mode 100644
index 0000000..be63f11
--- /dev/null
+++ b/lib/bds/ui/registry.ex
@@ -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
\ No newline at end of file
diff --git a/lib/bds/ui/session.ex b/lib/bds/ui/session.ex
new file mode 100644
index 0000000..a6f2394
--- /dev/null
+++ b/lib/bds/ui/session.ex
@@ -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
\ No newline at end of file
diff --git a/lib/bds/ui/shell_page.ex b/lib/bds/ui/shell_page.ex
new file mode 100644
index 0000000..b5be7c8
--- /dev/null
+++ b/lib/bds/ui/shell_page.ex
@@ -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
+ }
+ }
+
+ [
+ "",
+ "",
+ "",
+ " ",
+ " ",
+ " bDS Shell ",
+ " ",
+ "",
+ "",
+ " ",
+ "
",
+ "
",
+ "
",
+ "
",
+ "
",
+ "
",
+ " ",
+ " ",
+ " ",
+ "
",
+ "
",
+ "
",
+ "
",
+ " ",
+ " ",
+ "",
+ ""
+ ]
+ |> Enum.join("\n")
+ end
+end
\ No newline at end of file
diff --git a/lib/bds/ui/workbench.ex b/lib/bds/ui/workbench.ex
new file mode 100644
index 0000000..2a44a52
--- /dev/null
+++ b/lib/bds/ui/workbench.ex
@@ -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
\ No newline at end of file
diff --git a/mix.exs b/mix.exs
index b924744..f1c5e81 100644
--- a/mix.exs
+++ b/mix.exs
@@ -16,7 +16,7 @@ defmodule BDS.MixProject do
def application do
[
- extra_applications: [:logger],
+ extra_applications: [:logger, :wx],
mod: {BDS.Application, []}
]
end
@@ -30,6 +30,8 @@ defmodule BDS.MixProject do
{:earmark, "~> 1.4"},
{:liquex, "~> 0.13.1"},
{:plug, "~> 1.18"},
+ {:bandit, "~> 1.5"},
+ {:desktop, "~> 1.5"},
{:image, "~> 0.65"},
{:stemex, "~> 0.2.1"}
]
diff --git a/mix.lock b/mix.lock
index c630be1..3cb63b8 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,16 +1,25 @@
%{
+ "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"color": {:hex, :color, "0.12.0", "f59f9bb6452a460760d44116ec0c1cf86f9d7707c8756c01f83c6d8fe042ae67", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "1e17768919dad0bd44f48d0daf294d24bdd5a615bbfe0b4e01a51312203bd294"},
"date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
+ "dbus": {:hex, :dbus, "0.8.0", "7c800681f35d909c199265e55a8ee4aea9ebe4acccce77a0740f89f29cc57648", [:make], [], "hexpm", "a9784f2d9717ffa1f74169144a226c39633ac0d9c7fe8cb3594aeb89c827cca5"},
+ "debouncer": {:hex, :debouncer, "0.1.13", "af5906b231c196943ac8386b5b5f45a2f36d54a8bcd7e1b29eef2671de33d287", [:mix], [], "hexpm", "a14f57420c7d4a287f8f08e715fc8759b5d28dcd1032f9585d57c45d22123382"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
+ "desktop": {:hex, :desktop, "1.5.3", "dcf875dcff5b49a54646b4e6964acb079545c8c9c3790799aa5f1ccdcd314d15", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3750aabb8ed8aaf09b33f3cad5bda20f8ce4dfa65b026c019baed99c5264e2aa"},
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
+ "ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},
+ "ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"},
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
+ "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
+ "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
+ "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.4", "271455b4d300d5d53a5d92b5bd1c00ad14c5abf1c9ff87be069af5736496515c", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "12e1754204e7db5df1750df0a5dba1bbdf89260800019ab081f2b046596be56b"},
"image": {:hex, :image, "0.65.0", "44908233a1a0dcdbb6ae873ec09fd9ae533d1840d300d8b0b1b186d586b935e6", [:mix], [{:color, "~> 0.4", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "0.11.0", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.11.0", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "d2060e08d0f42564f49de1ea97a82a5d237f9ac91edb141dece51f1238dd8b4a"},
@@ -21,13 +30,22 @@
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
+ "oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"},
+ "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
+ "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
+ "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
+ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"rustler": {:hex, :rustler, "0.32.1", "f4cf5a39f9e85d182c0a3f75fa15b5d0add6542ab0bf9ceac6b4023109ebd3fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "b96be75526784f86f6587f051bc8d6f4eaff23d6e0f88dbcfe4d5871f52946f7"},
+ "saxy": {:hex, :saxy, "1.4.0", "c7203ad20001f72eaaad07d08f82be063fa94a40924e6bb39d93d55f979abcba", [:mix], [], "hexpm", "3fe790354d3f2234ad0b5be2d99822a23fa2d4e8ccd6657c672901dac172e9a9"},
"stemex": {:hex, :stemex, "0.2.1", "47017c6b10cdd6926a0d523ccf1f801c5f3faf5a0a9c862f49304e07f9b5584f", [:mix], [], "hexpm", "dbfc76d27adfa31d831d183979c595942884e6530a4496714aa5b70d0964c2e4"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
+ "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
"vix": {:hex, :vix, "0.38.0", "77529ee4f6ced339c3d5f90a9eacf306f5b7109d3d1b5e3ef391a984ad404f75", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "dca58f654922fa678d5df8e028317483d9c0f8acb2e2714076a8468695687aa7"},
+ "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
+ "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
}
diff --git a/priv/ui/app.css b/priv/ui/app.css
new file mode 100644
index 0000000..5551d56
--- /dev/null
+++ b/priv/ui/app.css
@@ -0,0 +1,701 @@
+:root {
+ --bg: #f2efe8;
+ --paper: #f8f5ef;
+ --ink: #1e1b18;
+ --muted: #6f665d;
+ --line: rgba(34, 28, 23, 0.12);
+ --line-strong: rgba(34, 28, 23, 0.22);
+ --accent: #b4472f;
+ --accent-soft: rgba(180, 71, 47, 0.12);
+ --activity: #171411;
+ --activity-ink: #f4efe7;
+ --shadow: 0 20px 60px rgba(41, 30, 20, 0.12);
+ --radius: 18px;
+ --sidebar-width: 280px;
+ --assistant-width: 360px;
+ --panel-height: 168px;
+ --title-height: 56px;
+ --status-height: 38px;
+ --activity-width: 60px;
+ color-scheme: light;
+ font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ min-height: 100%;
+ background:
+ radial-gradient(circle at top left, rgba(202, 156, 86, 0.18), transparent 28%),
+ radial-gradient(circle at top right, rgba(180, 71, 47, 0.12), transparent 26%),
+ linear-gradient(180deg, #f7f2e8 0%, #efe8dc 100%);
+ color: var(--ink);
+}
+
+body {
+ min-height: 100vh;
+ padding: 18px;
+}
+
+button,
+select {
+ font: inherit;
+}
+
+button {
+ :root {
+ color-scheme: dark;
+ --vscode-foreground: #cccccc;
+ --vscode-descriptionForeground: #8c8c8c;
+ --vscode-editor-background: #1e1e1e;
+ --vscode-editorGroupHeader-tabsBackground: #252526;
+ --vscode-editorGroupHeader-tabsBorder: #1f1f1f;
+ --vscode-panel-border: #2b2b2b;
+ --vscode-sideBar-background: #181818;
+ --vscode-sideBar-border: #2b2b2b;
+ --vscode-sideBar-foreground: #cccccc;
+ --vscode-statusBar-background: #007acc;
+ --vscode-statusBar-foreground: #ffffff;
+ --vscode-activityBar-background: #181818;
+ --vscode-activityBar-foreground: #cccccc;
+ --vscode-titleBar-activeForeground: #cccccc;
+ --vscode-list-hoverBackground: #2a2d2e;
+ --vscode-list-activeSelectionBackground: #37373d;
+ --vscode-tab-inactiveBackground: #2d2d2d;
+ --vscode-tab-activeBackground: #1e1e1e;
+ --vscode-tab-activeBorderTop: #0078d4;
+ --vscode-tab-border: #252526;
+ --vscode-menu-background: #252526;
+ --vscode-menu-selectionBackground: #094771;
+ --vscode-menu-border: #454545;
+ --vscode-widget-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
+ --sidebar-width: 320px;
+ --assistant-width: 336px;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ }
+
+ * {
+ box-sizing: border-box;
+ }
+
+ html,
+ body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ background: var(--vscode-editor-background);
+ color: var(--vscode-foreground);
+ }
+
+ body {
+ overflow: hidden;
+ }
+
+ button {
+ font: inherit;
+ }
+
+ .app {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background-color: var(--vscode-editor-background);
+ }
+
+ .window-titlebar {
+ position: relative;
+ height: 34px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: var(--vscode-editorGroupHeader-tabsBackground);
+ border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder);
+ flex-shrink: 0;
+ -webkit-app-region: drag;
+ app-region: drag;
+ }
+
+ .window-titlebar-menu-bar,
+ .window-titlebar-actions {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ gap: 2px;
+ padding: 0 6px;
+ -webkit-app-region: no-drag;
+ app-region: no-drag;
+ }
+
+ .window-titlebar-title {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 12px;
+ color: var(--vscode-titleBar-activeForeground);
+ pointer-events: none;
+ }
+
+ .window-titlebar-menu-button,
+ .window-titlebar-action-button {
+ height: 24px;
+ min-width: 24px;
+ border: none;
+ background: transparent;
+ color: var(--vscode-titleBar-activeForeground);
+ border-radius: 4px;
+ cursor: pointer;
+ }
+
+ .window-titlebar-menu-button:hover,
+ .window-titlebar-action-button:hover {
+ background-color: var(--vscode-list-hoverBackground);
+ }
+
+ .window-titlebar-sidebar-icon,
+ .window-titlebar-panel-icon {
+ width: 14px;
+ height: 14px;
+ border: 1.5px solid currentColor;
+ border-radius: 2px;
+ display: block;
+ position: relative;
+ }
+
+ .window-titlebar-sidebar-icon::before {
+ content: "";
+ position: absolute;
+ left: 33%;
+ top: 0;
+ bottom: 0;
+ width: 1.5px;
+ background: currentColor;
+ }
+
+ .window-titlebar-sidebar-pane {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 33%;
+ height: 100%;
+ background: currentColor;
+ }
+
+ .window-titlebar-panel-icon::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 66%;
+ height: 1.5px;
+ background: currentColor;
+ }
+
+ .app-main {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+ min-height: 0;
+ }
+
+ .activity-bar {
+ width: 48px;
+ height: 100%;
+ background-color: var(--vscode-activityBar-background);
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ border-right: 1px solid var(--vscode-panel-border);
+ }
+
+ .activity-bar-top,
+ .activity-bar-bottom {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 4px 0;
+ }
+
+ .activity-bar-item {
+ width: 48px;
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ border: none;
+ color: var(--vscode-activityBar-foreground);
+ opacity: 0.6;
+ cursor: pointer;
+ position: relative;
+ }
+
+ .activity-bar-item.active {
+ opacity: 1;
+ }
+
+ .activity-bar-item.active::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background-color: var(--vscode-activityBar-foreground);
+ }
+
+ .sidebar-shell,
+ .assistant-sidebar-shell {
+ display: flex;
+ min-width: 0;
+ }
+
+ .sidebar-shell {
+ width: var(--sidebar-width);
+ }
+
+ .assistant-sidebar-shell {
+ width: var(--assistant-width);
+ }
+
+ .sidebar,
+ .assistant-sidebar {
+ width: 100%;
+ height: 100%;
+ background-color: var(--vscode-sideBar-background);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ .sidebar {
+ border-right: 1px solid var(--vscode-sideBar-border);
+ }
+
+ .assistant-sidebar {
+ border-left: 1px solid var(--vscode-sideBar-border);
+ }
+
+ .sidebar-header,
+ .assistant-header,
+ .panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--vscode-sideBar-foreground);
+ border-bottom: 1px solid var(--vscode-panel-border);
+ }
+
+ .sidebar-content,
+ .assistant-content,
+ .panel-content {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+
+ .sidebar-item,
+ .assistant-card,
+ .panel-entry {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ padding: 8px 12px;
+ }
+
+ .sidebar-item:hover,
+ .assistant-card:hover,
+ .panel-entry:hover {
+ background-color: var(--vscode-list-hoverBackground);
+ }
+
+ .sidebar-item.active {
+ background-color: var(--vscode-list-activeSelectionBackground);
+ border-left: 2px solid var(--vscode-tab-activeBorderTop);
+ padding-left: 10px;
+ }
+
+ .sidebar-item strong,
+ .assistant-card strong,
+ .panel-entry strong {
+ font-size: 13px;
+ color: var(--vscode-sideBar-foreground);
+ }
+
+ .sidebar-item span,
+ .assistant-card span,
+ .panel-entry span,
+ .editor-subtitle {
+ font-size: 12px;
+ color: var(--vscode-descriptionForeground);
+ }
+
+ .app-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-width: 0;
+ }
+
+ .tab-bar {
+ display: flex;
+ align-items: center;
+ background-color: var(--vscode-editorGroupHeader-tabsBackground);
+ border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder);
+ height: 35px;
+ flex-shrink: 0;
+ }
+
+ .tab-bar-tabs {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ overflow-x: auto;
+ flex: 1;
+ }
+
+ .tab-bar-tabs::-webkit-scrollbar {
+ display: none;
+ }
+
+ .tab {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 10px;
+ height: 100%;
+ min-width: 110px;
+ max-width: 180px;
+ cursor: pointer;
+ background-color: var(--vscode-tab-inactiveBackground);
+ border-right: 1px solid var(--vscode-tab-border);
+ color: #969696;
+ font-size: 13px;
+ position: relative;
+ flex-shrink: 0;
+ }
+
+ .tab.active {
+ background-color: var(--vscode-tab-activeBackground);
+ color: #ffffff;
+ }
+
+ .tab.active::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background-color: var(--vscode-tab-activeBorderTop);
+ }
+
+ .tab.transient .tab-title {
+ font-style: italic;
+ }
+
+ .tab-title {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .tab-close,
+ .tab-dirty-indicator {
+ width: 18px;
+ text-align: center;
+ color: inherit;
+ }
+
+ .editor-shell {
+ flex: 1;
+ background: var(--vscode-editor-background);
+ overflow: auto;
+ }
+
+ .editor-frame {
+ min-height: 100%;
+ display: grid;
+ grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.75fr);
+ }
+
+ .editor-main {
+ padding: 24px;
+ }
+
+ .editor-meta {
+ border-left: 1px solid var(--vscode-panel-border);
+ background: #181818;
+ padding: 18px 16px;
+ }
+
+ .editor-title {
+ margin: 0;
+ font-size: 24px;
+ font-weight: 600;
+ }
+
+ .editor-toolbar {
+ display: flex;
+ gap: 8px;
+ margin: 16px 0 20px;
+ }
+
+ .editor-toolbar button {
+ border: 1px solid var(--vscode-panel-border);
+ background: #252526;
+ color: var(--vscode-foreground);
+ padding: 6px 10px;
+ border-radius: 4px;
+ }
+
+ .editor-paragraph {
+ max-width: 72ch;
+ line-height: 1.6;
+ color: #d4d4d4;
+ }
+
+ .panel-shell {
+ height: 220px;
+ background: #181818;
+ border-top: 1px solid var(--vscode-panel-border);
+ display: flex;
+ flex-direction: column;
+ }
+
+ .panel-tabs {
+ display: flex;
+ gap: 2px;
+ }
+
+ .panel-tab {
+ background: transparent;
+ border: none;
+ color: var(--vscode-descriptionForeground);
+ padding: 8px 10px;
+ cursor: pointer;
+ }
+
+ .panel-tab.active {
+ color: var(--vscode-foreground);
+ }
+
+ .status-bar {
+ height: 22px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: var(--vscode-statusBar-background);
+ color: var(--vscode-statusBar-foreground);
+ font-size: 12px;
+ padding: 0 8px;
+ flex-shrink: 0;
+ }
+
+ .status-bar-left,
+ .status-bar-right {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .status-bar-item {
+ display: flex;
+ align-items: center;
+ padding: 0 8px;
+ height: 100%;
+ }
+
+ .resizable-panel-divider {
+ width: 4px;
+ background: transparent;
+ cursor: col-resize;
+ flex-shrink: 0;
+ }
+
+ .resizable-panel-divider:hover,
+ .resizable-panel-divider.is-dragging {
+ background: rgba(0, 120, 212, 0.35);
+ }
+
+ .is-hidden {
+ display: none !important;
+ }
+
+ @media (max-width: 1200px) {
+ .assistant-sidebar-shell {
+ display: none;
+ }
+ }
+
+ @media (max-width: 900px) {
+ .sidebar-shell {
+ display: none;
+ }
+
+ .editor-frame {
+ grid-template-columns: 1fr;
+ }
+
+ .editor-meta {
+ border-left: none;
+ border-top: 1px solid var(--vscode-panel-border);
+ }
+ }
+[data-role="resize-handle"] {
+ background: linear-gradient(180deg, transparent, rgba(120, 93, 71, 0.3), transparent);
+ cursor: col-resize;
+}
+
+[data-role="resize-handle"][data-target="sidebar"] {
+ grid-area: sidebar-handle;
+}
+
+[data-role="resize-handle"][data-target="assistant"] {
+ grid-area: assistant-handle;
+}
+
+#bds-shell-app[data-sidebar-visible="false"] [data-role="resize-handle"][data-target="sidebar"],
+#bds-shell-app[data-assistant-visible="false"] [data-role="resize-handle"][data-target="assistant"] {
+ display: none;
+}
+
+.sidebar-header,
+.assistant-header {
+ padding: 18px 18px 10px;
+ border-bottom: 1px solid var(--line);
+ display: grid;
+ gap: 6px;
+}
+
+.sidebar-header strong,
+.assistant-header strong {
+ font-size: 20px;
+}
+
+.sidebar-placeholder,
+.assistant-placeholder {
+ padding: 18px;
+ display: grid;
+ gap: 16px;
+}
+
+.placeholder-note {
+ color: var(--muted);
+ line-height: 1.5;
+}
+
+[data-region="status-bar"] {
+ grid-area: status;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ padding: 0 14px;
+ border-top: 1px solid var(--line);
+ background: rgba(24, 19, 15, 0.92);
+ color: rgba(255, 248, 240, 0.88);
+ font-size: 13px;
+}
+
+.status-pill,
+.status-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.status-post[data-status="draft"]::before,
+.status-post[data-status="published"]::before,
+.status-post[data-status="archived"]::before {
+ content: "";
+ width: 8px;
+ height: 8px;
+ border-radius: 999px;
+ background: #d6a347;
+}
+
+.status-post[data-status="published"]::before {
+ background: #5aa86b;
+}
+
+.status-post[data-status="archived"]::before {
+ background: #8b7c72;
+}
+
+.status-right select,
+.status-left select {
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.08);
+ color: inherit;
+ padding: 6px 10px;
+}
+
+.offline-toggle[data-active="true"] {
+ background: rgba(180, 71, 47, 0.26);
+ border-color: rgba(255, 255, 255, 0.08);
+ color: #fff5ec;
+}
+
+kbd {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px;
+ padding: 2px 6px;
+ border-radius: 7px;
+ background: rgba(22, 18, 14, 0.08);
+ border: 1px solid rgba(22, 18, 14, 0.12);
+}
+
+@media (max-width: 1100px) {
+ #bds-shell-app,
+ #bds-shell-app[data-sidebar-visible="false"],
+ #bds-shell-app[data-assistant-visible="false"],
+ #bds-shell-app[data-sidebar-visible="false"][data-assistant-visible="false"] {
+ grid-template-columns: var(--activity-width) minmax(0, 1fr);
+ grid-template-rows: var(--title-height) auto minmax(180px, auto) minmax(320px, 1fr) auto var(--status-height);
+ grid-template-areas:
+ "title title"
+ "activity activity"
+ "sidebar sidebar"
+ "content content"
+ "assistant assistant"
+ "status status";
+ height: auto;
+ }
+
+ [data-role="resize-handle"] {
+ display: none !important;
+ }
+
+ [data-region="activity-bar"] {
+ flex-direction: row;
+ justify-content: space-between;
+ gap: 12px;
+ overflow-x: auto;
+ }
+
+ .activity-stack {
+ display: flex;
+ }
+
+ [data-region="status-bar"] {
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 10px 14px;
+ }
+}
diff --git a/priv/ui/app.js b/priv/ui/app.js
new file mode 100644
index 0000000..2d0d511
--- /dev/null
+++ b/priv/ui/app.js
@@ -0,0 +1,688 @@
+const shell = document.getElementById("bds-shell-app");
+const bootstrap = JSON.parse(document.getElementById("bds-shell-bootstrap").textContent);
+const bootstrap = JSON.parse(document.getElementById('bds-bootstrap').textContent);
+
+const state = {
+ ...bootstrap,
+};
+
+const root = document.getElementById('app');
+
+function render() {
+ root.style.setProperty('--sidebar-width', state.sidebarVisible ? `${state.sidebarWidth}px` : '0px');
+ root.style.setProperty('--assistant-width', state.assistantVisible ? `${state.assistantWidth}px` : '0px');
+
+ renderMenuBar();
+ renderActivityBar();
+ renderSidebar();
+ renderTabs();
+ renderEditor();
+ renderPanel();
+ renderAssistant();
+ renderStatusBar();
+ applyVisibility();
+ bindEvents();
+}
+
+function renderMenuBar() {
+ const menuBar = root.querySelector('.window-titlebar-menu-bar');
+ menuBar.innerHTML = state.menuGroups
+ .map((group) => ``)
+ .join('');
+}
+
+function renderActivityBar() {
+ const node = root.querySelector('.activity-bar');
+ const top = state.sidebarViews.filter((view) => view.group === 'top');
+ const bottom = state.sidebarViews.filter((view) => view.group === 'bottom');
+
+ node.innerHTML = `
+ ${top.map(renderActivityButton).join('')}
+ ${bottom.map(renderActivityButton).join('')}
+ `;
+}
+
+function renderActivityButton(view) {
+ const active = state.sidebarVisible && state.activeView === view.id;
+ return `${view.label.slice(0, 1)} `;
+}
+
+function renderSidebar() {
+ const view = state.sidebarViews.find((entry) => entry.id === state.activeView) || state.sidebarViews[0];
+ const node = root.querySelector('.sidebar');
+
+ node.innerHTML = `
+
+
+ `;
+}
+
+function renderTabs() {
+ const node = root.querySelector('.tab-bar');
+ node.innerHTML = `${state.tabs.map(renderTab).join('')}
`;
+}
+
+function renderTab(tab) {
+ const active = tab.id === state.activeTabId;
+ const dirtyMarker = tab.dirty ? '● ' : '× ';
+ return `
+
+ ${tab.title}
+ ${dirtyMarker}
+
+ `;
+}
+
+function renderEditor() {
+ const node = root.querySelector('.editor-shell');
+ const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId) || state.tabs[0];
+
+ node.innerHTML = `
+
+
+ ${activeTab.title}
+ ${activeTab.kind} editor surface routed through the desktop shell
+
+ Publish
+ Preview
+ Metadata
+
+ This desktop shell now runs inside an Elixir Desktop window instead of a standalone browser page. The frame, activity bar, tab strip, status bar and resizable side areas match the old application structure so the next slice can replace placeholders with the real editors and lists.
+ Preview tabs, dirty state and shell routing still come from the shared Elixir workbench modules. The runtime boundary is now a desktop window backed by a local shell server.
+
+
+
+ `;
+}
+
+function renderPanel() {
+ const node = root.querySelector('.panel-shell');
+ const tabs = ['problems', 'search', 'tasks'];
+
+ node.innerHTML = `
+
+
+
+ ${state.panelTab}
+ Shared bottom panel host for problems, search, tasks and later runtime details.
+
+
+ `;
+}
+
+function renderAssistant() {
+ const node = root.querySelector('.assistant-sidebar');
+ node.innerHTML = `
+
+
+
+ Next shell work
+ Swap sidebar placeholders with real project data views.
+
+
+ Offline gate
+ Automatic AI remains gated by airplane mode.
+
+
+ Desktop runtime
+ Window, menu bar and launch path now come from Elixir Desktop.
+
+
+ `;
+}
+
+function renderStatusBar() {
+ const node = root.querySelector('.status-bar');
+ node.innerHTML = `
+ ${state.statusBar.left.map(renderStatusItem).join('')}
+ ${state.statusBar.right.map(renderStatusItem).join('')}
+ `;
+}
+
+function renderStatusItem(item) {
+ return `${item.label}
`;
+}
+
+function applyVisibility() {
+ root.querySelector('.sidebar-shell').classList.toggle('is-hidden', !state.sidebarVisible);
+ root.querySelector('.assistant-sidebar-shell').classList.toggle('is-hidden', !state.assistantVisible);
+ root.querySelector('.panel-shell').classList.toggle('is-hidden', !state.panelVisible);
+}
+
+function bindEvents() {
+ root.querySelectorAll('[data-activity]').forEach((button) => {
+ button.onclick = () => {
+ const next = button.getAttribute('data-activity');
+ if (state.activeView === next && state.sidebarVisible) {
+ state.sidebarVisible = false;
+ } else {
+ state.activeView = next;
+ state.sidebarVisible = true;
+ }
+ render();
+ };
+ });
+
+ root.querySelectorAll('[data-open-tab]').forEach((button) => {
+ button.onclick = () => {
+ const tabId = button.getAttribute('data-open-tab');
+ let tab = state.tabs.find((entry) => entry.id === tabId);
+ if (!tab) {
+ tab = {
+ id: tabId,
+ title: tabId.split(':').pop(),
+ kind: state.activeView,
+ pinned: false,
+ dirty: false,
+ };
+ state.tabs.push(tab);
+ }
+ state.activeTabId = tab.id;
+ render();
+ };
+ });
+
+ root.querySelectorAll('[data-tab]').forEach((tab) => {
+ tab.onclick = () => {
+ state.activeTabId = tab.getAttribute('data-tab');
+ render();
+ };
+ });
+
+ root.querySelectorAll('[data-command]').forEach((button) => {
+ button.onclick = () => {
+ const command = button.getAttribute('data-command');
+ if (command === 'toggle-sidebar') {
+ state.sidebarVisible = !state.sidebarVisible;
+ }
+ if (command === 'toggle-panel') {
+ state.panelVisible = !state.panelVisible;
+ }
+ render();
+ };
+ });
+
+ root.querySelectorAll('[data-panel-tab]').forEach((button) => {
+ button.onclick = () => {
+ state.panelTab = button.getAttribute('data-panel-tab');
+ state.panelVisible = true;
+ render();
+ };
+ });
+
+ root.querySelectorAll('[data-resize]').forEach((handle) => {
+ handle.onpointerdown = (event) => {
+ const target = handle.getAttribute('data-resize');
+ const startX = event.clientX;
+ const startWidth = target === 'sidebar' ? state.sidebarWidth : state.assistantWidth;
+ handle.classList.add('is-dragging');
+
+ const onMove = (moveEvent) => {
+ const delta = moveEvent.clientX - startX;
+ if (target === 'sidebar') {
+ state.sidebarWidth = clamp(startWidth + delta, 220, 520);
+ } else {
+ state.assistantWidth = clamp(startWidth - delta, 280, 520);
+ }
+ render();
+ };
+
+ const onUp = () => {
+ handle.classList.remove('is-dragging');
+ window.removeEventListener('pointermove', onMove);
+ window.removeEventListener('pointerup', onUp);
+ };
+
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+ };
+ });
+}
+
+function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+}
+
+render();
+function renderStatusBar() {
+ const active = activeTab();
+ const dirty = active && rootState.dirty_tabs.some(entry => sameTab(entry, active));
+ const postStatus = active && active.type === "post" ? (dirty ? "draft" : "published") : null;
+ const tokenUsage = active && active.type === "chat" ? "I 120 · O 42 · C 8" : null;
+
+ statusBar.innerHTML = `
+
+
+ ${rootState.project}
+
+ ${rootState.running_task_message}${rootState.running_task_overflow ? ` +${rootState.running_task_overflow}` : ""}
+
+
+ ${postStatus ? `${postStatus} ` : ""}
+ ${rootState.post_count} posts
+ ${rootState.media_count} media
+ ${tokenUsage ? `${tokenUsage} ` : ""}
+
+ ${["zinc", "amber", "jade", "sand"].map(theme => `${theme} `).join("")}
+
+ ${rootState.offline_mode ? "Offline" : "Online"}
+
+ ${["en", "de", "fr", "it", "es"].map(language => `${language.toUpperCase()} `).join("")}
+
+ bDS
+
+ `;
+}
+
+function wireGlobalEvents() {
+ document.addEventListener("click", handleClick);
+ document.addEventListener("dblclick", handleDoubleClick);
+ document.addEventListener("keydown", handleKeyDown);
+ document.addEventListener("change", handleChange);
+ document.querySelectorAll('[data-role="resize-handle"]').forEach(handle => {
+ handle.addEventListener("pointerdown", startResize);
+ });
+}
+
+function handleClick(event) {
+ const commandButton = event.target.closest("[data-command]");
+ if (commandButton) {
+ event.preventDefault();
+ executeCommand(commandButton.dataset.command);
+ return;
+ }
+
+ const menuTrigger = event.target.closest("[data-menu-trigger]");
+ if (menuTrigger) {
+ const id = menuTrigger.dataset.menuTrigger;
+ openMenu = openMenu === id ? null : id;
+ render();
+ return;
+ }
+
+ if (!event.target.closest(".menu-group")) {
+ if (openMenu !== null) {
+ openMenu = null;
+ renderTitleBar();
+ }
+ }
+
+ const activity = event.target.closest("[data-activity]");
+ if (activity) {
+ clickActivity(activity.dataset.activity);
+ return;
+ }
+
+ const activate = event.target.closest("[data-tab-activate]");
+ if (activate) {
+ const [type, id] = activate.dataset.tabActivate.split(":");
+ rootState.active_tab = { type, id };
+ normalizePanel();
+ render();
+ return;
+ }
+
+ const closeButton = event.target.closest("[data-tab-close]");
+ if (closeButton) {
+ const [type, id] = closeButton.dataset.tabClose.split(":");
+ closeTab(type, id);
+ return;
+ }
+
+ const demoButton = event.target.closest("[data-open-demo]");
+ if (demoButton) {
+ openDemoFromView(demoButton.dataset.openDemo);
+ return;
+ }
+
+ const action = event.target.closest("[data-action]");
+ if (action) {
+ handleAction(action.dataset.action);
+ return;
+ }
+
+ const panelTab = event.target.closest("[data-panel-tab]");
+ if (panelTab && !panelTab.disabled) {
+ rootState.panel.active_tab = panelTab.dataset.panelTab;
+ render();
+ }
+}
+
+function handleDoubleClick(event) {
+ const tab = event.target.closest("[data-tab-type][data-tab-id]");
+ if (!tab) return;
+
+ const type = tab.dataset.tabType;
+ const id = tab.dataset.tabId;
+ const found = rootState.tabs.find(entry => entry.type === type && entry.id === id);
+
+ if (found && found.is_transient) {
+ found.is_transient = false;
+ render();
+ }
+}
+
+function handleKeyDown(event) {
+ const primary = event.metaKey || event.ctrlKey;
+ if (!primary) return;
+
+ const key = event.key.toLowerCase();
+
+ if (key === "b") {
+ event.preventDefault();
+ executeCommand("toggle_sidebar");
+ }
+
+ if (key === "w") {
+ event.preventDefault();
+ executeCommand("close_tab");
+ }
+}
+
+function handleChange(event) {
+ const action = event.target.dataset.action;
+ if (!action) return;
+
+ if (action === "theme-select") {
+ rootState.theme_badge = event.target.value;
+ render();
+ }
+
+ if (action === "language-select") {
+ rootState.ui_language = event.target.value;
+ render();
+ }
+}
+
+function startResize(event) {
+ const target = event.currentTarget.dataset.target;
+ const initialX = event.clientX;
+ const initialWidth = target === "sidebar" ? rootState.sidebar_width : rootState.assistant_sidebar_width;
+
+ const move = moveEvent => {
+ const delta = moveEvent.clientX - initialX;
+
+ if (target === "sidebar") {
+ rootState.sidebar_width = clamp(initialWidth + delta, 200, 500);
+ } else {
+ rootState.assistant_sidebar_width = clamp(initialWidth - delta, 280, 640);
+ }
+
+ render();
+ };
+
+ const stop = () => {
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", stop);
+ };
+
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", stop);
+}
+
+function executeCommand(command) {
+ if (command === "toggle_sidebar") {
+ rootState.sidebar_visible = !rootState.sidebar_visible;
+ }
+
+ if (command === "toggle_panel") {
+ rootState.panel.visible = !rootState.panel.visible;
+ }
+
+ if (command === "toggle_assistant_sidebar") {
+ rootState.assistant_sidebar_visible = !rootState.assistant_sidebar_visible;
+ }
+
+ if (command === "close_tab" && rootState.active_tab) {
+ closeTab(rootState.active_tab.type, rootState.active_tab.id);
+ return;
+ }
+
+ if (command === "settings") {
+ openTab("settings", "settings", "pin");
+ }
+
+ if (command === "documentation") {
+ openTab("documentation", "documentation", "pin");
+ }
+
+ if (command === "api_documentation") {
+ openTab("api_documentation", "api_documentation", "pin");
+ }
+
+ normalizePanel();
+ render();
+}
+
+function handleAction(action) {
+ if (action === "toggle-dirty") {
+ const active = activeTab();
+ if (!active || active.type !== "post") return;
+
+ const index = rootState.dirty_tabs.findIndex(entry => sameTab(entry, active));
+ if (index >= 0) {
+ rootState.dirty_tabs.splice(index, 1);
+ } else {
+ rootState.dirty_tabs.push({ type: active.type, id: active.id });
+ }
+ render();
+ return;
+ }
+
+ if (action === "pin-active") {
+ const active = activeTab();
+ if (!active) return;
+ const found = rootState.tabs.find(tab => sameTab(tab, active));
+ if (found) found.is_transient = false;
+ render();
+ return;
+ }
+
+ if (action === "toggle-offline") {
+ rootState.offline_mode = !rootState.offline_mode;
+ render();
+ return;
+ }
+
+ if (action === "reset-session") {
+ localStorage.removeItem(sessionKey);
+ location.reload();
+ return;
+ }
+
+ if (action === "open-chat") {
+ openTab("chat", "conversation-demo", "pin");
+ }
+}
+
+function clickActivity(id) {
+ if (rootState.active_view === id) {
+ rootState.sidebar_visible = !rootState.sidebar_visible;
+ } else {
+ rootState.active_view = id;
+ rootState.sidebar_visible = true;
+ }
+ render();
+}
+
+function openDemoFromView(mode) {
+ const view = registry.sidebar_views.find(entry => entry.id === rootState.active_view) || registry.sidebar_views[0];
+ const type = view.editor_route;
+ const isSingleton = !!view.singleton;
+ const id = isSingleton ? type : `${rootState.active_view}-demo-${view.activity_group === "bottom" ? "tool" : "item"}`;
+ const intent = mode === "pin" ? "pin" : "preview";
+
+ if (mode === "background") {
+ openTab(type, id, intent, true);
+ } else {
+ openTab(type, id, intent, false);
+ }
+}
+
+function openTab(type, id, intent, background) {
+ const existing = rootState.tabs.find(tab => tab.type === type && tab.id === id);
+ const singleton = !!routeMeta(type)?.singleton;
+ const sticky = type === "chat" || type === "import" || singleton;
+ const transient = !sticky && intent === "preview";
+
+ if (existing) {
+ if (intent === "pin") existing.is_transient = false;
+ if (!background) rootState.active_tab = { type, id };
+ normalizePanel();
+ render();
+ return;
+ }
+
+ if (transient) {
+ const replacementIndex = rootState.tabs.findIndex(tab => tab.type === type && tab.is_transient);
+ const newTab = { type, id, is_transient: true };
+ if (replacementIndex >= 0) {
+ rootState.tabs.splice(replacementIndex, 1, newTab);
+ } else {
+ rootState.tabs.push(newTab);
+ }
+ } else {
+ rootState.tabs.push({ type, id, is_transient: false });
+ }
+
+ if (!background) {
+ rootState.active_tab = { type, id };
+ }
+
+ normalizePanel();
+ render();
+}
+
+function closeTab(type, id) {
+ const index = rootState.tabs.findIndex(tab => tab.type === type && tab.id === id);
+ if (index < 0) return;
+
+ rootState.tabs.splice(index, 1);
+ rootState.dirty_tabs = rootState.dirty_tabs.filter(tab => !(tab.type === type && tab.id === id));
+
+ if (rootState.active_tab && rootState.active_tab.type === type && rootState.active_tab.id === id) {
+ if (rootState.tabs[index]) {
+ rootState.active_tab = { type: rootState.tabs[index].type, id: rootState.tabs[index].id };
+ } else if (rootState.tabs[index - 1]) {
+ rootState.active_tab = { type: rootState.tabs[index - 1].type, id: rootState.tabs[index - 1].id };
+ } else {
+ rootState.active_tab = null;
+ }
+ }
+
+ normalizePanel();
+ render();
+}
+
+function normalizePanel() {
+ const route = activeTab() ? activeTab().type : "dashboard";
+ if (!panelAvailable(route, rootState.panel.active_tab)) {
+ rootState.panel.active_tab = "tasks";
+ }
+}
+
+function panelAvailable(route, tab) {
+ if (tab === "tasks" || tab === "output") return true;
+ if (tab === "post_links") return route === "post";
+ if (tab === "git_log") return route === "post" || route === "media";
+ return false;
+}
+
+function activeTab() {
+ if (!rootState.active_tab) return null;
+ return rootState.tabs.find(tab => sameTab(tab, rootState.active_tab)) || null;
+}
+
+function routeMeta(id) {
+ return registry.editor_routes.find(route => route.id === id) || null;
+}
+
+function sameTab(left, right) {
+ return !!left && !!right && left.type === right.type && left.id === right.id;
+}
+
+function tabTitle(tab) {
+ const meta = routeMeta(tab.type);
+ const prefix = meta ? meta.title : titleCase(tab.type);
+ if (meta && meta.singleton) return prefix;
+ return `${prefix} ${tab.id}`;
+}
+
+function labelForPanel(tab) {
+ return {
+ tasks: "Tasks",
+ output: "Output",
+ post_links: "Post Links",
+ git_log: "Git Log"
+ }[tab] || titleCase(tab);
+}
+
+function labelForCommand(id) {
+ return id
+ .split("_")
+ .map(titleCase)
+ .join(" ");
+}
+
+function compactLabel(label) {
+ return label.split(" ").map(part => part[0]).join("").slice(0, 3).toUpperCase();
+}
+
+function titleCase(value) {
+ return String(value)
+ .split(/[_\s-]+/)
+ .filter(Boolean)
+ .map(part => part[0].toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, Number(value) || min));
+}
+
+function safeParse(value) {
+ try {
+ return value ? JSON.parse(value) : null;
+ } catch (_error) {
+ return null;
+ }
+}
diff --git a/priv/ui/index.html b/priv/ui/index.html
new file mode 100644
index 0000000..c3e0a1d
--- /dev/null
+++ b/priv/ui/index.html
@@ -0,0 +1,104 @@
+
+
+
+
+
+ bDS Shell
+
+
+
+
+
+
+
+
+ Blogging Desktop Server
+
+
+
+
+
+
+
Blogging Desktop Server
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/bds/desktop_test.exs b/test/bds/desktop_test.exs
new file mode 100644
index 0000000..63e56e3
--- /dev/null
+++ b/test/bds/desktop_test.exs
@@ -0,0 +1,64 @@
+defmodule BDS.DesktopTest do
+ use ExUnit.Case, async: false
+
+ import Plug.Test
+
+ test "desktop configuration no longer uses a pending adapter" do
+ assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop
+ end
+
+ test "desktop child specs include the local shell server and desktop window in non-test environments" do
+ children = BDS.Application.desktop_children(:dev)
+
+ assert Enum.any?(children, fn child ->
+ match?({BDS.Desktop.Server, _opts}, child)
+ end)
+
+ assert Enum.any?(children, fn child ->
+ match?({Desktop.Window, opts} when is_list(opts), child) and
+ Keyword.fetch!(elem(child, 1), :id) == BDS.Desktop.MainWindow
+ end)
+ end
+
+ test "desktop children stay disabled in test so command-line tests do not spawn wx windows" do
+ assert BDS.Application.desktop_children(:test) == []
+ end
+
+ test "desktop shell url points at the embedded shell route" do
+ url = BDS.Desktop.url(4011)
+
+ assert url == "http://127.0.0.1:4011/"
+ end
+
+ test "desktop menu bar exposes the native menu groups for the shell window" do
+ groups = BDS.Desktop.MenuBar.groups(dev_mode?: false)
+
+ assert Enum.map(groups, & &1.id) == [:app, :file, :edit, :view, :window, :help]
+
+ view_group = Enum.find(groups, &(&1.id == :view))
+ assert :toggle_sidebar in Enum.map(view_group.items, & &1.id)
+ assert :toggle_panel in Enum.map(view_group.items, & &1.id)
+ assert :toggle_assistant_sidebar in Enum.map(view_group.items, & &1.id)
+ end
+
+ test "desktop shell html follows the old app frame regions and references bundled assets" do
+ html = BDS.Desktop.ShellController.index_html()
+
+ assert html =~ ~s(class="app")
+ assert html =~ ~s(class="window-titlebar")
+ assert html =~ ~s(class="activity-bar")
+ assert html =~ ~s(class="sidebar")
+ assert html =~ ~s(class="tab-bar")
+ assert html =~ ~s(class="status-bar")
+ assert html =~ ~s(src="/assets/app.js")
+ assert html =~ ~s(href="/assets/app.css")
+ end
+
+ test "desktop router serves the shell without requiring Phoenix endpoint secrets" do
+ conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
+ conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
+
+ assert conn.status == 200
+ assert conn.resp_body =~ ~s(class="app")
+ end
+end
\ No newline at end of file
diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs
new file mode 100644
index 0000000..7f36678
--- /dev/null
+++ b/test/bds/ui/shell_test.exs
@@ -0,0 +1,109 @@
+defmodule BDS.UI.ShellTest do
+ use ExUnit.Case, async: true
+
+ alias BDS.UI.Commands
+ alias BDS.UI.Registry
+ alias BDS.UI.Session
+ alias BDS.UI.ShellPage
+ alias BDS.UI.Workbench
+
+ test "registry exposes the shared sidebar and editor contracts for the base shell" do
+ sidebar_views = Registry.sidebar_views()
+ editor_routes = Registry.editor_routes()
+
+ assert Registry.default_sidebar_view() == :posts
+ assert Enum.map(sidebar_views, & &1.id) == [
+ :posts,
+ :pages,
+ :media,
+ :scripts,
+ :templates,
+ :tags,
+ :chat,
+ :import,
+ :git,
+ :settings
+ ]
+
+ assert Enum.find(sidebar_views, &(&1.id == :media)).activity_group == :top
+ assert Enum.find(sidebar_views, &(&1.id == :git)).activity_group == :bottom
+ assert Enum.any?(editor_routes, &(&1.id == :dashboard))
+ assert Enum.any?(editor_routes, &(&1.id == :post and &1.entity_tab == true))
+ assert Enum.any?(editor_routes, &(&1.id == :settings and &1.singleton == true))
+ end
+
+ test "workbench session roundtrips tabs, dirty state, shell visibility, and widths" do
+ state =
+ Workbench.new(sidebar_visible: false, panel_visible: true)
+ |> Workbench.set_sidebar_width(412)
+ |> Workbench.set_assistant_sidebar_width(511)
+ |> Workbench.open_tab(:post, "post-1", :pin)
+ |> Workbench.open_tab(:media, "media-1", :preview)
+ |> Workbench.mark_dirty(:post, "post-1")
+ |> Workbench.click_activity(:media)
+
+ payload = Session.serialize(state)
+ restored = Session.restore(payload)
+
+ assert restored.sidebar_visible == true
+ assert restored.panel.visible == true
+ assert restored.sidebar_width == 412
+ assert restored.assistant_sidebar_width == 511
+ assert restored.active_view == :media
+ assert restored.active_tab == {:media, "media-1"}
+ assert Workbench.dirty?(restored, :post, "post-1") == true
+ assert Enum.map(restored.tabs, &{&1.type, &1.id, &1.is_transient}) == [
+ {:post, "post-1", false},
+ {:media, "media-1", true}
+ ]
+ end
+
+ test "keyboard commands drive the same shared workbench policy" do
+ state =
+ Workbench.new(sidebar_visible: true)
+ |> Workbench.open_tab(:post, "post-1", :pin)
+
+ state = Commands.handle_shortcut(state, %{meta: true, key: "b"})
+ assert state.sidebar_visible == false
+
+ state = Commands.handle_shortcut(state, %{meta: true, key: "w"})
+ assert state.tabs == []
+ assert state.editor_route == :dashboard
+ end
+
+ test "resizing is clamped to the shell limits and dirty flags only apply to post tabs" do
+ state =
+ Workbench.new()
+ |> Workbench.set_sidebar_width(999)
+ |> Workbench.set_assistant_sidebar_width(120)
+ |> Workbench.open_tab(:media, "media-1", :pin)
+ |> Workbench.mark_dirty(:media, "media-1")
+ |> Workbench.open_tab(:post, "post-1", :pin)
+ |> Workbench.mark_dirty(:post, "post-1")
+
+ assert state.sidebar_width == 500
+ assert state.assistant_sidebar_width == 280
+ assert Workbench.dirty?(state, :media, "media-1") == false
+ assert Workbench.dirty?(state, :post, "post-1") == true
+ end
+
+ test "shell page renders the inspectable base app with bootstrap data and shell controls" do
+ html = ShellPage.render()
+
+ assert html =~ ~s( Workbench.open_tab(:post, "post-1", :preview)
+
+ assert state.active_tab == {:post, "post-1"}
+ assert state.editor_route == :post
+ assert [%{type: :post, id: "post-1", is_transient: true}] = state.tabs
+
+ state = Workbench.open_tab(state, :post, "post-2", :preview)
+
+ assert state.active_tab == {:post, "post-2"}
+ assert state.editor_route == :post
+ assert [%{type: :post, id: "post-2", is_transient: true}] = state.tabs
+ end
+
+ test "opening an existing preview tab as pinned upgrades it instead of duplicating it" do
+ state =
+ Workbench.new()
+ |> Workbench.open_tab(:post, "post-1", :preview)
+ |> Workbench.open_tab(:post, "post-1", :pin)
+
+ assert state.active_tab == {:post, "post-1"}
+ assert [%{type: :post, id: "post-1", is_transient: false}] = state.tabs
+ end
+
+ test "singleton tabs deduplicate and are never transient" do
+ state =
+ Workbench.new()
+ |> Workbench.open_tab(:settings, "settings", :preview)
+ |> Workbench.open_tab(:settings, "settings", :pin)
+
+ assert state.active_tab == {:settings, "settings"}
+ assert [%{type: :settings, id: "settings", is_transient: false}] = state.tabs
+ end
+
+ test "background opening keeps the active editor unchanged while still applying tab policy" do
+ state =
+ Workbench.new()
+ |> Workbench.open_tab(:post, "post-1", :pin)
+ |> Workbench.open_tab_in_background(:media, "media-1", :preview)
+
+ assert state.active_tab == {:post, "post-1"}
+ assert state.editor_route == :post
+
+ assert Enum.map(state.tabs, &{&1.type, &1.id, &1.is_transient}) == [
+ {:post, "post-1", false},
+ {:media, "media-1", true}
+ ]
+ end
+
+ test "closing the active tab activates the next tab at the same index and falls back to dashboard" do
+ state =
+ Workbench.new()
+ |> Workbench.open_tab(:post, "post-1", :pin)
+ |> Workbench.open_tab(:media, "media-1", :pin)
+ |> Workbench.open_tab(:chat, "conversation-1", :pin)
+
+ state = Workbench.close_tab(state, :media, "media-1")
+
+ assert state.active_tab == {:chat, "conversation-1"}
+ assert state.editor_route == :chat
+
+ state =
+ state
+ |> Workbench.close_tab(:chat, "conversation-1")
+ |> Workbench.close_tab(:post, "post-1")
+
+ assert state.tabs == []
+ assert state.active_tab == nil
+ assert state.editor_route == :dashboard
+ end
+
+ test "activity clicks switch sidebar views and toggle visibility when re-clicking the active view" do
+ state = Workbench.new()
+
+ assert state.active_view == :posts
+ assert state.sidebar_visible == true
+
+ state = Workbench.click_activity(state, :posts)
+ assert state.sidebar_visible == false
+ assert state.active_view == :posts
+
+ state = Workbench.click_activity(state, :media)
+ assert state.sidebar_visible == true
+ assert state.active_view == :media
+
+ buttons = Workbench.activity_buttons(state, 108)
+ media_button = Enum.find(buttons, &(&1.id == :media))
+ git_button = Enum.find(buttons, &(&1.id == :git))
+
+ assert media_button.active == true
+ assert git_button.badge.display == "99+"
+ end
+
+ test "panel tab availability falls back to tasks when the active editor no longer supports the panel" do
+ state =
+ Workbench.new()
+ |> Workbench.set_panel_visible(true)
+ |> Workbench.set_panel_tab(:post_links)
+ |> Workbench.open_tab(:post, "post-1", :pin)
+
+ assert state.panel.active_tab == :post_links
+
+ state = Workbench.open_tab(state, :settings, "settings", :pin)
+
+ assert state.editor_route == :settings
+ assert state.panel.active_tab == :tasks
+
+ state = Workbench.set_panel_tab(state, :git_log)
+ state = Workbench.open_tab(state, :media, "media-1", :pin)
+ assert state.panel.active_tab == :git_log
+ end
+
+ test "status bar data follows the active editor kind" do
+ state =
+ Workbench.new()
+ |> Workbench.open_tab(:post, "post-1", :pin)
+
+ status =
+ Workbench.status_bar(state,
+ post_count: 12,
+ media_count: 7,
+ theme_badge: "zinc",
+ ui_language: "de",
+ offline_mode: true,
+ running_task_message: "Generating site",
+ running_task_overflow: 2,
+ active_post_status: :draft,
+ token_usage: %{input_tokens: 10, output_tokens: 20, cache_read_tokens: 3}
+ )
+
+ assert status.left.running_task_message == "Generating site"
+ assert status.right.post_status == "draft"
+ assert status.right.post_count == "12 posts"
+ assert status.right.media_count == "7 media"
+ assert status.right.token_usage == nil
+
+ state = Workbench.open_tab(state, :chat, "conversation-1", :pin)
+
+ status =
+ Workbench.status_bar(state,
+ post_count: 12,
+ media_count: 7,
+ theme_badge: "zinc",
+ ui_language: "de",
+ offline_mode: true,
+ token_usage: %{input_tokens: 10, output_tokens: 20, cache_read_tokens: 3}
+ )
+
+ assert status.right.post_status == nil
+ assert status.right.token_usage == %{input_tokens: 10, output_tokens: 20, cache_read_tokens: 3}
+ end
+
+ test "menu commands expose generic shell controls through a shared command model" do
+ state = Workbench.new(sidebar_visible: false, panel_visible: false)
+ groups = MenuBar.default_groups(dev_mode?: false)
+
+ assert Enum.map(groups, & &1.id) == [:app, :file, :edit, :view, :window, :help]
+
+ view_group = Enum.find(groups, &(&1.id == :view))
+ command_ids = Enum.map(view_group.items, & &1.id)
+
+ assert :toggle_sidebar in command_ids
+ assert :toggle_panel in command_ids
+ refute :toggle_dev_tools in command_ids
+
+ state = MenuBar.execute(state, :toggle_sidebar)
+ state = MenuBar.execute(state, :toggle_panel)
+
+ assert state.sidebar_visible == true
+ assert state.panel.visible == true
+ end
+end
\ No newline at end of file