feat: first take at UI app
This commit is contained in:
@@ -9,7 +9,14 @@ config :bds, BDS.Repo,
|
||||
stacktrace: true,
|
||||
show_sensitive_data_on_connection_error: true
|
||||
|
||||
config :bds, BDS.Application, desktop_adapter: :pending_selection
|
||||
config :bds, BDS.Application, desktop_adapter: :desktop
|
||||
|
||||
config :bds, :desktop,
|
||||
port: 4010,
|
||||
window_size: {1440, 900},
|
||||
window_min_size: {1100, 700},
|
||||
title: "Blogging Desktop Server",
|
||||
secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001"
|
||||
|
||||
config :bds, :scripting,
|
||||
runtime: BDS.Scripting.Lua,
|
||||
|
||||
@@ -7,5 +7,5 @@ if config_env() == :prod do
|
||||
|
||||
config :bds, BDS.Repo,
|
||||
database: database_path,
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "1")
|
||||
end
|
||||
|
||||
@@ -3,6 +3,31 @@ defmodule BDS.Application do
|
||||
|
||||
use Application
|
||||
|
||||
def desktop_children(env \\ nil)
|
||||
|
||||
def desktop_children(:test), do: []
|
||||
|
||||
def desktop_children(_env) do
|
||||
if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do
|
||||
[
|
||||
{BDS.Desktop.Server, []},
|
||||
{Desktop.Window,
|
||||
[
|
||||
app: :bds,
|
||||
id: BDS.Desktop.MainWindow,
|
||||
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
|
||||
size: Application.get_env(:bds, :desktop)[:window_size] || {1440, 900},
|
||||
min_size: Application.get_env(:bds, :desktop)[:window_min_size] || {1100, 700},
|
||||
menubar: BDS.Desktop.MenuBar,
|
||||
icon_menu: BDS.Desktop.Menu,
|
||||
url: &BDS.Desktop.url/0
|
||||
]}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
@@ -14,9 +39,15 @@ defmodule BDS.Application do
|
||||
BDS.Scripting.JobStore,
|
||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||
BDS.Scripting.JobSupervisor
|
||||
| desktop_children(current_env())
|
||||
]
|
||||
|
||||
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
|
||||
defp current_env do
|
||||
Application.get_env(:bds, :current_env_override) ||
|
||||
if(Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod)
|
||||
end
|
||||
end
|
||||
|
||||
12
lib/bds/desktop.ex
Normal file
12
lib/bds/desktop.ex
Normal file
@@ -0,0 +1,12 @@
|
||||
defmodule BDS.Desktop do
|
||||
@moduledoc false
|
||||
|
||||
def url do
|
||||
Application.get_env(:bds, :desktop)[:port]
|
||||
|> url()
|
||||
end
|
||||
|
||||
def url(port) when is_integer(port) do
|
||||
"http://127.0.0.1:#{port}/"
|
||||
end
|
||||
end
|
||||
3
lib/bds/desktop/main_window.ex
Normal file
3
lib/bds/desktop/main_window.ex
Normal file
@@ -0,0 +1,3 @@
|
||||
defmodule BDS.Desktop.MainWindow do
|
||||
@moduledoc false
|
||||
end
|
||||
42
lib/bds/desktop/menu.ex
Normal file
42
lib/bds/desktop/menu.ex
Normal file
@@ -0,0 +1,42 @@
|
||||
defmodule BDS.Desktop.Menu do
|
||||
@moduledoc false
|
||||
|
||||
use Desktop.Menu
|
||||
alias Desktop.Window
|
||||
|
||||
@impl true
|
||||
def mount(menu) do
|
||||
{:ok, menu}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<menu>
|
||||
<item onclick="open">Open</item>
|
||||
<hr />
|
||||
<item onclick="quit">Quit</item>
|
||||
</menu>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open", menu) do
|
||||
Window.show(BDS.Desktop.MainWindow)
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
def handle_event("quit", menu) do
|
||||
Window.quit()
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
def handle_event(_, menu) do
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(_, menu) do
|
||||
{:noreply, menu}
|
||||
end
|
||||
end
|
||||
73
lib/bds/desktop/menu_bar.ex
Normal file
73
lib/bds/desktop/menu_bar.ex
Normal file
@@ -0,0 +1,73 @@
|
||||
defmodule BDS.Desktop.MenuBar do
|
||||
@moduledoc false
|
||||
|
||||
use Desktop.Menu
|
||||
alias Desktop.Window
|
||||
|
||||
def groups(opts \\ []) do
|
||||
dev_mode? = Keyword.get(opts, :dev_mode?, false)
|
||||
|
||||
[
|
||||
%{id: :app, label: "App", items: [%{id: :about, label: "About"}]},
|
||||
%{id: :file, label: "File", items: [%{id: :new_post, label: "New Post"}, %{id: :close_tab, label: "Close Tab"}]},
|
||||
%{id: :edit, label: "Edit", items: [%{id: :undo, label: "Undo"}, %{id: :redo, label: "Redo"}]},
|
||||
%{id: :view, label: "View", items: view_items(dev_mode?)},
|
||||
%{id: :window, label: "Window", items: [%{id: :minimize, label: "Minimize"}]},
|
||||
%{id: :help, label: "Help", items: [%{id: :documentation, label: "Documentation"}]}
|
||||
]
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(menu) do
|
||||
{:ok,
|
||||
Desktop.Menu.assign(
|
||||
menu,
|
||||
:groups,
|
||||
groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<menubar>
|
||||
<%= for group <- @groups do %>
|
||||
<menu label={group.label}>
|
||||
<%= for item <- group.items do %>
|
||||
<item onclick={Atom.to_string(item.id)}>{item.label}</item>
|
||||
<% end %>
|
||||
</menu>
|
||||
<% end %>
|
||||
</menubar>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("quit", menu) do
|
||||
Window.quit()
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
def handle_event(_, menu) do
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(_, menu) do
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
defp view_items(dev_mode?) do
|
||||
items = [
|
||||
%{id: :toggle_sidebar, label: "Toggle Sidebar"},
|
||||
%{id: :toggle_panel, label: "Toggle Panel"},
|
||||
%{id: :toggle_assistant_sidebar, label: "Toggle Assistant Sidebar"}
|
||||
]
|
||||
|
||||
if dev_mode? do
|
||||
items ++ [%{id: :toggle_dev_tools, label: "Toggle Dev Tools"}]
|
||||
else
|
||||
items
|
||||
end
|
||||
end
|
||||
end
|
||||
49
lib/bds/desktop/router.ex
Normal file
49
lib/bds/desktop/router.ex
Normal file
@@ -0,0 +1,49 @@
|
||||
defmodule BDS.Desktop.Router do
|
||||
@moduledoc false
|
||||
|
||||
use Plug.Router
|
||||
|
||||
plug :put_secret_key_base
|
||||
|
||||
plug Plug.Session,
|
||||
store: :cookie,
|
||||
key: "_bds_desktop_key",
|
||||
signing_salt: "desktop-shell"
|
||||
|
||||
plug :match
|
||||
plug Desktop.Auth
|
||||
|
||||
plug Plug.Static,
|
||||
at: "/assets",
|
||||
from: {:bds, "priv/ui"},
|
||||
only: ["app.css", "app.js"]
|
||||
|
||||
plug :dispatch
|
||||
|
||||
get "/" do
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("text/html")
|
||||
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.index_html())
|
||||
end
|
||||
|
||||
get "/health" do
|
||||
Plug.Conn.send_resp(conn, 200, "ok")
|
||||
end
|
||||
|
||||
match _ do
|
||||
Plug.Conn.send_resp(conn, 404, "not found")
|
||||
end
|
||||
|
||||
defp put_secret_key_base(conn, _opts) do
|
||||
if conn.secret_key_base do
|
||||
conn
|
||||
else
|
||||
%{conn | secret_key_base: desktop_secret_key_base()}
|
||||
end
|
||||
end
|
||||
|
||||
defp desktop_secret_key_base do
|
||||
Application.get_env(:bds, :desktop)[:secret_key_base] ||
|
||||
raise "missing :desktop secret_key_base configuration"
|
||||
end
|
||||
end
|
||||
38
lib/bds/desktop/server.ex
Normal file
38
lib/bds/desktop/server.ex
Normal file
@@ -0,0 +1,38 @@
|
||||
defmodule BDS.Desktop.Server do
|
||||
@moduledoc false
|
||||
|
||||
use GenServer
|
||||
|
||||
def child_spec(opts) do
|
||||
%{
|
||||
id: __MODULE__,
|
||||
start: {__MODULE__, :start_link, [opts]}
|
||||
}
|
||||
end
|
||||
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
def url do
|
||||
BDS.Desktop.url(port())
|
||||
end
|
||||
|
||||
def port do
|
||||
Application.get_env(:bds, :desktop)[:port] || 4010
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
{:ok, bandit_pid} =
|
||||
Bandit.start_link(
|
||||
plug: BDS.Desktop.Router,
|
||||
scheme: :http,
|
||||
ip: {127, 0, 0, 1},
|
||||
port: port(),
|
||||
startup_log: false
|
||||
)
|
||||
|
||||
{:ok, %{bandit_pid: bandit_pid}}
|
||||
end
|
||||
end
|
||||
7
lib/bds/desktop/shell_controller.ex
Normal file
7
lib/bds/desktop/shell_controller.ex
Normal file
@@ -0,0 +1,7 @@
|
||||
defmodule BDS.Desktop.ShellController do
|
||||
@moduledoc false
|
||||
|
||||
def index_html do
|
||||
File.read!(Application.app_dir(:bds, ["priv", "ui", "index.html"]))
|
||||
end
|
||||
end
|
||||
16
lib/bds/ui/commands.ex
Normal file
16
lib/bds/ui/commands.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule BDS.UI.Commands do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.MenuBar
|
||||
|
||||
def handle_shortcut(state, shortcut) when is_map(shortcut) do
|
||||
key = shortcut |> Map.get(:key, Map.get(shortcut, "key", "")) |> String.downcase()
|
||||
primary = Map.get(shortcut, :meta, false) or Map.get(shortcut, :ctrl, false)
|
||||
|
||||
cond do
|
||||
primary and key == "b" -> MenuBar.execute(state, :toggle_sidebar)
|
||||
primary and key == "w" -> MenuBar.execute(state, :close_tab)
|
||||
true -> state
|
||||
end
|
||||
end
|
||||
end
|
||||
41
lib/bds/ui/menu_bar.ex
Normal file
41
lib/bds/ui/menu_bar.ex
Normal file
@@ -0,0 +1,41 @@
|
||||
defmodule BDS.UI.MenuBar do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
def default_groups(opts \\ []) do
|
||||
dev_mode? = Keyword.get(opts, :dev_mode?, false)
|
||||
|
||||
[
|
||||
%{id: :app, items: [%{id: :about}, %{id: :settings}]},
|
||||
%{id: :file, items: [%{id: :new_post}, %{id: :new_page}, %{id: :close_tab}]},
|
||||
%{id: :edit, items: [%{id: :undo}, %{id: :redo}]},
|
||||
%{id: :view, items: view_items(dev_mode?)},
|
||||
%{id: :window, items: [%{id: :minimize}, %{id: :zoom}]},
|
||||
%{id: :help, items: [%{id: :documentation}, %{id: :api_documentation}]}
|
||||
]
|
||||
end
|
||||
|
||||
def execute(state, :toggle_sidebar), do: Workbench.toggle_sidebar(state)
|
||||
def execute(state, :toggle_panel), do: Workbench.toggle_panel(state)
|
||||
def execute(state, :toggle_assistant_sidebar), do: Workbench.toggle_assistant_sidebar(state)
|
||||
|
||||
def execute(state, :close_tab) do
|
||||
case state.active_tab do
|
||||
{type, id} -> Workbench.close_tab(state, type, id)
|
||||
nil -> state
|
||||
end
|
||||
end
|
||||
|
||||
def execute(state, _command_id), do: state
|
||||
|
||||
defp view_items(dev_mode?) do
|
||||
base = [
|
||||
%{id: :toggle_sidebar},
|
||||
%{id: :toggle_panel},
|
||||
%{id: :toggle_assistant_sidebar}
|
||||
]
|
||||
|
||||
if dev_mode?, do: base ++ [%{id: :toggle_dev_tools}], else: base
|
||||
end
|
||||
end
|
||||
44
lib/bds/ui/registry.ex
Normal file
44
lib/bds/ui/registry.ex
Normal file
@@ -0,0 +1,44 @@
|
||||
defmodule BDS.UI.Registry do
|
||||
@moduledoc false
|
||||
|
||||
@sidebar_views [
|
||||
%{id: :posts, label: "Posts", activity_group: :top, editor_route: :post, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :pages, label: "Pages", activity_group: :top, editor_route: :post, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :media, label: "Media", activity_group: :top, editor_route: :media, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :scripts, label: "Scripts", activity_group: :top, editor_route: :scripts, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :templates, label: "Templates", activity_group: :top, editor_route: :templates, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :tags, label: "Tags", activity_group: :top, editor_route: :tags, singleton: true, demo_kind: :singleton},
|
||||
%{id: :chat, label: "AI Assistant", activity_group: :top, editor_route: :chat, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :import, label: "Import", activity_group: :top, editor_route: :import, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :git, label: "Source Control", activity_group: :bottom, editor_route: :git_diff, entity_tab: true, demo_kind: :entity},
|
||||
%{id: :settings, label: "Settings", activity_group: :bottom, editor_route: :settings, singleton: true, demo_kind: :singleton}
|
||||
]
|
||||
|
||||
@editor_routes [
|
||||
%{id: :dashboard, singleton: true, entity_tab: false, title: "Dashboard"},
|
||||
%{id: :post, singleton: false, entity_tab: true, title: "Post"},
|
||||
%{id: :media, singleton: false, entity_tab: true, title: "Media"},
|
||||
%{id: :settings, singleton: true, entity_tab: false, title: "Settings"},
|
||||
%{id: :style, singleton: true, entity_tab: false, title: "Style"},
|
||||
%{id: :tags, singleton: true, entity_tab: false, title: "Tags"},
|
||||
%{id: :chat, singleton: false, entity_tab: true, title: "Chat"},
|
||||
%{id: :import, singleton: false, entity_tab: true, title: "Import"},
|
||||
%{id: :menu_editor, singleton: true, entity_tab: false, title: "Menu"},
|
||||
%{id: :metadata_diff, singleton: true, entity_tab: false, title: "Metadata Diff"},
|
||||
%{id: :git_diff, singleton: false, entity_tab: true, title: "Git Diff"},
|
||||
%{id: :documentation, singleton: true, entity_tab: false, title: "Documentation"},
|
||||
%{id: :api_documentation, singleton: true, entity_tab: false, title: "API"},
|
||||
%{id: :site_validation, singleton: true, entity_tab: false, title: "Site Validation"},
|
||||
%{id: :translation_validation, singleton: true, entity_tab: false, title: "Translations"},
|
||||
%{id: :scripts, singleton: false, entity_tab: true, title: "Script"},
|
||||
%{id: :templates, singleton: false, entity_tab: true, title: "Template"},
|
||||
%{id: :find_duplicates, singleton: true, entity_tab: false, title: "Find Duplicates"}
|
||||
]
|
||||
|
||||
def default_sidebar_view, do: :posts
|
||||
def sidebar_views, do: @sidebar_views
|
||||
def editor_routes, do: @editor_routes
|
||||
|
||||
def sidebar_view(id) when is_atom(id), do: Enum.find(@sidebar_views, &(&1.id == id))
|
||||
def editor_route(id) when is_atom(id), do: Enum.find(@editor_routes, &(&1.id == id))
|
||||
end
|
||||
68
lib/bds/ui/session.ex
Normal file
68
lib/bds/ui/session.ex
Normal file
@@ -0,0 +1,68 @@
|
||||
defmodule BDS.UI.Session do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
def serialize(state) do
|
||||
%{
|
||||
"sidebar_visible" => state.sidebar_visible,
|
||||
"sidebar_width" => state.sidebar_width,
|
||||
"active_view" => Atom.to_string(state.active_view),
|
||||
"assistant_sidebar_visible" => state.assistant_sidebar_visible,
|
||||
"assistant_sidebar_width" => state.assistant_sidebar_width,
|
||||
"panel" => %{
|
||||
"visible" => state.panel.visible,
|
||||
"active_tab" => Atom.to_string(state.panel.active_tab)
|
||||
},
|
||||
"tabs" =>
|
||||
Enum.map(state.tabs, fn tab ->
|
||||
%{
|
||||
"type" => Atom.to_string(tab.type),
|
||||
"id" => tab.id,
|
||||
"is_transient" => tab.is_transient
|
||||
}
|
||||
end),
|
||||
"active_tab" => encode_tab_ref(state.active_tab),
|
||||
"dirty_tabs" => Enum.map(state.dirty_tabs, &encode_tab_ref/1)
|
||||
}
|
||||
end
|
||||
|
||||
def restore(payload) when is_map(payload) do
|
||||
state =
|
||||
Workbench.new(
|
||||
sidebar_visible: Map.get(payload, "sidebar_visible", true),
|
||||
sidebar_width: Map.get(payload, "sidebar_width", 280),
|
||||
active_view: Map.get(payload, "active_view", "posts"),
|
||||
assistant_sidebar_visible: Map.get(payload, "assistant_sidebar_visible", false),
|
||||
assistant_sidebar_width: Map.get(payload, "assistant_sidebar_width", 360),
|
||||
panel_visible: get_in(payload, ["panel", "visible"]) || false,
|
||||
panel_tab: atomize(get_in(payload, ["panel", "active_tab"]) || "tasks"),
|
||||
dirty_tabs: Enum.map(Map.get(payload, "dirty_tabs", []), &decode_tab_ref/1)
|
||||
)
|
||||
|
||||
tabs =
|
||||
Enum.map(Map.get(payload, "tabs", []), fn tab ->
|
||||
%{
|
||||
type: atomize(Map.get(tab, "type", "post")),
|
||||
id: Map.get(tab, "id"),
|
||||
is_transient: Map.get(tab, "is_transient", false)
|
||||
}
|
||||
end)
|
||||
|
||||
active_tab = decode_tab_ref(Map.get(payload, "active_tab"))
|
||||
|
||||
%{state | tabs: tabs, active_tab: active_tab, editor_route: active_route(active_tab)}
|
||||
end
|
||||
|
||||
defp encode_tab_ref(nil), do: nil
|
||||
defp encode_tab_ref({type, id}), do: %{"type" => Atom.to_string(type), "id" => id}
|
||||
|
||||
defp decode_tab_ref(nil), do: nil
|
||||
defp decode_tab_ref(%{"type" => type, "id" => id}), do: {atomize(type), id}
|
||||
|
||||
defp atomize(value) when is_atom(value), do: value
|
||||
defp atomize(value) when is_binary(value), do: String.to_atom(value)
|
||||
|
||||
defp active_route(nil), do: :dashboard
|
||||
defp active_route({type, _id}), do: type
|
||||
end
|
||||
62
lib/bds/ui/shell_page.ex
Normal file
62
lib/bds/ui/shell_page.ex
Normal file
@@ -0,0 +1,62 @@
|
||||
defmodule BDS.UI.ShellPage do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.MenuBar
|
||||
alias BDS.UI.Registry
|
||||
alias BDS.UI.Session
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
def render do
|
||||
bootstrap =
|
||||
%{
|
||||
registry: %{
|
||||
sidebar_views: Registry.sidebar_views(),
|
||||
editor_routes: Registry.editor_routes(),
|
||||
default_sidebar_view: Registry.default_sidebar_view()
|
||||
},
|
||||
menu_groups: MenuBar.default_groups(),
|
||||
session: Session.serialize(Workbench.new(panel_visible: true)),
|
||||
status: %{
|
||||
post_count: 12,
|
||||
media_count: 34,
|
||||
theme_badge: "zinc",
|
||||
ui_language: "en",
|
||||
offline_mode: true,
|
||||
running_task_message: "Building starter shell",
|
||||
running_task_overflow: 1,
|
||||
git_badge_count: 7
|
||||
}
|
||||
}
|
||||
|
||||
[
|
||||
"<!DOCTYPE html>",
|
||||
"<html lang=\"en\">",
|
||||
"<head>",
|
||||
" <meta charset=\"utf-8\">",
|
||||
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
|
||||
" <title>bDS Shell</title>",
|
||||
" <link rel=\"stylesheet\" href=\"./app.css\">",
|
||||
"</head>",
|
||||
"<body>",
|
||||
" <div id=\"bds-shell-app\">",
|
||||
" <header data-region=\"title-bar\"></header>",
|
||||
" <div data-region=\"activity-bar\"></div>",
|
||||
" <aside data-region=\"sidebar\"></aside>",
|
||||
" <div data-role=\"resize-handle\" data-target=\"sidebar\"></div>",
|
||||
" <main data-region=\"content\">",
|
||||
" <div data-region=\"tab-bar\"></div>",
|
||||
" <section data-region=\"editor\"></section>",
|
||||
" <section data-region=\"panel\"></section>",
|
||||
" </main>",
|
||||
" <div data-role=\"resize-handle\" data-target=\"assistant\"></div>",
|
||||
" <aside data-region=\"assistant-sidebar\"></aside>",
|
||||
" <footer data-region=\"status-bar\"></footer>",
|
||||
" </div>",
|
||||
" <script id=\"bds-shell-bootstrap\" type=\"application/json\">#{Jason.encode!(bootstrap)}</script>",
|
||||
" <script src=\"./app.js\"></script>",
|
||||
"</body>",
|
||||
"</html>"
|
||||
]
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
end
|
||||
268
lib/bds/ui/workbench.ex
Normal file
268
lib/bds/ui/workbench.ex
Normal file
@@ -0,0 +1,268 @@
|
||||
defmodule BDS.UI.Workbench do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.UI.Registry
|
||||
|
||||
@singleton_tabs MapSet.new([
|
||||
:settings,
|
||||
:tags,
|
||||
:style,
|
||||
:scripts,
|
||||
:menu_editor,
|
||||
:documentation,
|
||||
:api_documentation,
|
||||
:metadata_diff,
|
||||
:site_validation,
|
||||
:translation_validation,
|
||||
:find_duplicates
|
||||
])
|
||||
|
||||
defstruct sidebar_visible: true,
|
||||
sidebar_width: 280,
|
||||
active_view: :posts,
|
||||
assistant_sidebar_visible: false,
|
||||
assistant_sidebar_width: 360,
|
||||
panel: %{visible: false, active_tab: :tasks},
|
||||
tabs: [],
|
||||
active_tab: nil,
|
||||
editor_route: :dashboard,
|
||||
dirty_tabs: MapSet.new()
|
||||
|
||||
def new(opts \\ []) do
|
||||
%__MODULE__{
|
||||
sidebar_visible: Keyword.get(opts, :sidebar_visible, true),
|
||||
sidebar_width: clamp_sidebar_width(Keyword.get(opts, :sidebar_width, 280)),
|
||||
active_view: normalize_type(Keyword.get(opts, :active_view, :posts)),
|
||||
assistant_sidebar_visible: Keyword.get(opts, :assistant_sidebar_visible, false),
|
||||
assistant_sidebar_width:
|
||||
clamp_assistant_sidebar_width(Keyword.get(opts, :assistant_sidebar_width, 360)),
|
||||
panel: %{
|
||||
visible: Keyword.get(opts, :panel_visible, false),
|
||||
active_tab: Keyword.get(opts, :panel_tab, :tasks)
|
||||
},
|
||||
dirty_tabs: MapSet.new(Keyword.get(opts, :dirty_tabs, []))
|
||||
}
|
||||
|> sync_editor_route()
|
||||
|> normalize_panel()
|
||||
end
|
||||
|
||||
def set_sidebar_width(state, width) when is_integer(width) do
|
||||
%{state | sidebar_width: clamp_sidebar_width(width)}
|
||||
end
|
||||
|
||||
def set_assistant_sidebar_width(state, width) when is_integer(width) do
|
||||
%{state | assistant_sidebar_width: clamp_assistant_sidebar_width(width)}
|
||||
end
|
||||
|
||||
def open_tab(state, type, id, intent) do
|
||||
{tabs, opened_tab} = upsert_tab(state.tabs, normalize_type(type), id, intent)
|
||||
|
||||
state
|
||||
|> Map.put(:tabs, tabs)
|
||||
|> Map.put(:active_tab, tab_ref(opened_tab))
|
||||
|> sync_editor_route()
|
||||
|> normalize_panel()
|
||||
end
|
||||
|
||||
def open_tab_in_background(state, type, id, intent) do
|
||||
current_active = state.active_tab
|
||||
{tabs, _opened_tab} = upsert_tab(state.tabs, normalize_type(type), id, intent)
|
||||
|
||||
state
|
||||
|> Map.put(:tabs, tabs)
|
||||
|> Map.put(:active_tab, current_active)
|
||||
|> sync_editor_route()
|
||||
|> normalize_panel()
|
||||
end
|
||||
|
||||
def close_tab(state, type, id) do
|
||||
type = normalize_type(type)
|
||||
target = {type, id}
|
||||
index = Enum.find_index(state.tabs, &(tab_ref(&1) == target))
|
||||
|
||||
if is_nil(index) do
|
||||
state
|
||||
else
|
||||
tabs = List.delete_at(state.tabs, index)
|
||||
|
||||
next_active =
|
||||
cond do
|
||||
state.active_tab != target -> state.active_tab
|
||||
tabs == [] -> nil
|
||||
index < length(tabs) -> tab_ref(Enum.at(tabs, index))
|
||||
true -> tab_ref(List.last(tabs))
|
||||
end
|
||||
|
||||
state
|
||||
|> Map.put(:tabs, tabs)
|
||||
|> Map.put(:active_tab, next_active)
|
||||
|> sync_editor_route()
|
||||
|> normalize_panel()
|
||||
end
|
||||
end
|
||||
|
||||
def pin_tab(state, type, id) do
|
||||
type = normalize_type(type)
|
||||
|
||||
tabs =
|
||||
Enum.map(state.tabs, fn tab ->
|
||||
if tab_ref(tab) == {type, id}, do: %{tab | is_transient: false}, else: tab
|
||||
end)
|
||||
|
||||
%{state | tabs: tabs}
|
||||
end
|
||||
|
||||
def clear_tabs(state) do
|
||||
%{state | tabs: [], active_tab: nil, editor_route: :dashboard, dirty_tabs: MapSet.new()}
|
||||
|> normalize_panel()
|
||||
end
|
||||
|
||||
def mark_dirty(state, type, id) do
|
||||
if normalize_type(type) == :post do
|
||||
%{state | dirty_tabs: MapSet.put(state.dirty_tabs, {normalize_type(type), id})}
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
def clear_dirty(state, type, id) do
|
||||
%{state | dirty_tabs: MapSet.delete(state.dirty_tabs, {normalize_type(type), id})}
|
||||
end
|
||||
|
||||
def dirty?(state, type, id) do
|
||||
MapSet.member?(state.dirty_tabs, {normalize_type(type), id})
|
||||
end
|
||||
|
||||
def toggle_sidebar(state), do: %{state | sidebar_visible: not state.sidebar_visible}
|
||||
|
||||
def set_panel_visible(state, visible) when is_boolean(visible) do
|
||||
%{state | panel: %{state.panel | visible: visible}}
|
||||
end
|
||||
|
||||
def toggle_panel(state) do
|
||||
set_panel_visible(state, not state.panel.visible)
|
||||
end
|
||||
|
||||
def toggle_assistant_sidebar(state) do
|
||||
%{state | assistant_sidebar_visible: not state.assistant_sidebar_visible}
|
||||
end
|
||||
|
||||
def set_panel_tab(state, tab) when tab in [:tasks, :output, :post_links, :git_log] do
|
||||
%{state | panel: %{state.panel | active_tab: tab}}
|
||||
end
|
||||
|
||||
def click_activity(state, activity_id) do
|
||||
activity_id = normalize_type(activity_id)
|
||||
|
||||
cond do
|
||||
state.active_view == activity_id -> toggle_sidebar(state)
|
||||
state.sidebar_visible -> %{state | active_view: activity_id}
|
||||
true -> %{state | active_view: activity_id, sidebar_visible: true}
|
||||
end
|
||||
end
|
||||
|
||||
def activity_buttons(state, git_badge_count \\ 0) do
|
||||
Registry.sidebar_views()
|
||||
|> Enum.map(fn view ->
|
||||
%{
|
||||
id: view.id,
|
||||
label: view.label,
|
||||
activity_group: view.activity_group,
|
||||
active: state.sidebar_visible and state.active_view == view.id,
|
||||
badge: activity_badge(view.id, git_badge_count)
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
def status_bar(state, opts) do
|
||||
%{
|
||||
left: %{
|
||||
running_task_message: Keyword.get(opts, :running_task_message),
|
||||
running_task_overflow: Keyword.get(opts, :running_task_overflow)
|
||||
},
|
||||
right: %{
|
||||
post_status: post_status(state, Keyword.get(opts, :active_post_status)),
|
||||
post_count: "#{Keyword.get(opts, :post_count, 0)} posts",
|
||||
media_count: "#{Keyword.get(opts, :media_count, 0)} media",
|
||||
token_usage: token_usage(state, Keyword.get(opts, :token_usage)),
|
||||
theme_badge: Keyword.get(opts, :theme_badge, "default"),
|
||||
offline_mode: Keyword.get(opts, :offline_mode, false),
|
||||
ui_language: Keyword.get(opts, :ui_language, "en"),
|
||||
brand: "bDS"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp upsert_tab(tabs, type, id, intent) do
|
||||
transient? = transient_tab?(type, intent)
|
||||
|
||||
case Enum.find_index(tabs, &(tab_ref(&1) == {type, id})) do
|
||||
nil ->
|
||||
new_tab = %{type: type, id: id, is_transient: transient?}
|
||||
|
||||
tabs =
|
||||
cond do
|
||||
transient? -> replace_transient_tab(tabs, type, new_tab)
|
||||
true -> tabs ++ [new_tab]
|
||||
end
|
||||
|
||||
{tabs, new_tab}
|
||||
|
||||
index ->
|
||||
existing = Enum.at(tabs, index)
|
||||
updated = if intent == :pin, do: %{existing | is_transient: false}, else: existing
|
||||
{List.replace_at(tabs, index, updated), updated}
|
||||
end
|
||||
end
|
||||
|
||||
defp replace_transient_tab(tabs, type, new_tab) do
|
||||
case Enum.find_index(tabs, &(&1.type == type and &1.is_transient)) do
|
||||
nil -> tabs ++ [new_tab]
|
||||
index -> List.replace_at(tabs, index, new_tab)
|
||||
end
|
||||
end
|
||||
|
||||
defp transient_tab?(type, _intent) when type in [:chat, :import], do: false
|
||||
defp transient_tab?(type, intent) do
|
||||
if MapSet.member?(@singleton_tabs, type), do: false, else: transient_from_intent(intent)
|
||||
end
|
||||
|
||||
defp transient_from_intent(:preview), do: true
|
||||
defp transient_from_intent(_intent), do: false
|
||||
|
||||
defp sync_editor_route(%{active_tab: nil} = state), do: %{state | editor_route: :dashboard}
|
||||
defp sync_editor_route(%{active_tab: {type, _id}} = state), do: %{state | editor_route: type}
|
||||
|
||||
defp normalize_panel(state) do
|
||||
if panel_tab_available?(state.editor_route, state.panel.active_tab) do
|
||||
state
|
||||
else
|
||||
%{state | panel: %{state.panel | active_tab: :tasks}}
|
||||
end
|
||||
end
|
||||
|
||||
defp panel_tab_available?(_route, tab) when tab in [:tasks, :output], do: true
|
||||
defp panel_tab_available?(:post, :post_links), do: true
|
||||
defp panel_tab_available?(route, :git_log) when route in [:post, :media], do: true
|
||||
defp panel_tab_available?(_route, _tab), do: false
|
||||
|
||||
defp activity_badge(:git, count) when is_integer(count) and count > 0 do
|
||||
%{count: count, display: if(count > 99, do: "99+", else: Integer.to_string(count))}
|
||||
end
|
||||
|
||||
defp activity_badge(_id, _count), do: nil
|
||||
|
||||
defp post_status(%{editor_route: :post}, status) when not is_nil(status), do: to_string(status)
|
||||
defp post_status(_state, _status), do: nil
|
||||
|
||||
defp token_usage(%{editor_route: :chat}, usage), do: usage
|
||||
defp token_usage(_state, _usage), do: nil
|
||||
|
||||
defp normalize_type(type) when is_atom(type), do: type
|
||||
defp normalize_type(type) when is_binary(type), do: String.to_atom(type)
|
||||
|
||||
defp tab_ref(tab), do: {tab.type, tab.id}
|
||||
|
||||
defp clamp_sidebar_width(width), do: max(200, min(width, 500))
|
||||
defp clamp_assistant_sidebar_width(width), do: max(280, min(width, 640))
|
||||
end
|
||||
4
mix.exs
4
mix.exs
@@ -16,7 +16,7 @@ defmodule BDS.MixProject do
|
||||
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:logger],
|
||||
extra_applications: [:logger, :wx],
|
||||
mod: {BDS.Application, []}
|
||||
]
|
||||
end
|
||||
@@ -30,6 +30,8 @@ defmodule BDS.MixProject do
|
||||
{:earmark, "~> 1.4"},
|
||||
{:liquex, "~> 0.13.1"},
|
||||
{:plug, "~> 1.18"},
|
||||
{:bandit, "~> 1.5"},
|
||||
{:desktop, "~> 1.5"},
|
||||
{:image, "~> 0.65"},
|
||||
{:stemex, "~> 0.2.1"}
|
||||
]
|
||||
|
||||
18
mix.lock
18
mix.lock
@@ -1,16 +1,25 @@
|
||||
%{
|
||||
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"color": {:hex, :color, "0.12.0", "f59f9bb6452a460760d44116ec0c1cf86f9d7707c8756c01f83c6d8fe042ae67", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "1e17768919dad0bd44f48d0daf294d24bdd5a615bbfe0b4e01a51312203bd294"},
|
||||
"date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"},
|
||||
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
|
||||
"dbus": {:hex, :dbus, "0.8.0", "7c800681f35d909c199265e55a8ee4aea9ebe4acccce77a0740f89f29cc57648", [:make], [], "hexpm", "a9784f2d9717ffa1f74169144a226c39633ac0d9c7fe8cb3594aeb89c827cca5"},
|
||||
"debouncer": {:hex, :debouncer, "0.1.13", "af5906b231c196943ac8386b5b5f45a2f36d54a8bcd7e1b29eef2671de33d287", [:mix], [], "hexpm", "a14f57420c7d4a287f8f08e715fc8759b5d28dcd1032f9585d57c45d22123382"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"desktop": {:hex, :desktop, "1.5.3", "dcf875dcff5b49a54646b4e6964acb079545c8c9c3790799aa5f1ccdcd314d15", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3750aabb8ed8aaf09b33f3cad5bda20f8ce4dfa65b026c019baed99c5264e2aa"},
|
||||
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
|
||||
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
|
||||
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},
|
||||
"ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"},
|
||||
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
|
||||
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
|
||||
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.4", "271455b4d300d5d53a5d92b5bd1c00ad14c5abf1c9ff87be069af5736496515c", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "12e1754204e7db5df1750df0a5dba1bbdf89260800019ab081f2b046596be56b"},
|
||||
"image": {:hex, :image, "0.65.0", "44908233a1a0dcdbb6ae873ec09fd9ae533d1840d300d8b0b1b186d586b935e6", [:mix], [{:color, "~> 0.4", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "0.11.0", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.11.0", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "d2060e08d0f42564f49de1ea97a82a5d237f9ac91edb141dece51f1238dd8b4a"},
|
||||
@@ -21,13 +30,22 @@
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||
"oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"rustler": {:hex, :rustler, "0.32.1", "f4cf5a39f9e85d182c0a3f75fa15b5d0add6542ab0bf9ceac6b4023109ebd3fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "b96be75526784f86f6587f051bc8d6f4eaff23d6e0f88dbcfe4d5871f52946f7"},
|
||||
"saxy": {:hex, :saxy, "1.4.0", "c7203ad20001f72eaaad07d08f82be063fa94a40924e6bb39d93d55f979abcba", [:mix], [], "hexpm", "3fe790354d3f2234ad0b5be2d99822a23fa2d4e8ccd6657c672901dac172e9a9"},
|
||||
"stemex": {:hex, :stemex, "0.2.1", "47017c6b10cdd6926a0d523ccf1f801c5f3faf5a0a9c862f49304e07f9b5584f", [:mix], [], "hexpm", "dbfc76d27adfa31d831d183979c595942884e6530a4496714aa5b70d0964c2e4"},
|
||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
|
||||
"vix": {:hex, :vix, "0.38.0", "77529ee4f6ced339c3d5f90a9eacf306f5b7109d3d1b5e3ef391a984ad404f75", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "dca58f654922fa678d5df8e028317483d9c0f8acb2e2714076a8468695687aa7"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
}
|
||||
|
||||
701
priv/ui/app.css
Normal file
701
priv/ui/app.css
Normal 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
688
priv/ui/app.js
Normal 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
104
priv/ui/index.html
Normal 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
64
test/bds/desktop_test.exs
Normal 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
109
test/bds/ui/shell_test.exs
Normal 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
|
||||
180
test/bds/ui/workbench_test.exs
Normal file
180
test/bds/ui/workbench_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user