feat: first take at UI app
This commit is contained in:
1 parent
78609377be
commit
1b5a5008eb
24 files changed
+2630
-3
No files matched your search
+8
-1
@@ -9,7 +9,14 @@ config :bds, BDS.Repo,
|
|||||||
stacktrace: true,
|
stacktrace: true,
|
||||||
show_sensitive_data_on_connection_error: true
|
show_sensitive_data_on_connection_error: true
|
||||||
|
|
||||||
config :bds, BDS.Application, desktop_adapter: :pending_selection
|
config :bds, BDS.Application, desktop_adapter: :desktop
|
||||||
|
|
||||||
|
config :bds, :desktop,
|
||||||
|
port: 4010,
|
||||||
|
window_size: {1440, 900},
|
||||||
|
window_min_size: {1100, 700},
|
||||||
|
title: "Blogging Desktop Server",
|
||||||
|
secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001"
|
||||||
|
|
||||||
config :bds, :scripting,
|
config :bds, :scripting,
|
||||||
runtime: BDS.Scripting.Lua,
|
runtime: BDS.Scripting.Lua,
|
||||||
|
|||||||
+1
-1
@@ -7,5 +7,5 @@ if config_env() == :prod do
|
|||||||
|
|
||||||
config :bds, BDS.Repo,
|
config :bds, BDS.Repo,
|
||||||
database: database_path,
|
database: database_path,
|
||||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
|
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "1")
|
||||||
end
|
end
|
||||||
@@ -3,6 +3,31 @@ defmodule BDS.Application do
|
|||||||
|
|
||||||
use Application
|
use Application
|
||||||
|
|
||||||
|
def desktop_children(env \\ nil)
|
||||||
|
|
||||||
|
def desktop_children(:test), do: []
|
||||||
|
|
||||||
|
def desktop_children(_env) do
|
||||||
|
if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do
|
||||||
|
[
|
||||||
|
{BDS.Desktop.Server, []},
|
||||||
|
{Desktop.Window,
|
||||||
|
[
|
||||||
|
app: :bds,
|
||||||
|
id: BDS.Desktop.MainWindow,
|
||||||
|
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
|
||||||
|
size: Application.get_env(:bds, :desktop)[:window_size] || {1440, 900},
|
||||||
|
min_size: Application.get_env(:bds, :desktop)[:window_min_size] || {1100, 700},
|
||||||
|
menubar: BDS.Desktop.MenuBar,
|
||||||
|
icon_menu: BDS.Desktop.Menu,
|
||||||
|
url: &BDS.Desktop.url/0
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
children = [
|
children = [
|
||||||
@@ -14,9 +39,15 @@ defmodule BDS.Application do
|
|||||||
BDS.Scripting.JobStore,
|
BDS.Scripting.JobStore,
|
||||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||||
BDS.Scripting.JobSupervisor
|
BDS.Scripting.JobSupervisor
|
||||||
|
| desktop_children(current_env())
|
||||||
]
|
]
|
||||||
|
|
||||||
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
||||||
Supervisor.start_link(children, opts)
|
Supervisor.start_link(children, opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp current_env do
|
||||||
|
Application.get_env(:bds, :current_env_override) ||
|
||||||
|
if(Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
defmodule BDS.Desktop.MainWindow do
|
||||||
|
@moduledoc false
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -16,7 +16,7 @@ defmodule BDS.MixProject do
|
|||||||
|
|
||||||
def application do
|
def application do
|
||||||
[
|
[
|
||||||
extra_applications: [:logger],
|
extra_applications: [:logger, :wx],
|
||||||
mod: {BDS.Application, []}
|
mod: {BDS.Application, []}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
@@ -30,6 +30,8 @@ defmodule BDS.MixProject do
|
|||||||
{:earmark, "~> 1.4"},
|
{:earmark, "~> 1.4"},
|
||||||
{:liquex, "~> 0.13.1"},
|
{:liquex, "~> 0.13.1"},
|
||||||
{:plug, "~> 1.18"},
|
{:plug, "~> 1.18"},
|
||||||
|
{:bandit, "~> 1.5"},
|
||||||
|
{:desktop, "~> 1.5"},
|
||||||
{:image, "~> 0.65"},
|
{:image, "~> 0.65"},
|
||||||
{:stemex, "~> 0.2.1"}
|
{:stemex, "~> 0.2.1"}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
%{
|
%{
|
||||||
|
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
|
||||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||||
"color": {:hex, :color, "0.12.0", "f59f9bb6452a460760d44116ec0c1cf86f9d7707c8756c01f83c6d8fe042ae67", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "1e17768919dad0bd44f48d0daf294d24bdd5a615bbfe0b4e01a51312203bd294"},
|
"color": {:hex, :color, "0.12.0", "f59f9bb6452a460760d44116ec0c1cf86f9d7707c8756c01f83c6d8fe042ae67", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "1e17768919dad0bd44f48d0daf294d24bdd5a615bbfe0b4e01a51312203bd294"},
|
||||||
"date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"},
|
"date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"},
|
||||||
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
|
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
|
||||||
|
"dbus": {:hex, :dbus, "0.8.0", "7c800681f35d909c199265e55a8ee4aea9ebe4acccce77a0740f89f29cc57648", [:make], [], "hexpm", "a9784f2d9717ffa1f74169144a226c39633ac0d9c7fe8cb3594aeb89c827cca5"},
|
||||||
|
"debouncer": {:hex, :debouncer, "0.1.13", "af5906b231c196943ac8386b5b5f45a2f36d54a8bcd7e1b29eef2671de33d287", [:mix], [], "hexpm", "a14f57420c7d4a287f8f08e715fc8759b5d28dcd1032f9585d57c45d22123382"},
|
||||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||||
|
"desktop": {:hex, :desktop, "1.5.3", "dcf875dcff5b49a54646b4e6964acb079545c8c9c3790799aa5f1ccdcd314d15", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3750aabb8ed8aaf09b33f3cad5bda20f8ce4dfa65b026c019baed99c5264e2aa"},
|
||||||
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
|
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
|
||||||
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||||
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
|
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
|
||||||
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
|
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
|
||||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||||
|
"ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},
|
||||||
|
"ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"},
|
||||||
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
|
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
|
||||||
|
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||||
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
|
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
|
||||||
|
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||||
|
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||||
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
|
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
|
||||||
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.4", "271455b4d300d5d53a5d92b5bd1c00ad14c5abf1c9ff87be069af5736496515c", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "12e1754204e7db5df1750df0a5dba1bbdf89260800019ab081f2b046596be56b"},
|
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.4", "271455b4d300d5d53a5d92b5bd1c00ad14c5abf1c9ff87be069af5736496515c", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "12e1754204e7db5df1750df0a5dba1bbdf89260800019ab081f2b046596be56b"},
|
||||||
"image": {:hex, :image, "0.65.0", "44908233a1a0dcdbb6ae873ec09fd9ae533d1840d300d8b0b1b186d586b935e6", [:mix], [{:color, "~> 0.4", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "0.11.0", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.11.0", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "d2060e08d0f42564f49de1ea97a82a5d237f9ac91edb141dece51f1238dd8b4a"},
|
"image": {:hex, :image, "0.65.0", "44908233a1a0dcdbb6ae873ec09fd9ae533d1840d300d8b0b1b186d586b935e6", [:mix], [{:color, "~> 0.4", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "0.11.0", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.11.0", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "d2060e08d0f42564f49de1ea97a82a5d237f9ac91edb141dece51f1238dd8b4a"},
|
||||||
@@ -21,13 +30,22 @@
|
|||||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||||
"mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},
|
"mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},
|
||||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||||
|
"oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"},
|
||||||
|
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
|
||||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||||
|
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
|
||||||
|
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||||
|
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||||
"rustler": {:hex, :rustler, "0.32.1", "f4cf5a39f9e85d182c0a3f75fa15b5d0add6542ab0bf9ceac6b4023109ebd3fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "b96be75526784f86f6587f051bc8d6f4eaff23d6e0f88dbcfe4d5871f52946f7"},
|
"rustler": {:hex, :rustler, "0.32.1", "f4cf5a39f9e85d182c0a3f75fa15b5d0add6542ab0bf9ceac6b4023109ebd3fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "b96be75526784f86f6587f051bc8d6f4eaff23d6e0f88dbcfe4d5871f52946f7"},
|
||||||
|
"saxy": {:hex, :saxy, "1.4.0", "c7203ad20001f72eaaad07d08f82be063fa94a40924e6bb39d93d55f979abcba", [:mix], [], "hexpm", "3fe790354d3f2234ad0b5be2d99822a23fa2d4e8ccd6657c672901dac172e9a9"},
|
||||||
"stemex": {:hex, :stemex, "0.2.1", "47017c6b10cdd6926a0d523ccf1f801c5f3faf5a0a9c862f49304e07f9b5584f", [:mix], [], "hexpm", "dbfc76d27adfa31d831d183979c595942884e6530a4496714aa5b70d0964c2e4"},
|
"stemex": {:hex, :stemex, "0.2.1", "47017c6b10cdd6926a0d523ccf1f801c5f3faf5a0a9c862f49304e07f9b5584f", [:mix], [], "hexpm", "dbfc76d27adfa31d831d183979c595942884e6530a4496714aa5b70d0964c2e4"},
|
||||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||||
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
|
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
|
||||||
|
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||||
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
|
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
|
||||||
"vix": {:hex, :vix, "0.38.0", "77529ee4f6ced339c3d5f90a9eacf306f5b7109d3d1b5e3ef391a984ad404f75", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "dca58f654922fa678d5df8e028317483d9c0f8acb2e2714076a8468695687aa7"},
|
"vix": {:hex, :vix, "0.38.0", "77529ee4f6ced339c3d5f90a9eacf306f5b7109d3d1b5e3ef391a984ad404f75", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "dca58f654922fa678d5df8e028317483d9c0f8acb2e2714076a8468695687aa7"},
|
||||||
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
|
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||||
}
|
}
|
||||||
+701
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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