feat: more work on UI cleanup

This commit is contained in:
2026-04-24 17:11:55 +02:00
parent 7a4c46b0df
commit eb609e1934
16 changed files with 1372 additions and 61 deletions

View File

@@ -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}

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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}

View File

@@ -9,8 +9,12 @@ 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
end
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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
.map(
(tab) => `
<button class="panel-tab ${state.session.panel.active_tab === tab ? "active" : ""}" data-panel-tab="${tab}" type="button">${escapeHtml(routeLabel(tab))}</button>
`
)
.join("")}
${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(
(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>
`;
}
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("&", "&amp;")

View 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

View File

@@ -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

View File

@@ -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}

View File

@@ -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

View File

@@ -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