@@ -5,24 +5,17 @@ defmodule BDS.Application do
|
||||
|
||||
def desktop_children(env \\ nil)
|
||||
|
||||
def desktop_children(:test), do: []
|
||||
def desktop_children(:test) do
|
||||
if desktop_automation?() do
|
||||
[{BDS.Desktop.Server, []}]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
]}
|
||||
]
|
||||
[{BDS.Desktop.Server, []} | desktop_window_children()]
|
||||
else
|
||||
[]
|
||||
end
|
||||
@@ -50,4 +43,28 @@ defmodule BDS.Application do
|
||||
Application.get_env(:bds, :current_env_override) ||
|
||||
if(Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod)
|
||||
end
|
||||
|
||||
defp desktop_window_children do
|
||||
if desktop_automation?() do
|
||||
[]
|
||||
else
|
||||
[
|
||||
{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
|
||||
]}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
defp desktop_automation? do
|
||||
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ defmodule BDS.Desktop do
|
||||
@moduledoc false
|
||||
|
||||
def url do
|
||||
Application.get_env(:bds, :desktop)[:port]
|
||||
BDS.Desktop.Server.port()
|
||||
|> url()
|
||||
end
|
||||
|
||||
|
||||
320
lib/bds/desktop/automation.ex
Normal file
320
lib/bds/desktop/automation.ex
Normal file
@@ -0,0 +1,320 @@
|
||||
defmodule BDS.Desktop.Automation do
|
||||
@moduledoc false
|
||||
|
||||
use GenServer
|
||||
|
||||
@ready_timeout 60_000
|
||||
@request_timeout 30_000
|
||||
|
||||
def start_session(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts)
|
||||
end
|
||||
|
||||
def stop_session(session) do
|
||||
GenServer.stop(session, :normal, @request_timeout)
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
end
|
||||
|
||||
def snapshot(session) do
|
||||
GenServer.call(session, :snapshot, @request_timeout)
|
||||
end
|
||||
|
||||
def click(session, selector) when is_binary(selector) do
|
||||
GenServer.call(session, {:click, selector}, @request_timeout)
|
||||
end
|
||||
|
||||
def capture_screenshot(session, destination) when is_binary(destination) do
|
||||
GenServer.call(session, {:capture_screenshot, destination}, @request_timeout)
|
||||
end
|
||||
|
||||
def child_info(session) do
|
||||
GenServer.call(session, :child_info, @request_timeout)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
Process.flag(:trap_exit, true)
|
||||
|
||||
screenshot_dir = Keyword.get(opts, :screenshot_dir, System.tmp_dir!())
|
||||
port = Keyword.get_lazy(opts, :port, &free_tcp_port/0)
|
||||
project_root = project_root()
|
||||
base_url = BDS.Desktop.url(port)
|
||||
|
||||
File.mkdir_p!(screenshot_dir)
|
||||
ensure_http_client_started()
|
||||
|
||||
app_port = start_app_process(project_root, port)
|
||||
:ok = wait_for_server(base_url)
|
||||
|
||||
driver_port = start_driver_process(project_root, base_url, screenshot_dir)
|
||||
|
||||
state = %{
|
||||
app_port: app_port,
|
||||
app_os_pid: port_os_pid(app_port),
|
||||
driver_port: driver_port,
|
||||
driver_os_pid: port_os_pid(driver_port),
|
||||
driver_buffer: "",
|
||||
base_url: base_url,
|
||||
screenshot_dir: screenshot_dir
|
||||
}
|
||||
|
||||
{reply, state} = await_driver_ready(state)
|
||||
|
||||
case reply do
|
||||
:ok -> {:ok, state}
|
||||
{:error, reason} -> {:stop, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:snapshot, _from, state) do
|
||||
{reply, state} = driver_request(state, %{"command" => "snapshot"})
|
||||
{:reply, atomize_map(reply), state}
|
||||
end
|
||||
|
||||
def handle_call({:click, selector}, _from, state) do
|
||||
{reply, state} = driver_request(state, %{"command" => "click", "selector" => selector})
|
||||
{:reply, normalize_simple_reply(reply), state}
|
||||
end
|
||||
|
||||
def handle_call({:capture_screenshot, destination}, _from, state) do
|
||||
File.mkdir_p!(Path.dirname(destination))
|
||||
|
||||
{reply, state} =
|
||||
driver_request(state, %{"command" => "screenshot", "path" => destination})
|
||||
|
||||
{:reply, reply, state}
|
||||
end
|
||||
|
||||
def handle_call(:child_info, _from, state) do
|
||||
{:reply, %{app_os_pid: state.app_os_pid, driver_os_pid: state.driver_os_pid}, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, state) do
|
||||
shutdown_driver(state)
|
||||
shutdown_app(state)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp start_app_process(project_root, port) do
|
||||
executable = System.find_executable("elixir") || raise "missing elixir executable"
|
||||
|
||||
Port.open(
|
||||
{:spawn_executable, executable},
|
||||
[
|
||||
:binary,
|
||||
:stderr_to_stdout,
|
||||
:exit_status,
|
||||
:use_stdio,
|
||||
:hide,
|
||||
{:cd, project_root},
|
||||
{:env,
|
||||
[
|
||||
{~c"MIX_ENV", ~c"test"},
|
||||
{~c"BDS_DESKTOP_AUTOMATION", ~c"1"},
|
||||
{~c"BDS_DESKTOP_PORT", String.to_charlist(Integer.to_string(port))}
|
||||
]},
|
||||
{:args,
|
||||
[
|
||||
"-S",
|
||||
"mix",
|
||||
"run",
|
||||
"scripts/desktop_automation_app.exs"
|
||||
]}
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
defp start_driver_process(project_root, base_url, screenshot_dir) do
|
||||
executable = System.find_executable("node") || raise "missing node executable"
|
||||
|
||||
Port.open(
|
||||
{:spawn_executable, executable},
|
||||
[
|
||||
:binary,
|
||||
:stderr_to_stdout,
|
||||
:exit_status,
|
||||
:use_stdio,
|
||||
:hide,
|
||||
{:cd, project_root},
|
||||
{:args,
|
||||
[
|
||||
Path.join([project_root, "scripts", "desktop_automation_runner.mjs"]),
|
||||
base_url,
|
||||
screenshot_dir
|
||||
]}
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
defp await_driver_ready(state) do
|
||||
receive_driver_message(state, @ready_timeout, fn message ->
|
||||
case message do
|
||||
%{"status" => "ready"} -> {:ok, :ok}
|
||||
%{"status" => "error", "message" => reason} -> {:ok, {:error, reason}}
|
||||
_other -> :continue
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp driver_request(state, payload) do
|
||||
ref = Integer.to_string(System.unique_integer([:positive, :monotonic]))
|
||||
request = Map.put(payload, "ref", ref)
|
||||
Port.command(state.driver_port, Jason.encode!(request) <> "\n")
|
||||
|
||||
receive_driver_message(state, @request_timeout, fn message ->
|
||||
case message do
|
||||
%{"ref" => ^ref, "status" => "ok", "result" => result} -> {:ok, result}
|
||||
%{"ref" => ^ref, "status" => "error", "message" => reason} ->
|
||||
raise "desktop automation request failed: #{reason}"
|
||||
|
||||
_other ->
|
||||
:continue
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp safe_driver_request(state, payload) do
|
||||
driver_request(state, payload)
|
||||
rescue
|
||||
_error -> :ok
|
||||
end
|
||||
|
||||
defp shutdown_driver(state) do
|
||||
_ = safe_driver_request(state, %{"command" => "close"})
|
||||
await_port_exit(state[:driver_port], 5_000)
|
||||
safe_close_port(state[:driver_port])
|
||||
end
|
||||
|
||||
defp shutdown_app(state) do
|
||||
if state[:app_port] do
|
||||
Port.command(state.app_port, "stop\n")
|
||||
await_port_exit(state.app_port, 10_000)
|
||||
safe_close_port(state.app_port)
|
||||
end
|
||||
end
|
||||
|
||||
defp receive_driver_message(state, timeout, matcher) do
|
||||
deadline = System.monotonic_time(:millisecond) + timeout
|
||||
process_driver_messages(state, deadline, matcher)
|
||||
end
|
||||
|
||||
defp process_driver_messages(state, deadline, matcher) do
|
||||
{messages, buffer} = split_driver_buffer(state.driver_buffer)
|
||||
|
||||
case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message, {acc, _} ->
|
||||
decoded = Jason.decode!(message)
|
||||
|
||||
case matcher.(decoded) do
|
||||
{:ok, reply} -> {:halt, {acc, reply}}
|
||||
:continue -> {:cont, {acc, nil}}
|
||||
end
|
||||
end) do
|
||||
{state, nil} ->
|
||||
remaining = max(deadline - System.monotonic_time(:millisecond), 0)
|
||||
|
||||
receive do
|
||||
{port, {:data, data}} when port == state.driver_port ->
|
||||
process_driver_messages(%{state | driver_buffer: state.driver_buffer <> data}, deadline, matcher)
|
||||
|
||||
{port, {:exit_status, status}} when port == state.driver_port ->
|
||||
raise "desktop automation driver exited with status #{status}"
|
||||
|
||||
{port, {:exit_status, status}} when port == state.app_port ->
|
||||
raise "desktop app process exited with status #{status}"
|
||||
|
||||
{_port, {:data, _data}} ->
|
||||
process_driver_messages(state, deadline, matcher)
|
||||
after
|
||||
remaining -> raise "desktop automation timed out waiting for driver response"
|
||||
end
|
||||
|
||||
{state, reply} ->
|
||||
{reply, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp split_driver_buffer(buffer) do
|
||||
case String.split(buffer, "\n") do
|
||||
[] -> {[], ""}
|
||||
[single] -> {[], single}
|
||||
parts -> {Enum.drop(parts, -1), List.last(parts)}
|
||||
end
|
||||
end
|
||||
|
||||
defp wait_for_server(base_url) do
|
||||
deadline = System.monotonic_time(:millisecond) + @ready_timeout
|
||||
do_wait_for_server(base_url, deadline)
|
||||
end
|
||||
|
||||
defp do_wait_for_server(base_url, deadline) do
|
||||
case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do
|
||||
{:ok, {{_, 200, _}, _headers, _body}} -> :ok
|
||||
_other ->
|
||||
if System.monotonic_time(:millisecond) >= deadline do
|
||||
raise "desktop app process did not become healthy in time"
|
||||
else
|
||||
Process.sleep(200)
|
||||
do_wait_for_server(base_url, deadline)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp free_tcp_port do
|
||||
{:ok, socket} = :gen_tcp.listen(0, [:binary, packet: :raw, active: false, reuseaddr: true])
|
||||
{:ok, port} = :inet.port(socket)
|
||||
:gen_tcp.close(socket)
|
||||
port
|
||||
end
|
||||
|
||||
defp ensure_http_client_started do
|
||||
_ = Application.ensure_all_started(:inets)
|
||||
_ = Application.ensure_all_started(:ssl)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp await_port_exit(nil, _timeout), do: :ok
|
||||
|
||||
defp await_port_exit(port, timeout) do
|
||||
receive do
|
||||
{^port, {:exit_status, _status}} -> :ok
|
||||
{^port, {:data, _data}} -> await_port_exit(port, timeout)
|
||||
after
|
||||
timeout -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp port_os_pid(nil), do: nil
|
||||
|
||||
defp port_os_pid(port) do
|
||||
case Port.info(port, :os_pid) do
|
||||
{:os_pid, pid} when is_integer(pid) -> pid
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp safe_close_port(nil), do: :ok
|
||||
|
||||
defp safe_close_port(port) do
|
||||
Port.close(port)
|
||||
catch
|
||||
:error, _reason -> :ok
|
||||
end
|
||||
|
||||
defp normalize_simple_reply("ok"), do: :ok
|
||||
defp normalize_simple_reply(reply), do: reply
|
||||
|
||||
defp atomize_map(map) when is_map(map) do
|
||||
Enum.into(map, %{}, fn {key, value} ->
|
||||
normalized_key = if is_binary(key), do: String.to_atom(key), else: key
|
||||
normalized_value = if is_map(value), do: atomize_map(value), else: value
|
||||
{normalized_key, normalized_value}
|
||||
end)
|
||||
end
|
||||
|
||||
defp project_root do
|
||||
Path.expand("../../..", __DIR__)
|
||||
end
|
||||
end
|
||||
@@ -11,7 +11,7 @@ defmodule BDS.Desktop.Router do
|
||||
signing_salt: "desktop-shell"
|
||||
|
||||
plug :match
|
||||
plug Desktop.Auth
|
||||
plug :maybe_require_desktop_auth
|
||||
|
||||
plug Plug.Static,
|
||||
at: "/assets",
|
||||
@@ -46,4 +46,12 @@ defmodule BDS.Desktop.Router do
|
||||
Application.get_env(:bds, :desktop)[:secret_key_base] ||
|
||||
raise "missing :desktop secret_key_base configuration"
|
||||
end
|
||||
|
||||
defp maybe_require_desktop_auth(conn, _opts) do
|
||||
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do
|
||||
conn
|
||||
else
|
||||
Desktop.Auth.call(conn, [])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,10 @@ defmodule BDS.Desktop.Server do
|
||||
end
|
||||
|
||||
def port do
|
||||
Application.get_env(:bds, :desktop)[:port] || 4010
|
||||
case System.get_env("BDS_DESKTOP_PORT") do
|
||||
value when is_binary(value) -> String.to_integer(value)
|
||||
_other -> Application.get_env(:bds, :desktop)[:port] || 4010
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
||||
@@ -2,6 +2,6 @@ defmodule BDS.Desktop.ShellController do
|
||||
@moduledoc false
|
||||
|
||||
def index_html do
|
||||
File.read!(Application.app_dir(:bds, ["priv", "ui", "index.html"]))
|
||||
BDS.UI.ShellPage.render()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,26 +7,7 @@ defmodule BDS.UI.ShellPage do
|
||||
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
|
||||
}
|
||||
}
|
||||
bootstrap = bootstrap()
|
||||
|
||||
[
|
||||
"<!DOCTYPE html>",
|
||||
@@ -34,29 +15,215 @@ defmodule BDS.UI.ShellPage do
|
||||
"<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\">",
|
||||
" <title>Blogging Desktop Server</title>",
|
||||
" <link rel=\"stylesheet\" href=\"/assets/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 class=\"app\" id=\"bds-shell-app\">",
|
||||
" <div class=\"window-titlebar\" data-region=\"title-bar\"></div>",
|
||||
" <div class=\"app-main\">",
|
||||
" <aside class=\"activity-bar\" data-region=\"activity-bar\"></aside>",
|
||||
" <section class=\"sidebar-shell\" data-testid=\"sidebar-shell\">",
|
||||
" <div class=\"sidebar\" data-region=\"sidebar\"></div>",
|
||||
" <div class=\"resizable-panel-divider sidebar-divider\" data-resize=\"sidebar\" data-role=\"resize-handle\"></div>",
|
||||
" </section>",
|
||||
" <main class=\"app-content\" data-region=\"content\">",
|
||||
" <div class=\"tab-bar\" data-region=\"tab-bar\"></div>",
|
||||
" <section class=\"editor-shell\" data-region=\"editor\"></section>",
|
||||
" <section class=\"panel-shell\" data-region=\"panel\"></section>",
|
||||
" </main>",
|
||||
" <section class=\"assistant-sidebar-shell\" data-testid=\"assistant-shell\">",
|
||||
" <div class=\"resizable-panel-divider assistant-divider\" data-resize=\"assistant\" data-role=\"resize-handle\"></div>",
|
||||
" <aside class=\"assistant-sidebar\" data-region=\"assistant-sidebar\"></aside>",
|
||||
" </section>",
|
||||
" </div>",
|
||||
" <footer class=\"status-bar\" data-region=\"status-bar\"></footer>",
|
||||
" </div>",
|
||||
" <script id=\"bds-shell-bootstrap\" type=\"application/json\">#{Jason.encode!(bootstrap)}</script>",
|
||||
" <script src=\"./app.js\"></script>",
|
||||
" <script src=\"/assets/app.js\"></script>",
|
||||
"</body>",
|
||||
"</html>"
|
||||
]
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
defp bootstrap do
|
||||
workbench = Workbench.new(panel_visible: true, assistant_sidebar_visible: true)
|
||||
|
||||
%{
|
||||
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
|
||||
registry: %{
|
||||
sidebar_views: Enum.map(Registry.sidebar_views(), &encode_sidebar_view/1),
|
||||
editor_routes: Enum.map(Registry.editor_routes(), &encode_editor_route/1),
|
||||
default_sidebar_view: Atom.to_string(Registry.default_sidebar_view())
|
||||
},
|
||||
menu_groups: Enum.map(MenuBar.default_groups(), &encode_menu_group/1),
|
||||
session: Session.serialize(workbench),
|
||||
content: %{
|
||||
sidebar: sidebar_content(),
|
||||
dashboard: dashboard_content(),
|
||||
assistant_cards: assistant_cards(),
|
||||
editor_meta: editor_meta()
|
||||
},
|
||||
status:
|
||||
Workbench.status_bar(workbench,
|
||||
post_count: 42,
|
||||
media_count: 18,
|
||||
theme_badge: "desktop-shell",
|
||||
ui_language: "en",
|
||||
offline_mode: true,
|
||||
running_task_message: "Desktop shell ready",
|
||||
running_task_overflow: 0,
|
||||
git_badge_count: 3
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
defp encode_sidebar_view(view) do
|
||||
%{
|
||||
id: Atom.to_string(view.id),
|
||||
label: normalize_view_label(view.id, view.label),
|
||||
activity_group: Atom.to_string(view.activity_group),
|
||||
editor_route: Atom.to_string(view.editor_route),
|
||||
entity_tab: Map.get(view, :entity_tab, false),
|
||||
singleton: Map.get(view, :singleton, false)
|
||||
}
|
||||
end
|
||||
|
||||
defp encode_editor_route(route) do
|
||||
%{
|
||||
id: Atom.to_string(route.id),
|
||||
singleton: route.singleton,
|
||||
entity_tab: route.entity_tab,
|
||||
title: route.title
|
||||
}
|
||||
end
|
||||
|
||||
defp encode_menu_group(group) do
|
||||
%{
|
||||
id: Atom.to_string(group.id),
|
||||
label: humanize(group.id),
|
||||
items:
|
||||
Enum.map(group.items, fn item ->
|
||||
%{id: Atom.to_string(item.id), label: humanize(item.id)}
|
||||
end)
|
||||
}
|
||||
end
|
||||
|
||||
defp sidebar_content do
|
||||
%{
|
||||
"posts" => %{
|
||||
title: "Posts",
|
||||
subtitle: "Drafts, published entries, and archive history",
|
||||
sections: [
|
||||
%{
|
||||
title: "Drafts",
|
||||
items: [
|
||||
%{id: "post-welcome", title: "Welcome to bDS2", meta: "Updated today", badge: "draft", route: "post"},
|
||||
%{id: "post-launch-plan", title: "Launch plan", meta: "Updated yesterday", badge: "draft", route: "post"}
|
||||
]
|
||||
},
|
||||
%{
|
||||
title: "Published",
|
||||
items: [
|
||||
%{id: "post-roadmap", title: "Roadmap", meta: "Published Feb 10, 2026", badge: "2 langs", route: "post"}
|
||||
]
|
||||
},
|
||||
%{
|
||||
title: "Archived",
|
||||
items: [
|
||||
%{id: "post-retrospective", title: "Retrospective", meta: "Archived Jan 12, 2026", badge: "archive", route: "post"}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"pages" => simple_list_view("Pages", "Standalone pages", [
|
||||
%{id: "page-about", title: "About", meta: "Static page", route: "post"},
|
||||
%{id: "page-contact", title: "Contact", meta: "Static page", route: "post"}
|
||||
]),
|
||||
"media" => simple_list_view("Media", "Images and files", [
|
||||
%{id: "media-hero", title: "hero-shot.jpg", meta: "Image asset", route: "media"},
|
||||
%{id: "media-banner", title: "launch-banner.png", meta: "Image asset", route: "media"}
|
||||
]),
|
||||
"scripts" => simple_list_view("Scripts", "Automation helpers", [
|
||||
%{id: "script-import", title: "Import posts", meta: "Lua utility", route: "scripts"},
|
||||
%{id: "script-sync", title: "Sync tags", meta: "Lua utility", route: "scripts"}
|
||||
]),
|
||||
"templates" => simple_list_view("Templates", "Site rendering", [
|
||||
%{id: "template-post", title: "post.liquid", meta: "Post template", route: "templates"},
|
||||
%{id: "template-list", title: "list.liquid", meta: "List template", route: "templates"}
|
||||
]),
|
||||
"tags" => simple_list_view("Tags", "Tag management", [
|
||||
%{id: "tag-launch", title: "launch", meta: "12 posts", route: "tags"},
|
||||
%{id: "tag-writing", title: "writing", meta: "7 posts", route: "tags"}
|
||||
]),
|
||||
"chat" => simple_list_view("Chat", "AI conversations", [
|
||||
%{id: "chat-planning", title: "Planning session", meta: "Offline gated", route: "chat"},
|
||||
%{id: "chat-translation", title: "Translation QA", meta: "Offline gated", route: "chat"}
|
||||
]),
|
||||
"import" => simple_list_view("Import", "Import definitions", [
|
||||
%{id: "import-wordpress", title: "WordPress import", meta: "Ready", route: "import"}
|
||||
]),
|
||||
"git" => simple_list_view("Git", "Working tree and history", [
|
||||
%{id: "git-working-tree", title: "Working tree", meta: "3 changed files", route: "git_diff"}
|
||||
]),
|
||||
"settings" => simple_list_view("Settings", "Project and publishing", [
|
||||
%{id: "settings-project", title: "Project", meta: "Paths and defaults", route: "settings"},
|
||||
%{id: "settings-ai", title: "AI", meta: "Offline controls", route: "settings"}
|
||||
])
|
||||
}
|
||||
end
|
||||
|
||||
defp simple_list_view(title, subtitle, items) do
|
||||
%{title: title, subtitle: subtitle, sections: [%{title: title, items: items}]}
|
||||
end
|
||||
|
||||
defp dashboard_content do
|
||||
%{
|
||||
title: "Dashboard",
|
||||
subtitle: "Desktop workbench shell wired through Elixir",
|
||||
summary_cards: [
|
||||
%{label: "Posts", value: "42", detail: "Across draft, published, and archive"},
|
||||
%{label: "Media", value: "18", detail: "Images and documents indexed"},
|
||||
%{label: "Tasks", value: "1", detail: "One background action visible in the status bar"}
|
||||
],
|
||||
checklist: [
|
||||
"Native menu groups mirror the old application shell",
|
||||
"Sidebar, tabs, panel, and assistant panes are inspectable DOM regions",
|
||||
"Automation can boot the shell in a separate process and capture screenshots"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
defp assistant_cards do
|
||||
[
|
||||
%{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."},
|
||||
%{label: "Filesystem Sync", text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."},
|
||||
%{label: "Desktop Runtime", text: "The app window is now served from the Elixir shell renderer."}
|
||||
]
|
||||
end
|
||||
|
||||
defp editor_meta do
|
||||
%{
|
||||
dashboard: [
|
||||
%{label: "Status", value: "Workbench shell ready"},
|
||||
%{label: "Mode", value: "Offline"},
|
||||
%{label: "Main Language", value: "en"}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_view_label(:chat, _label), do: "Chat"
|
||||
defp normalize_view_label(:git, _label), do: "Git"
|
||||
defp normalize_view_label(_id, label), do: label
|
||||
|
||||
defp humanize(value) when is_atom(value), do: value |> Atom.to_string() |> humanize()
|
||||
|
||||
defp humanize(value) when is_binary(value) do
|
||||
value
|
||||
|> String.replace("_", " ")
|
||||
|> String.split(" ")
|
||||
|> Enum.map(&String.capitalize/1)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user