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 = `
- ${tabs - .map( - (tab) => ` - - ` - ) - .join("")} + ${tabs.map((tab) => renderPanelTab(tab)).join("")}
+ ${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 ` +
+
+ ${escapeHtml(task.name)} + ${escapeHtml(statusLabel(task.status))} +
+ ${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(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)} + + ${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 ` +
+ +
+
+

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

+ ${renderKeyedEntries(payload.missing, ["post_id", "language"])} +
+
+

Orphan Files

+ ${renderStringList(payload.orphan_files, "No orphan translation files")} +
+ `; + case "find_duplicates": + return ` +
+ +
+
+

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 ``; +} + +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 ``; + } + + if (tab === "output") { + return ``; + } + + if (tab === "git_log") { + return ``; + } + + return ``; +} + +function renderLanguageOptions() { + return state.supportedUiLanguages + .map((language) => { + const selected = language.code === state.uiLanguage ? " selected" : ""; + return ``; + }) + .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