feat: first take at UI app

This commit is contained in:
2026-04-24 14:54:04 +02:00
parent 78609377be
commit 1b5a5008eb
24 changed files with 2630 additions and 3 deletions

View File

@@ -9,7 +9,14 @@ config :bds, BDS.Repo,
stacktrace: true, stacktrace: true,
show_sensitive_data_on_connection_error: 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, config :bds, :scripting,
runtime: BDS.Scripting.Lua, runtime: BDS.Scripting.Lua,

View File

@@ -7,5 +7,5 @@ if config_env() == :prod do
config :bds, BDS.Repo, config :bds, BDS.Repo,
database: database_path, 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 end

View File

@@ -3,6 +3,31 @@ defmodule BDS.Application do
use Application 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 @impl true
def start(_type, _args) do def start(_type, _args) do
children = [ children = [
@@ -14,9 +39,15 @@ defmodule BDS.Application do
BDS.Scripting.JobStore, BDS.Scripting.JobStore,
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor}, {Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
BDS.Scripting.JobSupervisor BDS.Scripting.JobSupervisor
| desktop_children(current_env())
] ]
opts = [strategy: :one_for_one, name: BDS.Supervisor] opts = [strategy: :one_for_one, name: BDS.Supervisor]
Supervisor.start_link(children, opts) Supervisor.start_link(children, opts)
end end
defp current_env do
Application.get_env(:bds, :current_env_override) ||
if(Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod)
end
end end

12
lib/bds/desktop.ex Normal file
View 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

View File

@@ -0,0 +1,3 @@
defmodule BDS.Desktop.MainWindow do
@moduledoc false
end

42
lib/bds/desktop/menu.ex Normal file
View 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

View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -16,7 +16,7 @@ defmodule BDS.MixProject do
def application do def application do
[ [
extra_applications: [:logger], extra_applications: [:logger, :wx],
mod: {BDS.Application, []} mod: {BDS.Application, []}
] ]
end end
@@ -30,6 +30,8 @@ defmodule BDS.MixProject do
{:earmark, "~> 1.4"}, {:earmark, "~> 1.4"},
{:liquex, "~> 0.13.1"}, {:liquex, "~> 0.13.1"},
{:plug, "~> 1.18"}, {:plug, "~> 1.18"},
{:bandit, "~> 1.5"},
{:desktop, "~> 1.5"},
{:image, "~> 0.65"}, {:image, "~> 0.65"},
{:stemex, "~> 0.2.1"} {:stemex, "~> 0.2.1"}
] ]

View File

@@ -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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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": {: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_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"}, "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"}, "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"}, "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"}, "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_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"}, "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"}, "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"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"}, "mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "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_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": {: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"}, "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"}, "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"}, "stemex": {:hex, :stemex, "0.2.1", "47017c6b10cdd6926a0d523ccf1f801c5f3faf5a0a9c862f49304e07f9b5584f", [:mix], [], "hexpm", "dbfc76d27adfa31d831d183979c595942884e6530a4496714aa5b70d0964c2e4"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "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"}, "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"}, "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"},
} }

701
priv/ui/app.css Normal file
View File

@@ -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;
}
}

688
priv/ui/app.js Normal file
View File

