feat: switch to phoenix liveview

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-25 23:04:16 +02:00
parent fac55bfb3b
commit 2f34040aed
23 changed files with 1835 additions and 3785 deletions

View File

@@ -13,7 +13,7 @@ The rewrite already implements most of the backend and compatibility-critical su
- Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting. - Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting.
- Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata. - Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata.
- Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync. - Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync.
- Desktop shell foundation: native menu definitions, shell HTML/CSS/JS bundle, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, project switcher, and shell command routing. - Desktop shell foundation: native menu definitions, LiveView shell endpoint, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, project switcher, and shell command routing.
### Implemented But Not Yet At Parity ### Implemented But Not Yet At Parity
@@ -38,7 +38,7 @@ Ordered from base contracts upward:
| Persistence and file contracts | `schema`, `frontmatter`, `project`, `post`, `translation`, `media`, `tag`, `template`, `script`, `menu`, `metadata` | Implemented | Core schemas, file formats, publish flows, sidecars, rebuild, and metadata diff are present and tested. | | Persistence and file contracts | `schema`, `frontmatter`, `project`, `post`, `translation`, `media`, `tag`, `template`, `script`, `menu`, `metadata` | Implemented | Core schemas, file formats, publish flows, sidecars, rebuild, and metadata diff are present and tested. |
| Rendering and output pipelines | `template_context`, `search`, `generation`, `preview`, `publishing`, `task`, `i18n` | Implemented | Rendering, generation, preview, publishing, task tracking, and localization are in place. | | Rendering and output pipelines | `template_context`, `search`, `generation`, `preview`, `publishing`, `task`, `i18n` | Implemented | Rendering, generation, preview, publishing, task tracking, and localization are in place. |
| Integrations | `git`, `mcp`, `ai`, `embedding`, `cli_sync`, `metadata_diff` | Implemented | Service layer and test coverage exist for these domains. | | Integrations | `git`, `mcp`, `ai`, `embedding`, `cli_sync`, `metadata_diff` | Implemented | Service layer and test coverage exist for these domains. |
| Desktop shell primitives | `layout`, `tabs`, `sidebar_views` | Implemented | Route state, registry-backed sidebar/editor coverage, panel fallback rules, menu/native-command wiring, and shell frame parity are in place; route bodies remain generic until the editor UX phase. | | Desktop shell primitives | `layout`, `tabs`, `sidebar_views` | Implemented | Route state, registry-backed sidebar/editor coverage, panel fallback rules, menu/native-command wiring, and a LiveView-owned shell frame are in place; route bodies remain generic until the editor UX phase. |
| Cross-cutting flows | `ui_data_flow`, `engine_side_effects`, `action_patterns`, `media_processing` | Partial | Most backend behavior exists, but UI coordination, explicit engine event modeling, and some parity details are still incomplete. | | Cross-cutting flows | `ui_data_flow`, `engine_side_effects`, `action_patterns`, `media_processing` | Partial | Most backend behavior exists, but UI coordination, explicit engine event modeling, and some parity details are still incomplete. |
| Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals` | Partial to missing | Route registration exists, but feature-complete editors and modal workflows are not done. | | Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals` | Partial to missing | Route registration exists, but feature-complete editors and modal workflows are not done. |

View File

@@ -20,6 +20,13 @@ config :bds, :desktop,
title: "Blogging Desktop Server", title: "Blogging Desktop Server",
secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001" secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001"
config :bds, BDS.Desktop.Endpoint,
url: [host: "127.0.0.1"],
adapter: Bandit.PhoenixAdapter,
render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false],
pubsub_server: BDS.PubSub,
live_view: [signing_salt: "desktop-live-view"]
config :bds, :scripting, config :bds, :scripting,
runtime: BDS.Scripting.Lua, runtime: BDS.Scripting.Lua,
timeout: 300_000, timeout: 300_000,

View File

