diff --git a/lib/bds/desktop/menu_bar.ex b/lib/bds/desktop/menu_bar.ex
index 6199652..6b2c519 100644
--- a/lib/bds/desktop/menu_bar.ex
+++ b/lib/bds/desktop/menu_bar.ex
@@ -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}
diff --git a/lib/bds/desktop/router.ex b/lib/bds/desktop/router.ex
index 2ee0c49..0843227 100644
--- a/lib/bds/desktop/router.ex
+++ b/lib/bds/desktop/router.ex
@@ -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
diff --git a/lib/bds/desktop/shell_commands.ex b/lib/bds/desktop/shell_commands.ex
new file mode 100644
index 0000000..8528dd2
--- /dev/null
+++ b/lib/bds/desktop/shell_commands.ex
@@ -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
diff --git a/lib/bds/desktop/shell_controller.ex b/lib/bds/desktop/shell_controller.ex
index 20bea1d..7ccd67f 100644
--- a/lib/bds/desktop/shell_controller.ex
+++ b/lib/bds/desktop/shell_controller.ex
@@ -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
diff --git a/lib/bds/projects.ex b/lib/bds/projects.ex
index 517e5af..2c688b6 100644
--- a/lib/bds/projects.ex
+++ b/lib/bds/projects.ex
@@ -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)
diff --git a/lib/bds/tasks.ex b/lib/bds/tasks.ex
index 529107a..8eb1445 100644
--- a/lib/bds/tasks.ex
+++ b/lib/bds/tasks.ex
@@ -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}
diff --git a/lib/bds/ui/commands.ex b/lib/bds/ui/commands.ex
index 53f924c..14f9b33 100644
--- a/lib/bds/ui/commands.ex
+++ b/lib/bds/ui/commands.ex
@@ -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
\ No newline at end of file
+end
diff --git a/lib/bds/ui/menu_bar.ex b/lib/bds/ui/menu_bar.ex
index fa11dc6..13bc461 100644
--- a/lib/bds/ui/menu_bar.ex
+++ b/lib/bds/ui/menu_bar.ex
@@ -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
diff --git a/lib/bds/ui/shell_page.ex b/lib/bds/ui/shell_page.ex
index e1bd1d2..e43b6df 100644
--- a/lib/bds/ui/shell_page.ex
+++ b/lib/bds/ui/shell_page.ex
@@ -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
diff --git a/priv/ui/app.css b/priv/ui/app.css
index 5f4f442..910c842 100644
--- a/priv/ui/app.css
+++ b/priv/ui/app.css
@@ -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;
}
diff --git a/priv/ui/app.js b/priv/ui/app.js
index f9e4382..66fd788 100644
--- a/priv/ui/app.js
+++ b/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 `
@@ -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 = `
+ ${renderPanelBody()}
+
+ `;
+}
+
+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 `
+
+ ${escapeHtml(routeLabel(state.session.panel.active_tab))}
+ The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.
+
+ `;
+}
+
+function renderTaskPanelEntries() {
+ if (!state.taskStatus.tasks.length) {
+ return `
+
+ Tasks
+ No background tasks running
+
+ `;
+ }
+
+ return `
+
+ ${state.taskStatus.tasks.map((task) => renderTaskEntry(task)).join("")}
+
+ `;
+}
+
+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 `
+
+
+ ${statusDetail ? `${escapeHtml(statusDetail)} ` : ""}
+ ${escapeHtml(message)}
+
+ `;
+}
+
+function renderOutputEntries() {
+ if (!state.outputEntries.length) {
+ return `
+
+ Output
+ No shell output yet
+
+ `;
+ }
+
+ return `
+
+ ${state.outputEntries
+ .map(
+ (entry) => `
+
+
${escapeHtml(entry.title)}
+
${escapeHtml(entry.message)}
+ ${entry.details ? `
${escapeHtml(entry.details)} ` : ""}
+
+ `
+ )
+ .join("")}
+
+ `;
+}
+
+function renderGitLogEntries() {
+ return `
+
- ${escapeHtml(routeLabel(state.session.panel.active_tab))}
- The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.
+ Git Log
+ Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.
`;
@@ -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 = `
- ${escapeHtml(status.left.running_task_message || "Idle")}
+
+ ${escapeHtml(taskMessage)}
+ ${taskOverflow > 0 ? `+${taskOverflow} ` : ""}
+
${escapeHtml(status.right.post_count)}
${escapeHtml(status.right.media_count)}
- ${escapeHtml(status.right.theme_badge)}
- ${status.right.offline_mode ? "Offline" : "Online"}
- ${escapeHtml(status.right.ui_language.toUpperCase())}
+ ${escapeHtml(status.right.theme_badge)}
+ ✈
+
+ UI
+ ${renderLanguageOptions()}
+
${escapeHtml(status.right.brand)}
`;
@@ -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 `
+
+
+ Diffs: ${escapeHtml(String(payload.summary?.diff_count || 0))}
+ Orphans: ${escapeHtml(String(payload.summary?.orphan_count || 0))}
+
+
+
+ Diff Reports
+ ${renderKeyedEntries(payload.diff_reports, ["entity_type", "entity_id", "differences"])}
+
+
+ Orphan Reports
+ ${renderKeyedEntries(payload.orphan_reports, ["entity_type", "path"])}
+
+ `;
+ case "site_validation":
+ return `
+
+
+ Missing: ${escapeHtml(String(payload.summary?.missing_count || 0))}
+ Extra: ${escapeHtml(String(payload.summary?.extra_count || 0))}
+ Stale: ${escapeHtml(String(payload.summary?.stale_count || 0))}
+
+
+
+ Missing Pages
+ ${renderStringList(payload.missing_pages, "No missing pages")}
+
+
+ Extra Pages
+ ${renderStringList(payload.extra_pages, "No extra pages")}
+
+
+ Stale Pages
+ ${renderStringList(payload.stale_pages, "No stale pages")}
+
+ `;
+ case "translation_validation":
+ return `
+
+
+ Missing: ${escapeHtml(String(payload.summary?.missing_count || 0))}
+ Orphan Files: ${escapeHtml(String(payload.summary?.orphan_count || 0))}
+ Do Not Translate: ${escapeHtml(String(payload.summary?.do_not_translate_count || 0))}
+
+
+
+ Missing Translations
+ ${renderKeyedEntries(payload.missing, ["post_id", "language"])}
+
+
+ Orphan Files
+ ${renderStringList(payload.orphan_files, "No orphan translation files")}
+
+ `;
+ case "find_duplicates":
+ return `
+
+
+ Pairs: ${escapeHtml(String(payload.summary?.pair_count || 0))}
+
+
+
+ Duplicate Candidates
+ ${renderKeyedEntries(payload.pairs, ["title_a", "title_b", "score"])}
+
+ `;
+ default:
+ return `
+
+ ${escapeHtml(JSON.stringify(payload, null, 2))}
+
+ `;
+ }
+}
+
+function renderStringList(items, emptyMessage) {
+ if (!items || !items.length) {
+ return `
${escapeHtml(emptyMessage)}
`;
+ }
+
+ return `
${items.map((item) => `${escapeHtml(String(item))} `).join("")} `;
+}
+
+function renderKeyedEntries(items, keys) {
+ if (!items || !items.length) {
+ return `
No items
`;
+ }
+
+ return `
+
+ ${items
+ .map((item) => `
+
+ ${keys
+ .filter((key) => item[key] !== undefined)
+ .map((key) => `${escapeHtml(titleCase(key))}: ${escapeHtml(formatPayloadValue(item[key]))} `)
+ .join("")}
+
+ `)
+ .join("")}
+
+ `;
+}
+
+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 `
Tasks `;
+ }
+
+ if (tab === "output") {
+ return `
Output `;
+ }
+
+ if (tab === "git_log") {
+ return `
Git Log `;
+ }
+
+ return `
${escapeHtml(routeLabel(tab))} `;
+}
+
+function renderLanguageOptions() {
+ return state.supportedUiLanguages
+ .map((language) => {
+ const selected = language.code === state.uiLanguage ? " selected" : "";
+ return `
${escapeHtml(language.code.toUpperCase())} `;
+ })
+ .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("&", "&")
diff --git a/test/bds/desktop/shell_commands_test.exs b/test/bds/desktop/shell_commands_test.exs
new file mode 100644
index 0000000..ce2bb67
--- /dev/null
+++ b/test/bds/desktop/shell_commands_test.exs
@@ -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
diff --git a/test/bds/desktop_test.exs b/test/bds/desktop_test.exs
index 9601a47..ebae4f2 100644
--- a/test/bds/desktop_test.exs
+++ b/test/bds/desktop_test.exs
@@ -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
diff --git a/test/bds/tasks_test.exs b/test/bds/tasks_test.exs
index 649bc9f..1a72865 100644
--- a/test/bds/tasks_test.exs
+++ b/test/bds/tasks_test.exs
@@ -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}
diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs
index 348bf54..d802752 100644
--- a/test/bds/ui/shell_test.exs
+++ b/test/bds/ui/shell_test.exs
@@ -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
diff --git a/test/bds/ui/workbench_test.exs b/test/bds/ui/workbench_test.exs
index 4b57894..3086406 100644
--- a/test/bds/ui/workbench_test.exs
+++ b/test/bds/ui/workbench_test.exs
@@ -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