@@ -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) => `<button class="window-titlebar-menu-button">${group.label}</button>`)
.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 = `
<div class="activity-bar-top">${top.map(renderActivityButton).join('')}</div>
<div class="activity-bar-bottom">${bottom.map(renderActivityButton).join('')}</div>
`;
}
function renderActivityButton(view) {
const active = state.sidebarVisible && state.activeView === view.id;
return `<button class="activity-bar-item ${active ? 'active' : ''}" data-activity="${view.id}" title="${view.label}">${view.label.slice(0, 1)}</button>`;
}
function renderSidebar() {
const view = state.sidebarViews.find((entry) => entry.id === state.activeView) || state.sidebarViews[0];
const node = root.querySelector('.sidebar');
node.innerHTML = `
<div class="sidebar-header">
<span>${view.label}</span>
<span>${view.items.length}</span>
</div>
<div class="sidebar-content">
${view.items
.map((item, index) => {
const itemId = `${view.id}:${index}`;
const active = state.activeTabId === itemId;
return `
<button class="sidebar-item ${active ? 'active' : ''}" data-open-tab="${itemId}">
<strong>${item}</strong>
<span>${view.label} entry</span>
</button>
`;
})
.join('')}
</div>
`;
}
function renderTabs() {
const node = root.querySelector('.tab-bar');
node.innerHTML = `<div class="tab-bar-tabs">${state.tabs.map(renderTab).join('')}</div>`;
}
function renderTab(tab) {
const active = tab.id === state.activeTabId;
const dirtyMarker = tab.dirty ? '<span class="tab-dirty-indicator">●</span>' : '<span class="tab-close">×</span>';
return `
<div class="tab ${active ? 'active' : ''} ${tab.pinned ? '' : 'transient'}" data-tab="${tab.id}">
<span class="tab-title">${tab.title}</span>
${dirtyMarker}
</div>
`;
}
function renderEditor() {
const node = root.querySelector('.editor-shell');
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId) || state.tabs[0];
node.innerHTML = `
<div class="editor-frame">
<section class="editor-main">
<h1 class="editor-title">${activeTab.title}</h1>
<div class="editor-subtitle">${activeTab.kind} editor surface routed through the desktop shell</div>
<div class="editor-toolbar">
<button type="button">Publish</button>
<button type="button">Preview</button>
<button type="button">Metadata</button>
</div>
<p class="editor-paragraph">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.</p>
<p class="editor-paragraph">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.</p>
</section>
<aside class="editor-meta">
<div class="assistant-card">
<strong>Document State</strong>
<span>${activeTab.dirty ? 'Unsaved changes' : 'Clean'}</span>
</div>
<div class="assistant-card">
<strong>Publishing</strong>
<span>Main language: en</span>
</div>
<div class="assistant-card">
<strong>Filesystem</strong>
<span>Metadata flush pending wiring</span>
</div>
</aside>
</div>
`;
}
function renderPanel() {
const node = root.querySelector('.panel-shell');
const tabs = ['problems', 'search', 'tasks'];
node.innerHTML = `
<div class="panel-header">
<div class="panel-tabs">
${tabs
.map((tab) => `<button class="panel-tab ${state.panelTab === tab ? 'active' : ''}" data-panel-tab="${tab}">${tab}</button>`)
.join('')}
</div>
<span>${state.panelVisible ? 'Visible' : 'Hidden'}</span>
</div>
<div class="panel-content">
<div class="panel-entry">
<strong>${state.panelTab}</strong>
<span>Shared bottom panel host for problems, search, tasks and later runtime details.</span>
</div>
</div>
`;
}
function renderAssistant() {
const node = root.querySelector('.assistant-sidebar');
node.innerHTML = `
<div class="assistant-header">
<span>Assistant</span>
<span>Project context</span>
</div>
<div class="assistant-content">
<div class="assistant-card">
<strong>Next shell work</strong>
<span>Swap sidebar placeholders with real project data views.</span>
</div>
<div class="assistant-card">
<strong>Offline gate</strong>
<span>Automatic AI remains gated by airplane mode.</span>
</div>
<div class="assistant-card">
<strong>Desktop runtime</strong>
<span>Window, menu bar and launch path now come from Elixir Desktop.</span>
</div>
</div>
`;
}
function renderStatusBar() {
const node = root.querySelector('.status-bar');
node.innerHTML = `
<div class="status-bar-left">${state.statusBar.left.map(renderStatusItem).join('')}</div>
<div class="status-bar-right">${state.statusBar.right.map(renderStatusItem).join('')}</div>
`;
}
function renderStatusItem(item) {
return `<div class="status-bar-item">${item.label}</div>`;
}
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 = `
<div class="status-left">
<select data-action="project-select">
<option selected>${rootState.project}</option>
</select>
<span class="status-pill">${rootState.running_task_message}${rootState.running_task_overflow ? ` +${rootState.running_task_overflow}` : ""}</span>
</div>
<div class="status-right">
${postStatus ? `<span class="status-chip status-post" data-status="${postStatus}">${postStatus}</span>` : ""}
<span class="status-chip">${rootState.post_count} posts</span>
<span class="status-chip">${rootState.media_count} media</span>
${tokenUsage ? `<span class="status-chip">${tokenUsage}</span>` : ""}
<select data-action="theme-select">
${["zinc", "amber", "jade", "sand"].map(theme => `<option value="${theme}" ${theme === rootState.theme_badge ? "selected" : ""}>${theme}</option>`).join("")}
</select>
<button class="offline-toggle" data-action="toggle-offline" data-active="${String(rootState.offline_mode)}">${rootState.offline_mode ? "Offline" : "Online"}</button>
<select data-action="language-select">
${["en", "de", "fr", "it", "es"].map(language => `<option value="${language}" ${language === rootState.ui_language ? "selected" : ""}>${language.toUpperCase()}</option>`).join("")}
</select>
<span class="status-pill">bDS</span>
</div>
`;
}
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;
}
}

104
priv/ui/index.html Normal file
View File

