diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex
index 87e4455..8c29a98 100644
--- a/lib/bds/desktop/shell_live.ex
+++ b/lib/bds/desktop/shell_live.ex
@@ -44,7 +44,9 @@ defmodule BDS.Desktop.ShellLive do
@impl true
def mount(_params, _session, socket) do
- if connected?(socket) do
+ connected = connected?(socket)
+
+ if connected do
:timer.send_interval(@refresh_interval, :refresh_task_status)
end
@@ -55,7 +57,8 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:page_title, ShellData.title())
|> assign(:page_language, ShellData.ui_language())
|> assign(:client_shortcuts, Commands.client_shortcuts())
- |> assign(:offline_mode, AI.airplane_mode?(true))
+ |> assign(:offline_mode, if(connected, do: AI.airplane_mode?(true), else: true))
+ |> assign(:handled_task_results, initial_handled_task_results())
|> assign(:assistant_prompt, "")
|> assign(:assistant_messages, [])
|> assign(:is_mac_ui, mac_ui?())
@@ -1003,19 +1006,30 @@ defmodule BDS.Desktop.ShellLive do
@impl true
def handle_info(:refresh_task_status, socket) do
- task_status = BDS.Tasks.status_snapshot()
+ raw_task_status = BDS.Tasks.status_snapshot()
- {:noreply,
- socket
- |> assign(:task_status, task_status)
- |> assign(:editor_meta, ShellData.editor_meta(task_status))
- |> assign(
- :status,
- ShellData.status_bar(socket.assigns.workbench, task_status, socket.assigns.dashboard,
- ui_language: socket.assigns.page_language,
- offline_mode: socket.assigns.offline_mode
- )
- )}
+ case next_completed_task_result(socket, raw_task_status) do
+ nil ->
+ task_status = localize_task_status(raw_task_status, socket.assigns.page_language)
+
+ {:noreply,
+ socket
+ |> assign(:task_status, task_status)
+ |> assign(:editor_meta, ShellData.editor_meta(task_status))
+ |> assign(
+ :status,
+ ShellData.status_bar(socket.assigns.workbench, task_status, socket.assigns.dashboard,
+ ui_language: socket.assigns.page_language,
+ offline_mode: socket.assigns.offline_mode
+ )
+ )}
+
+ task ->
+ {:noreply,
+ socket
+ |> mark_task_result_handled(task.id)
+ |> apply_shell_command_result(task.result)}
+ end
end
@impl true
@@ -1031,10 +1045,17 @@ defmodule BDS.Desktop.ShellLive do
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data = ShellData.sidebar_view(projects.active_project_id, active_view_id, ShellSidebarState.current_filters(socket, active_view_id))
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
- task_status = BDS.Tasks.status_snapshot()
+ raw_task_status = BDS.Tasks.status_snapshot()
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
page_language = socket.assigns[:page_language] || ShellData.ui_language()
- offline_mode = Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
+ offline_mode =
+ if connected?(socket) do
+ Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
+ else
+ Map.get(socket.assigns, :offline_mode, true)
+ end
+
+ task_status = localize_task_status(raw_task_status, page_language)
socket
|> assign(:workbench, workbench)
@@ -1118,9 +1139,15 @@ defmodule BDS.Desktop.ShellLive do
<%= task.message || task.group_name || "" %>
+ <%= if is_number(task.progress) do %>
+
+
+
<%= Map.get(task, :progress_label, progress_percent(task.progress)) %>
+
+ <% end %>
<% end %>
@@ -1574,17 +1601,17 @@ defmodule BDS.Desktop.ShellLive do
|> Workbench.set_panel_tab(String.to_existing_atom(panel_tab))
socket
- |> append_output_entry(title, message)
+ |> append_output_entry(translate_for_socket(socket, title), translate_for_socket(socket, message))
|> reload_shell(workbench)
end
defp apply_shell_command_result(socket, %{kind: "output", title: title, message: message} = result) do
socket
- |> append_output_entry(title, message, Map.get(result, :details), Map.get(result, :level, "info"))
+ |> append_output_entry(translate_for_socket(socket, title), translate_for_socket(socket, message), Map.get(result, :details), Map.get(result, :level, "info"))
end
defp apply_shell_command_result(socket, %{kind: "open_url", title: title, message: message, url: url}) do
- append_output_entry(socket, title, message, url)
+ append_output_entry(socket, translate_for_socket(socket, title), translate_for_socket(socket, message), url)
end
defp apply_shell_command_result(socket, %{kind: "open_editor", route: route, title: title, subtitle: subtitle} = result) do
@@ -1594,12 +1621,12 @@ defmodule BDS.Desktop.ShellLive do
tab_meta =
Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{
- title: title,
- subtitle: subtitle,
+ title: translate_for_socket(socket, title),
+ subtitle: translate_for_socket(socket, subtitle),
action: Map.get(result, :action),
payload: Map.get(result, :payload),
project_id: Map.get(result, :project_id),
- editor_meta: Map.get(result, :editorMeta, [])
+ editor_meta: translate_editor_meta(Map.get(result, :editorMeta, []), socket.assigns.page_language)
})
socket
@@ -1609,6 +1636,90 @@ defmodule BDS.Desktop.ShellLive do
defp apply_shell_command_result(socket, _result), do: socket
+ defp initial_handled_task_results do
+ BDS.Tasks.status_snapshot()
+ |> Map.get(:tasks, [])
+ |> Enum.filter(fn task -> task.status == :completed and is_map(task.result) end)
+ |> Enum.map(& &1.id)
+ |> MapSet.new()
+ end
+
+ defp next_completed_task_result(socket, task_status) do
+ handled = Map.get(socket.assigns, :handled_task_results, MapSet.new())
+
+ Enum.find(Map.get(task_status, :tasks, []), fn task ->
+ task.status == :completed and is_map(task.result) and not MapSet.member?(handled, task.id)
+ end)
+ end
+
+ defp mark_task_result_handled(socket, task_id) do
+ handled = Map.get(socket.assigns, :handled_task_results, MapSet.new())
+ assign(socket, :handled_task_results, MapSet.put(handled, task_id))
+ end
+
+ defp localize_task_status(task_status, locale) do
+ tasks = Enum.map(Map.get(task_status, :tasks, []), &localize_task(&1, locale))
+ active = Enum.filter(tasks, &(&1.status in [:running, :pending]))
+
+ task_status
+ |> Map.put(:tasks, tasks)
+ |> Map.put(:running_task_message, localized_running_task_message(active, locale))
+ end
+
+ defp localize_task(task, locale) do
+ progress = Map.get(task, :progress)
+
+ task
+ |> Map.put(:name, ShellData.translate(task.name, %{}, locale))
+ |> Map.put(:message, localize_task_message(Map.get(task, :message), locale))
+ |> Map.put(:group_name, localize_task_group(Map.get(task, :group_name), locale))
+ |> Map.put(:status_label, localize_task_status_label(task.status, locale))
+ |> Map.put(:progress_label, if(is_number(progress), do: progress_percent(progress), else: nil))
+ end
+
+ defp localize_task_message(nil, _locale), do: nil
+ defp localize_task_message("", _locale), do: ""
+ defp localize_task_message(message, locale), do: ShellData.translate(message, %{}, locale)
+
+ defp localize_task_group(nil, _locale), do: nil
+ defp localize_task_group(group, locale), do: ShellData.translate(group, %{}, locale)
+
+ defp localize_task_status_label(status, locale) do
+ status
+ |> to_string()
+ |> String.capitalize()
+ |> ShellData.translate(%{}, locale)
+ end
+
+ defp localized_running_task_message([], _locale), do: nil
+
+ defp localized_running_task_message([task | _rest], locale) do
+ cond do
+ task.status == :pending -> ShellData.translate("Queued", %{}, locale) <> ": " <> task.name
+ is_binary(task.message) and task.message != "" -> task.name <> ": " <> task.message
+ true -> task.name
+ end
+ end
+
+ defp translate_editor_meta(items, locale) do
+ Enum.map(items, fn item ->
+ item
+ |> Map.update(:label, nil, &ShellData.translate(&1, %{}, locale))
+ |> Map.update(:value, nil, &translate_editor_meta_value(&1, locale))
+ end)
+ end
+
+ defp translate_editor_meta_value(value, locale) when is_binary(value), do: ShellData.translate(value, %{}, locale)
+ defp translate_editor_meta_value(value, _locale), do: value
+
+ defp translate_for_socket(socket, text) when is_binary(text), do: ShellData.translate(text, %{}, socket.assigns.page_language)
+ defp translate_for_socket(_socket, text), do: text
+
+ defp progress_percent(progress) when is_number(progress) do
+ percentage = progress |> Kernel.*(100) |> round()
+ Integer.to_string(percentage) <> "%"
+ end
+
defp command_title(action) do
action
|> to_string()
diff --git a/priv/i18n/locales/de.json b/priv/i18n/locales/de.json
index c7d80bc..748b107 100644
--- a/priv/i18n/locales/de.json
+++ b/priv/i18n/locales/de.json
@@ -172,7 +172,11 @@
"Menu": "Menü",
"Metadata": "Metadaten",
"Metadata Diff": "Metadaten-Diff",
+ "Metadata diff complete": "Metadaten-Diff abgeschlossen",
"Metadata flush, diffing, and rebuild hooks still need editor wiring.": "Metadaten-Schreiben, Diffing und Rebuild-Hooks brauchen noch die Editor-Anbindung.",
+ "Comparing database and filesystem metadata": "Vergleicht Datenbank- und Dateisystem-Metadaten",
+ "Database state compared against filesystem metadata": "Datenbankstatus mit Dateisystem-Metadaten verglichen",
+ "Maintenance": "Wartung",
"Missing": "Fehlend",
"Missing Pages": "Fehlende Seiten",
"Missing Translations": "Fehlende Übersetzungen",
@@ -206,10 +210,14 @@
"Published": "Veröffentlicht",
"Published Feb 10, 2026": "Veröffentlicht am 10. Feb. 2026",
"Queued": "In Warteschlange",
+ "Pending": "Ausstehend",
"Regenerate Calendar": "Kalender neu erzeugen",
"Retrospective": "Rückblick",
"Roadmap": "Fahrplan",
"Running": "Läuft",
+ "Completed": "Abgeschlossen",
+ "Failed": "Fehlgeschlagen",
+ "Cancelled": "Abgebrochen",
"Script": "Skript",
"Scripts": "Skripte",
"Select Project": "Projekt auswählen",
@@ -217,7 +225,9 @@
"Settings": "Einstellungen",
"Sidebar, tabs, panel, and assistant panes are inspectable DOM regions": "Seitenleiste, Tabs, Panel und Assistentenbereiche sind als DOM-Regionen inspizierbar",
"Site Validation": "Website-Validierung",
+ "Validation": "Validierung",
"Source Control": "Quellcodeverwaltung",
+ "Embeddings": "Embeddings",
"Stale": "Veraltet",
"Stale Pages": "Veraltete Seiten",
"Slug": "Slug",
diff --git a/priv/i18n/locales/en.json b/priv/i18n/locales/en.json
index 51d1fd3..811739b 100644
--- a/priv/i18n/locales/en.json
+++ b/priv/i18n/locales/en.json
@@ -172,7 +172,11 @@
"Menu": "Menu",
"Metadata": "Metadata",
"Metadata Diff": "Metadata Diff",
+ "Metadata diff complete": "Metadata diff complete",
"Metadata flush, diffing, and rebuild hooks still need editor wiring.": "Metadata flush, diffing, and rebuild hooks still need editor wiring.",
+ "Comparing database and filesystem metadata": "Comparing database and filesystem metadata",
+ "Database state compared against filesystem metadata": "Database state compared against filesystem metadata",
+ "Maintenance": "Maintenance",
"Missing": "Missing",
"Missing Pages": "Missing Pages",
"Missing Translations": "Missing Translations",
@@ -206,10 +210,14 @@
"Published": "Published",
"Published Feb 10, 2026": "Published Feb 10, 2026",
"Queued": "Queued",
+ "Pending": "Pending",
"Regenerate Calendar": "Regenerate Calendar",
"Retrospective": "Retrospective",
"Roadmap": "Roadmap",
"Running": "Running",
+ "Completed": "Completed",
+ "Failed": "Failed",
+ "Cancelled": "Cancelled",
"Script": "Script",
"Scripts": "Scripts",
"Select Project": "Select Project",
@@ -217,7 +225,9 @@
"Settings": "Settings",
"Sidebar, tabs, panel, and assistant panes are inspectable DOM regions": "Sidebar, tabs, panel, and assistant panes are inspectable DOM regions",
"Site Validation": "Site Validation",
+ "Validation": "Validation",
"Source Control": "Source Control",
+ "Embeddings": "Embeddings",
"Stale": "Stale",
"Stale Pages": "Stale Pages",
"Slug": "Slug",
diff --git a/priv/i18n/locales/es.json b/priv/i18n/locales/es.json
index 258eca9..f0fae31 100644
--- a/priv/i18n/locales/es.json
+++ b/priv/i18n/locales/es.json
@@ -172,7 +172,11 @@
"Menu": "Menú",
"Metadata": "Metadatos",
"Metadata Diff": "Diff de metadatos",
+ "Metadata diff complete": "Diff de metadatos completado",
"Metadata flush, diffing, and rebuild hooks still need editor wiring.": "El guardado de metadatos, el diff y los hooks de reconstrucción todavía necesitan la conexión del editor.",
+ "Comparing database and filesystem metadata": "Comparando metadatos de la base de datos y del sistema de archivos",
+ "Database state compared against filesystem metadata": "Estado de la base de datos comparado con los metadatos del sistema de archivos",
+ "Maintenance": "Mantenimiento",
"Missing": "Faltante",
"Missing Pages": "Páginas faltantes",
"Missing Translations": "Traducciones faltantes",
@@ -206,10 +210,14 @@
"Published": "Publicado",
"Published Feb 10, 2026": "Publicado el 10 feb 2026",
"Queued": "En cola",
+ "Pending": "Pendiente",
"Regenerate Calendar": "Regenerar calendario",
"Retrospective": "Retrospectiva",
"Roadmap": "Hoja de ruta",
"Running": "En ejecución",
+ "Completed": "Completado",
+ "Failed": "Fallido",
+ "Cancelled": "Cancelado",
"Script": "Script",
"Scripts": "Scripts",
"Select Project": "Seleccionar proyecto",
@@ -217,7 +225,9 @@
"Settings": "Configuración",
"Sidebar, tabs, panel, and assistant panes are inspectable DOM regions": "La barra lateral, las pestañas, el panel y el asistente son regiones DOM inspeccionables",
"Site Validation": "Validación del sitio",
+ "Validation": "Validación",
"Source Control": "Control de código fuente",
+ "Embeddings": "Embeddings",
"Stale": "Desactualizado",
"Stale Pages": "Páginas desactualizadas",
"Slug": "Slug",
diff --git a/priv/i18n/locales/fr.json b/priv/i18n/locales/fr.json
index 346d360..39de281 100644
--- a/priv/i18n/locales/fr.json
+++ b/priv/i18n/locales/fr.json
@@ -172,7 +172,11 @@
"Menu": "Menu",
"Metadata": "Métadonnées",
"Metadata Diff": "Diff des métadonnées",
+ "Metadata diff complete": "Diff des métadonnées terminé",
"Metadata flush, diffing, and rebuild hooks still need editor wiring.": "L’écriture des métadonnées, le diff et les hooks de reconstruction ont encore besoin du câblage de l’éditeur.",
+ "Comparing database and filesystem metadata": "Comparaison des métadonnées entre la base et le système de fichiers",
+ "Database state compared against filesystem metadata": "État de la base comparé aux métadonnées du système de fichiers",
+ "Maintenance": "Maintenance",
"Missing": "Manquant",
"Missing Pages": "Pages manquantes",
"Missing Translations": "Traductions manquantes",
@@ -206,10 +210,14 @@
"Published": "Publié",
"Published Feb 10, 2026": "Publié le 10 févr. 2026",
"Queued": "En file",
+ "Pending": "En attente",
"Regenerate Calendar": "Régénérer le calendrier",
"Retrospective": "Rétrospective",
"Roadmap": "Feuille de route",
"Running": "En cours",
+ "Completed": "Terminé",
+ "Failed": "Échec",
+ "Cancelled": "Annulé",
"Script": "Script",
"Scripts": "Scripts",
"Select Project": "Sélectionner un projet",
@@ -217,7 +225,9 @@
"Settings": "Paramètres",
"Sidebar, tabs, panel, and assistant panes are inspectable DOM regions": "La barre latérale, les onglets, le panneau et l’assistant sont des régions DOM inspectables",
"Site Validation": "Validation du site",
+ "Validation": "Validation",
"Source Control": "Contrôle de source",
+ "Embeddings": "Embeddings",
"Stale": "Obsolète",
"Stale Pages": "Pages obsolètes",
"Slug": "Slug",
diff --git a/priv/i18n/locales/it.json b/priv/i18n/locales/it.json
index 4881032..e301021 100644
--- a/priv/i18n/locales/it.json
+++ b/priv/i18n/locales/it.json
@@ -172,7 +172,11 @@
"Menu": "Menu",
"Metadata": "Metadati",
"Metadata Diff": "Diff metadati",
+ "Metadata diff complete": "Diff metadati completato",
"Metadata flush, diffing, and rebuild hooks still need editor wiring.": "Il salvataggio dei metadati, il diff e gli hook di ricostruzione hanno ancora bisogno del collegamento nell’editor.",
+ "Comparing database and filesystem metadata": "Confronto tra i metadati del database e del filesystem",
+ "Database state compared against filesystem metadata": "Stato del database confrontato con i metadati del filesystem",
+ "Maintenance": "Manutenzione",
"Missing": "Mancante",
"Missing Pages": "Pagine mancanti",
"Missing Translations": "Traduzioni mancanti",
@@ -206,10 +210,14 @@
"Published": "Pubblicato",
"Published Feb 10, 2026": "Pubblicato il 10 feb 2026",
"Queued": "In coda",
+ "Pending": "In attesa",
"Regenerate Calendar": "Rigenera calendario",
"Retrospective": "Retrospettiva",
"Roadmap": "Roadmap",
"Running": "In esecuzione",
+ "Completed": "Completato",
+ "Failed": "Fallito",
+ "Cancelled": "Annullato",
"Script": "Script",
"Scripts": "Script",
"Select Project": "Seleziona progetto",
@@ -217,7 +225,9 @@
"Settings": "Impostazioni",
"Sidebar, tabs, panel, and assistant panes are inspectable DOM regions": "Barra laterale, schede, pannello e assistente sono regioni DOM ispezionabili",
"Site Validation": "Validazione sito",
+ "Validation": "Validazione",
"Source Control": "Controllo del codice sorgente",
+ "Embeddings": "Embeddings",
"Stale": "Obsoleto",
"Stale Pages": "Pagine obsolete",
"Slug": "Slug",
diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs
index fe2562d..3745116 100644
--- a/test/bds/desktop/shell_live_test.exs
+++ b/test/bds/desktop/shell_live_test.exs
@@ -839,6 +839,82 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(class="task-list") or html =~ "No background tasks running"
end
+ test "metadata diff tasks localize task text, show progress, and open the diff result in the UI" do
+ parent = self()
+ :ok = BDS.Tasks.clear_finished()
+
+ {:ok, _task} =
+ BDS.Tasks.submit_task(
+ "Metadata Diff",
+ fn report ->
+ send(parent, {:metadata_diff_worker, self()})
+ report.(0.35, "Comparing database and filesystem metadata")
+
+ receive do
+ :finish ->
+ %{
+ kind: "open_editor",
+ action: "metadata_diff",
+ project_id: "test-project",
+ route: "metadata_diff",
+ title: "Metadata Diff",
+ subtitle: "Database state compared against filesystem metadata",
+ editorMeta: [
+ %{label: "Diffs", value: "1"},
+ %{label: "Orphans", value: "1"}
+ ],
+ payload: %{
+ summary: %{diff_count: 1, orphan_count: 1},
+ diff_reports: [
+ %{
+ entity_type: "post",
+ entity_id: "post-1",
+ differences: [
+ %{field: "slug", db_value: "hello-db", file_value: "hello-file"}
+ ]
+ }
+ ],
+ orphan_reports: [
+ %{path: "posts/2026/04/orphan.md", entity_type: "post"}
+ ]
+ }
+ }
+ end
+ end,
+ %{group_name: "Maintenance"}
+ )
+
+ assert_receive {:metadata_diff_worker, worker_pid}
+
+ {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
+
+ _html = render_change(view, "change_ui_language", %{"ui_language" => "de"})
+
+ send(view.pid, :refresh_task_status)
+
+ html =
+ view
+ |> element("[data-testid='status-task-button']")
+ |> render_click()
+
+ assert html =~ "Metadaten-Diff"
+ assert html =~ "Vergleicht Datenbank- und Dateisystem-Metadaten"
+ assert html =~ "35%"
+ assert html =~ ~s(task-status-running)
+
+ send(worker_pid, :finish)
+ send(view.pid, :refresh_task_status)
+
+ html = render(view)
+
+ assert html =~ ~s(data-tab-type="metadata_diff")
+ assert html =~ "Metadaten-Diff"
+ assert html =~ "slug"
+ assert html =~ "hello-db"
+ assert html =~ "hello-file"
+ assert html =~ "posts/2026/04/orphan.md"
+ end
+
test "post tabs render a real editor and drive save publish discard flows", %{project: project} do
assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "alpha", color: "#112233"})
assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "beta", color: "#445566"})