feat: more work on UI cleanup
This commit is contained in:
@@ -58,6 +58,20 @@ defmodule BDS.Desktop.MenuBar do
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
def handle_event("open_in_browser", menu) do
|
||||
case BDS.Desktop.ShellCommands.execute("open_in_browser") do
|
||||
{:ok, %{url: url}} -> OS.launch_default_browser(url)
|
||||
_other -> :ok
|
||||
end
|
||||
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
def handle_event("open_data_folder", menu) do
|
||||
_ = BDS.Desktop.ShellCommands.execute("open_data_folder")
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
def handle_event("report_issue", menu) do
|
||||
OS.launch_default_browser("https://github.com/rfc1437/bDS/issues")
|
||||
{:noreply, menu}
|
||||
|
||||
@@ -30,6 +30,21 @@ defmodule BDS.Desktop.Router do
|
||||
Plug.Conn.send_resp(conn, 200, "ok")
|
||||
end
|
||||
|
||||
get "/api/tasks" do
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("application/json")
|
||||
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.task_status_json())
|
||||
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
|
||||
|
||||
342
lib/bds/desktop/shell_commands.ex
Normal file
342
lib/bds/desktop/shell_commands.ex
Normal file
@@ -0,0 +1,342 @@
|
||||
defmodule BDS.Desktop.ShellCommands do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.Embeddings
|
||||
alias BDS.Generation
|
||||
alias BDS.Maintenance
|
||||
alias BDS.Metadata
|
||||
alias BDS.Posts
|
||||
alias BDS.Preview
|
||||
alias BDS.Projects
|
||||
alias BDS.Publishing
|
||||
alias BDS.Search
|
||||
alias BDS.Tasks
|
||||
|
||||
@site_sections [:core, :single, :category, :tag, :date]
|
||||
|
||||
def execute(action, params \\ %{})
|
||||
|
||||
def execute(action, params) when is_atom(action) do
|
||||
execute(Atom.to_string(action), params)
|
||||
end
|
||||
|
||||
def execute(action, params) when is_binary(action) and is_map(params) do
|
||||
with {:ok, project} <- active_project() do
|
||||
dispatch(action, project, params)
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch("open_in_browser", project, _params) do
|
||||
with {:ok, server} <- Preview.start_preview(project.id) do
|
||||
{:ok,
|
||||
%{
|
||||
kind: "open_url",
|
||||
action: "open_in_browser",
|
||||
title: "Open in Browser",
|
||||
message: "Preview server ready",
|
||||
project_id: project.id,
|
||||
url: preview_url(server)
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch("preview_post", project, _params) do
|
||||
with {:ok, server} <- Preview.start_preview(project.id) do
|
||||
{:ok,
|
||||
%{
|
||||
kind: "open_url",
|
||||
action: "preview_post",
|
||||
title: "Preview Post",
|
||||
message: "Preview server ready",
|
||||
project_id: project.id,
|
||||
url: preview_url(server)
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch("open_data_folder", project, _params) do
|
||||
path = Projects.project_data_dir(project)
|
||||
|
||||
case open_system_path(path) do
|
||||
:ok ->
|
||||
{:ok,
|
||||
%{
|
||||
kind: "output",
|
||||
action: "open_data_folder",
|
||||
title: "Open Data Folder",
|
||||
message: path,
|
||||
project_id: project.id,
|
||||
level: "info"
|
||||
}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, %{action: "open_data_folder", message: "#{path}: #{inspect(reason)}"}}
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch("reindex_text", project, _params) do
|
||||
queue_task(project, "reindex_text", "Reindex Text", "Search", fn report ->
|
||||
report.(0.2, "Clearing and rebuilding text indexes")
|
||||
:ok = Search.reindex_project(project.id)
|
||||
report.(1.0, "Text indexes rebuilt")
|
||||
%{project_id: project.id}
|
||||
end)
|
||||
end
|
||||
|
||||
defp dispatch("rebuild_embedding_index", project, _params) do
|
||||
queue_task(project, "rebuild_embedding_index", "Rebuild Embedding Index", "Embeddings", fn report ->
|
||||
report.(0.2, "Rebuilding semantic index")
|
||||
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id)
|
||||
report.(1.0, "Embedding index rebuilt")
|
||||
%{project_id: project.id, rebuilt_post_ids: rebuilt_post_ids, rebuilt_count: length(rebuilt_post_ids)}
|
||||
end)
|
||||
end
|
||||
|
||||
defp dispatch("rebuild_database", project, _params) do
|
||||
queue_task(project, "rebuild_database", "Rebuild Database", "Maintenance", fn report ->
|
||||
report.(0.1, "Rebuilding posts")
|
||||
{:ok, posts} = Maintenance.rebuild_from_filesystem(project.id, "post")
|
||||
report.(0.3, "Rebuilding media")
|
||||
{:ok, media} = Maintenance.rebuild_from_filesystem(project.id, "media")
|
||||
report.(0.5, "Rebuilding scripts")
|
||||
{:ok, scripts} = Maintenance.rebuild_from_filesystem(project.id, "script")
|
||||
report.(0.7, "Rebuilding templates")
|
||||
{:ok, templates} = Maintenance.rebuild_from_filesystem(project.id, "template")
|
||||
report.(0.9, "Rebuilding embeddings")
|
||||
{:ok, embeddings} = Maintenance.rebuild_from_filesystem(project.id, "embedding")
|
||||
report.(1.0, "Database rebuild complete")
|
||||
|
||||
%{
|
||||
project_id: project.id,
|
||||
counts: %{
|
||||
posts: length(posts),
|
||||
media: length(media),
|
||||
scripts: length(scripts),
|
||||
templates: length(templates),
|
||||
embeddings: length(embeddings)
|
||||
}
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp dispatch("generate_sitemap", project, _params) do
|
||||
queue_task(project, "generate_sitemap", "Generate Sitemap", "Generation", fn report ->
|
||||
report.(0.2, "Generating site output")
|
||||
{:ok, generation} = Generation.generate_site(project.id, @site_sections)
|
||||
report.(1.0, "Generated site output")
|
||||
%{project_id: project.id, sections: generation.sections, generated_count: length(generation.generated_files)}
|
||||
end)
|
||||
end
|
||||
|
||||
defp dispatch("validate_site", project, _params) do
|
||||
with {:ok, report} <- Generation.validate_site(project.id, @site_sections) do
|
||||
{:ok,
|
||||
%{
|
||||
kind: "open_editor",
|
||||
action: "validate_site",
|
||||
project_id: project.id,
|
||||
route: "site_validation",
|
||||
title: "Site Validation",
|
||||
subtitle: "Generated output checked against expected site files",
|
||||
editorMeta: [
|
||||
%{label: "Missing", value: Integer.to_string(length(report.missing_pages))},
|
||||
%{label: "Extra", value: Integer.to_string(length(report.extra_pages))},
|
||||
%{label: "Stale", value: Integer.to_string(length(report.stale_pages))}
|
||||
],
|
||||
payload: normalize_site_validation(report)
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch("metadata_diff", project, _params) do
|
||||
with {:ok, report} <- Maintenance.metadata_diff(project.id) do
|
||||
{:ok,
|
||||
%{
|
||||
kind: "open_editor",
|
||||
action: "metadata_diff",
|
||||
project_id: project.id,
|
||||
route: "metadata_diff",
|
||||
title: "Metadata Diff",
|
||||
subtitle: "Database state compared against filesystem metadata",
|
||||
editorMeta: [
|
||||
%{label: "Diffs", value: Integer.to_string(length(report.diff_reports))},
|
||||
%{label: "Orphans", value: Integer.to_string(length(report.orphan_reports))}
|
||||
],
|
||||
payload: normalize_metadata_diff(report)
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch("validate_translations", project, _params) do
|
||||
with {:ok, report} <- Posts.validate_translations(project.id) do
|
||||
{:ok,
|
||||
%{
|
||||
kind: "open_editor",
|
||||
action: "validate_translations",
|
||||
project_id: project.id,
|
||||
route: "translation_validation",
|
||||
title: "Translation Validation",
|
||||
subtitle: "Published posts checked against required blog languages",
|
||||
editorMeta: [
|
||||
%{label: "Missing", value: Integer.to_string(length(report.missing))},
|
||||
%{label: "Orphans", value: Integer.to_string(length(report.orphan_files))},
|
||||
%{label: "Skipped", value: Integer.to_string(length(report.do_not_translate_posts))}
|
||||
],
|
||||
payload: normalize_translation_validation(report)
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch("find_duplicates", project, _params) do
|
||||
with {:ok, pairs} <- Embeddings.find_duplicates(project.id) do
|
||||
{:ok,
|
||||
%{
|
||||
kind: "open_editor",
|
||||
action: "find_duplicates",
|
||||
project_id: project.id,
|
||||
route: "find_duplicates",
|
||||
title: "Find Duplicates",
|
||||
subtitle: "Potential duplicate posts found via embeddings",
|
||||
editorMeta: [%{label: "Pairs", value: Integer.to_string(length(pairs))}],
|
||||
payload: normalize_duplicate_pairs(pairs)
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch("upload_site", project, _params) do
|
||||
with {:ok, metadata} <- Metadata.get_project_metadata(project.id),
|
||||
{:ok, credentials} <- upload_credentials(metadata.publishing_preferences),
|
||||
{:ok, job} <- Publishing.upload_site(project.id, credentials) do
|
||||
{:ok,
|
||||
%{
|
||||
kind: "task_queued",
|
||||
action: "upload_site",
|
||||
title: "Upload Site",
|
||||
message: "Upload queued",
|
||||
project_id: project.id,
|
||||
task_id: job.task_id,
|
||||
publish_job_id: job.id,
|
||||
panel_tab: "tasks"
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch(action, _project, _params) do
|
||||
{:error, %{action: action, message: "Unsupported shell command"}}
|
||||
end
|
||||
|
||||
defp queue_task(project, action, title, group_name, work) do
|
||||
{:ok, task} =
|
||||
Tasks.submit_task(title, work, %{
|
||||
group_id: project.id,
|
||||
group_name: group_name
|
||||
})
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
kind: "task_queued",
|
||||
action: action,
|
||||
title: title,
|
||||
message: "#{title} queued",
|
||||
project_id: project.id,
|
||||
task_id: task.id,
|
||||
panel_tab: "tasks"
|
||||
}}
|
||||
end
|
||||
|
||||
defp active_project do
|
||||
case Projects.get_active_project() do
|
||||
nil -> {:error, %{message: "No active project selected"}}
|
||||
project -> {:ok, project}
|
||||
end
|
||||
end
|
||||
|
||||
defp preview_url(server) do
|
||||
"http://#{server.host}:#{server.port}/"
|
||||
end
|
||||
|
||||
defp normalize_site_validation(report) do
|
||||
%{
|
||||
summary: %{
|
||||
missing_count: length(report.missing_pages),
|
||||
extra_count: length(report.extra_pages),
|
||||
stale_count: length(report.stale_pages)
|
||||
},
|
||||
missing_pages: report.missing_pages,
|
||||
extra_pages: report.extra_pages,
|
||||
stale_pages: report.stale_pages,
|
||||
sections: Enum.map(report.sections, &to_string/1)
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_metadata_diff(report) do
|
||||
%{
|
||||
summary: %{
|
||||
diff_count: length(report.diff_reports),
|
||||
orphan_count: length(report.orphan_reports)
|
||||
},
|
||||
diff_reports: Enum.map(report.diff_reports, &stringify_map/1),
|
||||
orphan_reports: Enum.map(report.orphan_reports, &stringify_map/1)
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_translation_validation(report) do
|
||||
%{
|
||||
summary: %{
|
||||
missing_count: length(report.missing),
|
||||
orphan_count: length(report.orphan_files),
|
||||
do_not_translate_count: length(report.do_not_translate_posts)
|
||||
},
|
||||
missing: Enum.map(report.missing, &stringify_map/1),
|
||||
orphan_files: report.orphan_files,
|
||||
do_not_translate_posts: report.do_not_translate_posts
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_duplicate_pairs(pairs) do
|
||||
%{
|
||||
summary: %{pair_count: length(pairs)},
|
||||
pairs: Enum.map(pairs, &stringify_map/1)
|
||||
}
|
||||
end
|
||||
|
||||
defp stringify_map(map) when is_map(map) do
|
||||
Map.new(map, fn {key, value} -> {to_string(key), stringify_value(value)} end)
|
||||
end
|
||||
|
||||
defp stringify_value(value) when is_map(value), do: stringify_map(value)
|
||||
defp stringify_value(value) when is_list(value), do: Enum.map(value, &stringify_value/1)
|
||||
defp stringify_value(value) when is_atom(value), do: Atom.to_string(value)
|
||||
defp stringify_value(value), do: value
|
||||
|
||||
defp upload_credentials(prefs) when is_map(prefs) do
|
||||
credentials = %{
|
||||
ssh_host: Map.get(prefs, "ssh_host"),
|
||||
ssh_user: Map.get(prefs, "ssh_user"),
|
||||
ssh_remote_path: Map.get(prefs, "ssh_remote_path"),
|
||||
ssh_mode: Map.get(prefs, "ssh_mode")
|
||||
}
|
||||
|
||||
if Enum.all?([credentials.ssh_host, credentials.ssh_user, credentials.ssh_remote_path], &is_binary/1) do
|
||||
{:ok, credentials}
|
||||
else
|
||||
{:error, %{action: "upload_site", message: "Publishing preferences are incomplete"}}
|
||||
end
|
||||
end
|
||||
|
||||
defp open_system_path(path) do
|
||||
{command, args} =
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> {"open", [path]}
|
||||
{:unix, _other} -> {"xdg-open", [path]}
|
||||
{:win32, _other} -> {"cmd", ["/c", "start", "", path]}
|
||||
end
|
||||
|
||||
case System.cmd(command, args, stderr_to_stdout: true) do
|
||||
{_output, 0} -> :ok
|
||||
{output, status} -> {:error, {status, String.trim(output)}}
|
||||
end
|
||||
rescue
|
||||
error -> {:error, error}
|
||||
end
|
||||
end
|
||||
@@ -4,4 +4,21 @@ defmodule BDS.Desktop.ShellController do
|
||||
def index_html do
|
||||
BDS.UI.ShellPage.render()
|
||||
end
|
||||
|
||||
def task_status_json do
|
||||
Jason.encode!(BDS.Tasks.status_snapshot())
|
||||
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
|
||||
|
||||
defp normalize_error(error) when is_map(error), do: error
|
||||
defp normalize_error(error), do: %{message: inspect(error)}
|
||||
end
|
||||
|
||||
@@ -17,6 +17,10 @@ defmodule BDS.Projects do
|
||||
Repo.all(from project in Project, order_by: [asc: project.created_at])
|
||||
end
|
||||
|
||||
def get_active_project do
|
||||
Repo.one(from project in Project, where: project.is_active == true, limit: 1)
|
||||
end
|
||||
|
||||
def get_project(id), do: Repo.get(Project, id)
|
||||
def get_project!(id), do: Repo.get!(Project, id)
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ defmodule BDS.Tasks do
|
||||
GenServer.call(__MODULE__, {:get_task, task_id})
|
||||
end
|
||||
|
||||
def status_snapshot do
|
||||
GenServer.call(__MODULE__, :status_snapshot)
|
||||
end
|
||||
|
||||
def cancel_task(task_id) when is_binary(task_id) do
|
||||
GenServer.call(__MODULE__, {:cancel_task, task_id})
|
||||
end
|
||||
@@ -67,6 +71,10 @@ defmodule BDS.Tasks do
|
||||
{:reply, state.tasks[task_id] && public_task(state.tasks[task_id]), state}
|
||||
end
|
||||
|
||||
def handle_call(:status_snapshot, _from, state) do
|
||||
{:reply, build_status_snapshot(state), state}
|
||||
end
|
||||
|
||||
def handle_call({:cancel_task, task_id}, _from, state) do
|
||||
cond do
|
||||
Map.has_key?(state.running, task_id) ->
|
||||
@@ -302,6 +310,46 @@ defmodule BDS.Tasks do
|
||||
Map.drop(task, [:last_reported_at])
|
||||
end
|
||||
|
||||
defp build_status_snapshot(state) do
|
||||
tasks = active_tasks(state)
|
||||
|
||||
%{
|
||||
active_count: length(tasks),
|
||||
running_count: Enum.count(tasks, &(&1.status == :running)),
|
||||
pending_count: Enum.count(tasks, &(&1.status == :pending)),
|
||||
running_task_message: running_task_message(tasks),
|
||||
running_task_overflow: running_task_overflow(tasks),
|
||||
tasks: Enum.map(tasks, &public_task/1)
|
||||
}
|
||||
end
|
||||
|
||||
defp active_tasks(state) do
|
||||
state.tasks
|
||||
|> Map.values()
|
||||
|> Enum.filter(&(&1.status in [:running, :pending]))
|
||||
|> Enum.sort_by(&task_sort_key/1)
|
||||
end
|
||||
|
||||
defp task_sort_key(task) do
|
||||
{task_priority(task.status), task.started_at || task.created_at}
|
||||
end
|
||||
|
||||
defp task_priority(:running), do: 0
|
||||
defp task_priority(:pending), do: 1
|
||||
|
||||
defp running_task_message([]), do: nil
|
||||
|
||||
defp running_task_message([task | _rest]) do
|
||||
cond do
|
||||
task.status == :pending -> "Queued: #{task.name}"
|
||||
is_binary(task.message) and task.message != "" -> "#{task.name}: #{task.message}"
|
||||
true -> task.name
|
||||
end
|
||||
end
|
||||
|
||||
defp running_task_overflow([]), do: 0
|
||||
defp running_task_overflow(tasks), do: max(length(tasks) - 1, 0)
|
||||
|
||||
defp normalize_result({:ok, _value} = result), do: result
|
||||
defp normalize_result({:error, _reason} = result), do: result
|
||||
defp normalize_result(value), do: {:ok, value}
|
||||
|
||||
@@ -9,6 +9,10 @@ defmodule BDS.UI.Commands do
|
||||
|
||||
cond do
|
||||
primary and key == "b" -> MenuBar.execute(state, :toggle_sidebar)
|
||||
primary and key == "j" -> MenuBar.execute(state, :toggle_panel)
|
||||
primary and key == "1" -> MenuBar.execute(state, :view_posts)
|
||||
primary and key == "2" -> MenuBar.execute(state, :view_media)
|
||||
primary and key == "\\" -> MenuBar.execute(state, :toggle_assistant_sidebar)
|
||||
primary and key == "w" -> MenuBar.execute(state, :close_tab)
|
||||
true -> state
|
||||
end
|
||||
|
||||
@@ -82,6 +82,8 @@ defmodule BDS.UI.MenuBar do
|
||||
def execute(state, :toggle_sidebar), do: Workbench.toggle_sidebar(state)
|
||||
def execute(state, :toggle_panel), do: Workbench.toggle_panel(state)
|
||||
def execute(state, :toggle_assistant_sidebar), do: Workbench.toggle_assistant_sidebar(state)
|
||||
def execute(state, :view_posts), do: %{state | active_view: :posts, sidebar_visible: true}
|
||||
def execute(state, :view_media), do: %{state | active_view: :media, sidebar_visible: true}
|
||||
|
||||
def execute(state, :close_tab) do
|
||||
case state.active_tab do
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
defmodule BDS.UI.ShellPage do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.I18n
|
||||
alias BDS.UI.MenuBar
|
||||
alias BDS.UI.Registry
|
||||
alias BDS.UI.Session
|
||||
@@ -49,9 +50,15 @@ defmodule BDS.UI.ShellPage do
|
||||
|
||||
defp bootstrap do
|
||||
workbench = Workbench.new()
|
||||
task_status = BDS.Tasks.status_snapshot()
|
||||
ui_language = I18n.current_ui_locale()
|
||||
|
||||
%{
|
||||
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
|
||||
i18n: %{
|
||||
ui_language: ui_language,
|
||||
supported_ui_languages: Enum.map(I18n.supported_languages(), &Map.take(&1, [:code, :flag]))
|
||||
},
|
||||
registry: %{
|
||||
sidebar_views: Enum.map(Registry.sidebar_views(), &encode_sidebar_view/1),
|
||||
editor_routes: Enum.map(Registry.editor_routes(), &encode_editor_route/1),
|
||||
@@ -59,21 +66,22 @@ defmodule BDS.UI.ShellPage do
|
||||
},
|
||||
menu_groups: Enum.map(MenuBar.default_groups(), &encode_menu_group/1),
|
||||
session: Session.serialize(workbench),
|
||||
task_status: task_status,
|
||||
content: %{
|
||||
sidebar: sidebar_content(),
|
||||
dashboard: dashboard_content(),
|
||||
dashboard: dashboard_content(task_status),
|
||||
assistant_cards: assistant_cards(),
|
||||
editor_meta: editor_meta()
|
||||
editor_meta: editor_meta(task_status)
|
||||
},
|
||||
status:
|
||||
Workbench.status_bar(workbench,
|
||||
post_count: 42,
|
||||
media_count: 18,
|
||||
theme_badge: "desktop-shell",
|
||||
ui_language: "en",
|
||||
ui_language: ui_language,
|
||||
offline_mode: true,
|
||||
running_task_message: "Desktop shell ready",
|
||||
running_task_overflow: 0,
|
||||
running_task_message: task_status.running_task_message,
|
||||
running_task_overflow: task_status.running_task_overflow,
|
||||
git_badge_count: 3
|
||||
)
|
||||
}
|
||||
@@ -180,14 +188,18 @@ defmodule BDS.UI.ShellPage do
|
||||
%{title: title, subtitle: subtitle, sections: [%{title: title, items: items}]}
|
||||
end
|
||||
|
||||
defp dashboard_content do
|
||||
defp dashboard_content(task_status) do
|
||||
%{
|
||||
title: "Dashboard",
|
||||
subtitle: "Desktop workbench shell wired through Elixir",
|
||||
summary_cards: [
|
||||
%{label: "Posts", value: "42", detail: "Across draft, published, and archive"},
|
||||
%{label: "Media", value: "18", detail: "Images and documents indexed"},
|
||||
%{label: "Tasks", value: "1", detail: "One background action visible in the status bar"}
|
||||
%{
|
||||
label: "Tasks",
|
||||
value: Integer.to_string(task_status.active_count),
|
||||
detail: task_summary_detail(task_status)
|
||||
}
|
||||
],
|
||||
checklist: [
|
||||
"Native menu groups mirror the old application shell",
|
||||
@@ -205,16 +217,28 @@ defmodule BDS.UI.ShellPage do
|
||||
]
|
||||
end
|
||||
|
||||
defp editor_meta do
|
||||
defp editor_meta(task_status) do
|
||||
%{
|
||||
dashboard: [
|
||||
%{label: "Status", value: "Workbench shell ready"},
|
||||
%{label: "Status", value: task_status.running_task_message || "Idle"},
|
||||
%{label: "Mode", value: "Offline"},
|
||||
%{label: "Main Language", value: "en"}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
defp task_summary_detail(%{active_count: 0}), do: "No active background tasks"
|
||||
|
||||
defp task_summary_detail(%{running_count: running, pending_count: pending}) do
|
||||
segments = []
|
||||
segments = if running > 0, do: ["#{running} running" | segments], else: segments
|
||||
segments = if pending > 0, do: ["#{pending} queued" | segments], else: segments
|
||||
|
||||
segments
|
||||
|> Enum.reverse()
|
||||
|> Enum.join(", ")
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
--vscode-panel-background: #1e1e1e;
|
||||
--vscode-titleBar-activeBackground: #252526;
|
||||
--vscode-titleBar-activeForeground: #cccccc;
|
||||
--vscode-statusBar-background: #007acc;
|
||||
--vscode-statusBar-background: #181818;
|
||||
--vscode-statusBar-foreground: #ffffff;
|
||||
--vscode-tab-activeBackground: #1e1e1e;
|
||||
--vscode-tab-inactiveBackground: #2d2d2d;
|
||||
@@ -682,6 +682,44 @@ button {
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.output-list,
|
||||
.git-log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-entry-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.task-status-running {
|
||||
color: var(--vscode-terminal-ansiGreen, var(--vscode-statusBar-foreground));
|
||||
}
|
||||
|
||||
.task-status-pending {
|
||||
color: var(--vscode-terminal-ansiYellow, var(--vscode-statusBar-foreground));
|
||||
}
|
||||
|
||||
.panel-empty-state {
|
||||
min-height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
height: 22px;
|
||||
background: var(--vscode-statusBar-background);
|
||||
@@ -722,6 +760,56 @@ button {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.status-bar-task-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-bar-item.theme-badge {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.status-bar-item.language-badge {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 3px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-bar-item.offline-badge {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.4;
|
||||
font-size: 13px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.status-bar-item.offline-badge.active {
|
||||
background-color: rgba(255, 196, 0, 0.28);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.status-bar-language-select {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.status-bar-language-select:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.status-bar-count {
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.status-bar-item.brand {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
609
priv/ui/app.js
609
priv/ui/app.js
@@ -7,14 +7,23 @@ if (!root || !bootstrapNode) {
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar";
|
||||
const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar";
|
||||
const TASK_STATUS_POLL_MS = 1500;
|
||||
const bootstrap = JSON.parse(bootstrapNode.textContent);
|
||||
const isMac = typeof navigator !== "undefined" && navigator.platform.toLowerCase().includes("mac");
|
||||
const state = {
|
||||
session: hydrateSession(clone(bootstrap.session)),
|
||||
status: clone(bootstrap.status),
|
||||
taskStatus: normalizeTaskStatus(bootstrap.task_status),
|
||||
outputEntries: [],
|
||||
gitLogEntries: [],
|
||||
uiLanguage: readStoredUiLanguage(bootstrap.i18n?.ui_language || bootstrap.status.right.ui_language),
|
||||
supportedUiLanguages: bootstrap.i18n?.supported_ui_languages || [],
|
||||
tabMeta: {},
|
||||
};
|
||||
|
||||
bindNativeMenuBridge();
|
||||
bindGlobalHotkeys();
|
||||
scheduleTaskPolling();
|
||||
render();
|
||||
|
||||
function render() {
|
||||
@@ -210,6 +219,8 @@ function renderEditor() {
|
||||
}
|
||||
|
||||
function renderEditorBody(route) {
|
||||
const meta = currentTabMeta();
|
||||
|
||||
if (route === "dashboard") {
|
||||
const dashboard = bootstrap.content.dashboard;
|
||||
return `
|
||||
@@ -229,6 +240,10 @@ function renderEditorBody(route) {
|
||||
`;
|
||||
}
|
||||
|
||||
if (meta?.payload) {
|
||||
return renderCommandPayload(route, meta.payload);
|
||||
}
|
||||
|
||||
const active = activeItem();
|
||||
return `
|
||||
<div class="editor-toolbar">
|
||||
@@ -244,24 +259,108 @@ function renderEditorBody(route) {
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
const tabs = [state.session.panel.active_tab, "output", "git_log"].filter(uniqueValue);
|
||||
const tabs = panelTabs();
|
||||
|
||||
root.querySelector(".panel-shell").innerHTML = `
|
||||
<div class="panel-header">
|
||||
<div class="panel-tabs">
|
||||
${tabs
|
||||
${tabs.map((tab) => renderPanelTab(tab)).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
${renderPanelBody()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPanelBody() {
|
||||
if (state.session.panel.active_tab === "tasks") {
|
||||
return renderTaskPanelEntries();
|
||||
}
|
||||
|
||||
if (state.session.panel.active_tab === "output") {
|
||||
return renderOutputEntries();
|
||||
}
|
||||
|
||||
if (state.session.panel.active_tab === "git_log") {
|
||||
return renderGitLogEntries();
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="panel-entry">
|
||||
<strong>${escapeHtml(routeLabel(state.session.panel.active_tab))}</strong>
|
||||
<span>The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTaskPanelEntries() {
|
||||
if (!state.taskStatus.tasks.length) {
|
||||
return `
|
||||
<div class="panel-entry panel-empty-state">
|
||||
<strong>Tasks</strong>
|
||||
<span>No background tasks running</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="task-list">
|
||||
${state.taskStatus.tasks.map((task) => renderTaskEntry(task)).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTaskEntry(task) {
|
||||
const progress = typeof task.progress === "number" ? `${Math.round(task.progress * 100)}%` : null;
|
||||
const statusDetail = [task.group_name, progress].filter(Boolean).join(" • ");
|
||||
const message = task.message || statusLabel(task.status);
|
||||
|
||||
return `
|
||||
<div class="panel-entry task-entry">
|
||||
<div class="task-entry-header">
|
||||
<strong>${escapeHtml(task.name)}</strong>
|
||||
<span class="task-status task-status-${escapeHtmlAttribute(task.status)}">${escapeHtml(statusLabel(task.status))}</span>
|
||||
</div>
|
||||
${statusDetail ? `<span>${escapeHtml(statusDetail)}</span>` : ""}
|
||||
<span>${escapeHtml(message)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderOutputEntries() {
|
||||
if (!state.outputEntries.length) {
|
||||
return `
|
||||
<div class="panel-entry panel-empty-state output-list">
|
||||
<strong>Output</strong>
|
||||
<span>No shell output yet</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="output-list">
|
||||
${state.outputEntries
|
||||
.map(
|
||||
(tab) => `
|
||||
<button class="panel-tab ${state.session.panel.active_tab === tab ? "active" : ""}" data-panel-tab="${tab}" type="button">${escapeHtml(routeLabel(tab))}</button>
|
||||
(entry) => `
|
||||
<div class="panel-entry output-item">
|
||||
<strong>${escapeHtml(entry.title)}</strong>
|
||||
<span>${escapeHtml(entry.message)}</span>
|
||||
${entry.details ? `<pre class="output-item-details">${escapeHtml(entry.details)}</pre>` : ""}
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGitLogEntries() {
|
||||
return `
|
||||
<div class="git-log-list">
|
||||
<div class="panel-entry">
|
||||
<strong>${escapeHtml(routeLabel(state.session.panel.active_tab))}</strong>
|
||||
<span>The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.</span>
|
||||
<strong>Git Log</strong>
|
||||
<span>Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -288,18 +387,26 @@ function renderAssistant() {
|
||||
}
|
||||
|
||||
function renderStatusBar() {
|
||||
const status = bootstrap.status;
|
||||
const status = state.status;
|
||||
const taskOverflow = state.taskStatus.running_task_overflow;
|
||||
const taskMessage = state.taskStatus.running_task_message || "Idle";
|
||||
|
||||
root.querySelector(".status-bar").innerHTML = `
|
||||
<div class="status-bar-left">
|
||||
<span class="status-bar-item">${escapeHtml(status.left.running_task_message || "Idle")}</span>
|
||||
<button class="status-bar-item status-bar-task-button" data-command="open-tasks-panel" type="button">
|
||||
<span>${escapeHtml(taskMessage)}</span>
|
||||
${taskOverflow > 0 ? `<span class="status-bar-count">+${taskOverflow}</span>` : ""}
|
||||
</button>
|
||||
</div>
|
||||
<div class="status-bar-right">
|
||||
<span class="status-bar-item">${escapeHtml(status.right.post_count)}</span>
|
||||
<span class="status-bar-item">${escapeHtml(status.right.media_count)}</span>
|
||||
<span class="status-bar-item">${escapeHtml(status.right.theme_badge)}</span>
|
||||
<span class="status-bar-item">${status.right.offline_mode ? "Offline" : "Online"}</span>
|
||||
<span class="status-bar-item">${escapeHtml(status.right.ui_language.toUpperCase())}</span>
|
||||
<span class="status-bar-item theme-badge">${escapeHtml(status.right.theme_badge)}</span>
|
||||
<button class="status-bar-item offline-badge${status.right.offline_mode ? " active" : ""}" data-command="toggle-offline-mode" type="button" title="Toggle offline mode">✈</button>
|
||||
<label class="status-bar-item language-badge">
|
||||
<span>UI</span>
|
||||
<select class="status-bar-language-select" data-command="set-ui-language">${renderLanguageOptions()}</select>
|
||||
</label>
|
||||
<span class="status-bar-item brand">${escapeHtml(status.right.brand)}</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -315,17 +422,20 @@ function bindEvents() {
|
||||
root.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.onclick = () => {
|
||||
const command = button.dataset.command;
|
||||
if (command === "toggle-sidebar") {
|
||||
state.session.sidebar_visible = !state.session.sidebar_visible;
|
||||
persistSessionWidths();
|
||||
if (command === "open-tasks-panel") {
|
||||
openTasksPanel();
|
||||
}
|
||||
if (command === "toggle-panel") {
|
||||
state.session.panel.visible = !state.session.panel.visible;
|
||||
}
|
||||
if (command === "toggle-assistant") {
|
||||
state.session.assistant_sidebar_visible = !state.session.assistant_sidebar_visible;
|
||||
persistSessionWidths();
|
||||
if (command === "toggle-offline-mode") {
|
||||
executeShellCommand("toggle_offline_mode");
|
||||
return;
|
||||
}
|
||||
executeShellCommand(command.replace(/-/g, "_"));
|
||||
};
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-command='set-ui-language']").forEach((select) => {
|
||||
select.onchange = (event) => {
|
||||
setUiLanguage(event.target.value);
|
||||
render();
|
||||
};
|
||||
});
|
||||
@@ -404,67 +514,253 @@ function bindNativeMenuBridge() {
|
||||
});
|
||||
}
|
||||
|
||||
function bindGlobalHotkeys() {
|
||||
if (window.__BDS_KEYBOARD_BOUND__) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__BDS_KEYBOARD_BOUND__ = true;
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
let command = null;
|
||||
|
||||
switch (key) {
|
||||
case "b":
|
||||
command = "toggle_sidebar";
|
||||
break;
|
||||
case "j":
|
||||
command = "toggle_panel";
|
||||
break;
|
||||
case "1":
|
||||
command = "view_posts";
|
||||
break;
|
||||
case "2":
|
||||
command = "view_media";
|
||||
break;
|
||||
case "\\":
|
||||
command = "toggle_assistant_sidebar";
|
||||
break;
|
||||
case "w":
|
||||
command = "close_tab";
|
||||
break;
|
||||
default:
|
||||
command = null;
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
executeShellCommand(command);
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleTaskPolling() {
|
||||
window.setInterval(fetchTaskStatus, TASK_STATUS_POLL_MS);
|
||||
void fetchTaskStatus();
|
||||
}
|
||||
|
||||
async function fetchTaskStatus() {
|
||||
try {
|
||||
const response = await fetch("/api/tasks", {
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = normalizeTaskStatus(await response.json());
|
||||
|
||||
if (JSON.stringify(next) === JSON.stringify(state.taskStatus)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.taskStatus = next;
|
||||
state.status.left.running_task_message = next.running_task_message;
|
||||
state.status.left.running_task_overflow = next.running_task_overflow;
|
||||
render();
|
||||
} catch (_error) {
|
||||
// Keep the shell usable if task polling is temporarily unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function openTasksPanel() {
|
||||
state.session.panel.visible = true;
|
||||
state.session.panel.active_tab = "tasks";
|
||||
}
|
||||
|
||||
function handleNativeMenuAction(action) {
|
||||
executeShellCommand(action);
|
||||
}
|
||||
|
||||
function executeShellCommand(action) {
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (executeLocalShellCommand(action)) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
void executeBackendShellCommand(action);
|
||||
}
|
||||
|
||||
function executeLocalShellCommand(action) {
|
||||
switch (action) {
|
||||
case "toggle_sidebar":
|
||||
state.session.sidebar_visible = !state.session.sidebar_visible;
|
||||
persistSessionWidths();
|
||||
break;
|
||||
return true;
|
||||
case "toggle_panel":
|
||||
state.session.panel.visible = !state.session.panel.visible;
|
||||
break;
|
||||
if (state.session.panel.visible && !state.session.panel.active_tab) {
|
||||
state.session.panel.active_tab = "tasks";
|
||||
}
|
||||
return true;
|
||||
case "toggle_assistant_sidebar":
|
||||
state.session.assistant_sidebar_visible = !state.session.assistant_sidebar_visible;
|
||||
persistSessionWidths();
|
||||
break;
|
||||
return true;
|
||||
case "view_posts":
|
||||
state.session.active_view = "posts";
|
||||
state.session.sidebar_visible = true;
|
||||
break;
|
||||
return true;
|
||||
case "view_media":
|
||||
state.session.active_view = "media";
|
||||
state.session.sidebar_visible = true;
|
||||
break;
|
||||
return true;
|
||||
case "close_tab":
|
||||
closeActiveTab();
|
||||
break;
|
||||
return true;
|
||||
case "edit_preferences":
|
||||
openSingletonTab("settings");
|
||||
break;
|
||||
return true;
|
||||
case "edit_menu":
|
||||
openSingletonTab("menu_editor");
|
||||
break;
|
||||
case "metadata_diff":
|
||||
openSingletonTab("metadata_diff");
|
||||
break;
|
||||
return true;
|
||||
case "documentation":
|
||||
openSingletonTab("documentation");
|
||||
break;
|
||||
return true;
|
||||
case "api_documentation":
|
||||
openSingletonTab("api_documentation");
|
||||
return true;
|
||||
case "regenerate_calendar":
|
||||
appendOutputEntry("Regenerate Calendar", "Calendar regeneration is not wired yet, but the base shell now surfaces the command and keeps the Output tab selectable.");
|
||||
setPanelTab("output");
|
||||
return true;
|
||||
case "fill_missing_translations":
|
||||
appendOutputEntry("Fill Missing Translations", "Translation fill is not wired yet, but the command is now routed into Output instead of being ignored.");
|
||||
setPanelTab("output");
|
||||
return true;
|
||||
case "toggle_offline_mode":
|
||||
state.status.right.offline_mode = !state.status.right.offline_mode;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeBackendShellCommand(action) {
|
||||
try {
|
||||
const response = await fetch("/api/commands", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
appendOutputEntry(routeLabel(action), `Command failed with HTTP ${response.status}`);
|
||||
setPanelTab("output");
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (payload.status !== "ok") {
|
||||
applyShellCommandError(action, payload.error || { message: "Unknown shell command error" });
|
||||
return;
|
||||
}
|
||||
|
||||
applyShellCommandResult(payload.result);
|
||||
} catch (error) {
|
||||
applyShellCommandError(action, { message: error?.message || String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
function applyShellCommandResult(result) {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (result.kind) {
|
||||
case "task_queued":
|
||||
appendOutputEntry(result.title, result.message);
|
||||
setPanelTab(result.panel_tab || "tasks");
|
||||
void fetchTaskStatus();
|
||||
break;
|
||||
case "validate_site":
|
||||
openSingletonTab("site_validation");
|
||||
case "open_url":
|
||||
appendOutputEntry(result.title, result.url || result.message || "Opened URL");
|
||||
setPanelTab("output");
|
||||
|
||||
if (result.url) {
|
||||
window.open(result.url, "_blank", "noopener");
|
||||
}
|
||||
|
||||
break;
|
||||
case "validate_translations":
|
||||
openSingletonTab("translation_validation");
|
||||
break;
|
||||
case "find_duplicates":
|
||||
openSingletonTab("find_duplicates");
|
||||
case "open_editor":
|
||||
openSingletonTab(result.route, {
|
||||
title: result.title,
|
||||
subtitle: result.subtitle,
|
||||
editorMeta: result.editorMeta,
|
||||
payload: result.payload,
|
||||
});
|
||||
return;
|
||||
case "output":
|
||||
appendOutputEntry(result.title, result.message, result.details);
|
||||
setPanelTab(result.panel_tab || "output");
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
appendOutputEntry(routeLabel(result.action || "output"), result.message || "Command completed");
|
||||
setPanelTab("output");
|
||||
break;
|
||||
}
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
function openSingletonTab(type) {
|
||||
openTab(type, type, routeLabel(type), false);
|
||||
function applyShellCommandError(action, error) {
|
||||
appendOutputEntry(routeLabel(action), error?.message || "Command failed");
|
||||
setPanelTab("output");
|
||||
render();
|
||||
}
|
||||
|
||||
function setPanelTab(tab) {
|
||||
state.session.panel.visible = true;
|
||||
state.session.panel.active_tab = tab;
|
||||
}
|
||||
|
||||
function appendOutputEntry(title, message, details = "") {
|
||||
state.outputEntries = [{ title, message, details }, ...state.outputEntries].slice(0, 20);
|
||||
}
|
||||
|
||||
function openSingletonTab(type, meta = {}) {
|
||||
openTab(type, type, meta.title || routeLabel(type), false, meta);
|
||||
}
|
||||
|
||||
function closeActiveTab() {
|
||||
@@ -494,7 +790,7 @@ function closeActiveTab() {
|
||||
}
|
||||
}
|
||||
|
||||
function openTab(type, id, title, transient) {
|
||||
function openTab(type, id, title, transient, meta = {}) {
|
||||
const existingIndex = state.session.tabs.findIndex((tab) => tab.type === type && tab.id === id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
@@ -512,11 +808,16 @@ function openTab(type, id, title, transient) {
|
||||
state.session.tabs.push({ type, id, is_transient: false });
|
||||
}
|
||||
|
||||
state.tabMeta[`${type}:${id}`] = { title };
|
||||
state.tabMeta[`${type}:${id}`] = { title, ...meta };
|
||||
state.session.active_tab = { type, id };
|
||||
render();
|
||||
}
|
||||
|
||||
function currentTabMeta() {
|
||||
const tab = currentTabRef();
|
||||
return tab ? state.tabMeta[`${tab.type}:${tab.id}`] : null;
|
||||
}
|
||||
|
||||
function activeItem() {
|
||||
const tab = currentTabRef();
|
||||
|
||||
@@ -559,15 +860,26 @@ function currentRoute() {
|
||||
}
|
||||
|
||||
function currentEditorMeta() {
|
||||
return bootstrap.content.editor_meta[currentRoute()] || bootstrap.content.editor_meta.dashboard;
|
||||
const meta = currentTabMeta();
|
||||
return meta?.editorMeta || bootstrap.content.editor_meta[currentRoute()] || bootstrap.content.editor_meta.dashboard;
|
||||
}
|
||||
|
||||
function editorTitle() {
|
||||
const meta = currentTabMeta();
|
||||
if (meta?.title) {
|
||||
return meta.title;
|
||||
}
|
||||
|
||||
const item = activeItem();
|
||||
return item?.title || bootstrap.content.dashboard.title;
|
||||
}
|
||||
|
||||
function editorSubtitle(route) {
|
||||
const meta = currentTabMeta();
|
||||
if (meta?.subtitle) {
|
||||
return meta.subtitle;
|
||||
}
|
||||
|
||||
if (route === "dashboard") {
|
||||
return bootstrap.content.dashboard.subtitle;
|
||||
}
|
||||
@@ -581,6 +893,26 @@ function routeLabel(route) {
|
||||
return "Dashboard";
|
||||
}
|
||||
|
||||
if (route === "output") {
|
||||
return "Output";
|
||||
}
|
||||
|
||||
if (route === "git_log") {
|
||||
return "Git Log";
|
||||
}
|
||||
|
||||
if (route === "open_in_browser") {
|
||||
return "Open in Browser";
|
||||
}
|
||||
|
||||
if (route === "open_data_folder") {
|
||||
return "Open Data Folder";
|
||||
}
|
||||
|
||||
if (route === "upload_site") {
|
||||
return "Upload Site";
|
||||
}
|
||||
|
||||
return (
|
||||
bootstrap.registry.editor_routes.find((item) => item.id === route)?.title ||
|
||||
sidebarViews().find((item) => item.id === route)?.label ||
|
||||
@@ -588,6 +920,127 @@ function routeLabel(route) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderCommandPayload(route, payload) {
|
||||
switch (route) {
|
||||
case "metadata_diff":
|
||||
return `
|
||||
<section class="editor-section">
|
||||
<ul class="editor-list compact">
|
||||
<li><strong>Diffs:</strong> ${escapeHtml(String(payload.summary?.diff_count || 0))}</li>
|
||||
<li><strong>Orphans:</strong> ${escapeHtml(String(payload.summary?.orphan_count || 0))}</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="editor-section">
|
||||
<h2>Diff Reports</h2>
|
||||
${renderKeyedEntries(payload.diff_reports, ["entity_type", "entity_id", "differences"])}
|
||||
</section>
|
||||
<section class="editor-section">
|
||||
<h2>Orphan Reports</h2>
|
||||
${renderKeyedEntries(payload.orphan_reports, ["entity_type", "path"])}
|
||||
</section>
|
||||
`;
|
||||
case "site_validation":
|
||||
return `
|
||||
<section class="editor-section">
|
||||
<ul class="editor-list compact">
|
||||
<li><strong>Missing:</strong> ${escapeHtml(String(payload.summary?.missing_count || 0))}</li>
|
||||
<li><strong>Extra:</strong> ${escapeHtml(String(payload.summary?.extra_count || 0))}</li>
|
||||
<li><strong>Stale:</strong> ${escapeHtml(String(payload.summary?.stale_count || 0))}</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="editor-section">
|
||||
<h2>Missing Pages</h2>
|
||||
${renderStringList(payload.missing_pages, "No missing pages")}
|
||||
</section>
|
||||
<section class="editor-section">
|
||||
<h2>Extra Pages</h2>
|
||||
${renderStringList(payload.extra_pages, "No extra pages")}
|
||||
</section>
|
||||
<section class="editor-section">
|
||||
<h2>Stale Pages</h2>
|
||||
${renderStringList(payload.stale_pages, "No stale pages")}
|
||||
</section>
|
||||
`;
|
||||
case "translation_validation":
|
||||
return `
|
||||
<section class="editor-section">
|
||||
<ul class="editor-list compact">
|
||||
<li><strong>Missing:</strong> ${escapeHtml(String(payload.summary?.missing_count || 0))}</li>
|
||||
<li><strong>Orphan Files:</strong> ${escapeHtml(String(payload.summary?.orphan_count || 0))}</li>
|
||||
<li><strong>Do Not Translate:</strong> ${escapeHtml(String(payload.summary?.do_not_translate_count || 0))}</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="editor-section">
|
||||
<h2>Missing Translations</h2>
|
||||
${renderKeyedEntries(payload.missing, ["post_id", "language"])}
|
||||
</section>
|
||||
<section class="editor-section">
|
||||
<h2>Orphan Files</h2>
|
||||
${renderStringList(payload.orphan_files, "No orphan translation files")}
|
||||
</section>
|
||||
`;
|
||||
case "find_duplicates":
|
||||
return `
|
||||
<section class="editor-section">
|
||||
<ul class="editor-list compact">
|
||||
<li><strong>Pairs:</strong> ${escapeHtml(String(payload.summary?.pair_count || 0))}</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="editor-section">
|
||||
<h2>Duplicate Candidates</h2>
|
||||
${renderKeyedEntries(payload.pairs, ["title_a", "title_b", "score"])}
|
||||
</section>
|
||||
`;
|
||||
default:
|
||||
return `
|
||||
<section class="editor-section">
|
||||
<pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStringList(items, emptyMessage) {
|
||||
if (!items || !items.length) {
|
||||
return `<p>${escapeHtml(emptyMessage)}</p>`;
|
||||
}
|
||||
|
||||
return `<ul class="editor-list">${items.map((item) => `<li>${escapeHtml(String(item))}</li>`).join("")}</ul>`;
|
||||
}
|
||||
|
||||
function renderKeyedEntries(items, keys) {
|
||||
if (!items || !items.length) {
|
||||
return `<p>No items</p>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="panel-entry-list">
|
||||
${items
|
||||
.map((item) => `
|
||||
<div class="panel-entry">
|
||||
${keys
|
||||
.filter((key) => item[key] !== undefined)
|
||||
.map((key) => `<span><strong>${escapeHtml(titleCase(key))}:</strong> ${escapeHtml(formatPayloadValue(item[key]))}</span>`)
|
||||
.join("")}
|
||||
</div>
|
||||
`)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatPayloadValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => formatPayloadValue(entry)).join(", ");
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function tabIdForItem(item, route) {
|
||||
if (route === "settings" || route === "tags") {
|
||||
return route;
|
||||
@@ -699,6 +1152,68 @@ function tabIcon(type) {
|
||||
return activityIcon(type === "post" ? "posts" : type);
|
||||
}
|
||||
|
||||
function normalizeTaskStatus(taskStatus) {
|
||||
return {
|
||||
active_count: taskStatus?.active_count || 0,
|
||||
running_count: taskStatus?.running_count || 0,
|
||||
pending_count: taskStatus?.pending_count || 0,
|
||||
running_task_message: taskStatus?.running_task_message || null,
|
||||
running_task_overflow: taskStatus?.running_task_overflow || 0,
|
||||
tasks: Array.isArray(taskStatus?.tasks) ? taskStatus.tasks : [],
|
||||
};
|
||||
}
|
||||
|
||||
function panelTabs() {
|
||||
return ["tasks", state.session.panel.active_tab, "output", "git_log"].filter(uniqueValue);
|
||||
}
|
||||
|
||||
function renderPanelTab(tab) {
|
||||
if (tab === "tasks") {
|
||||
return `<button class="panel-tab ${state.session.panel.active_tab === "tasks" ? "active" : ""}" data-panel-tab="tasks" type="button">Tasks</button>`;
|
||||
}
|
||||
|
||||
if (tab === "output") {
|
||||
return `<button class="panel-tab ${state.session.panel.active_tab === "output" ? "active" : ""}" data-panel-tab="output" type="button">Output</button>`;
|
||||
}
|
||||
|
||||
if (tab === "git_log") {
|
||||
return `<button class="panel-tab ${state.session.panel.active_tab === "git_log" ? "active" : ""}" data-panel-tab="git_log" type="button">Git Log</button>`;
|
||||
}
|
||||
|
||||
return `<button class="panel-tab ${state.session.panel.active_tab === tab ? "active" : ""}" data-panel-tab="${tab}" type="button">${escapeHtml(routeLabel(tab))}</button>`;
|
||||
}
|
||||
|
||||
function renderLanguageOptions() {
|
||||
return state.supportedUiLanguages
|
||||
.map((language) => {
|
||||
const selected = language.code === state.uiLanguage ? " selected" : "";
|
||||
return `<option value="${escapeHtmlAttribute(language.code)}"${selected}>${escapeHtml(language.code.toUpperCase())}</option>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function setUiLanguage(nextLanguage) {
|
||||
state.uiLanguage = nextLanguage;
|
||||
state.status.right.ui_language = nextLanguage;
|
||||
localStorage.setItem("bds-ui-language", nextLanguage);
|
||||
}
|
||||
|
||||
function readStoredUiLanguage(fallback) {
|
||||
const stored = localStorage.getItem("bds-ui-language");
|
||||
return stored || fallback || "en";
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "Running";
|
||||
case "pending":
|
||||
return "Queued";
|
||||
default:
|
||||
return titleCase(status || "task");
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
|
||||
92
test/bds/desktop/shell_commands_test.exs
Normal file
92
test/bds/desktop/shell_commands_test.exs
Normal file
@@ -0,0 +1,92 @@
|
||||
defmodule BDS.Desktop.ShellCommandsTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias BDS.Desktop.ShellCommands
|
||||
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), Process.whereis(BDS.Preview))
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), Process.whereis(BDS.Publishing))
|
||||
|
||||
temp_dir =
|
||||
Path.join(System.tmp_dir!(), "bds-shell-commands-#{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: "Shell Commands", data_path: temp_dir})
|
||||
{:ok, project} = BDS.Projects.set_active_project(project.id)
|
||||
|
||||
%{project: project, temp_dir: temp_dir}
|
||||
end
|
||||
|
||||
test "open_in_browser starts preview for the active project and returns a preview url", %{project: project} do
|
||||
assert {:ok, result} = ShellCommands.execute("open_in_browser")
|
||||
|
||||
assert result.kind == "open_url"
|
||||
assert result.action == "open_in_browser"
|
||||
assert result.url == "http://127.0.0.1:4123/"
|
||||
assert result.project_id == project.id
|
||||
end
|
||||
|
||||
test "validate_translations returns an editor payload with current translation gaps", %{project: project} do
|
||||
assert {:ok, _metadata} =
|
||||
BDS.Metadata.update_project_metadata(project.id, %{
|
||||
main_language: "en",
|
||||
blog_languages: ["en", "de"]
|
||||
})
|
||||
|
||||
assert {:ok, post} =
|
||||
BDS.Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Hello",
|
||||
content: "World",
|
||||
language: "en"
|
||||
})
|
||||
|
||||
assert {:ok, _published_post} = BDS.Posts.publish_post(post.id)
|
||||
|
||||
assert {:ok, result} = ShellCommands.execute("validate_translations")
|
||||
|
||||
assert result.kind == "open_editor"
|
||||
assert result.route == "translation_validation"
|
||||
assert result.payload.summary.missing_count == 1
|
||||
post_id = post.id
|
||||
assert [%{"language" => "de", "post_id" => ^post_id}] = result.payload.missing
|
||||
end
|
||||
|
||||
test "reindex_text queues a tracked background task for the active project", %{project: project} do
|
||||
assert {:ok, result} = ShellCommands.execute("reindex_text")
|
||||
|
||||
assert result.kind == "task_queued"
|
||||
assert result.action == "reindex_text"
|
||||
assert result.project_id == project.id
|
||||
assert is_binary(result.task_id)
|
||||
|
||||
assert task = BDS.Tasks.get_task(result.task_id)
|
||||
assert task.group_name == "Search"
|
||||
assert wait_for_task(result.task_id, &(&1.status in [:completed, :failed])).status == :completed
|
||||
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
|
||||
@@ -84,4 +84,64 @@ defmodule BDS.DesktopTest do
|
||||
assert conn.status == 200
|
||||
assert conn.resp_body =~ ~s(class="app")
|
||||
end
|
||||
|
||||
test "desktop router exposes live task status for shell polling" do
|
||||
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)
|
||||
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 Plug.Conn.get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"]
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
@@ -115,6 +115,39 @@ defmodule BDS.TasksTest do
|
||||
:completed
|
||||
end
|
||||
|
||||
test "status_snapshot exposes active task details for the desktop shell" do
|
||||
assert {:ok, first} =
|
||||
BDS.Tasks.register_external_task("preview build", %{
|
||||
group_id: "generation",
|
||||
group_name: "Generation"
|
||||
})
|
||||
|
||||
assert {:ok, second} =
|
||||
BDS.Tasks.register_external_task("reindex text", %{
|
||||
group_id: "search",
|
||||
group_name: "Search"
|
||||
})
|
||||
|
||||
on_exit(fn ->
|
||||
_ = BDS.Tasks.complete_task(first.id)
|
||||
_ = BDS.Tasks.complete_task(second.id)
|
||||
end)
|
||||
|
||||
assert :ok = BDS.Tasks.report_progress(first.id, 0.5, "halfway")
|
||||
|
||||
snapshot = BDS.Tasks.status_snapshot()
|
||||
|
||||
assert snapshot.active_count == 2
|
||||
assert snapshot.running_task_overflow == 1
|
||||
assert snapshot.running_task_message == "preview build: halfway"
|
||||
|
||||
assert [%{id: first_id, status: :running, progress: 0.5, group_name: "Generation"}, %{id: second_id, status: :running}] =
|
||||
snapshot.tasks
|
||||
|
||||
assert first_id == first.id
|
||||
assert second_id == second.id
|
||||
end
|
||||
|
||||
defp receive_started do
|
||||
receive do
|
||||
{:started, name, pid} -> {name, pid}
|
||||
|
||||
@@ -66,6 +66,15 @@ defmodule BDS.UI.ShellTest do
|
||||
state = Commands.handle_shortcut(state, %{meta: true, key: "b"})
|
||||
assert state.sidebar_visible == false
|
||||
|
||||
state = Commands.handle_shortcut(state, %{meta: true, key: "j"})
|
||||
assert state.panel.visible == true
|
||||
|
||||
state = Commands.handle_shortcut(state, %{meta: true, key: "1"})
|
||||
assert state.active_view == :posts
|
||||
|
||||
state = Commands.handle_shortcut(state, %{meta: true, key: "2"})
|
||||
assert state.active_view == :media
|
||||
|
||||
state = Commands.handle_shortcut(state, %{meta: true, key: "w"})
|
||||
assert state.tabs == []
|
||||
assert state.editor_route == :dashboard
|
||||
@@ -99,7 +108,7 @@ defmodule BDS.UI.ShellTest do
|
||||
assert html =~ ~s(id="bds-shell-bootstrap")
|
||||
assert html =~ ~s(src="/assets/app.js")
|
||||
assert html =~ ~s(href="/assets/app.css")
|
||||
assert html =~ ~s(Desktop shell ready)
|
||||
assert html =~ ~s("task_status")
|
||||
end
|
||||
|
||||
test "static shell bundle exists for direct browser inspection" do
|
||||
@@ -134,9 +143,51 @@ defmodule BDS.UI.ShellTest do
|
||||
assert js =~ "window-titlebar-menu-bar is-hidden"
|
||||
|
||||
assert css =~ ".window-titlebar-menu-bar.is-hidden"
|
||||
assert css =~ "--vscode-statusBar-background: #181818"
|
||||
assert css =~ ".status-bar-left,"
|
||||
assert css =~ "gap: 4px"
|
||||
assert css =~ "padding: 0 8px"
|
||||
assert css =~ "height: 100%"
|
||||
assert css =~ ".status-bar-language-select"
|
||||
assert css =~ ".status-bar-item.language-badge"
|
||||
assert css =~ ".status-bar-item.offline-badge"
|
||||
|
||||
assert js =~ "renderLanguageOptions"
|
||||
assert js =~ "status-bar-language-select"
|
||||
assert js =~ "setUiLanguage"
|
||||
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/commands"
|
||||
assert js =~ "fetchTaskStatus"
|
||||
assert js =~ "executeBackendShellCommand"
|
||||
assert js =~ "applyShellCommandResult"
|
||||
assert js =~ "openTasksPanel"
|
||||
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 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 =~ "case \"view_posts\""
|
||||
assert js =~ "case \"view_media\""
|
||||
assert js =~ "executeBackendShellCommand(action)"
|
||||
assert js =~ "case \"metadata_diff\""
|
||||
assert js =~ "case \"regenerate_calendar\""
|
||||
assert js =~ "case \"fill_missing_translations\""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -181,8 +181,10 @@ defmodule BDS.UI.WorkbenchTest do
|
||||
|
||||
state = MenuBar.execute(state, :toggle_sidebar)
|
||||
state = MenuBar.execute(state, :toggle_panel)
|
||||
state = MenuBar.execute(state, :view_media)
|
||||
|
||||
assert state.sidebar_visible == true
|
||||
assert state.panel.visible == true
|
||||
assert state.active_view == :media
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user