@@ -0,0 +1,104 @@
<!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>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Blogging Desktop Server</title>
<link rel="stylesheet" href="/assets/app.css" />
</head>
<body>
<div class="app" id="app">
<div class="window-titlebar">
<div class="window-titlebar-menu-bar"></div>
<div class="window-titlebar-title">Blogging Desktop Server</div>
<div class="window-titlebar-actions">
<button class="window-titlebar-action-button" data-command="toggle-sidebar" aria-label="Toggle sidebar">
<span class="window-titlebar-sidebar-icon"><span class="window-titlebar-sidebar-pane"></span></span>
</button>
<button class="window-titlebar-action-button" data-command="toggle-panel" aria-label="Toggle panel">
<span class="window-titlebar-panel-icon"></span>
</button>
</div>
</div>
<div class="app-main">
<aside class="activity-bar"></aside>
<section class="sidebar-shell">
<div class="sidebar"></div>
<div class="resizable-panel-divider sidebar-divider" data-resize="sidebar"></div>
</section>
<main class="app-content">
<div class="tab-bar"></div>
<div class="editor-shell"></div>
<div class="panel-shell"></div>
</main>
<section class="assistant-sidebar-shell">
<div class="resizable-panel-divider assistant-divider" data-resize="assistant"></div>
<div class="assistant-sidebar"></div>
</section>
</div>
<div class="status-bar"></div>
</div>
<script id="bds-bootstrap" type="application/json">
{
"menuGroups": [
{"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":[{"id":"toggle_sidebar","label":"Toggle Sidebar"},{"id":"toggle_panel","label":"Toggle Panel"},{"id":"toggle_assistant_sidebar","label":"Toggle Assistant Sidebar"}]},
{"id":"window","label":"Window","items":[{"id":"minimize","label":"Minimize"}]},
{"id":"help","label":"Help","items":[{"id":"documentation","label":"Documentation"}]}
],
"sidebarViews": [
{"id":"posts","label":"Posts","group":"top","items":["welcome.md","launch-plan.md","publishing-notes.md"]},
{"id":"pages","label":"Pages","group":"top","items":["about.md","contact.md"]},
{"id":"media","label":"Media","group":"top","items":["cover.jpg","launch-banner.png"]},
{"id":"scripts","label":"Scripts","group":"top","items":["import_posts.exs","sync_tags.exs"]},
{"id":"templates","label":"Templates","group":"top","items":["post.liquid","listing.liquid"]},
{"id":"git","label":"Git","group":"bottom","items":["Working tree clean"]},
{"id":"settings","label":"Settings","group":"bottom","items":["Project", "Publishing", "AI"]}
],
"tabs": [
{"id":"dashboard","title":"Dashboard","kind":"dashboard","pinned":true,"dirty":false},
{"id":"post:welcome","title":"welcome.md","kind":"post","pinned":true,"dirty":true},
{"id":"post:launch","title":"launch-plan.md","kind":"post","pinned":false,"dirty":false}
],
"activeTabId": "post:welcome",
"activeView": "posts",
"sidebarVisible": true,
"sidebarWidth": 320,
"assistantVisible": true,
"assistantWidth": 336,
"panelVisible": true,
"panelTab": "problems",
"statusBar": {
"left": [
{"id":"branch","label":"main"},
{"id":"sync","label":"Filesystem synced"},
{"id":"language","label":"EN"}
],
"right": [
{"id":"project","label":"Starter project"},
{"id":"mode","label":"Airplane off"},
{"id":"theme","label":"Desktop shell"}
]
}
}
</script>
<script src="/assets/app.js"></script>
</body>
</html>

64
test/bds/desktop_test.exs Normal file
View File

@@ -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

109
test/bds/ui/shell_test.exs Normal file
View File

@@ -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(<div id="bds-shell-app")
assert html =~ ~s(data-region="activity-bar")
assert html =~ ~s(data-region="sidebar")
assert html =~ ~s(data-region="editor")
assert html =~ ~s(data-region="status-bar")
assert html =~ ~s(data-role="resize-handle")
assert html =~ ~s(id="bds-shell-bootstrap")
assert html =~ ~s(src="./app.js")
assert html =~ ~s(href="./app.css")
end
test "static shell bundle exists for direct browser inspection" do
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/index.html")
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.css")
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.js")
end
end

View File

@@ -0,0 +1,180 @@
defmodule BDS.UI.WorkbenchTest do
use ExUnit.Case, async: true
alias BDS.UI.MenuBar
alias BDS.UI.Workbench
test "preview tabs reuse the transient slot per type and route editors through the active tab" do
state =
Workbench.new()
|> 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