@@ -26,6 +26,8 @@ defmodule BDS.Application do
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
children = [ children = [
{Phoenix.PubSub, name: BDS.PubSub},
{BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
BDS.Repo, BDS.Repo,
BDS.RepoBootstrap, BDS.RepoBootstrap,
BDS.Tasks, BDS.Tasks,
@@ -67,4 +69,9 @@ defmodule BDS.Application do
defp desktop_automation? do defp desktop_automation? do
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
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 end

View File

@@ -0,0 +1,42 @@
defmodule BDS.Desktop.Endpoint do
@moduledoc false
use Phoenix.Endpoint, otp_app: :bds
@session_options [
store: :cookie,
key: "_bds_desktop_key",
signing_salt: "desktop-shell"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]]
plug Plug.Session, @session_options
plug :maybe_require_desktop_auth
plug Plug.Static,
at: "/assets",
from: {:bds, "priv/ui"},
only: ["app.css", "live.js"]
plug Plug.Static,
at: "/vendor/phoenix",
from: {:phoenix, "priv/static"},
only: ["phoenix.min.js"]
plug Plug.Static,
at: "/vendor/live_view",
from: {:phoenix_live_view, "priv/static"},
only: ["phoenix_live_view.min.js"]
plug BDS.Desktop.Router
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

View File

@@ -0,0 +1,9 @@
defmodule BDS.Desktop.ErrorHTML do
@moduledoc false
use Phoenix.Component
def render(_template, _assigns) do
"not found"
end
end

View File

@@ -0,0 +1,9 @@
defmodule BDS.Desktop.HealthController do
@moduledoc false
use Phoenix.Controller, formats: [:html]
def show(conn, _params) do
text(conn, "ok")
end
end

View File

@@ -0,0 +1,26 @@
defmodule BDS.Desktop.Layouts do
@moduledoc false
use Phoenix.Component
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang={@page_language || "en"}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= @page_title || "Blogging Desktop Server" %></title>
<meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
<link phx-track-static rel="stylesheet" href="/assets/app.css" />
</head>
<body>
<%= @inner_content %>
<script defer phx-track-static src="/vendor/phoenix/phoenix.min.js"></script>
<script defer phx-track-static src="/vendor/live_view/phoenix_live_view.min.js"></script>
<script defer phx-track-static src="/assets/live.js"></script>
</body>
</html>
"""
end
end

View File

@@ -0,0 +1,59 @@
defmodule BDS.Desktop.MediaController do
@moduledoc false
use Phoenix.Controller, formats: [:html]
alias BDS.Media
alias BDS.Media.Media, as: MediaRecord
alias BDS.Projects
alias BDS.Repo
def thumbnail(conn, %{"media_id" => media_id} = params) do
case active_media_thumbnail(media_id, Map.get(params, "size")) do
{:ok, content_type, path} ->
conn
|> Plug.Conn.put_resp_content_type(content_type)
|> Plug.Conn.send_file(200, path)
:error ->
send_resp(conn, 404, "not found")
end
end
defp active_media_thumbnail(media_id, size) do
with %{} = project <- Projects.get_active_project(),
%MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
true <- media.project_id == project.id,
relative_path when is_binary(relative_path) <- Media.thumbnail_paths(media)[thumbnail_size(size)],
absolute_path = Path.join(Projects.project_data_dir(project), relative_path),
true <- File.exists?(absolute_path) do
{:ok, thumbnail_content_type(relative_path), absolute_path}
else
_other -> :error
end
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
:error
end
defp thumbnail_size(size) do
case to_string(size || "small") do
"medium" -> :medium
"large" -> :large
"ai" -> :ai
_other -> :small
end
end
defp thumbnail_content_type(path) do
case Path.extname(path) do
".jpg" -> "image/jpeg"
".jpeg" -> "image/jpeg"
_other -> "image/webp"
end
end
end

View File

@@ -1,109 +1,28 @@
defmodule BDS.Desktop.Router do defmodule BDS.Desktop.Router do
@moduledoc false @moduledoc false
use Plug.Router use Phoenix.Router
plug :put_secret_key_base import Phoenix.LiveView.Router
plug Plug.Session, pipeline :browser do
store: :cookie, plug :accepts, ["html"]
key: "_bds_desktop_key", plug :fetch_session
signing_salt: "desktop-shell" plug :fetch_live_flash
plug :put_root_layout, html: {BDS.Desktop.Layouts, :root}
plug :match plug :protect_from_forgery
plug :maybe_require_desktop_auth plug :put_secure_browser_headers
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 end
get "/health" do scope "/", BDS.Desktop do
Plug.Conn.send_resp(conn, 200, "ok") pipe_through :browser
end
get "/api/tasks" do get "/health", HealthController, :show
conn get "/media-thumbnail/:media_id", MediaController, :thumbnail
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.task_status_json())
end
get "/api/projects" do live_session :desktop_shell,
conn root_layout: {BDS.Desktop.Layouts, :root} do
|> Plug.Conn.put_resp_content_type("application/json") live "/", ShellLive, :index
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.projects_json())
end
get "/api/media-thumbnail/:media_id" do
BDS.Desktop.ShellController.media_thumbnail(conn, media_id, conn.params)
end
post "/api/sidebar" do
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.sidebar_json(payload))
end
post "/api/projects" do
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.upsert_project_json(payload))
end
post "/api/project-folder" do
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.choose_project_folder_json(payload))
end
post "/api/commands" do
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.command_json(payload))
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
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 end
end end

View File

@@ -29,7 +29,7 @@ defmodule BDS.Desktop.Server do
def init(_opts) do def init(_opts) do
{:ok, bandit_pid} = {:ok, bandit_pid} =
Bandit.start_link( Bandit.start_link(
plug: BDS.Desktop.Router, plug: BDS.Desktop.Endpoint,
scheme: :http, scheme: :http,
ip: {127, 0, 0, 1}, ip: {127, 0, 0, 1},
port: port(), port: port(),

View File

@@ -1,249 +0,0 @@
defmodule BDS.Desktop.ShellController do
@moduledoc false
alias BDS.Media
alias BDS.Media.Media, as: MediaRecord
alias BDS.Projects
alias BDS.Repo
alias BDS.UI.Sidebar
def index_html do
BDS.UI.ShellPage.render()
end
def task_status_json do
Jason.encode!(BDS.Tasks.status_snapshot())
end
def projects_json do
Jason.encode!(BDS.Projects.shell_snapshot())
rescue
error in [Exqlite.Error] ->
if String.contains?(Exception.message(error), "no such table: projects") do
Jason.encode!(default_project_snapshot())
else
reraise error, __STACKTRACE__
end
end
def upsert_project_json(payload) when is_map(payload) do
case normalize_project_request(payload) do
{:create, attrs} -> create_project_json(attrs)
{:select, project_id} -> select_project_json(project_id)
:error -> Jason.encode!(%{status: "error", error: %{message: "Missing project name or project_id"}})
end
end
def choose_project_folder_json(payload \\ %{}) when is_map(payload) do
prompt = Map.get(payload, "prompt") || Map.get(payload, :prompt) || "Select existing blog folder"
case folder_picker().choose_directory(prompt) do
{:ok, path} -> Jason.encode!(project_folder_payload(path))
:cancel -> Jason.encode!(%{status: "cancel"})
{:error, error} -> Jason.encode!(%{status: "error", error: normalize_error(error)})
end
end
def command_json(payload) when is_map(payload) do
action = Map.get(payload, "action") || Map.get(payload, :action)
params = Map.get(payload, "params") || Map.get(payload, :params) || %{}
case BDS.Desktop.ShellCommands.execute(action, params) do
{:ok, result} -> Jason.encode!(%{status: "ok", result: result})
{:error, error} -> Jason.encode!(%{status: "error", error: normalize_error(error)})
end
end
def media_thumbnail(conn, media_id, params \\ %{}) when is_binary(media_id) do
case active_media_thumbnail(media_id, Map.get(params, "size") || Map.get(params, :size)) do
{:ok, content_type, path} ->
conn
|> Plug.Conn.put_resp_content_type(content_type)
|> Plug.Conn.send_file(200, path)
:error ->
Plug.Conn.send_resp(conn, 404, "not found")
end
end
def sidebar_json(payload) when is_map(payload) do
view = Map.get(payload, "view") || Map.get(payload, :view)
filters = Map.get(payload, "filters") || Map.get(payload, :filters) || %{}
data =
try do
case active_project_id() do
nil -> Sidebar.view(nil, view, filters)
project_id -> Sidebar.view(project_id, view, filters)
end
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
Sidebar.view(nil, view, filters)
end
Jason.encode!(%{status: "ok", view: view, data: data})
end
defp normalize_error(error) when is_map(error), do: error
defp normalize_error(error), do: %{message: inspect(error)}
defp normalize_project_request(payload) do
cond do
present?(Map.get(payload, "name") || Map.get(payload, :name)) ->
{:create,
%{
name: String.trim(Map.get(payload, "name") || Map.get(payload, :name)),
description: blank_to_nil(Map.get(payload, "description") || Map.get(payload, :description)),
data_path: blank_to_nil(Map.get(payload, "data_path") || Map.get(payload, :data_path))
}}
present?(Map.get(payload, "project_id") || Map.get(payload, :project_id)) ->
{:select, Map.get(payload, "project_id") || Map.get(payload, :project_id)}
true ->
:error
end
end
defp create_project_json(attrs) do
with {:ok, project} <- BDS.Projects.create_project(attrs),
{:ok, active_project} <- BDS.Projects.set_active_project(project.id) do
Jason.encode!(%{status: "ok", project: project_response(active_project), active_project_id: active_project.id})
else
{:error, error} -> Jason.encode!(%{status: "error", error: normalize_error(error)})
end
end
defp select_project_json(project_id) do
case BDS.Projects.set_active_project(project_id) do
{:ok, project} ->
Jason.encode!(%{status: "ok", project: project_response(project), active_project_id: project.id})
{:error, :not_found} ->
Jason.encode!(%{status: "error", error: %{message: "Project not found"}})
{:error, error} ->
Jason.encode!(%{status: "error", error: normalize_error(error)})
end
end
defp project_response(project) do
%{id: project.id, name: project.name, slug: project.slug, data_path: project.data_path, is_active: project.is_active}
end
defp project_folder_payload(path) do
normalized_path = Path.expand(path)
project_metadata = read_project_metadata(normalized_path)
existing_project = find_project_by_data_path(normalized_path)
%{
status: "ok",
path: normalized_path,
name: Map.get(project_metadata, "name") || Path.basename(normalized_path),
description: Map.get(project_metadata, "description"),
existing_project_id: existing_project && existing_project.id
}
end
defp read_project_metadata(path) do
project_json_path = Path.join([path, "meta", "project.json"])
case File.read(project_json_path) do
{:ok, contents} -> Jason.decode!(contents)
{:error, :enoent} -> %{}
end
end
defp find_project_by_data_path(path) do
normalized_path = Path.expand(path)
BDS.Projects.list_projects()
|> Enum.find(fn project ->
case project.data_path do
value when is_binary(value) -> Path.expand(value) == normalized_path
_other -> false
end
end)
end
defp folder_picker do
Application.get_env(:bds, :desktop, [])[:folder_picker] || BDS.Desktop.FolderPicker
end
defp default_project_snapshot do
%{
active_project_id: "default",
projects: [
%{
id: "default",
name: "My Blog",
slug: "my-blog",
data_path: nil,
is_active: true
}
]
}
end
defp active_project_id do
BDS.Projects.shell_snapshot().active_project_id
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
nil
end
defp present?(value) when is_binary(value), do: String.trim(value) != ""
defp present?(_value), do: false
defp active_media_thumbnail(media_id, size) do
with %{} = project <- Projects.get_active_project(),
%MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
true <- media.project_id == project.id,
relative_path when is_binary(relative_path) <- Media.thumbnail_paths(media)[thumbnail_size(size)],
absolute_path = Path.join(Projects.project_data_dir(project), relative_path),
true <- File.exists?(absolute_path) do
{:ok, thumbnail_content_type(relative_path), absolute_path}
else
_other -> :error
end
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
:error
end
defp thumbnail_size(size) do
case to_string(size || "small") do
"medium" -> :medium
"large" -> :large
"ai" -> :ai
_other -> :small
end
end
defp thumbnail_content_type(path) do
case Path.extname(path) do
".jpg" -> "image/jpeg"
".jpeg" -> "image/jpeg"
_other -> "image/webp"
end
end
defp blank_to_nil(value) when is_binary(value) do
trimmed = String.trim(value)
if trimmed == "", do: nil, else: trimmed
end
defp blank_to_nil(_value), do: nil
end

View File

@@ -0,0 +1,246 @@
defmodule BDS.Desktop.ShellData do
@moduledoc false
alias BDS.I18n
alias BDS.Projects
alias BDS.UI.Dashboard
alias BDS.UI.Sidebar
alias BDS.UI.Workbench
def title do
Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server"
end
def ui_language do
I18n.current_ui_locale()
end
def translations do
I18n.get_ui_translations(ui_language())
end
def supported_ui_languages do
Enum.map(I18n.supported_languages(), fn language ->
%{code: language.code, flag: I18n.flag(language.code)}
end)
end
def translate(key, bindings \\ %{}) do
text = Map.get(translations(), to_string(key), to_string(key))
Enum.reduce(bindings, text, fn {binding, value}, acc ->
String.replace(acc, "%{#{binding}}", to_string(value))
end)
end
def project_snapshot do
Projects.shell_snapshot()
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table: projects") do
reraise error, __STACKTRACE__
end
default_project_snapshot()
end
def current_project(projects_snapshot) do
Enum.find(projects_snapshot.projects, &(&1.id == projects_snapshot.active_project_id)) ||
List.first(projects_snapshot.projects)
end
def dashboard(project_id) do
Dashboard.snapshot(project_id)
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
Dashboard.empty_snapshot()
end
def sidebar_view(project_id, view_id) do
Sidebar.view(project_id, view_id, %{})
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
Sidebar.view(nil, view_id, %{})
end
def 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 LiveView state."}
]
end
def editor_meta(task_status) do
[
%{label: "Status", value: task_status.running_task_message || "Idle"},
%{label: "Mode", value: "Offline"},
%{label: "Main Language", value: ui_language()}
]
end
def status_bar(workbench, task_status, dashboard) do
Workbench.status_bar(workbench,
post_count: dashboard.post_stats.total_posts,
media_count: dashboard.media_stats.media_count,
theme_badge: "desktop-shell",
ui_language: ui_language(),
offline_mode: true,
running_task_message: task_status.running_task_message,
running_task_overflow: task_status.running_task_overflow,
active_post_status: nil
)
end
def panel_tabs(workbench) do
[:tasks, :output, :git_log, workbench.panel.active_tab]
|> Enum.uniq()
end
def activity_icon(id) do
case to_string(id) do
"posts" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"></path><path d="M8 12h8v2H8zm0 4h8v2H8z"></path></svg>)
"pages" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h10v4h6v12H4V4zm10 1.5V9h4.5L14 5.5zM7 12h10v1.5H7V12zm0 3h10v1.5H7V15z"></path></svg>)
"media" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path></svg>)
"scripts" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"></path></svg>)
"templates" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7zM5.5 5.5v4h4v-4h-4zm9 0v4h4v-4h-4zm-9 9v4h4v-4h-4zm9 0v4h4v-4h-4z"></path></svg>)
"tags" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"></path></svg>)
"chat" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"></path><circle cx="8" cy="10" r="1.5"></circle><circle cx="12" cy="10" r="1.5"></circle><circle cx="16" cy="10" r="1.5"></circle></svg>)
"import" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path></svg>)
"git" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M22 11.73L12.27 2a1 1 0 0 0-1.41 0L8.84 4.02l2.56 2.56a1.2 1.2 0 0 1 1.52 1.53l2.47 2.47a1.2 1.2 0 1 1-.72.67l-2.3-2.3v6.06a1.2 1.2 0 1 1-.85 0V8.9a1.2 1.2 0 0 1-.66-1.59L8.35 4.8 2 11.16a1 1 0 0 0 0 1.41L11.73 22a1 1 0 0 0 1.41 0L22 13.14a1 1 0 0 0 0-1.41z"></path></svg>)
"settings" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"></path></svg>)
_other -> activity_icon("posts")
end
end
def dashboard_status_label(status) do
case to_string(status) do
"draft" -> translate("dashboard.status.draft")
"published" -> translate("dashboard.status.published")
"archived" -> translate("dashboard.status.archived")
other -> other |> String.replace("_", " ") |> String.capitalize()
end
end
def dashboard_post_count_label(count) do
normalized_count = count || 0
key = if normalized_count == 1, do: "dashboard.postCount.one", else: "dashboard.postCount.other"
translate(key, %{count: normalized_count})
end
def dashboard_tag_cloud_items(items) when is_list(items) do
top_items =
items
|> Enum.sort_by(fn item -> -(item.count || 0) end)
|> Enum.take(40)
counts = Enum.map(top_items, &(&1.count || 0))
max_count = Enum.max([1 | counts])
min_count = Enum.min([max_count | counts])
range = max(max_count - min_count, 1)
top_items
|> Enum.map(fn item ->
font_size = 11 + (((item.count || 0) - min_count) / range) * 11
Map.merge(item, %{font_size: font_size, color: normalize_dashboard_tag_color(item.color)})
end)
|> Enum.sort_by(&String.downcase(to_string(&1.tag || "")))
end
def render_dashboard_tag_style(item) do
declarations = ["font-size: #{Float.round(item.font_size || 11, 1)}px;"]
declarations =
if item.color do
declarations ++ [
"background-color: #{item.color};",
"color: #{dashboard_contrast_color(item.color)};"
]
else
declarations
end
Enum.join(declarations, " ")
end
def format_dashboard_month(year, month) do
{:ok, date} = Date.new(year, month, 1)
Calendar.strftime(date, "%b")
end
def format_dashboard_date(nil), do: ""
def format_dashboard_date(timestamp) do
timestamp
|> DateTime.from_unix!(:millisecond)
|> Calendar.strftime("%x")
end
def route_label(route) do
case to_string(route) do
"git_log" -> "Git Log"
"post_links" -> "Post Links"
other -> other |> String.replace("_", " ") |> String.split() |> Enum.map_join(" ", &String.capitalize/1)
end
end
def format_bytes(bytes) do
normalized_bytes = max(bytes || 0, 0)
cond do
normalized_bytes == 0 ->
"0 B"
true ->
units = ["B", "KB", "MB", "GB"]
unit_index = min(trunc(:math.log(normalized_bytes) / :math.log(1024)), length(units) - 1)
value = normalized_bytes / :math.pow(1024, unit_index)
decimals = if value >= 10 or unit_index == 0, do: 0, else: 1
unit = Enum.at(units, unit_index)
:erlang.float_to_binary(value, decimals: decimals) <> " " <> unit
end
end
defp default_project_snapshot do
%{
active_project_id: "default",
projects: [
%{
id: "default",
name: "My Blog",
slug: "my-blog",
data_path: nil,
is_active: true
}
]
}
end
defp normalize_dashboard_tag_color(nil), do: nil
defp normalize_dashboard_tag_color(""), do: nil
defp normalize_dashboard_tag_color("#" <> rest = color) when byte_size(rest) == 6 do
if String.match?(rest, ~r/\A[0-9a-fA-F]{6}\z/), do: color, else: nil
end
defp normalize_dashboard_tag_color(_color), do: nil
defp dashboard_contrast_color("#" <> rgb) do
<<r::binary-size(2), g::binary-size(2), b::binary-size(2)>> = rgb
{red, _} = Integer.parse(r, 16)
{green, _} = Integer.parse(g, 16)
{blue, _} = Integer.parse(b, 16)
luminance = (red * 299 + green * 587 + blue * 114) / 1000
if luminance > 150, do: "#1e1e1e", else: "#ffffff"
end
defp dashboard_contrast_color(_color), do: "#ffffff"
end

View File

@@ -0,0 +1,630 @@
defmodule BDS.Desktop.ShellLive do
@moduledoc false
use Phoenix.LiveView
import Phoenix.HTML
alias BDS.Desktop.ShellData
alias BDS.UI.Workbench
@refresh_interval 1_500
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
:timer.send_interval(@refresh_interval, :refresh_task_status)
end
workbench = Workbench.new()
{:ok,
socket
|> assign(:page_title, ShellData.title())
|> assign(:page_language, ShellData.ui_language())
|> reload_shell(workbench)}
end
@impl true
def handle_event("toggle_sidebar", _params, socket) do
{:noreply, reload_shell(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
end
def handle_event("toggle_panel", _params, socket) do
{:noreply, reload_shell(socket, Workbench.toggle_panel(socket.assigns.workbench))}
end
def handle_event("toggle_assistant_sidebar", _params, socket) do
{:noreply, reload_shell(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
end
def handle_event("select_view", %{"view" => view_id}, socket) do
workbench = Workbench.click_activity(socket.assigns.workbench, String.to_existing_atom(view_id))
{:noreply, reload_shell(socket, workbench)}
end
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
workbench =
socket.assigns.workbench
|> Workbench.set_panel_visible(true)
|> Workbench.set_panel_tab(String.to_existing_atom(tab))
{:noreply, reload_shell(socket, workbench)}
end
@impl true
def handle_info(:refresh_task_status, socket) do
task_status = BDS.Tasks.status_snapshot()
{:noreply,
socket
|> assign(:task_status, task_status)
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|> assign(:status, ShellData.status_bar(socket.assigns.workbench, task_status, socket.assigns.dashboard))}
end
@impl true
def render(assigns) do
~H"""
<div class="app" id="bds-shell-app">
<div class="window-titlebar" data-region="title-bar">
<div class="window-titlebar-menu-bar is-hidden">
<button class="window-titlebar-menu-button" type="button">File</button>
<button class="window-titlebar-menu-button" type="button">Edit</button>
<button class="window-titlebar-menu-button" type="button">View</button>
<button class="window-titlebar-menu-button" type="button">Blog</button>
<button class="window-titlebar-menu-button" type="button">Help</button>
</div>
<div class="window-titlebar-drag-region"></div>
<div class="window-titlebar-title" data-testid="window-title"><%= @page_title %></div>
<div class="window-titlebar-actions">
<button
class="window-titlebar-action-button"
data-testid="toggle-sidebar"
type="button"
phx-click="toggle_sidebar"
aria-label="Toggle sidebar"
title="Toggle sidebar"
>
<span class={["window-titlebar-sidebar-icon", if(@workbench.sidebar_visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-sidebar-pane"></span>
</span>
</button>
<button
class="window-titlebar-action-button"
data-testid="toggle-panel"
type="button"
phx-click="toggle_panel"
aria-label="Toggle panel"
title="Toggle panel"
>
<span class={["window-titlebar-panel-icon", if(@workbench.panel.visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-panel-pane"></span>
</span>
</button>
<button
class="window-titlebar-action-button"
data-testid="toggle-assistant"
type="button"
phx-click="toggle_assistant_sidebar"
aria-label="Toggle assistant"
title="Toggle assistant"
>
<span class={["window-titlebar-assistant-icon", if(@workbench.assistant_sidebar_visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-assistant-pane"></span>
</span>
</button>
</div>
</div>
<div class="app-main">
<aside class="activity-bar" data-region="activity-bar">
<div class="activity-bar-top">
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :top)) do %>
<button
class={["activity-bar-item", if(button.active, do: "active")]}
data-testid="activity-button"
data-view={button.id}
data-active={to_string(button.active)}
type="button"
phx-click="select_view"
phx-value-view={button.id}
title={activity_label(button.label)}
aria-label={activity_label(button.label)}
>
<%= raw(ShellData.activity_icon(button.id)) %>
</button>
<% end %>
</div>
<div class="activity-bar-bottom">
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :bottom)) do %>
<button
class={["activity-bar-item", if(button.active, do: "active")]}
data-testid="activity-button"
data-view={button.id}
data-active={to_string(button.active)}
type="button"
phx-click="select_view"
phx-value-view={button.id}
title={activity_label(button.label)}
aria-label={activity_label(button.label)}
>
<%= raw(ShellData.activity_icon(button.id)) %>
</button>
<% end %>
</div>
</aside>
<section
class={["sidebar-shell", if(not @workbench.sidebar_visible, do: "is-hidden")]}
data-testid="sidebar-shell"
style={"width: #{@workbench.sidebar_width}px;"}
>
<div class="sidebar" data-region="sidebar">
<div class="sidebar-content sidebar-body">
<div class="sidebar-section">
<div class="sidebar-section-header">
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
</div>
</div>
<%= render_sidebar_body(assigns) %>
</div>
</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">
<div class="editor-empty">
<div class="dashboard-content">
<h1 data-testid="editor-title"><%= translated("dashboard.title") %></h1>
<p class="text-muted"><%= translated("dashboard.subtitle") %></p>
<div class="dashboard-stats">
<div class="stat-card">
<div class="stat-number"><%= @dashboard.post_stats.total_posts || 0 %></div>
<div class="stat-label"><%= translated("dashboard.stats.totalPosts") %></div>
<div class="stat-breakdown">
<span class="stat-tag stat-published"><%= translated("dashboard.stats.published", %{count: @dashboard.post_stats.published_count || 0}) %></span>
<span class="stat-tag stat-draft"><%= translated("dashboard.stats.drafts", %{count: @dashboard.post_stats.draft_count || 0}) %></span>
<%= if (@dashboard.post_stats.archived_count || 0) > 0 do %>
<span class="stat-tag stat-archived"><%= translated("dashboard.stats.archived", %{count: @dashboard.post_stats.archived_count || 0}) %></span>
<% end %>
</div>
</div>
<div class="stat-card">
<div class="stat-number"><%= @dashboard.media_stats.media_count || 0 %></div>
<div class="stat-label"><%= translated("dashboard.stats.mediaFiles") %></div>
<div class="stat-breakdown">
<span class="stat-tag"><%= translated("dashboard.stats.images", %{count: @dashboard.media_stats.image_count || 0}) %></span>
<span class="stat-tag"><%= ShellData.format_bytes(@dashboard.media_stats.total_bytes || 0) %></span>
</div>
</div>
<div class="stat-card">
<div class="stat-number"><%= length(@dashboard.tag_cloud_items || []) %></div>
<div class="stat-label"><%= translated("dashboard.stats.tags") %></div>
<div class="stat-breakdown">
<span class="stat-tag"><%= translated("dashboard.stats.categories", %{count: length(@dashboard.category_counts || [])}) %></span>
</div>
</div>
</div>
<%= if Enum.any?(@dashboard.timeline_entries || []) do %>
<div class="dashboard-section">
<h4><%= translated("dashboard.section.postsOverTime") %></h4>
<div class="timeline-chart">
<%= for entry <- @dashboard.timeline_entries || [] do %>
<div class="timeline-bar-container">
<div class="timeline-bar" style={"height: #{timeline_height(entry, @dashboard.timeline_entries || [])}%"}>
<span class="timeline-bar-count"><%= entry.count || 0 %></span>
</div>
<div class="timeline-bar-label">
<span class="timeline-bar-label-month"><%= ShellData.format_dashboard_month(entry.year, entry.month) %></span>
<span class="timeline-bar-label-year"><%= entry.year %></span>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<%= if Enum.any?(@dashboard_tag_cloud_items) do %>
<div class="dashboard-section">
<h4><%= translated("dashboard.section.tags") %></h4>
<div class="tag-cloud">
<%= for item <- @dashboard_tag_cloud_items do %>
<span class={["dashboard-tag", if(item.color, do: "has-color")]} style={ShellData.render_dashboard_tag_style(item)} title={ShellData.dashboard_post_count_label(item.count)}><%= item.tag %></span>
<% end %>
<%= if length(@dashboard.tag_cloud_items || []) > 40 do %>
<span class="text-muted tag-cloud-more"><%= translated("dashboard.tagCloud.more", %{count: length(@dashboard.tag_cloud_items) - 40}) %></span>
<% end %>
</div>
</div>
<% end %>
<%= if Enum.any?(@dashboard.category_counts || []) do %>
<div class="dashboard-section">
<h4><%= translated("dashboard.section.categories") %></h4>
<div class="tag-cloud">
<%= for category <- @dashboard.category_counts || [] do %>
<span class="dashboard-tag dashboard-category" title={ShellData.dashboard_post_count_label(category.count || 0)}>
<%= category.category || "" %>
<span class="tag-count"><%= category.count || 0 %></span>
</span>
<% end %>
</div>
</div>
<% end %>
<%= if Enum.any?(@dashboard.recent_posts || []) do %>
<div class="dashboard-section">
<h4><%= translated("dashboard.section.recentlyUpdated") %></h4>
<div class="recent-posts-list">
<%= for post <- @dashboard.recent_posts || [] do %>
<button class="recent-post-item" type="button">
<span class="recent-post-title"><%= post.title || "" %></span>
<span class={"recent-post-status status-#{post.status || "draft"}"}><%= ShellData.dashboard_status_label(post.status || "draft") %></span>
<span class="recent-post-date"><%= ShellData.format_dashboard_date(post.updated_at) %></span>
</button>
<% end %>
</div>
</div>
<% end %>
<div class="dashboard-inspector-meta" hidden>
<%= for item <- @editor_meta do %>
<section class="editor-meta-row">
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
<span><%= translated(item.value) %></span>
</section>
<% end %>
</div>
</div>
</div>
</section>
<section class={["panel-shell", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
<div class="panel-header">
<div class="panel-tabs">
<%= for tab <- @panel_tabs do %>
<button
class={["panel-tab", if(@workbench.panel.active_tab == tab, do: "active")]}
type="button"
phx-click="select_panel_tab"
phx-value-tab={tab}
>
<%= panel_tab_label(tab) %>
</button>
<% end %>
</div>
</div>
<div class="panel-content">
<%= render_panel_body(assigns) %>
</div>
</section>
</main>
<section
class={["assistant-sidebar-shell", if(not @workbench.assistant_sidebar_visible, do: "is-hidden")]}
data-testid="assistant-shell"
style={"width: #{@workbench.assistant_sidebar_width}px;"}
>
<div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div>
<aside class="assistant-sidebar" data-region="assistant-sidebar">
<div class="assistant-header">
<strong><%= translated("Assistant") %></strong>
</div>
<div class="assistant-content">
<%= for card <- @assistant_cards do %>
<section class="assistant-card">
<strong><%= translated(card.label) %></strong>
<span><%= translated(card.text) %></span>
</section>
<% end %>
</div>
</aside>
</section>
</div>
<footer class="status-bar" data-region="status-bar">
<div class="status-bar-left">
<div class="project-selector">
<button class="project-selector-trigger" type="button" title={translated("Switch project")}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon">
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"></path>
</svg>
<span class="project-name"><%= @current_project && @current_project.name || "My Blog" %></span>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" class="dropdown-arrow">
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"></path>
</svg>
</button>
</div>
<button class="status-bar-item status-bar-task-button" type="button" phx-click="toggle_panel">
<span><%= @status.left.running_task_message || translated("Idle") %></span>
<%= if (@status.left.running_task_overflow || 0) > 0 do %>
<span class="status-bar-count">+<%= @status.left.running_task_overflow %></span>
<% end %>
</button>
</div>
<div class="status-bar-right">
<span class="status-bar-item"><%= @status.right.post_count %></span>
<span class="status-bar-item"><%= @status.right.media_count %></span>
<span class="status-bar-item theme-badge"><%= @status.right.theme_badge %></span>
<button class={["status-bar-item", "offline-badge", if(@status.right.offline_mode, do: "active")]} type="button" title={translated("Toggle offline mode")}>✈</button>
<label class="status-bar-item language-badge">
<span><%= translated("UI") %></span>
<select class="status-bar-language-select">
<%= for language <- @supported_ui_languages do %>
<option selected={language.code == @page_language} value={language.code}><%= language.flag %></option>
<% end %>
</select>
</label>
<span class="status-bar-item brand"><%= @status.right.brand %></span>
</div>
</footer>
</div>
"""
end
defp reload_shell(socket, workbench) do
projects = ShellData.project_snapshot()
dashboard = ShellData.dashboard(projects.active_project_id)
sidebar_data = ShellData.sidebar_view(projects.active_project_id, Atom.to_string(workbench.active_view))
task_status = BDS.Tasks.status_snapshot()
activity_buttons = Workbench.activity_buttons(workbench, 0)
socket
|> assign(:workbench, workbench)
|> assign(:projects, projects)
|> assign(:current_project, ShellData.current_project(projects))
|> assign(:dashboard, dashboard)
|> assign(:dashboard_tag_cloud_items, ShellData.dashboard_tag_cloud_items(dashboard.tag_cloud_items || []))
|> assign(:sidebar_data, sidebar_data)
|> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data))
|> assign(:assistant_cards, ShellData.assistant_cards())
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|> assign(:task_status, task_status)
|> assign(:status, ShellData.status_bar(workbench, task_status, dashboard))
|> assign(:activity_buttons, activity_buttons)
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
end
defp render_sidebar_body(assigns) do
case assigns.sidebar_data.layout do
"post_list" -> render_post_sidebar(assigns)
"media_grid" -> render_media_sidebar(assigns)
"entity_list" -> render_entity_sidebar(assigns)
"nav_list" -> render_nav_sidebar(assigns)
_other -> render_default_sidebar(assigns)
end
end
defp render_post_sidebar(assigns) do
~H"""
<%= for section <- @sidebar_data.sections || [] do %>
<section class="sidebar-section">
<div class="sidebar-section-title">
<span class={"section-icon status-#{section.status || "draft"}"}>●</span>
<span data-testid="sidebar-section-title"><%= translated(section.title) %></span>
<span class="sidebar-section-count"><%= section.count || length(section.items || []) %></span>
</div>
<div class="sidebar-list">
<%= for item <- section.items || [] do %>
<button class="sidebar-item sidebar-post-item post-type-post" type="button">
<span class="post-type-icon" title="post">●</span>
<span class="sidebar-item-content">
<span class="sidebar-item-title-row">
<span class="sidebar-item-title"><%= item.title || "" %></span>
</span>
<span class="sidebar-item-meta"><%= format_sidebar_timestamp(item.meta_timestamp) %></span>
</span>
</button>
<% end %>
</div>
</section>
<% end %>
<%= if Enum.empty?(@sidebar_data.sections || []) do %>
<div class="sidebar-empty">
<p><%= translated(@sidebar_data.empty_message || "No items") %></p>
</div>
<% end %>
"""
end
defp render_media_sidebar(assigns) do
~H"""
<%= if Enum.any?(@sidebar_data.items || []) do %>
<div class="sidebar-list media-grid">
<%= for item <- @sidebar_data.items || [] do %>
<button class="media-item" type="button" title={item.title || ""}>
<span class={media_thumbnail_class(item)}>
<%= if image_media?(item) do %>
<span class="media-thumbnail-fallback"><%= media_thumbnail_glyph(item.mime_type) %></span>
<img class="media-thumbnail-image" src={"/media-thumbnail/#{item.id}"} alt="" loading="lazy" decoding="async" />
<% else %>
<span class="media-thumbnail-fallback"><%= media_thumbnail_glyph(item.mime_type) %></span>
<% end %>
</span>
<span class="media-item-info">
<span class="media-item-name"><%= item.title || "" %></span>
<span class="media-item-size"><%= item.meta || "" %></span>
</span>
</button>
<% end %>
</div>
<% else %>
<div class="sidebar-empty">
<p><%= translated(@sidebar_data.empty_message || "No items") %></p>
</div>
<% end %>
"""
end
defp render_entity_sidebar(assigns) do
~H"""
<%= if Enum.any?(@sidebar_data.items || []) do %>
<div class="settings-nav-list">
<%= for item <- @sidebar_data.items || [] do %>
<button class="chat-list-item" type="button">
<span class="chat-item-content">
<span class="chat-item-title"><%= item.title || "" %></span>
<span class="chat-item-date"><%= translated(item.meta || "") %></span>
</span>
</button>
<% end %>
</div>
<% else %>
<div class="sidebar-empty">
<p><%= translated(@sidebar_data.empty_message || "No items") %></p>
</div>
<% end %>
"""
end
defp render_nav_sidebar(assigns) do
~H"""
<div class="settings-nav-list">
<%= for item <- @sidebar_data.items || [] do %>
<button class="settings-nav-entry" type="button">
<span class="settings-nav-entry-icon"><%= item.icon || "" %></span>
<span><%= translated(item.title || "") %></span>
</button>
<% end %>
</div>
"""
end
defp render_default_sidebar(assigns) do
~H"""
<%= for section <- @sidebar_data.sections || [] do %>
<section class="sidebar-section">
<div class="sidebar-section-header">
<span data-testid="sidebar-section-title"><%= translated(section.title) %></span>
</div>
<div class="sidebar-section-items">
<%= for item <- section.items || [] do %>
<div class="sidebar-list-item"><%= item.title || "" %></div>
<% end %>
</div>
</section>
<% end %>
"""
end
defp render_panel_body(assigns) do
case assigns.workbench.panel.active_tab do
:tasks -> render_task_entries(assigns)
:output -> render_output_entries(assigns)
:git_log -> render_git_log(assigns)
other -> render_generic_panel(assigns, other)
end
end
defp render_task_entries(assigns) do
~H"""
<%= if Enum.empty?(@task_status.tasks || []) do %>
<div class="panel-entry panel-empty-state">
<strong><%= translated("Tasks") %></strong>
<span><%= translated("No background tasks running") %></span>
</div>
<% else %>
<div class="task-list">
<%= for task <- @task_status.tasks || [] do %>
<div class="panel-entry task-entry">
<div class="task-entry-header">
<strong><%= task.name %></strong>
<span class={"task-status task-status-#{task.status}"}><%= task.status |> to_string() |> String.capitalize() %></span>
</div>
<span><%= task.message || task.group_name || "" %></span>
</div>
<% end %>
</div>
<% end %>
"""
end
defp render_output_entries(assigns) do
~H"""
<div class="panel-entry panel-empty-state output-list">
<strong><%= translated("Output") %></strong>
<span><%= translated("No shell output yet") %></span>
</div>
"""
end
defp render_git_log(assigns) do
~H"""
<div class="git-log-list">
<div class="panel-entry">
<strong><%= translated("Git Log") %></strong>
<span><%= translated("Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.") %></span>
</div>
</div>
"""
end
defp render_generic_panel(assigns, tab) do
assigns = assign(assigns, :panel_label, ShellData.route_label(tab))
~H"""
<div class="panel-entry">
<strong><%= @panel_label %></strong>
<span><%= translated("The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.") %></span>
</div>
"""
end
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings)
defp panel_tab_label(:tasks), do: translated("Tasks")
defp panel_tab_label(:output), do: translated("Output")
defp panel_tab_label(:git_log), do: translated("Git Log")
defp panel_tab_label(tab), do: ShellData.route_label(tab)
defp activity_label("AI Assistant"), do: "Chat"
defp activity_label("Source Control"), do: "Git"
defp activity_label(label), do: translated(label)
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
Enum.find_value(activity_buttons, translated(sidebar_data.title || ""), fn button ->
if button.id == active_view, do: activity_label(button.label), else: nil
end)
end
defp sidebar_header_label(label), do: translated(label)
defp timeline_height(entry, entries) do
max_count =
entries
|> Enum.map(&(&1.count || 0))
|> Enum.max(fn -> 1 end)
max(4, ((entry.count || 0) / max_count) * 100)
end
defp format_sidebar_timestamp(nil), do: ""
defp format_sidebar_timestamp(timestamp) do
timestamp
|> DateTime.from_unix!(:millisecond)
|> Calendar.strftime("%x")
end
defp image_media?(item), do: String.starts_with?(to_string(item.mime_type || ""), "image/")
defp media_thumbnail_class(item) do
if image_media?(item), do: "media-thumbnail has-image", else: "media-thumbnail"
end
defp media_thumbnail_glyph(mime_type) do
case String.split(to_string(mime_type || ""), "/", parts: 2) do
["image", _rest] -> "IMG"
["video", _rest] -> "VID"
["audio", _rest] -> "AUD"
["application", _rest] -> "DOC"
_other -> "FILE"
end
end
end

View File

@@ -1,219 +0,0 @@
defmodule BDS.UI.ShellPage do
@moduledoc false
alias BDS.I18n
alias BDS.Projects
alias BDS.UI.Dashboard
alias BDS.UI.MenuBar
alias BDS.UI.Registry
alias BDS.UI.Sidebar
alias BDS.UI.Session
alias BDS.UI.Workbench
def render do
bootstrap = bootstrap()
ui_language = get_in(bootstrap, [:i18n, :ui_language]) || "en"
[
"<!DOCTYPE html>",
"<html lang=\"#{ui_language}\">",
"<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=\"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=\"/assets/app.js\"></script>",
"</body>",
"</html>"
]
|> Enum.join("\n")
end
defp bootstrap do
workbench = Workbench.new()
task_status = BDS.Tasks.status_snapshot()
ui_language = I18n.current_ui_locale()
projects = project_snapshot()
dashboard = dashboard_content(projects.active_project_id)
%{
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
i18n: %{
ui_language: ui_language,
catalogs:
Enum.into(I18n.supported_languages(), %{}, fn language ->
{language.code, I18n.get_ui_translations(language.code)}
end),
supported_ui_languages:
Enum.map(I18n.supported_languages(), fn language ->
%{
code: language.code,
flag: I18n.flag(language.code)
}
end)
},
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),
projects: projects,
session: Session.serialize(workbench),
task_status: task_status,
content: %{
sidebar: sidebar_content(projects.active_project_id),
dashboard: dashboard,
assistant_cards: assistant_cards(),
editor_meta: editor_meta(task_status)
},
status:
Workbench.status_bar(workbench,
post_count: dashboard.post_stats.total_posts,
media_count: dashboard.media_stats.media_count,
theme_badge: "desktop-shell",
ui_language: ui_language,
offline_mode: true,
running_task_message: task_status.running_task_message,
running_task_overflow: task_status.running_task_overflow,
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 project_snapshot do
Projects.shell_snapshot()
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table: projects") do
reraise error, __STACKTRACE__
end
default_project_snapshot()
end
defp default_project_snapshot do
%{
active_project_id: "default",
projects: [
%{
id: "default",
name: "My Blog",
slug: "my-blog",
data_path: nil,
is_active: true
}
]
}
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:
group.items
|> Enum.reject(&Map.get(&1, :separator, false))
|> Enum.map(fn item ->
%{id: Atom.to_string(item.id), label: humanize(item.id)}
end)
}
end
defp sidebar_content(project_id) do
Sidebar.snapshot(project_id)
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
Sidebar.empty_snapshot()
end
defp dashboard_content(project_id) do
Dashboard.snapshot(project_id)
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
Dashboard.empty_snapshot()
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(task_status) do
%{
dashboard: [
%{label: "Status", value: task_status.running_task_message || "Idle"},
%{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

View File

@@ -35,6 +35,7 @@ defmodule BDS.MixProject do
{:desktop, "~> 1.5"}, {:desktop, "~> 1.5"},
{:image, "~> 0.65"}, {:image, "~> 0.65"},
{:stemex, "~> 0.2.1"}, {:stemex, "~> 0.2.1"},
{:lazy_html, ">= 0.1.0", only: :test},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
] ]
end end

View File

@@ -20,6 +20,7 @@
"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"}, "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"},
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "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"}, "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"},
@@ -27,6 +28,7 @@
"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"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"}, "kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"},
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"}, "liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"},
"luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"}, "luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},

View File

@@ -1039,6 +1039,701 @@ button {
padding: 14px 14px 0; padding: 14px 14px 0;
} }
.overlay-root {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10000;
}
.overlay-root:empty {
display: none;
}
.editor-shared-actions {
position: relative;
margin-bottom: 14px;
}
.ai-suggestions-modal-backdrop,
.insert-modal-backdrop,
.language-picker-modal-backdrop,
.confirm-delete-modal-backdrop,
.confirm-dialog-overlay,
.gallery-overlay,
.lightbox-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.68);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
}
.ai-suggestions-modal,
.insert-modal,
.language-picker-modal,
.confirm-delete-modal,
.confirm-dialog,
.gallery-overlay-content {
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.ai-suggestions-modal,
.language-picker-modal,
.confirm-delete-modal,
.confirm-dialog {
width: min(680px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
}
.insert-modal {
width: min(680px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.gallery-overlay-content {
width: min(980px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.ai-suggestions-modal-header,
.language-picker-modal-header,
.confirm-delete-modal-header,
.insert-modal-header,
.gallery-overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #3c3c3c;
}
.insert-modal-header {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.insert-modal-header.media-header-only {
flex-direction: row;
align-items: center;
}
.ai-suggestions-modal-header h2,
.language-picker-modal-header h2,
.confirm-delete-modal-header h2,
.gallery-overlay-header h2,
.insert-modal-title,
.confirm-dialog h3 {
margin: 0;
color: #ffffff;
}
.ai-suggestions-modal-close,
.confirm-delete-modal-close,
.gallery-overlay-close,
.shared-popover-close,
.lightbox-close {
border: none;
background: transparent;
color: #c5c5c5;
cursor: pointer;
font-size: 20px;
line-height: 1;
}
.ai-suggestions-modal-body,
.language-picker-modal-body,
.confirm-delete-modal-body {
padding: 20px;
overflow: auto;
}
.ai-suggestions-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-suggestion-item {
display: flex;
gap: 12px;
padding: 16px;
border: 1px solid #3c3c3c;
border-radius: 6px;
background: #252526;
}
.ai-suggestion-checkbox {
position: relative;
display: flex;
align-items: flex-start;
cursor: pointer;
}
.ai-suggestion-checkbox input {
position: absolute;
opacity: 0;
}
.checkmark {
width: 20px;
height: 20px;
border: 2px solid #555555;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #1e1e1e;
}
.ai-suggestion-checkbox input:checked + .checkmark,
.ai-suggestion-checkbox input:checked ~ .checkmark {
background: #0078d4;
border-color: #0078d4;
}
.ai-suggestion-checkbox input:checked + .checkmark::after,
.ai-suggestion-checkbox input:checked ~ .checkmark::after {
content: "✓";
color: #ffffff;
font-size: 12px;
}
.ai-suggestion-content {
flex: 1;
min-width: 0;
}
.ai-suggestion-label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-weight: 600;
}
.ai-suggestion-has-value,
.language-picker-badge,
.insert-modal-similarity-badge {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #c5c5c5;
font-size: 11px;
}
.ai-suggestion-comparison {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.ai-suggestion-column {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
}
.ai-suggestion-column.muted {
color: #9d9d9d;
}
.ai-suggestion-column.highlighted {
border: 1px solid rgba(0, 122, 204, 0.4);
color: #ffffff;
}
.ai-suggestion-column-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ai-suggestion-arrow {
color: #9d9d9d;
}
.ai-suggestions-modal-footer,
.confirm-delete-modal-footer,
.confirm-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid #3c3c3c;
}
.button-cancel,
.button-delete,
.button-apply,
.confirm-dialog-actions button,
.insert-modal-submit,
.language-picker-row,
.shared-popover-entry,
.colour-swatch {
cursor: pointer;
}
.button-cancel,
.confirm-dialog-actions button,
.insert-modal-submit {
border: 1px solid #4c4c4c;
border-radius: 4px;
padding: 8px 14px;
background: transparent;
color: #f0f0f0;
}
.button-apply,
.confirm-dialog-actions .primary,
.insert-modal-submit {
background: #0e639c;
border-color: #0e639c;
}
.button-delete {
border: none;
border-radius: 4px;
padding: 8px 14px;
background: #c73c3c;
color: #ffffff;
}
.insert-modal-tabs {
display: flex;
margin: 0 -20px;
}
.insert-modal-tab {
flex: 1;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: #9d9d9d;
padding: 10px 16px;
}
.insert-modal-tab.active {
color: #ffffff;
border-bottom-color: #0e639c;
background: #252526;
}
.insert-modal-search {
border-bottom: 1px solid #3c3c3c;
}
.insert-modal-input,
.shared-popover-input {
width: 100%;
border: none;
background: transparent;
color: #f0f0f0;
padding: 14px 20px;
font: inherit;
}
.insert-modal-results,
.insert-media-grid,
.shared-popover-list,
.language-picker-list {
overflow: auto;
}
.insert-modal-results {
padding: 8px;
}
.insert-modal-result-item,
.insert-modal-result-create,
.language-picker-row,
.shared-popover-entry {
width: 100%;
border: none;
border-radius: 4px;
padding: 12px 16px;
background: transparent;
color: inherit;
text-align: left;
}
.insert-modal-result-item:hover,
.insert-modal-result-create:hover,
.language-picker-row:hover,
.shared-popover-entry:hover,
.insert-media-card:hover,
.gallery-overlay-item:hover,
.colour-swatch:hover {
background: #2a2a2a;
}
.insert-modal-result-title,
.insert-media-card-title {
font-weight: 600;
color: #ffffff;
}
.insert-modal-result-meta,
.insert-media-card-meta,
.warning-note,
.shared-popover-footnote,
.language-picker-source {
color: #9d9d9d;
font-size: 12px;
}
.insert-modal-external {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px 20px;
}
.insert-modal-field,
.shared-popover-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.insert-modal-label,
.shared-popover-field span {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9d9d9d;
}
.insert-modal-footer {
border-top: 1px solid #3c3c3c;
padding: 12px 16px;
}
.insert-modal-footer-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.insert-modal-hint {
font-size: 11px;
color: #9d9d9d;
}
.insert-media-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
padding: 16px;
}
.insert-media-card {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid #3c3c3c;
border-radius: 8px;
background: #252526;
color: inherit;
padding: 10px;
text-align: left;
}
.insert-media-thumb {
display: flex;
align-items: center;
justify-content: center;
min-height: 112px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
}
.insert-media-thumb img,
.gallery-overlay-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.insert-media-file-pill {
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
font-size: 11px;
letter-spacing: 0.04em;
}
.language-picker-row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: center;
}
.language-picker-badge.published {
background: rgba(34, 197, 94, 0.2);
color: #9ae6b4;
}
.language-picker-badge.draft,
.language-picker-badge.empty {
background: rgba(14, 99, 156, 0.24);
color: #8ec5ff;
}
.confirm-delete-warning {
display: flex;
gap: 12px;
padding: 12px;
border-radius: 6px;
background: rgba(255, 165, 0, 0.08);
border: 1px solid rgba(255, 165, 0, 0.3);
}
.warning-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 165, 0, 0.18);
color: #ffbf47;
font-weight: 700;
}
.reference-list {
margin: 12px 0 0;
padding-left: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.reference-list li {
padding: 8px 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.03);
}
.confirm-dialog-overlay {
align-items: center;
}
.confirm-dialog {
width: min(460px, calc(100vw - 32px));
padding-top: 20px;
}
.confirm-dialog p {
margin: 0;
padding: 0 20px 12px;
color: #d0d0d0;
}
.gallery-overlay-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
padding: 18px;
overflow: auto;
}
.gallery-overlay-item {
border: none;
padding: 0;
border-radius: 8px;
overflow: hidden;
min-height: 148px;
background: #252526;
}
.lightbox-overlay {
background: rgba(0, 0, 0, 0.9);
}
.lightbox-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.lightbox-image-container {
max-width: 90%;
max-height: 78%;
}
.lightbox-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 48px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
font-size: 28px;
}
.lightbox-prev {
left: 16px;
}
.lightbox-next {
right: 16px;
}
.lightbox-close {
position: absolute;
top: 16px;
right: 16px;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
}
.lightbox-footer {
position: absolute;
bottom: 32px;
left: 50%;
transform: translateX(-50%);
text-align: center;
color: #ffffff;
}
.lightbox-caption {
margin: 0 0 6px;
}
.shared-popover-shell {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 20;
}
.shared-popover {
width: min(320px, calc(100vw - 48px));
border: 1px solid #3c3c3c;
border-radius: 8px;
background: #1e1e1e;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.shared-popover-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid #3c3c3c;
}
.shared-popover-list {
display: flex;
flex-direction: column;
max-height: 220px;
}
.shared-popover-entry {
padding: 10px 14px;
}
.shared-popover-empty,
.shared-popover-footnote,
.colour-picker-selection {
display: block;
padding: 10px 14px;
}
.colour-picker-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
padding: 12px 14px;
}
.colour-swatch {
width: 100%;
aspect-ratio: 1;
border-radius: 6px;
border: 2px solid transparent;
}
.colour-swatch.selected {
border-color: #ffffff;
}
.colour-picker-selection {
display: flex;
align-items: center;
gap: 10px;
color: #d0d0d0;
}
.colour-preview {
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.18);
}
@media (max-width: 720px) {
.insert-media-grid,
.gallery-overlay-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ai-suggestion-comparison {
grid-template-columns: 1fr;
}
.ai-suggestion-arrow {
display: none;
}
}
.sidebar-section-header { .sidebar-section-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

File diff suppressed because it is too large Load Diff

View File

@@ -1,138 +0,0 @@
<!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="./app.css" />
</head>
<body>
<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">
{
"title": "Blogging Desktop Server",
"registry": {
"default_sidebar_view": "posts",
"sidebar_views": [
{ "id": "posts", "label": "Posts", "activity_group": "top", "editor_route": "post", "entity_tab": true, "singleton": false },
{ "id": "media", "label": "Media", "activity_group": "top", "editor_route": "media", "entity_tab": true, "singleton": false },
{ "id": "settings", "label": "Settings", "activity_group": "bottom", "editor_route": "settings", "entity_tab": false, "singleton": true }
],
"editor_routes": [
{ "id": "dashboard", "title": "Dashboard", "singleton": true, "entity_tab": false },
{ "id": "post", "title": "Post", "singleton": false, "entity_tab": true },
{ "id": "media", "title": "Media", "singleton": false, "entity_tab": true },
{ "id": "settings", "title": "Settings", "singleton": true, "entity_tab": false }
]
},
"menu_groups": [
{ "id": "app", "label": "App", "items": [{ "id": "about", "label": "About" }] },
{ "id": "view", "label": "View", "items": [{ "id": "toggle_sidebar", "label": "Toggle Sidebar" }] }
],
"session": {
"sidebar_visible": true,
"sidebar_width": 280,
"active_view": "posts",
"layout": "post_list",
"sections": [
"assistant_sidebar_width": 360,
"status": "draft",
"count": 1,
"panel": { "visible": false, "active_tab": "tasks" },
"tabs": [],
{ "id": "post-welcome", "title": "Welcome to bDS2", "meta_timestamp": 1774972800000, "language_count": 1, "categories": ["note"], "route": "post" }
"dirty_tabs": []
},
"content": {
"sidebar": {
"posts": {
"title": "Posts",
"subtitle": "Drafts and publishing",
"layout": "post_list",
"sections": []
},
"media": {
"title": "Media",
"subtitle": "Images and files",
"layout": "media_grid",
"items": [
{ "id": "media-hero", "title": "hero.jpg", "meta": "1.2 MB", "mime_type": "image/jpeg", "route": "media" }
]
},
"scripts": {
"title": "Scripts",
"subtitle": "Automation helpers",
"layout": "entity_list",
"items": [
{ "id": "script-sync", "title": "Sync tags", "updated_at": 1774800000000, "route": "scripts" }
]
},
"templates": {
"title": "Templates",
"subtitle": "Site rendering",
"layout": "entity_list",
"items": [
{ "id": "template-post", "title": "post.liquid", "updated_at": 1774713600000, "route": "templates" }
]
},
"tags": {
"title": "Tags",
"subtitle": "Tag management",
"layout": "nav_list",
"items": [
{ "id": "tags-cloud", "title": "Tag Cloud", "icon": "☁️", "route": "tags" },
{ "id": "tags-manage", "title": "Create / Edit", "icon": "✏️", "route": "tags" },
{ "id": "tags-merge", "title": "Merge Tags", "icon": "🔀", "route": "tags" }
]
},
"chat": {
"title": "Chat",
"subtitle": "AI conversations",
"layout": "entity_list",
"items": [
{ "id": "chat-planning", "title": "Planning session", "updated_at": 1774886400000, "route": "chat" }
]
},
"import": {
"title": "Import",
"subtitle": "Import definitions",
"layout": "entity_list",
"items": []
},
"git": {
"title": "Git",
"subtitle": "Working tree and history",
"layout": "entity_list",
"items": [
{ "id": "git-working-tree", "title": "Working tree", "meta": "Working tree and history", "route": "git_diff" }
]
},
"settings": {
"title": "Settings",
"subtitle": "Project preferences",
"layout": "nav_list",
"items": [
{ "id": "settings-project", "title": "Project", "icon": "📁", "route": "settings" },
{ "id": "settings-style", "title": "Style", "icon": "🎨", "route": "style" }
]
},

12
priv/ui/live.js Normal file
View File

@@ -0,0 +1,12 @@
document.addEventListener("DOMContentLoaded", () => {
const csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute("content");
const liveSocket = new LiveView.LiveSocket("/live", Phoenix.Socket, {
params: { _csrf_token: csrfToken }
});
liveSocket.connect();
window.liveSocket = liveSocket;
});

View File

@@ -0,0 +1,42 @@
defmodule BDS.Desktop.ShellLiveTest do
use ExUnit.Case, async: false
import Phoenix.ConnTest
import Phoenix.LiveViewTest
@endpoint BDS.Desktop.Endpoint
test "shell live owns pane visibility and activity selection on the server" do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-testid="sidebar-shell")
assert html =~ ~s(class="panel-shell is-hidden")
assert html =~ ~s(data-testid="activity-button")
assert html =~ ~s(data-view="posts")
assert html =~ ~s(data-view="media")
assert html =~ ~s(aria-label="Posts")
html =
view
|> element("[data-testid='toggle-sidebar']")
|> render_click()
assert html =~ ~s(class="sidebar-shell is-hidden")
html =
view
|> element("[data-testid='toggle-panel']")
|> render_click()
assert html =~ ~s(data-region="panel")
refute html =~ ~s(class="panel-shell is-hidden")
html =
view
|> element("[data-testid='activity-button'][data-view='media']")
|> render_click()
assert html =~ ~s(aria-label="Media")
assert html =~ ~s(data-view="media")
end
end

View File

@@ -3,10 +3,6 @@ defmodule BDS.DesktopTest do
import Plug.Test import Plug.Test
defmodule TestFolderPicker do
def choose_directory(_prompt), do: Process.get(:test_folder_picker_response, :cancel)
end
test "desktop configuration no longer uses a pending adapter" do test "desktop configuration no longer uses a pending adapter" do
assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop
end end
@@ -99,220 +95,39 @@ defmodule BDS.DesktopTest do
assert menu_item(groups, :metadata_diff).shortcut == nil assert menu_item(groups, :metadata_diff).shortcut == nil
end end
test "desktop shell html follows the old app frame regions and references bundled assets" do test "desktop root html is a LiveView shell and references only the live bootstrap 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 = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([])) conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
assert conn.status == 200 assert conn.status == 200
assert conn.resp_body =~ ~s(class="app") assert conn.resp_body =~ ~s(class="app")
assert conn.resp_body =~ ~s(class="window-titlebar")
assert conn.resp_body =~ ~s(class="activity-bar")
assert conn.resp_body =~ ~s(class="sidebar")
assert conn.resp_body =~ ~s(class="status-bar")
assert conn.resp_body =~ ~s(data-phx-main)
assert conn.resp_body =~ ~s(src="/assets/live.js")
assert conn.resp_body =~ ~s(href="/assets/app.css")
refute conn.resp_body =~ ~s(src="/assets/app.js")
end end
test "desktop router exposes live task status for shell polling" do test "desktop endpoint serves the live shell without extra router-side secret injection" do
:ok = BDS.Tasks.clear_finished() conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
assert {:ok, task} =
BDS.Tasks.register_external_task("preview build", %{
group_id: "generation",
group_name: "Generation"
})
on_exit(fn ->
_ = BDS.Tasks.complete_task(task.id)
_ = BDS.Tasks.clear_finished()
end)
assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "halfway")
conn = conn(:get, "/api/tasks?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
assert conn.status == 200 assert conn.status == 200
assert Plug.Conn.get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"] assert conn.resp_body =~ ~s(data-phx-main)
payload = Jason.decode!(conn.resp_body)
assert payload["active_count"] >= 1
assert payload["running_task_message"] == "preview build: halfway"
assert Enum.any?(payload["tasks"], fn item ->
item["id"] == task.id and item["group_name"] == "Generation" and item["progress"] == 0.5
end)
end end
test "desktop router encodes failed task snapshots even when the task error is a tuple" do test "desktop endpoint exposes a simple health route" do
:ok = BDS.Tasks.clear_finished() conn = conn(:get, "/health?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
assert {:ok, task} =
BDS.Tasks.submit_task(
"broken rebuild",
fn _report ->
{:error, {{:badkey, "slug"}, [{BDS.Posts, :upsert_post_from_file, 3, [line: 644]}]}}
end,
%{group_id: "maintenance", group_name: "Maintenance"}
)
on_exit(fn ->
_ = BDS.Tasks.clear_finished()
end)
failed = wait_for_task(task.id, &(&1.status == :failed and &1.error != nil))
conn = conn(:get, "/api/tasks?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
assert conn.status == 200 assert conn.status == 200
payload = Jason.decode!(conn.resp_body) assert conn.resp_body == "ok"
assert Enum.any?(payload["tasks"], fn item ->
item["id"] == failed.id and item["status"] == "failed" and is_binary(item["error"])
end)
assert Enum.any?(payload["tasks"], fn item ->
item["id"] == failed.id and String.contains?(item["error"], "badkey")
end)
end end
test "desktop router exposes projects for shell project selection and creation" do test "desktop endpoint serves active-project media thumbnails for the live sidebar" do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
BDS.Repo.delete_all(BDS.Projects.Project)
internal_projects_root = "/Users/gb/Projects/bDS2/priv/data/projects"
before_internal_dirs =
case File.ls(internal_projects_root) do
{:ok, entries} -> MapSet.new(entries)
{:error, :enoent} -> MapSet.new()
end
temp_dir = Path.join(System.tmp_dir!(), "bds-desktop-projects-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn ->
File.rm_rf(temp_dir)
end)
{:ok, project} = BDS.Projects.create_project(%{name: "Desktop Projects", data_path: temp_dir})
{:ok, _active} = BDS.Projects.set_active_project(project.id)
conn = conn(:get, "/api/projects?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
assert conn.status == 200
payload = Jason.decode!(conn.resp_body)
assert payload["active_project_id"] == project.id
assert Enum.any?(payload["projects"], &(&1["id"] == project.id and &1["name"] == "Desktop Projects"))
assert Enum.any?(payload["projects"], &(&1["id"] == "default" and &1["name"] == "My Blog"))
created_data_dir = Path.join(temp_dir, "created-from-shell")
create_conn =
conn(
:post,
"/api/projects?k=#{Desktop.Auth.login_key()}",
Jason.encode!(%{"name" => "Created From Shell", "data_path" => created_data_dir})
)
|> Plug.Conn.put_req_header("content-type", "application/json")
create_conn = BDS.Desktop.Router.call(create_conn, BDS.Desktop.Router.init([]))
assert create_conn.status == 200
created_payload = Jason.decode!(create_conn.resp_body)
assert created_payload["project"]["name"] == "Created From Shell"
assert created_payload["active_project_id"] == created_payload["project"]["id"]
assert created_payload["project"]["data_path"] == created_data_dir
after_internal_dirs =
case File.ls(internal_projects_root) do
{:ok, entries} -> MapSet.new(entries)
{:error, :enoent} -> MapSet.new()
end
assert after_internal_dirs == before_internal_dirs
end
test "desktop router lets the shell choose an existing project folder and reuses matching projects" do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-desktop-existing-project-#{System.unique_integer([:positive])}")
meta_dir = Path.join(temp_dir, "meta")
File.mkdir_p!(meta_dir)
File.write!(
Path.join(meta_dir, "project.json"),
Jason.encode!(%{"name" => "Existing Blog", "description" => "Imported from disk"})
)
{:ok, project} = BDS.Projects.create_project(%{name: "Existing Blog", data_path: temp_dir})
previous_desktop = Application.get_env(:bds, :desktop, [])
Application.put_env(:bds, :desktop, Keyword.put(previous_desktop, :folder_picker, TestFolderPicker))
Process.put(:test_folder_picker_response, {:ok, temp_dir})
on_exit(fn ->
Application.put_env(:bds, :desktop, previous_desktop)
Process.delete(:test_folder_picker_response)
File.rm_rf(temp_dir)
end)
conn = conn(:post, "/api/project-folder?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
assert conn.status == 200
payload = Jason.decode!(conn.resp_body)
assert payload["status"] == "ok"
assert payload["path"] == temp_dir
assert payload["name"] == "Existing Blog"
assert payload["description"] == "Imported from disk"
assert payload["existing_project_id"] == project.id
end
test "desktop router executes shell commands through the JSON api" do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
:ok = Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), Process.whereis(BDS.Preview))
temp_dir = Path.join(System.tmp_dir!(), "bds-desktop-router-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn ->
File.rm_rf(temp_dir)
_ = BDS.Preview.stop_preview("default")
end)
{:ok, project} = BDS.Projects.create_project(%{name: "Desktop Router", data_path: temp_dir})
{:ok, _project} = BDS.Projects.set_active_project(project.id)
conn =
conn(:post, "/api/commands?k=#{Desktop.Auth.login_key()}", Jason.encode!(%{"action" => "open_in_browser"}))
|> Plug.Conn.put_req_header("content-type", "application/json")
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
assert conn.status == 200
assert Plug.Conn.get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"]
payload = Jason.decode!(conn.resp_body)
assert payload["result"]["kind"] == "open_url"
assert payload["result"]["project_id"] == project.id
assert payload["result"]["url"] == "http://127.0.0.1:4123/"
end
test "desktop router serves active-project media thumbnails for the sidebar" do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-desktop-thumbnail-#{System.unique_integer([:positive])}") temp_dir = Path.join(System.tmp_dir!(), "bds-desktop-thumbnail-#{System.unique_integer([:positive])}")
@@ -330,8 +145,8 @@ defmodule BDS.DesktopTest do
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
conn = conn(:get, "/api/media-thumbnail/#{media.id}?k=#{Desktop.Auth.login_key()}") conn = conn(:get, "/media-thumbnail/#{media.id}?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([])) conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
assert conn.status == 200 assert conn.status == 200
assert [content_type] = Plug.Conn.get_resp_header(conn, "content-type") assert [content_type] = Plug.Conn.get_resp_header(conn, "content-type")
@@ -349,21 +164,4 @@ defmodule BDS.DesktopTest do
Image.new!(3, 2, color: [255, 0, 0]) Image.new!(3, 2, color: [255, 0, 0])
|> Image.write!(:memory, suffix: ".jpg", quality: 85) |> Image.write!(:memory, suffix: ".jpg", quality: 85)
end end
defp wait_for_task(task_id, matcher, timeout \\ 2_000)
defp wait_for_task(task_id, _matcher, timeout) when timeout <= 0 do
BDS.Tasks.get_task(task_id)
end
defp wait_for_task(task_id, matcher, timeout) do
task = BDS.Tasks.get_task(task_id)
if task && matcher.(task) do
task
else
Process.sleep(50)
wait_for_task(task_id, matcher, timeout - 50)
end
end
end end

View File

@@ -4,7 +4,6 @@ defmodule BDS.UI.ShellTest do
alias BDS.UI.Commands alias BDS.UI.Commands
alias BDS.UI.Registry alias BDS.UI.Registry
alias BDS.UI.Session alias BDS.UI.Session
alias BDS.UI.ShellPage
alias BDS.UI.Workbench alias BDS.UI.Workbench
test "registry exposes the shared sidebar and editor contracts for the base shell" do test "registry exposes the shared sidebar and editor contracts for the base shell" do
@@ -12,6 +11,7 @@ defmodule BDS.UI.ShellTest do
editor_routes = Registry.editor_routes() editor_routes = Registry.editor_routes()
assert Registry.default_sidebar_view() == :posts assert Registry.default_sidebar_view() == :posts
assert Enum.map(sidebar_views, & &1.id) == [ assert Enum.map(sidebar_views, & &1.id) == [
:posts, :posts,
:pages, :pages,
@@ -52,6 +52,7 @@ defmodule BDS.UI.ShellTest do
assert restored.active_view == :media assert restored.active_view == :media
assert restored.active_tab == {:media, "media-1"} assert restored.active_tab == {:media, "media-1"}
assert Workbench.dirty?(restored, :post, "post-1") == true assert Workbench.dirty?(restored, :post, "post-1") == true
assert Enum.map(restored.tabs, &{&1.type, &1.id, &1.is_transient}) == [ assert Enum.map(restored.tabs, &{&1.type, &1.id, &1.is_transient}) == [
{:post, "post-1", false}, {:post, "post-1", false},
{:media, "media-1", true} {:media, "media-1", true}
@@ -96,175 +97,23 @@ defmodule BDS.UI.ShellTest do
assert Workbench.dirty?(state, :post, "post-1") == true assert Workbench.dirty?(state, :post, "post-1") == true
end end
test "shell page renders the inspectable base app with bootstrap data and shell controls" do test "desktop shell keeps the compact frame metrics and live bootstrap assets" do
html = ShellPage.render()
assert html =~ ~s(<div class="app" 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="/assets/app.js")
assert html =~ ~s(href="/assets/app.css")
assert html =~ ~s("task_status")
assert html =~ ~s("flag":"🇩🇪")
assert html =~ ~s("projects")
assert html =~ ~s("id":"default")
assert html =~ ~s("name":"My Blog")
end
test "shell page localizes bootstrap content for german ui locale" do
previous_lang = System.get_env("LANG")
previous_lc_all = System.get_env("LC_ALL")
on_exit(fn ->
restore_env("LANG", previous_lang)
restore_env("LC_ALL", previous_lc_all)
end)
System.put_env("LANG", "de_DE.UTF-8")
System.delete_env("LC_ALL")
html = ShellPage.render()
assert html =~ ~s("ui_language":"de")
assert html =~ ~s("catalogs")
assert html =~ ~s("File":"Datei")
assert html =~ ~s("Dashboard":"Instrumententafel")
assert html =~ ~s("Assistant":"Assistent")
end
test "shell bootstrap and static bundle expose the old dashboard sections" do
html = ShellPage.render()
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js") live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js")
assert html =~ ~s("timeline_entries")
assert html =~ ~s("tag_cloud_items")
assert html =~ ~s("category_counts")
assert html =~ ~s("recent_posts")
assert js =~ "dashboard-content"
assert js =~ "dashboard-stats"
assert js =~ "timeline-chart"
assert js =~ "tag-cloud"
assert js =~ "recent-posts-list"
assert css =~ ".dashboard-content"
assert css =~ ".dashboard-stats"
assert css =~ ".timeline-chart"
assert css =~ ".tag-cloud"
assert css =~ ".recent-posts-list"
end
test "shell bootstrap and static bundle expose the old sidebar view contracts" do
html = ShellPage.render()
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert html =~ ~s("layout":"post_list")
assert html =~ ~s("layout":"media_grid")
assert html =~ ~s("layout":"entity_list")
assert html =~ ~s("layout":"nav_list")
assert js =~ "renderSidebarPostList"
assert js =~ "renderSidebarMediaGrid"
assert js =~ "renderSidebarEntityList"
assert js =~ "renderSidebarNavList"
assert css =~ ".sidebar-section-title"
assert css =~ ".media-grid"
assert css =~ ".chat-list-item"
assert css =~ ".settings-nav-entry"
end
test "shell bootstrap and static bundle expose old sidebar filters and the post cutoff contract" do
html = ShellPage.render()
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert html =~ ~s("filters")
assert html =~ ~s("search_placeholder":"sidebar.searchPostsPlaceholder")
assert html =~ ~s("search_placeholder":"sidebar.searchMediaPlaceholder")
assert html =~ ~s("year_month_counts")
assert html =~ ~s("available_tags")
assert html =~ ~s("available_categories")
assert html =~ ~s("max_items":500)
assert js =~ "renderSidebarSearchBox"
assert js =~ "renderSidebarArchiveFilter"
assert js =~ "renderSidebarFilterPanel"
assert js =~ "renderSidebarFilterStatus"
assert js =~ "applySidebarPostFilters"
assert js =~ "applySidebarMediaFilters"
assert js =~ "media-thumbnail-image"
assert js =~ "/api/media-thumbnail/"
assert js =~ "loading=\"lazy\""
assert css =~ ".search-box"
assert css =~ ".filter-panel"
assert css =~ ".calendar-view"
assert css =~ ".filter-chip"
assert css =~ ".filter-status"
assert css =~ ".media-thumbnail-image"
end
test "clearing sidebar filters resets from the baseline seed instead of the filtered payload" do
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert js =~ "sidebarFilterSeeds"
assert js =~ "function sidebarFilterSeed(viewId)"
assert js =~ "defaultSidebarFilterState(viewId, sidebarFilterSeed(viewId))"
refute js =~ "defaultSidebarFilterState(viewId, state.sidebarContent[viewId])"
end
test "sidebar bundle follows the old app header and styling model instead of a subtitle header" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert js =~ "sidebar-section-header"
refute js =~ "sidebar-subtitle"
assert css =~ "--accent-color: #007acc"
assert css =~ "--vscode-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI'"
assert css =~ "border-left-color: var(--vscode-focusBorder);"
assert css =~ ".sidebar-section-header"
refute css =~ ".sidebar-subtitle"
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.css")
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.js") assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/live.js")
end
test "static shell bundle keeps the old compact frame metrics and icon-based controls" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert css =~ ".window-titlebar" assert css =~ ".window-titlebar"
assert css =~ "height: 34px" assert css =~ "height: 34px"
assert css =~ "width: 48px" assert css =~ "width: 48px"
assert css =~ "height: 35px" assert css =~ "height: 35px"
assert css =~ "height: 22px" assert css =~ "height: 22px"
assert live_js =~ "LiveView.LiveSocket"
assert js =~ "window-titlebar-sidebar-icon" assert live_js =~ "Phoenix.Socket"
assert js =~ "window-titlebar-panel-icon"
assert js =~ "window-titlebar-assistant-icon"
assert js =~ "toggle-assistant-sidebar"
assert js =~ "activity-bar-top"
assert js =~ "activity-bar-bottom"
end end
test "static shell bundle hides the fake titlebar menu on macOS and keeps old status-bar alignment rules" do test "desktop shell css keeps the status bar and hidden menu alignment rules" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert js =~ "navigator.platform"
assert js =~ "isMac"
assert js =~ "window-titlebar-menu-bar is-hidden"
assert css =~ ".window-titlebar-menu-bar.is-hidden" assert css =~ ".window-titlebar-menu-bar.is-hidden"
assert css =~ "--vscode-statusBar-background: #007acc" assert css =~ "--vscode-statusBar-background: #007acc"
@@ -276,95 +125,5 @@ defmodule BDS.UI.ShellTest do
assert css =~ ".status-bar-language-select" assert css =~ ".status-bar-language-select"
assert css =~ ".status-bar-item.language-badge" assert css =~ ".status-bar-item.language-badge"
assert css =~ ".status-bar-item.offline-badge" assert css =~ ".status-bar-item.offline-badge"
assert js =~ "renderLanguageOptions"
assert js =~ "language.flag || language.code.toUpperCase()"
assert js =~ "status-bar-language-select"
assert js =~ "setUiLanguage"
end end
test "static shell bundle polls live task status and renders a task-backed lower panel" do
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert js =~ "/api/tasks"
assert js =~ "/api/projects"
assert js =~ "/api/commands"
assert js =~ "fetchTaskStatus"
assert js =~ "executeBackendShellCommand"
assert js =~ "applyShellCommandResult"
assert js =~ "openTasksPanel"
assert js =~ "command === \"open-tasks-panel\")"
assert js =~ "openTasksPanel();"
assert js =~ "return;"
assert js =~ "No background tasks running"
assert js =~ "task-list"
assert js =~ "output-list"
assert js =~ "git-log-list"
assert js =~ "data-panel-tab=\"output\""
assert js =~ "data-panel-tab=\"git_log\""
end
test "static shell bundle keeps bottom panel tabs in a stable order" do
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert js =~ "return [\"tasks\", \"output\", \"git_log\", state.session.panel.active_tab].filter(uniqueValue);"
end
test "static shell bundle renders a left-side project field with selection, existing-folder import, and create affordances" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert js =~ "project-selector-trigger"
assert js =~ "project-dropdown"
assert js =~ "create-project-btn"
assert js =~ "existing-project-btn"
assert js =~ "/api/project-folder"
assert js =~ "fetchProjects"
assert js =~ "createProject"
assert js =~ "importExistingProject"
assert js =~ "selectProject"
assert js =~ "toggleProjectMenu"
assert js =~ "closeProjectMenu"
assert css =~ ".project-selector-trigger"
assert css =~ ".project-dropdown"
assert css =~ ".create-project-btn"
assert css =~ ".existing-project-btn"
end
test "static shell bundle uses translation catalogs for visible shell chrome" do
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert js =~ "translationsForLanguage"
assert js =~ "function t("
assert js =~ "New Project"
assert js =~ "No background tasks running"
assert js =~ "Idle"
end
test "static shell bundle binds base shell hotkeys and menu actions to existing shell functionality" do
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert js =~ "window.addEventListener(\"keydown\""
assert js =~ "event.metaKey"
assert js =~ "case \"j\""
assert js =~ "case \"1\""
assert js =~ "case \"2\""
assert js =~ "case \"\\\\\""
assert js =~ "function isSidebarViewCommand(action)"
assert js =~ "function isSingletonEditorCommand(action)"
assert js =~ "action.startsWith(\"view_\")"
assert js =~ "action.startsWith(\"open_\")"
assert js =~ "openSingletonTab(action.slice(5));"
assert js =~ "executeBackendShellCommand(action)"
assert js =~ "case \"metadata_diff\""
assert js =~ "case \"regenerate_calendar\""
assert js =~ "case \"fill_missing_translations\""
assert js =~ "root.querySelectorAll(\"button[data-command]\")"
assert js =~ "[data-close-tab]"
assert js =~ "language.flag"
end
defp restore_env(key, nil), do: System.delete_env(key)
defp restore_env(key, value), do: System.put_env(key, value)
end end