fix: refactorings to remove anti-patterns

This commit is contained in:
2026-06-25 15:42:32 +02:00
parent d78cb5c07b
commit e3e94ac2d9
28 changed files with 1028 additions and 604 deletions

204
AUDIT.md Normal file
View File

@@ -0,0 +1,204 @@
# Elixir Antipattern Audit — Working Document
Verified 2026-06-25 against `lib/bds/` (233 modules). Every item below is a standalone task.
Follow `AGENTS.md`: test-first, fix all failures, clean build/credo/dialyzer/deps.audit.
---
## Task 1 — Delete the dead externally-driven task API
**File:** `lib/bds/tasks.ex` (573 lines, GenServer)
`tasks.ex` has two task patterns. One is live; one is dead.
- **Live (keep):** `submit_task/3` runs a work function under `Task.Supervisor`; the GenServer auto-tracks lifecycle via its `{ref, result}` and `{:DOWN, ...}` handlers, and progress flows through the `reporter` closure passed into the work function. Used by 8 call sites across 3 modules (`publishing.ex:57`; `auto_translation.ex:182,215`; `shell_commands.ex:83,94,219,418,554`). The read/control functions are also exposed through the Lua script API (`lib/bds/scripting/capabilities.ex:240-247`): `get`, `status_snapshot`, `cancel`, `get_all`, `get_running`, `clear_completed`.
- **Dead (delete):** the externally-driven pattern — register a task that runs elsewhere, then manually push progress and mark it complete/failed. Zero production callers; only `test/bds/tasks_test.exs` and `test/bds/scripting/api_test.exs` exercise it.
### Delete exactly these
Public functions and their matching `handle_call` clauses:
| Public function | `handle_call` clause |
|---|---|
| `register_external_task/2` (line 51) | line 166 |
| `report_progress/3` (line 55) | line 172 |
| `complete_task/2` (line 59) | line 176 |
| `fail_task/2` (line 63) | line 192 |
### Keep these — do NOT delete
- `submit_task/3`, `get_task/1`, `status_snapshot/0`, `list_tasks/0`, `list_running_tasks/0`, `cancel_task/1`, `clear_completed/0` — all live, most exposed via the script API.
- `clear_finished/0` (line 43) and its `handle_call` (line 120). It has zero production callers but is used as a test-reset helper in 5 test files (`tasks_test.exs:7`, `post_translations_test.exs:57`, `real_blog_rebuild_diagnostic_test.exs:41`, `shell_commands_test.exs:105`, `shell_live_test.exs:1409`). `clear_completed/0` is not a drop-in replacement — it clears only `:completed`, not `:failed`/`:cancelled`, and its semantics are documented in the script API.
- `maybe_report_progress/4` (private, line 347). It is shared: the `{:task_progress, ...}` `handle_info` (line 209) on the live path calls it. It does not become unused.
### Tests
- Delete only the test blocks in `tasks_test.exs` / `api_test.exs` that exclusively cover `register_external_task` / `report_progress` / `complete_task` / `fail_task`.
- Do NOT touch test setup/`on_exit` that calls `clear_finished/0` for reset — those tests do not cover it, they use it.
### Verify after
- `grep -rn "register_external_task\|complete_task\|fail_task" lib test` returns no hits.
- `grep -rn "Tasks.report_progress" lib test` returns no hits (`Maintenance.Progress.report_progress/4` and the script-side `bds.report_progress` are different functions — leave them).
- No deleted function is wired into `lib/bds/scripting/capabilities.ex` or `api_docs.ex` (confirmed: the script API exposes none of the four).
- Build/test/credo/dialyzer clean.
---
## Task 2 — Log silently-swallowed errors
**Pattern:** a `rescue`/`catch` clause that discards the error with no log line and returns a bare `:ok`, `nil`, or `:error` (e.g. `rescue _error -> :ok`, `catch :exit, _reason -> :ok`, `rescue _error -> nil`).
> **Inventory is non-exhaustive — treat the table as a starting set, not a closed list.**
> Before remediating, re-sweep with `grep -rnE "_(error|reason|exception|e) -> (nil|:ok|:error)" lib` (≈27 hits).
> The table below is not the full set — e.g. `main_window.ex` 174/175 returns `nil`, and `_error -> nil`
> sites exist in `scripting/capabilities/app_shell.ex:142` and `settings_editor/ai_settings.ex:174`. Triage all hits, not just the table.
| File | Lines | Clause / return |
|------|-------|-----------------|
| `lib/bds/desktop/shutdown.ex` | 5 `rescue` + 5 `catch` pairs: 27/29, 104/106, 122/124, 135/137, 160/162 | `_error -> :ok` / `:exit,_reason -> :ok` |
| `lib/bds/desktop/main_window.ex` | 24/25 | `catch :exit,_reason -> :ok` |
| `lib/bds/desktop/deep_link.ex` | 68/69 | `catch :exit,_reason -> :ok` |
| `lib/bds/git.ex` | 564/565 | `catch :error,_reason -> :ok` |
| `lib/bds/desktop/automation.ex` | 15/16, 222, 370/371 | `catch :exit -> :ok`, `rescue -> :ok`, `catch :error -> :ok` |
| `lib/bds/preview.ex` | 448 | `rescue _error -> :ok` |
| `lib/bds/desktop/shell_live/post_editor/list_values.ex` | 93 | `_error -> :ok` |
| `lib/bds/desktop/shell_live/import_editor/analysis_state.ex` | 283 | `_error -> :ok` |
| `lib/bds/desktop/shell_live/import_editor.ex` | 1420 | `_error -> :ok` |
| `lib/bds/desktop/shell_live/chat_editor.ex` | 999 | `_error -> :ok` |
| `lib/bds/mcp/server.ex` | 338 (`rescue` keyword), body 339 | `_error -> :ok` |
| `lib/bds/embeddings/index.ex` | 347/348 → `:ok`, **387/388 → `:error`** | both `rescue _exception -> ...` |
**Action:** add a log line to each swallowing clause without changing control flow or its return value. The recipe is per-clause, not a single find/replace — the var name and return value vary:
- `rescue`/`catch` returning `:ok` → bind the value and log, keep `:ok`: `error -> Logger.debug("swallowed in <ctx>: #{inspect(error)}"); :ok`
- The `index.ex:387` clause returns **`:error`**, not `:ok` — log, then keep returning `:error`. The `_error -> :ok` recipe does not apply here.
- `catch :exit,_reason`/`catch :error,_reason` clauses bind `_reason` (rename to `reason`), not `error`.
Use `Logger.warning` only where the error is genuinely unexpected.
**`shutdown.ex` / Wx-teardown exception:** the `shutdown.ex`, `main_window.ex`, and `deep_link.ex` catches wrap Wx event-table / `Desktop.Env` calls during teardown or headless startup. Use `Logger.debug` only — do not escalate to `warning`.
**Done when:** no `rescue`/`catch` clause in the listed files (plus any found by the re-sweep) discards its error without a log line, and every clause keeps its original return value.
---
## Task 3 — Add @spec to tasks.ex and embeddings.ex
| File | Public functions | Current @specs |
|------|------------------|----------------|
| `lib/bds/tasks.ex` | 10 non-callback public functions after Task 1 | 0 |
| `lib/bds/embeddings.ex` | 20 | 0 |
**Scope of "public functions":** spec every non-callback public function. In `tasks.ex` after deletion that is **10**: the 8 client-API functions (`submit_task/3`, `get_task/1`, `status_snapshot/0`, `list_tasks/0`, `list_running_tasks/0`, `clear_completed/0`, `clear_finished/0`, `cancel_task/1`) **plus** `start_link/1` (line 12) and `topic/0` (line 16) — `topic/0` is used outside the module (`shell_commands.ex:566`). Do NOT spec the three `@impl` GenServer callbacks (`init/1` line 67, `handle_call/3` line 79, `handle_info/2` line 208); `start_link/1` is **not** an `@impl` callback. (`tasks.ex` has 31 total public `def`s; 21 are callback clauses.) Limited to these two files — adjacent surfaces like `ai.ex` are already fully specced; this is not a repo-wide gap.
**Action:** add `@spec` to every public function. Run `mix dialyzer` to validate inferred types.
**Sequence:** do this after Task 1 so you don't spec the deleted functions.
**Done when:** 100% `@spec` coverage on public functions in both files; dialyzer clean.
---
## Task 4 — Dedup sidebar `maybe_where_all_*` helpers
**File:** `lib/bds/ui/sidebar.ex` (1,137 lines)
Three near-identical helpers — `maybe_where_all_tags/2` (429), `maybe_where_all_categories/2` (445), `maybe_where_all_media_tags/2` (695) — differing only in the JSON field name and one extra `AND lower(value) != 'page'` condition.
**Action:** extract one parametric helper, keeping the `(query, []) -> query` empty-list clauses. All three queries use a single positional binding, so do NOT pass a binding argument — parametrize over the JSON field atom and an optional "exclude page" flag, building the fragment with `field(x, ^field_atom)`:
```elixir
defp where_all_in_json_array(query, items, field_atom, exclude_page? \\ false)
```
Three ~15-line functions become one helper + three 2-line callers.
**Done when:** identical query output (verify via existing sidebar/search tests); build/credo clean.
---
## Task 5 — Enrich the AI facade @moduledoc (no code change)
**File:** `lib/bds/ai.ex` (210 lines)
This module is the stable public AI surface and the airplane-mode boundary required by `AGENTS.md`. It is NOT pure delegation: 8 functions hold real logic — `put_endpoint`, `get_endpoint`, `delete_endpoint`, `set_airplane_mode`, `airplane_mode?`, `airplane_endpoint_configured?`, `put_model_preference`, `get_model_preference`. It is used by 6 production modules (`posts/auto_translation.ex`, `scripting/capabilities/bridges.ex`, `chat_editor/message_build.ex`, `chat_editor/model_selection.ex`, `settings_editor/ai_settings.ex`, `sidebar_create.ex`). The MCP server does not use it (0 `BDS.AI` references in `lib/bds/mcp/`).
A `@moduledoc` already exists (line 2) but only describes endpoint/catalog/dispatch. **Action:** extend it to state that this module owns the airplane-mode / endpoint / model-preference logic and is the stable public AI surface, with `Catalog`/`OneShot`/`Chat` as internals. No code change.
---
## Task 6 — WITHDRAWN (already handled)
Original claim: `Task.async/1` in `sidebar_create.ex:50` result is never handled.
**False on current HEAD.** The parent LiveView already handles both the task
reply and the monitor-down path: `handle_info({ref, result}, ...)` at
`shell_live.ex:563` (→ `handle_file_picker_result/2`) and
`handle_info({:DOWN, ref, ...}, ...)` at `shell_live.ex:585`, both keyed on the
stored `file_picker_task` ref. The result is not lost.
Only remaining nit (not worth its own task): the picker uses `Task.async`
(linked) rather than `Task.Supervisor.async_nolink`, so a crash in the picker
task *could* take down the LiveView before `{:DOWN}` fires. Low-risk, not
zero-risk: `file_picker.ex` is a simple path that mostly returns tagged tuples.
No reproduced failure mode and no separate task — if `sidebar_create.ex:50` is
edited for another reason, switch `Task.async``Task.Supervisor.async_nolink`
opportunistically.
---
## Task 7 — Dedup `search_posts` / `search_media`
**File:** `lib/bds/search.ex` (862 lines)
Mirrored trios: `search_posts/3` (108) → `search_posts_blank/2` (118) → `search_posts_fts/3` (139), and `search_media/3` (182) → `search_media_blank/2` (192) → `search_media_fts/3` (213), plus `apply_post_filters/2` (254) / `apply_media_filters/2` (267). Same CTE+join FTS *pattern*.
**The shapes are NOT identical** (earlier "common `%{entities:}`" claim was wrong):
- Return maps differ by key: `%{posts: ...}` (132, 167) vs `%{media: ...}` (206, 241) — no shared `entities:` key.
- FTS subquery select + join keys differ: `post_id` (146, 152) vs `media_id` (220, 226).
**Action:** do NOT force one broad `search_entities/5`. Extract the genuinely shared *pieces* instead — blank-search query builder, FTS CTE/join setup (parametrized by FTS table + join-key column), and pagination assembly — and let each entity keep its own result key and filter function. Smaller shared helpers, not one generic mega-function.
**Caution:** FTS SQL and filter semantics must stay byte-identical per entity, and each public function must keep returning its existing `%{posts:}` / `%{media:}` shape — verify against existing search tests before and after.
**Done when:** all search tests pass unchanged; net line reduction; credo clean.
---
## Task 8 — Extract api_docs static data (only if recompile hurts)
**File:** `lib/bds/scripting/api_docs.ex` (1,554 lines)
A `@methods` module attribute of map literals, surfaced by a public `render/0`
(`api_docs.ex:1211`); any API change forces a full recompile. (The module is
not inert static data — the earlier "0 public functions" note was wrong.)
**Conditional, not a default cleanup.** Extracting to `priv/api_docs/methods.yaml`
(or JSON) loaded at startup trades compile-time structure for runtime schema
validation — a tradeoff, not a clear win. **Only do this after measuring actual
compile pain.** If recompile isn't hurting, skip. The API-docs-sync tests
(`AGENTS.md`) must still pass against the loaded data.
---
## Task 9 — Split oversized files (one per PR)
Guideline: no file > ~600 lines; split by responsibility.
| File | Lines | Notes |
|------|-------|-------|
| `scripting/api_docs.ex` | 1,554 | mostly data; if Task 8 runs it shrinks, but Task 8 is conditional/skippable — if skipped, split the non-data code here instead |
| `desktop/shell_live.ex` | 1,466 | 68 public functions — split into sub-modules; do this first |
| `ui/sidebar.ex` | 1,137 | shrinks after Task 4 |
| `ai/chat_tools.ex` | 1,096 | review for splitting |
| `ai/chat.ex` | 1,024 | review for splitting |
| `search.ex` | 862 | shrinks after Task 7; split → `search/posts.ex`, `search/media.ex`, `search/filters.ex` |
**Action:** lowest priority; one file per change set, each its own reviewable PR. Start with `shell_live.ex`.
---
## Order
1. Task 1 — delete dead task API (pure deletion, no behavior risk)
2. Task 2 — log swallowed errors
3. Task 3 — @specs (after Task 1)
4. Task 4 — sidebar dedup
5. Task 5 — AI facade moduledoc
6. ~~Task 6~~ — withdrawn (already handled in `shell_live.ex`)
7. Task 7 — search dedup
8. Task 8 — api_docs extract (conditional: only if recompile pain is measured)
9. Task 9 — file splitting (ongoing, lowest priority)

View File

@@ -1,7 +1,15 @@
defmodule BDS.AI do
@moduledoc """
Public interface for AI features — endpoint configuration, secret management,
model catalog access, and dispatching chat and one-shot inference requests.
Stable public interface for AI features.
This facade owns the app-level AI state that other modules are expected to
depend on directly: endpoint configuration, secret management, airplane-mode
gating, and per-feature model preferences, alongside model catalog access and
dispatching chat and one-shot inference requests.
`BDS.AI.Catalog`, `BDS.AI.OneShot`, and `BDS.AI.Chat` are internal
implementation modules behind this boundary. Callers should treat `BDS.AI` as
the durable surface for AI behavior and configuration.
"""
alias BDS.AI.Catalog

View File

@@ -2,6 +2,7 @@ defmodule BDS.Desktop.Automation do
@moduledoc false
use GenServer
require Logger
@ready_timeout 60_000
@request_timeout 30_000
@@ -13,7 +14,9 @@ defmodule BDS.Desktop.Automation do
def stop_session(session) do
GenServer.stop(session, :normal, @request_timeout)
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed desktop automation stop_session exit: #{inspect(reason)}")
:ok
end
def snapshot(session) do
@@ -220,7 +223,12 @@ defmodule BDS.Desktop.Automation do
defp safe_driver_request(state, payload) do
driver_request(state, payload)
rescue
_error -> :ok
error ->
Logger.debug(
"swallowed desktop automation safe_driver_request error: #{inspect(error)} payload=#{inspect(payload)}"
)
:ok
end
defp shutdown_driver(state) do
@@ -368,7 +376,9 @@ defmodule BDS.Desktop.Automation do
defp safe_close_port(port) do
Port.close(port)
catch
:error, _reason -> :ok
:error, reason ->
Logger.debug("swallowed desktop automation safe_close_port error: #{inspect(reason)}")
:ok
end
defp normalize_simple_reply("ok"), do: :ok

View File

@@ -66,7 +66,9 @@ defmodule BDS.Desktop.DeepLink do
try do
Desktop.Env.subscribe()
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed deep_link Desktop.Env.subscribe exit: #{inspect(reason)}")
:ok
end
end

View File

@@ -4,6 +4,7 @@ defmodule BDS.Desktop.MainWindow do
use GenServer
alias Desktop.Window
require Logger
@window_id __MODULE__
@server_name BDS.Desktop.MainWindow.Watcher
@@ -22,7 +23,9 @@ defmodule BDS.Desktop.MainWindow do
def persist_now(timeout \\ 100) do
GenServer.call(@server_name, :persist_bounds_now, timeout)
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed main_window persist_now exit: #{inspect(reason)}")
:ok
end
def window_options(extra_opts \\ []) do
@@ -169,10 +172,17 @@ defmodule BDS.Desktop.MainWindow do
end
end)
rescue
ErlangError -> nil
FunctionClauseError -> nil
error in ErlangError ->
Logger.debug("swallowed main_window restore_bounds error: #{inspect(error)}")
nil
error in FunctionClauseError ->
Logger.debug("swallowed main_window restore_bounds error: #{inspect(error)}")
nil
catch
:exit, _reason -> nil
:exit, reason ->
Logger.debug("swallowed main_window restore_bounds exit: #{inspect(reason)}")
nil
end
end

View File

@@ -13,6 +13,7 @@ defmodule BDS.Desktop.ShellLive do
Bridges,
ChatEditor,
GalleryImport,
GitHandler,
ImportEditor,
MediaEditor,
MenuEditor,
@@ -22,6 +23,7 @@ defmodule BDS.Desktop.ShellLive do
SettingsEditor,
SidebarDelete,
TagsEditor,
TabActions,
TemplateEditor
}
@@ -34,13 +36,14 @@ defmodule BDS.Desktop.ShellLive do
alias BDS.Desktop.ShellLive.{
ChatSurface,
Layout,
PanelRenderer,
SessionUtil,
ShellCommandRunner,
SidebarCreate,
SocketState,
TabHelpers,
TaskLocalization,
TitlebarMenu
TitlebarMenu,
UrlState
}
import TaskLocalization,
@@ -64,8 +67,6 @@ defmodule BDS.Desktop.ShellLive do
@refresh_interval 1_500
def refresh_interval, do: @refresh_interval
@output_entry_limit 20
@sidebar_filter_events [
"toggle_sidebar_filters",
"toggle_sidebar_archive",
@@ -180,7 +181,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:panel_git_entries, [])
|> assign(:auto_save_timers, %{})
|> reload_shell(workbench)
|> apply_url_params(params)
|> UrlState.apply_params(params)
|> tap(&sync_menu_bar_locale/1)}
end
@@ -208,7 +209,7 @@ defmodule BDS.Desktop.ShellLive do
{:noreply,
socket
|> refresh_sidebar(workbench)
|> push_url_state()}
|> UrlState.push()}
end
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
@@ -241,17 +242,18 @@ defmodule BDS.Desktop.ShellLive do
end
def handle_event(event, _params, socket) when event in @git_action_events do
{:noreply, run_git_action(socket, event)}
{:noreply, GitHandler.run_action(socket, event)}
end
def handle_event("git_commit", params, socket) do
message = params |> get_in(["git", "message"]) |> to_string() |> String.trim()
{:noreply, commit_git(socket, message)}
{:noreply, GitHandler.commit(socket, message)}
end
def handle_event("git_initialize", params, socket) do
remote_url = params |> get_in(["git", "remote_url"]) |> normalize_git_remote_url()
{:noreply, initialize_git(socket, remote_url)}
remote_url = params |> get_in(["git", "remote_url"]) |> GitHandler.normalize_remote_url()
{:noreply, GitHandler.initialize(socket, remote_url)}
end
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
@@ -270,7 +272,7 @@ defmodule BDS.Desktop.ShellLive do
end
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
socket = auto_save_current_post(socket)
socket = TabActions.auto_save_current_post(socket)
workbench =
Workbench.open_tab(
@@ -286,11 +288,11 @@ defmodule BDS.Desktop.ShellLive do
socket
|> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench)
|> push_url_state()}
|> UrlState.push()}
end
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
socket = auto_save_current_post(socket)
socket = TabActions.auto_save_current_post(socket)
type_atom = BoundedAtoms.editor_route(type, :post)
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
@@ -300,7 +302,7 @@ defmodule BDS.Desktop.ShellLive do
socket
|> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench)
|> push_url_state()}
|> UrlState.push()}
end
def handle_event(
@@ -722,167 +724,9 @@ defmodule BDS.Desktop.ShellLive do
index(assigns)
end
defp refresh_layout(socket, workbench) do
git_badge_count = socket.assigns[:git_badge_count] || 0
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
defp refresh_layout(socket, workbench), do: SocketState.refresh_layout(socket, workbench)
task_status =
socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
page_language = socket.assigns[:page_language] || ShellData.ui_language()
offline_mode = Map.get(socket.assigns, :offline_mode, true)
sidebar_data = socket.assigns[:sidebar_data] || %{}
current_tab = current_tab(workbench)
prev_tab = socket.assigns[:current_tab]
prev_panel_tab =
case socket.assigns[:workbench] do
%Workbench{panel: %{active_tab: tab}} -> tab
_ -> nil
end
socket =
socket
|> assign(:workbench, workbench)
|> assign(:activity_buttons, activity_buttons)
|> assign(
:sidebar_header,
active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data)
)
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|> assign(:current_tab, current_tab)
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|> assign(
:status,
ShellData.status_bar(workbench, task_status, dashboard,
ui_language: page_language,
offline_mode: offline_mode
)
)
if panel_data_stale?(current_tab, prev_tab, workbench.panel.active_tab, prev_panel_tab) do
refresh_panel_data(socket)
else
socket
end
end
defp panel_data_stale?(current_tab, prev_tab, panel_tab, prev_panel_tab) do
current_tab != prev_tab or panel_tab != prev_panel_tab
end
defp refresh_panel_data(socket) do
panel_tab = socket.assigns.workbench.panel.active_tab
socket
|> assign(
:panel_post_links,
if(panel_tab == :post_links,
do: PanelRenderer.fetch_post_link_entries(socket.assigns),
else: socket.assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
)
)
|> assign(
:panel_git_entries,
if(panel_tab == :git_log,
do: PanelRenderer.fetch_git_log_entries(socket.assigns),
else: socket.assigns[:panel_git_entries] || []
)
)
end
defp push_url_state(socket) do
workbench = socket.assigns.workbench
params =
%{}
|> put_url_view(workbench.active_view)
|> put_url_tab(workbench.active_tab)
query = URI.encode_query(params)
path = if query == "", do: "/", else: "/?" <> query
push_event(socket, "url-state", %{path: path})
end
defp put_url_view(params, :posts), do: params
defp put_url_view(params, view), do: Map.put(params, "view", Atom.to_string(view))
defp put_url_tab(params, nil), do: params
defp put_url_tab(params, {type, id}),
do: Map.put(params, "tab", Atom.to_string(type) <> ":" <> id)
defp apply_url_params(socket, params) when is_map(params) and map_size(params) > 0 do
workbench = socket.assigns.workbench
workbench = apply_url_view(workbench, Map.get(params, "view"))
workbench = apply_url_tab(workbench, Map.get(params, "tab"))
if workbench == socket.assigns.workbench do
socket
else
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
socket
|> assign(:tab_meta, tab_meta)
|> refresh_sidebar(workbench)
end
end
defp apply_url_params(socket, _params), do: socket
defp apply_url_view(workbench, nil), do: workbench
defp apply_url_view(workbench, view_str) do
view = BoundedAtoms.sidebar_view(view_str, nil)
if view && view != workbench.active_view do
Workbench.click_activity(workbench, view)
else
workbench
end
end
defp apply_url_tab(workbench, nil), do: workbench
defp apply_url_tab(workbench, tab_str) do
case String.split(tab_str, ":", parts: 2) do
[type_str, id] ->
type = BoundedAtoms.editor_route(type_str, nil)
if type && workbench.active_tab != {type, id} do
Workbench.open_tab(workbench, type, id, :preview)
else
workbench
end
_ ->
workbench
end
end
defp refresh_sidebar(socket, workbench) do
project_id = (socket.assigns[:projects] || %{})[:active_project_id]
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data =
case ShellData.sidebar_view(
project_id,
active_view_id,
ShellSidebarState.current_filters(socket, active_view_id)
) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
end
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
socket
|> assign(:sidebar_data, sidebar_data)
|> refresh_layout(workbench)
end
defp refresh_sidebar(socket, workbench), do: SocketState.refresh_sidebar(socket, workbench)
defp refresh_content(socket, workbench) do
projects =
@@ -971,12 +815,6 @@ defmodule BDS.Desktop.ShellLive do
defp activity_label("Source Control"), do: dgettext("ui", "Git")
defp activity_label(label), do: label
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
Enum.find_value(activity_buttons, Map.get(sidebar_data, :title, ""), fn button ->
if button.id == active_view, do: activity_label(button.label), else: nil
end)
end
defp sidebar_header_label(label), do: label
defp timeline_height(entry, entries) do
@@ -988,12 +826,6 @@ defmodule BDS.Desktop.ShellLive do
max(4, (entry.count || 0) / max_count * 100)
end
defp current_tab(%{active_tab: nil}), do: nil
defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do
Enum.find(tabs, &(&1.type == type and &1.id == id))
end
defp create_sidebar_item(socket, kind),
do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())
@@ -1097,125 +929,11 @@ defmodule BDS.Desktop.ShellLive do
socket
|> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench)
|> push_url_state()
end
defp run_git_action(socket, event) do
project_id = current_project_id(socket)
{label, result} =
case event do
"git_fetch" -> {dgettext("ui", "Fetch"), git_call(project_id, &BDS.Git.fetch/1)}
"git_pull" -> {dgettext("ui", "Pull"), git_call(project_id, &BDS.Git.pull/1)}
"git_push" -> {dgettext("ui", "Push"), git_call(project_id, &BDS.Git.push/1)}
"git_prune_lfs" -> {dgettext("ui", "Prune LFS"), prune_lfs(project_id)}
end
socket
|> append_git_result(label, result)
|> refresh_sidebar(socket.assigns.workbench)
end
defp commit_git(socket, "") do
socket
|> append_output_entry(
dgettext("ui", "Commit"),
dgettext("ui", "Commit message is required"),
nil,
"error"
)
|> refresh_sidebar(socket.assigns.workbench)
end
defp commit_git(socket, message) do
case git_call(current_project_id(socket), &BDS.Git.commit_all(&1, message)) do
{:ok, _result} ->
workbench = close_git_diff_tabs(socket.assigns.workbench)
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
socket
|> assign(:tab_meta, tab_meta)
|> append_output_entry(dgettext("ui", "Commit"), message)
|> refresh_sidebar(workbench)
|> push_url_state()
{:error, reason} ->
socket
|> append_output_entry(dgettext("ui", "Commit"), format_git_error(reason), nil, "error")
|> refresh_sidebar(socket.assigns.workbench)
end
end
defp initialize_git(socket, remote_url) do
project_id = current_project_id(socket)
case git_call(project_id, &BDS.Git.initialize_repo/1) do
{:ok, _repo} ->
_ = maybe_set_git_remote(project_id, remote_url)
socket
|> append_output_entry(
dgettext("ui", "Initialize Git"),
dgettext("ui", "Repository initialized")
)
|> refresh_sidebar(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output_entry(
dgettext("ui", "Initialize Git"),
format_git_error(reason),
nil,
"error"
)
|> refresh_sidebar(socket.assigns.workbench)
end
end
defp git_call(nil, _fun), do: {:error, :no_project}
defp git_call("default", _fun), do: {:error, :no_project}
defp git_call(project_id, fun) when is_binary(project_id), do: fun.(project_id)
defp prune_lfs(nil), do: {:error, :no_project}
defp prune_lfs("default"), do: {:error, :no_project}
defp prune_lfs(project_id) when is_binary(project_id),
do: BDS.Git.prune_lfs_cache(project_id, 10)
defp maybe_set_git_remote(_project_id, nil), do: :ok
defp maybe_set_git_remote(project_id, remote_url),
do: BDS.Git.set_remote(project_id, remote_url)
defp append_git_result(socket, label, {:ok, _result}) do
append_output_entry(socket, label, dgettext("ui", "Done"))
end
defp append_git_result(socket, label, {:error, reason}) do
append_output_entry(socket, label, format_git_error(reason), nil, "error")
end
defp format_git_error(:no_project), do: dgettext("ui", "No active project")
defp format_git_error(%{message: message}) when is_binary(message), do: message
defp format_git_error(%{guidance: guidance}) when is_binary(guidance), do: guidance
defp format_git_error({:git_failed, message}) when is_binary(message), do: message
defp format_git_error(reason), do: inspect(reason)
defp close_git_diff_tabs(workbench) do
workbench.tabs
|> Enum.filter(&(&1.type == :git_diff))
|> Enum.reduce(workbench, fn tab, wb -> Workbench.close_tab(wb, :git_diff, tab.id) end)
|> UrlState.push()
end
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
defp normalize_git_remote_url(value) do
case value |> to_string() |> String.trim() do
"" -> nil
url -> url
end
end
defp sidebar_create_action(view), do: SidebarCreate.action(view)
defp set_page_language(socket, language) do
@@ -1258,7 +976,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:sidebar_filters_by_view, %{})
|> append_output_entry(title, message_fun.(project))
|> reload_shell(Workbench.new())
|> push_url_state()
|> UrlState.push()
{:error, reason} ->
socket
@@ -1268,11 +986,8 @@ defmodule BDS.Desktop.ShellLive do
end
end
defp append_output_entry(socket, title, message, details \\ nil, level \\ "info") do
entry = %{title: title, message: message, details: details, level: level}
entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
assign(socket, :output_entries, entries)
end
defp append_output_entry(socket, title, message, details \\ nil, level \\ "info"),
do: SocketState.append_output_entry(socket, title, message, details, level)
defp handle_native_menu_action(socket, action) do
case BoundedAtoms.menu_action(action) do
@@ -1293,7 +1008,7 @@ defmodule BDS.Desktop.ShellLive do
socket
|> assign(:tab_meta, tab_meta)
|> refresh_sidebar(workbench)
|> push_url_state()
|> UrlState.push()
MapSet.member?(@socket_menu_actions, action) ->
handle_socket_menu_action(socket, action)
@@ -1317,8 +1032,10 @@ defmodule BDS.Desktop.ShellLive do
defp handle_socket_menu_action(socket, :new_post), do: create_sidebar_item(socket, "post")
defp handle_socket_menu_action(socket, :import_media), do: create_sidebar_item(socket, "media")
defp handle_socket_menu_action(socket, :save), do: save_current_tab(socket)
defp handle_socket_menu_action(socket, :publish_selected), do: publish_current_tab(socket)
defp handle_socket_menu_action(socket, :save), do: TabActions.save_current_tab(socket)
defp handle_socket_menu_action(socket, :publish_selected), do: TabActions.publish_current_tab(socket)
defp handle_socket_menu_action(socket, :quit) do
Shutdown.request_quit()
@@ -1347,62 +1064,6 @@ defmodule BDS.Desktop.ShellLive do
defp shell_command?(action), do: not is_nil(shell_command_atom(action))
defp auto_save_current_post(
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
) do
if Workbench.dirty?(workbench, :post, post_id) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
end
socket
end
defp auto_save_current_post(socket), do: socket
defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do
send_update(MediaEditor, id: "media-editor-#{media_id}", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do
send_update(SettingsEditor, id: "settings-editor", action: :save_project)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :menu_editor}}} = socket) do
send_update(MenuEditor, id: "menu-editor", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :tags}}} = socket) do
send_update(TagsEditor, id: "tags-editor", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :scripts, id: script_id}}} = socket) do
send_update(ScriptEditor, id: "script-editor-#{script_id}", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :templates, id: template_id}}} = socket) do
send_update(TemplateEditor, id: "template-editor-#{template_id}", action: :save)
socket
end
defp save_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
defp publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :publish)
socket
end
defp publish_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
defp apply_shell_command(socket, action, params \\ %{}),
do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks())

View File

@@ -996,7 +996,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
rescue
_error -> :ok
error ->
Logger.debug("swallowed chat_editor allow_repo_sandbox error: #{inspect(error)}")
:ok
end
else
:ok

View File

@@ -0,0 +1,126 @@
defmodule BDS.Desktop.ShellLive.GitHandler do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias BDS.Desktop.ShellLive.{SocketState, TabHelpers, UrlState}
alias BDS.UI.Workbench
use Gettext, backend: BDS.Gettext
def run_action(socket, event) do
project_id = current_project_id(socket)
{label, result} =
case event do
"git_fetch" -> {dgettext("ui", "Fetch"), git_call(project_id, &BDS.Git.fetch/1)}
"git_pull" -> {dgettext("ui", "Pull"), git_call(project_id, &BDS.Git.pull/1)}
"git_push" -> {dgettext("ui", "Push"), git_call(project_id, &BDS.Git.push/1)}
"git_prune_lfs" -> {dgettext("ui", "Prune LFS"), prune_lfs(project_id)}
end
socket
|> append_git_result(label, result)
|> SocketState.refresh_sidebar(socket.assigns.workbench)
end
def commit(socket, "") do
socket
|> SocketState.append_output_entry(
dgettext("ui", "Commit"),
dgettext("ui", "Commit message is required"),
nil,
"error"
)
|> SocketState.refresh_sidebar(socket.assigns.workbench)
end
def commit(socket, message) do
case git_call(current_project_id(socket), &BDS.Git.commit_all(&1, message)) do
{:ok, _result} ->
workbench = close_git_diff_tabs(socket.assigns.workbench)
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
socket
|> assign(:tab_meta, tab_meta)
|> SocketState.append_output_entry(dgettext("ui", "Commit"), message, nil, "info")
|> SocketState.refresh_sidebar(workbench)
|> UrlState.push()
{:error, reason} ->
socket
|> SocketState.append_output_entry(dgettext("ui", "Commit"), format_git_error(reason), nil, "error")
|> SocketState.refresh_sidebar(socket.assigns.workbench)
end
end
def initialize(socket, remote_url) do
project_id = current_project_id(socket)
case git_call(project_id, &BDS.Git.initialize_repo/1) do
{:ok, _repo} ->
_ = maybe_set_git_remote(project_id, remote_url)
socket
|> SocketState.append_output_entry(
dgettext("ui", "Initialize Git"),
dgettext("ui", "Repository initialized"),
nil,
"info"
)
|> SocketState.refresh_sidebar(socket.assigns.workbench)
{:error, reason} ->
socket
|> SocketState.append_output_entry(
dgettext("ui", "Initialize Git"),
format_git_error(reason),
nil,
"error"
)
|> SocketState.refresh_sidebar(socket.assigns.workbench)
end
end
def normalize_remote_url(value) do
case value |> to_string() |> String.trim() do
"" -> nil
url -> url
end
end
defp git_call(nil, _fun), do: {:error, :no_project}
defp git_call("default", _fun), do: {:error, :no_project}
defp git_call(project_id, fun) when is_binary(project_id), do: fun.(project_id)
defp prune_lfs(nil), do: {:error, :no_project}
defp prune_lfs("default"), do: {:error, :no_project}
defp prune_lfs(project_id) when is_binary(project_id),
do: BDS.Git.prune_lfs_cache(project_id, 10)
defp maybe_set_git_remote(_project_id, nil), do: :ok
defp maybe_set_git_remote(project_id, remote_url), do: BDS.Git.set_remote(project_id, remote_url)
defp append_git_result(socket, label, {:ok, _result}) do
SocketState.append_output_entry(socket, label, dgettext("ui", "Done"), nil, "info")
end
defp append_git_result(socket, label, {:error, reason}) do
SocketState.append_output_entry(socket, label, format_git_error(reason), nil, "error")
end
defp format_git_error(:no_project), do: dgettext("ui", "No active project")
defp format_git_error(%{message: message}) when is_binary(message), do: message
defp format_git_error(%{guidance: guidance}) when is_binary(guidance), do: guidance
defp format_git_error({:git_failed, message}) when is_binary(message), do: message
defp format_git_error(reason), do: inspect(reason)
defp close_git_diff_tabs(workbench) do
workbench.tabs
|> Enum.filter(&(&1.type == :git_diff))
|> Enum.reduce(workbench, fn tab, wb -> Workbench.close_tab(wb, :git_diff, tab.id) end)
end
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
end

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
alias BDS.{AI, ImportAnalysis, ImportDefinitions, ImportExecution}
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData}
alias BDS.Desktop.ShellLive.Notify
require Logger
alias BDS.Desktop.ShellLive.ImportEditor.{
AnalysisState,
@@ -1417,7 +1418,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
rescue
_error -> :ok
error ->
Logger.debug("swallowed import_editor allow_repo_sandbox error: #{inspect(error)}")
:ok
end
else
:ok

View File

@@ -3,6 +3,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
alias BDS.{ImportAnalysis, ImportDefinitions, Metadata}
alias BDS.Desktop.{FilePicker, FolderPicker}
require Logger
use Gettext, backend: BDS.Gettext
@spec change_definition(term(), term(), term()) :: term()
@@ -280,7 +281,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
rescue
_error -> :ok
error ->
Logger.debug(
"swallowed import_editor analysis_state allow_repo_sandbox error: #{inspect(error)}"
)
:ok
end
else
:ok

View File

@@ -2,6 +2,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
@moduledoc false
alias BDS.{Metadata, Tags}
require Logger
@spec field_key(term()) :: term()
def field_key(:tags), do: "tags"
@@ -91,7 +92,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
:ok
end
rescue
_error -> :ok
error ->
Logger.warning("swallowed list_values ensure_list_value error: #{inspect(error)}")
:ok
end
@spec csv_to_list(term()) :: term()

View File

@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
alias BDS.AI
alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings
require Logger
use Gettext, backend: BDS.Gettext
@spec ai_form(term()) :: term()
@@ -171,7 +172,9 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
defp safe_endpoint(kind) do
case AI.get_endpoint(kind) do
{:ok, ep} -> ep
_error -> nil
error ->
Logger.debug("swallowed AI settings safe_endpoint error: #{inspect(error)}")
nil
end
end

View File

@@ -0,0 +1,123 @@
defmodule BDS.Desktop.ShellLive.SocketState do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{PanelRenderer, SidebarState}
alias BDS.UI.Workbench
use Gettext, backend: BDS.Gettext
@output_entry_limit 20
def refresh_layout(socket, workbench) do
git_badge_count = socket.assigns[:git_badge_count] || 0
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
task_status =
socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
page_language = socket.assigns[:page_language] || ShellData.ui_language()
offline_mode = Map.get(socket.assigns, :offline_mode, true)
sidebar_data = socket.assigns[:sidebar_data] || %{}
current_tab = current_tab(workbench)
prev_tab = socket.assigns[:current_tab]
prev_panel_tab =
case socket.assigns[:workbench] do
%Workbench{panel: %{active_tab: tab}} -> tab
_ -> nil
end
socket =
socket
|> assign(:workbench, workbench)
|> assign(:activity_buttons, activity_buttons)
|> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data))
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|> assign(:current_tab, current_tab)
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|> assign(
:status,
ShellData.status_bar(workbench, task_status, dashboard,
ui_language: page_language,
offline_mode: offline_mode
)
)
if panel_data_stale?(current_tab, prev_tab, workbench.panel.active_tab, prev_panel_tab) do
refresh_panel_data(socket)
else
socket
end
end
def refresh_sidebar(socket, workbench) do
project_id = (socket.assigns[:projects] || %{})[:active_project_id]
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data =
case ShellData.sidebar_view(
project_id,
active_view_id,
SidebarState.current_filters(socket, active_view_id)
) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
end
sidebar_data = SidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
socket
|> assign(:sidebar_data, sidebar_data)
|> refresh_layout(workbench)
end
def append_output_entry(socket, title, message, details \\ nil, level \\ "info") do
entry = %{title: title, message: message, details: details, level: level}
entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
assign(socket, :output_entries, entries)
end
defp panel_data_stale?(current_tab, prev_tab, panel_tab, prev_panel_tab) do
current_tab != prev_tab or panel_tab != prev_panel_tab
end
defp refresh_panel_data(socket) do
panel_tab = socket.assigns.workbench.panel.active_tab
socket
|> assign(
:panel_post_links,
if(panel_tab == :post_links,
do: PanelRenderer.fetch_post_link_entries(socket.assigns),
else: socket.assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
)
)
|> assign(
:panel_git_entries,
if(panel_tab == :git_log,
do: PanelRenderer.fetch_git_log_entries(socket.assigns),
else: socket.assigns[:panel_git_entries] || []
)
)
end
defp activity_label("AI Assistant"), do: dgettext("ui", "Chat")
defp activity_label("Source Control"), do: dgettext("ui", "Git")
defp activity_label(label), do: label
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
Enum.find_value(activity_buttons, Map.get(sidebar_data, :title, ""), fn button ->
if button.id == active_view, do: activity_label(button.label), else: nil
end)
end
defp current_tab(%{active_tab: nil}), do: nil
defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do
Enum.find(tabs, &(&1.type == type and &1.id == id))
end
end

View File

@@ -0,0 +1,72 @@
defmodule BDS.Desktop.ShellLive.TabActions do
@moduledoc false
alias BDS.Desktop.ShellLive.{
MediaEditor,
MenuEditor,
PostEditor,
ScriptEditor,
SocketState,
SettingsEditor,
TagsEditor,
TemplateEditor
}
alias BDS.UI.Workbench
def auto_save_current_post(
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
) do
if Workbench.dirty?(workbench, :post, post_id) do
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
end
socket
end
def auto_save_current_post(socket), do: socket
def save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do
Phoenix.LiveView.send_update(MediaEditor, id: "media-editor-#{media_id}", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do
Phoenix.LiveView.send_update(SettingsEditor, id: "settings-editor", action: :save_project)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :menu_editor}}} = socket) do
Phoenix.LiveView.send_update(MenuEditor, id: "menu-editor", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :tags}}} = socket) do
Phoenix.LiveView.send_update(TagsEditor, id: "tags-editor", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :scripts, id: script_id}}} = socket) do
Phoenix.LiveView.send_update(ScriptEditor, id: "script-editor-#{script_id}", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :templates, id: template_id}}} = socket) do
Phoenix.LiveView.send_update(TemplateEditor, id: "template-editor-#{template_id}", action: :save)
socket
end
def save_current_tab(socket), do: SocketState.refresh_layout(socket, socket.assigns.workbench)
def publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :publish)
socket
end
def publish_current_tab(socket), do: SocketState.refresh_layout(socket, socket.assigns.workbench)
end

View File

@@ -0,0 +1,78 @@
defmodule BDS.Desktop.ShellLive.UrlState do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias BDS.BoundedAtoms
alias BDS.Desktop.ShellLive.{SocketState, TabHelpers}
alias BDS.UI.Workbench
def push(socket) do
workbench = socket.assigns.workbench
params =
%{}
|> put_url_view(workbench.active_view)
|> put_url_tab(workbench.active_tab)
query = URI.encode_query(params)
path = if query == "", do: "/", else: "/?" <> query
Phoenix.LiveView.push_event(socket, "url-state", %{path: path})
end
def apply_params(socket, params) when is_map(params) and map_size(params) > 0 do
workbench = socket.assigns.workbench
workbench = apply_url_view(workbench, Map.get(params, "view"))
workbench = apply_url_tab(workbench, Map.get(params, "tab"))
if workbench == socket.assigns.workbench do
socket
else
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
socket
|> assign(:tab_meta, tab_meta)
|> SocketState.refresh_sidebar(workbench)
end
end
def apply_params(socket, _params), do: socket
defp put_url_view(params, :posts), do: params
defp put_url_view(params, view), do: Map.put(params, "view", Atom.to_string(view))
defp put_url_tab(params, nil), do: params
defp put_url_tab(params, {type, id}), do: Map.put(params, "tab", Atom.to_string(type) <> ":" <> id)
defp apply_url_view(workbench, nil), do: workbench
defp apply_url_view(workbench, view_str) do
view = BoundedAtoms.sidebar_view(view_str, nil)
if view && view != workbench.active_view do
Workbench.click_activity(workbench, view)
else
workbench
end
end
defp apply_url_tab(workbench, nil), do: workbench
defp apply_url_tab(workbench, tab_str) do
case String.split(tab_str, ":", parts: 2) do
[type_str, id] ->
type = BoundedAtoms.editor_route(type_str, nil)
if type && workbench.active_tab != {type, id} do
Workbench.open_tab(workbench, type, id, :preview)
else
workbench
end
_ ->
workbench
end
end
end

View File

@@ -5,6 +5,7 @@ defmodule BDS.Desktop.Shutdown do
alias Desktop.Wx
alias Desktop.Window
require Logger
require Record
Record.defrecordp(:wx, Record.extract(:wx, from_lib: "wx/include/wx.hrl"))
@@ -25,9 +26,13 @@ defmodule BDS.Desktop.Shutdown do
:ok
rescue
_error -> :ok
error ->
Logger.debug("swallowed shutdown install_handlers error: #{inspect(error)}")
:ok
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed shutdown install_handlers exit: #{inspect(reason)}")
:ok
end
@spec request_quit() :: :ok
@@ -102,9 +107,13 @@ defmodule BDS.Desktop.Shutdown do
fun.()
:ok
rescue
_error -> :ok
error ->
Logger.debug("swallowed shutdown persist_step error: #{inspect(error)}")
:ok
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed shutdown persist_step exit: #{inspect(reason)}")
:ok
end
# heart, when present, would relaunch the app after we kill the BEAM, so it
@@ -120,9 +129,13 @@ defmodule BDS.Desktop.Shutdown do
_ -> :ok
end
rescue
_error -> :ok
error ->
Logger.debug("swallowed shutdown kill_heart error: #{inspect(error)}")
:ok
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed shutdown kill_heart exit: #{inspect(reason)}")
:ok
end
defp kill_beam do
@@ -133,9 +146,13 @@ defmodule BDS.Desktop.Shutdown do
os_kill_fun().(os_pid)
:ok
rescue
_error -> :ok
error ->
Logger.debug("swallowed shutdown os_kill error: #{inspect(error)}")
:ok
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed shutdown os_kill exit: #{inspect(reason)}")
:ok
end
defp os_kill_fun do
@@ -158,9 +175,13 @@ defmodule BDS.Desktop.Shutdown do
:ok
rescue
_error -> :ok
error ->
Logger.debug("swallowed shutdown maybe_hide_window error: #{inspect(error)}")
:ok
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed shutdown maybe_hide_window exit: #{inspect(reason)}")
:ok
end
@doc false

View File

@@ -4,6 +4,13 @@ defmodule BDS.Embeddings do
import Ecto.Query
require Logger
@type project_id :: String.t()
@type post_id :: String.t()
@type progress_opts :: keyword()
@type similarity_map :: %{optional(post_id()) => float()}
@type indexed_posts_result :: {:ok, [post_id()]} | {:error, term()}
@type duplicate_pair_ids :: [{post_id(), post_id()}]
alias BDS.Persistence
alias BDS.Embeddings.DismissedDuplicatePair
alias BDS.Embeddings.Index
@@ -18,11 +25,19 @@ defmodule BDS.Embeddings do
@exact_match_score 0.999999
@key_batch_size 199
@spec model_id() :: term()
def model_id, do: configured_backend().model_info().model_id
@spec dimensions() :: pos_integer()
def dimensions, do: configured_backend().model_info().dimensions
@spec index_path(project_id()) :: String.t()
def index_path(project_id), do: Index.path(project_id)
@spec reindex_all(project_id()) :: indexed_posts_result()
def reindex_all(project_id), do: rebuild_project(project_id)
@spec refresh_snapshot(project_id()) :: :ok
def refresh_snapshot(project_id) when is_binary(project_id) do
if enabled_for_project?(project_id) do
:ok = rebuild_snapshot(project_id)
@@ -31,6 +46,8 @@ defmodule BDS.Embeddings do
:ok
end
@spec get_indexing_progress(project_id()) ::
{:ok, %{indexed: non_neg_integer(), total: non_neg_integer()}}
def get_indexing_progress(project_id) when is_binary(project_id) do
indexed =
Repo.one(
@@ -49,6 +66,7 @@ defmodule BDS.Embeddings do
{:ok, %{indexed: indexed, total: total}}
end
@spec sync_post(Post.t() | post_id()) :: :ok
def sync_post(%Post{} = post) do
if enabled_for_project?(post.project_id) do
sync_post_if_enabled(post, refresh_index: true)
@@ -64,6 +82,7 @@ defmodule BDS.Embeddings do
end
end
@spec repair_posts(project_id(), [post_id()]) :: indexed_posts_result()
def repair_posts(project_id, post_ids) when is_binary(project_id) and is_list(post_ids) do
if enabled_for_project?(project_id) do
post_ids = Enum.uniq(post_ids)
@@ -91,6 +110,8 @@ defmodule BDS.Embeddings do
end
end
@spec rebuild_project(project_id()) :: indexed_posts_result()
@spec rebuild_project(project_id(), progress_opts()) :: indexed_posts_result()
def rebuild_project(project_id, opts \\ [])
def rebuild_project(project_id, opts) when is_binary(project_id) and is_list(opts) do
@@ -130,6 +151,7 @@ defmodule BDS.Embeddings do
end
end
@spec diff_reports(project_id()) :: [map()]
def diff_reports(project_id) when is_binary(project_id) do
if enabled_for_project?(project_id) do
keys_by_post =
@@ -347,6 +369,7 @@ defmodule BDS.Embeddings do
end)
end
@spec remove_post(post_id()) :: :ok
def remove_post(post_id) when is_binary(post_id) do
project_id =
case Repo.get_by(Key, post_id: post_id) do
@@ -369,6 +392,7 @@ defmodule BDS.Embeddings do
:ok
end
@spec index_unindexed(project_id()) :: indexed_posts_result()
def index_unindexed(project_id) when is_binary(project_id) do
if enabled_for_project?(project_id) do
posts =
@@ -398,6 +422,8 @@ defmodule BDS.Embeddings do
end
end
@spec find_similar(post_id()) :: {:ok, [map()]}
@spec find_similar(post_id(), pos_integer()) :: {:ok, [map()]}
def find_similar(post_id, limit \\ 5) when is_binary(post_id) and is_integer(limit) do
case source_post_and_vector(post_id) do
{:disabled, _project_id} ->
@@ -431,6 +457,7 @@ defmodule BDS.Embeddings do
end
end
@spec compute_similarities(post_id(), [post_id()]) :: {:ok, similarity_map()}
def compute_similarities(source_post_id, target_post_ids)
when is_binary(source_post_id) and is_list(target_post_ids) do
case source_post_and_vector(source_post_id) do
@@ -468,6 +495,7 @@ defmodule BDS.Embeddings do
end
end
@spec suggest_tags(post_id(), term()) :: {:ok, [String.t()]}
def suggest_tags(post_id, _input_text) when is_binary(post_id) do
with {:ok, _post} <- fetch_post(post_id),
{:ok, similar} <- find_similar(post_id, 10) do
@@ -497,6 +525,8 @@ defmodule BDS.Embeddings do
end
end
@spec find_duplicates(project_id()) :: {:ok, [map()]}
@spec find_duplicates(project_id(), progress_opts()) :: {:ok, [map()]}
def find_duplicates(project_id, opts \\ []) when is_binary(project_id) do
if enabled_for_project?(project_id) do
on_progress = progress_callback(opts)
@@ -521,6 +551,8 @@ defmodule BDS.Embeddings do
end
end
@spec dismiss_duplicate_pair(post_id(), post_id()) ::
{:ok, DismissedDuplicatePair.t()} | {:error, :not_found}
def dismiss_duplicate_pair(post_id_a, post_id_b)
when is_binary(post_id_a) and is_binary(post_id_b) do
with {:ok, post_a} <- fetch_post(post_id_a),
@@ -552,6 +584,8 @@ defmodule BDS.Embeddings do
end
end
@spec dismiss_duplicate_pairs(duplicate_pair_ids()) ::
{:ok, [DismissedDuplicatePair.t()]} | {:error, term()}
def dismiss_duplicate_pairs(pair_ids) when is_list(pair_ids) do
pair_ids
|> Enum.filter(fn

View File

@@ -7,6 +7,15 @@ defmodule BDS.Embeddings.DismissedDuplicatePair do
@primary_key {:id, :string, autogenerate: false}
@foreign_key_type :string
@type t :: %__MODULE__{
id: String.t() | nil,
project_id: String.t() | nil,
project: term(),
post_id_a: String.t() | nil,
post_id_b: String.t() | nil,
dismissed_at: integer() | nil
}
schema "dismissed_duplicate_pairs" do
belongs_to :project, BDS.Projects.Project, type: :string
field :post_id_a, :string

View File

@@ -23,6 +23,7 @@ defmodule BDS.Embeddings.Index do
alias BDS.Projects
alias BDS.ProgressReporter
require Logger
@neighbor_limit 21
@debounce_ms 5_000
@@ -345,7 +346,12 @@ defmodule BDS.Embeddings.Index do
write_meta(index_path, dim, labels)
:ok
rescue
_exception -> :ok
exception ->
Logger.debug(
"swallowed embeddings index persist error for #{project_id}: #{inspect(exception)}"
)
:ok
end
defp write_meta(index_path, dim, labels) do
@@ -385,7 +391,12 @@ defmodule BDS.Embeddings.Index do
_other -> :error
end
rescue
_exception -> :error
exception ->
Logger.debug(
"swallowed embeddings index load_from_disk error for #{project_id}: #{inspect(exception)}"
)
:error
end
defp read_meta(index_path) do

View File

@@ -2,6 +2,7 @@ defmodule BDS.Git do
@moduledoc false
alias BDS.Projects
require Logger
@default_local_timeout_ms 15_000
@default_network_timeout_ms 120_000
@@ -562,7 +563,9 @@ defmodule BDS.Git do
defp safe_port_close(port) do
Port.close(port)
catch
:error, _reason -> :ok
:error, reason ->
Logger.debug("swallowed git safe_port_close error: #{inspect(reason)}")
:ok
end
defp port_os_pid(port) do

View File

@@ -2,6 +2,7 @@ defmodule BDS.MCP.Server do
@moduledoc false
use GenServer
require Logger
@host "127.0.0.1"
@server_name "Blogging Desktop Server"
@@ -336,7 +337,9 @@ defmodule BDS.MCP.Server do
try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, owner_pid, self())
rescue
_error -> :ok
error ->
Logger.debug("swallowed MCP server maybe_allow_repo error: #{inspect(error)}")
:ok
end
end

View File

@@ -2,6 +2,7 @@ defmodule BDS.Preview do
@moduledoc false
use GenServer
require Logger
alias BDS.Posts
alias BDS.Posts.Translation
@@ -446,7 +447,9 @@ defmodule BDS.Preview do
try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, owner_pid, self())
rescue
_error -> :ok
error ->
Logger.debug("swallowed preview maybe_allow_repo error: #{inspect(error)}")
:ok
end
end

View File

@@ -5,6 +5,7 @@ defmodule BDS.Scripting.Capabilities.AppShell do
alias BDS.Desktop.FolderPicker
alias BDS.Desktop.MenuBar
require Logger
@compiled_env Application.compile_env(:bds, :current_env, Mix.env())
@@ -139,7 +140,9 @@ defmodule BDS.Scripting.Capabilities.AppShell do
nil
rescue
_error -> nil
error ->
Logger.debug("swallowed app_shell trigger_menu_action error: #{inspect(error)}")
nil
end
def test_mode? do

View File

@@ -116,24 +116,9 @@ defmodule BDS.Search do
end
defp search_posts_blank(project_id, filters) do
base = from(post in Post, where: post.project_id == ^project_id)
filtered = apply_post_filters(base, filters)
total = count_query(filtered)
posts =
filtered
|> order_by([p], desc: p.created_at)
|> limit(^filters.limit)
|> offset(^filters.offset)
|> Repo.all()
{:ok,
%{
posts: posts,
total: total,
offset: filters.offset,
limit: filters.limit
}}
Post
|> blank_search_query(project_id, filters, &apply_post_filters/2)
|> paginate_search(filters, :posts, &order_by(&1, [p], desc: p.created_at))
end
defp search_posts_fts(project_id, query_text, filters) do
@@ -146,29 +131,9 @@ defmodule BDS.Search do
select: %{post_id: f.post_id, fts_rowid: fragment("rowid")}
)
base =
Post
|> with_cte("fts_results", as: ^fts_subquery)
|> join(:inner, [p], fts in "fts_results", on: fts.post_id == p.id)
|> where([p], p.project_id == ^project_id)
filtered = apply_post_filters(base, filters)
total = count_query(filtered)
posts =
filtered
|> order_by([_, fts], fts.fts_rowid)
|> limit(^filters.limit)
|> offset(^filters.offset)
|> Repo.all()
{:ok,
%{
posts: posts,
total: total,
offset: filters.offset,
limit: filters.limit
}}
Post
|> fts_search_query(project_id, filters, fts_subquery, :post_id, &apply_post_filters/2)
|> paginate_search(filters, :posts, &order_by(&1, [_, fts], fts.fts_rowid))
end
@spec search_media(String.t(), String.t() | nil, search_filters()) ::
@@ -190,24 +155,9 @@ defmodule BDS.Search do
end
defp search_media_blank(project_id, filters) do
base = from(media in Media, where: media.project_id == ^project_id)
filtered = apply_media_filters(base, filters)
total = count_query(filtered)
media_items =
filtered
|> order_by([m], desc: m.created_at)
|> limit(^filters.limit)
|> offset(^filters.offset)
|> Repo.all()
{:ok,
%{
media: media_items,
total: total,
offset: filters.offset,
limit: filters.limit
}}
Media
|> blank_search_query(project_id, filters, &apply_media_filters/2)
|> paginate_search(filters, :media, &order_by(&1, [m], desc: m.created_at))
end
defp search_media_fts(project_id, query_text, filters) do
@@ -220,25 +170,38 @@ defmodule BDS.Search do
select: %{media_id: f.media_id, fts_rowid: fragment("rowid")}
)
base =
Media
|> with_cte("fts_results", as: ^fts_subquery)
|> join(:inner, [m], fts in "fts_results", on: fts.media_id == m.id)
|> where([m], m.project_id == ^project_id)
Media
|> fts_search_query(project_id, filters, fts_subquery, :media_id, &apply_media_filters/2)
|> paginate_search(filters, :media, &order_by(&1, [_, fts], fts.fts_rowid))
end
filtered = apply_media_filters(base, filters)
total = count_query(filtered)
defp blank_search_query(schema, project_id, filters, apply_filters) do
schema
|> where([entity], field(entity, :project_id) == ^project_id)
|> apply_filters.(filters)
end
media_items =
filtered
|> order_by([_, fts], fts.fts_rowid)
defp fts_search_query(schema, project_id, filters, fts_subquery, join_key, apply_filters) do
schema
|> with_cte("fts_results", as: ^fts_subquery)
|> join(:inner, [entity], fts in "fts_results", on: field(fts, ^join_key) == entity.id)
|> where([entity], field(entity, :project_id) == ^project_id)
|> apply_filters.(filters)
end
defp paginate_search(query, filters, result_key, order_query) do
total = count_query(query)
items =
query
|> order_query.()
|> limit(^filters.limit)
|> offset(^filters.offset)
|> Repo.all()
{:ok,
%{
media: media_items,
result_key => items,
total: total,
offset: filters.offset,
limit: filters.limit

View File

@@ -3,67 +3,69 @@ defmodule BDS.Tasks do
use GenServer
@type task_id :: String.t()
@type task_attrs :: map()
@type task_info :: map()
@type status_snapshot :: map()
@type progress_reporter :: (number(), String.t() | nil -> any())
@type task_work :: (progress_reporter() -> term())
@default_max_concurrent 3
@default_progress_throttle_ms 250
@default_recent_finished_limit 10
@default_finished_task_ttl_ms :timer.hours(1)
@topic "tasks"
@spec start_link(term()) :: GenServer.on_start()
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
@spec topic() :: String.t()
def topic, do: @topic
@spec submit_task(String.t(), task_work()) :: {:ok, task_info()}
@spec submit_task(String.t(), task_work(), task_attrs()) :: {:ok, task_info()}
def submit_task(name, work, attrs \\ %{})
when is_binary(name) and is_function(work, 1) and is_map(attrs) do
GenServer.call(__MODULE__, {:submit_task, name, work, attrs})
end
@spec get_task(task_id()) :: task_info() | nil
def get_task(task_id) when is_binary(task_id) do
GenServer.call(__MODULE__, {:get_task, task_id})
end
@spec status_snapshot() :: status_snapshot()
def status_snapshot do
GenServer.call(__MODULE__, :status_snapshot)
end
@spec list_tasks() :: [task_info()]
def list_tasks do
GenServer.call(__MODULE__, :list_tasks)
end
@spec list_running_tasks() :: [task_info()]
def list_running_tasks do
GenServer.call(__MODULE__, :list_running_tasks)
end
@spec clear_completed() :: :ok
def clear_completed do
GenServer.call(__MODULE__, :clear_completed)
end
@spec clear_finished() :: :ok
def clear_finished do
GenServer.call(__MODULE__, :clear_finished)
end
@spec cancel_task(task_id()) :: :ok | {:error, :not_found | :not_running}
def cancel_task(task_id) when is_binary(task_id) do
GenServer.call(__MODULE__, {:cancel_task, task_id})
end
def register_external_task(name, attrs \\ %{}) when is_binary(name) and is_map(attrs) do
GenServer.call(__MODULE__, {:register_external_task, name, attrs})
end
def report_progress(task_id, value, message) when is_binary(task_id) do
GenServer.call(__MODULE__, {:report_progress, task_id, value, message})
end
def complete_task(task_id) when is_binary(task_id) do
GenServer.call(__MODULE__, {:complete_task, task_id})
end
def fail_task(task_id, error_message) when is_binary(task_id) do
GenServer.call(__MODULE__, {:fail_task, task_id, error_message})
end
@impl true
def init(_state) do
{:ok,
@@ -163,48 +165,6 @@ defmodule BDS.Tasks do
end
end
def handle_call({:register_external_task, name, attrs}, _from, state) do
task = new_task(name, :running, attrs) |> Map.put(:started_at, DateTime.utc_now())
next_state = put_in(state, [:tasks, task.id], task)
{:reply, {:ok, public_task(task)}, next_state}
end
def handle_call({:report_progress, task_id, value, message}, _from, state) do
{:reply, :ok, maybe_report_progress(state, task_id, value, message)}
end
def handle_call({:complete_task, task_id}, _from, state) do
next_state =
state
|> update_task(task_id, %{
status: :completed,
progress: 1.0,
finished_at: DateTime.utc_now()
})
|> start_queued_tasks()
|> schedule_finished_task_eviction()
broadcast_terminal_task(next_state.tasks[task_id])
{:reply, :ok, next_state}
end
def handle_call({:fail_task, task_id, error_message}, _from, state) do
next_state =
state
|> update_task(task_id, %{
status: :failed,
message: error_message,
finished_at: DateTime.utc_now()
})
|> start_queued_tasks()
|> schedule_finished_task_eviction()
broadcast_terminal_task(next_state.tasks[task_id])
{:reply, :ok, next_state}
end
@impl true
def handle_info({:task_progress, task_id, value, message}, state) do
{:noreply, maybe_report_progress(state, task_id, value, message)}

View File

@@ -426,35 +426,12 @@ defmodule BDS.UI.Sidebar do
defp maybe_where_all_tags(query, []), do: query
defp maybe_where_all_tags(query, tags) do
Enum.reduce(tags, query, fn tag, q ->
where(
q,
[p],
fragment(
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?))",
p.tags,
^tag
)
)
end)
end
defp maybe_where_all_tags(query, tags), do: where_all_in_json_array(query, tags, :tags)
defp maybe_where_all_categories(query, []), do: query
defp maybe_where_all_categories(query, categories) do
Enum.reduce(categories, query, fn category, q ->
where(
q,
[p],
fragment(
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?) AND lower(value) != 'page')",
p.categories,
^category
)
)
end)
end
defp maybe_where_all_categories(query, categories),
do: where_all_in_json_array(query, categories, :categories, true)
defp base_year_month_counts(project_id, pages?) do
is_page = if pages?, do: 1, else: 0
@@ -692,17 +669,32 @@ defmodule BDS.UI.Sidebar do
defp maybe_where_all_media_tags(query, []), do: query
defp maybe_where_all_media_tags(query, tags) do
Enum.reduce(tags, query, fn tag, q ->
where(
q,
[m],
fragment(
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?))",
m.tags,
^tag
defp maybe_where_all_media_tags(query, tags),
do: where_all_in_json_array(query, tags, :tags)
defp where_all_in_json_array(query, items, field_atom, exclude_page? \\ false) do
Enum.reduce(items, query, fn item, q ->
if exclude_page? do
where(
q,
[x],
fragment(
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?) AND lower(value) != 'page')",
field(x, ^field_atom),
^item
)
)
)
else
where(
q,
[x],
fragment(
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?))",
field(x, ^field_atom),
^item
)
)
end
end)
end

View File

@@ -310,14 +310,26 @@ defmodule BDS.Scripting.ApiTest do
assert {:ok, _published_template} = BDS.Templates.publish_template(template.id)
assert {:ok, running_task} =
BDS.Tasks.register_external_task("preview build", %{
group_id: "generation",
group_name: "Generation"
})
runner = self()
assert {:ok, _running_task} =
BDS.Tasks.submit_task(
"preview build",
fn report ->
report.(0.5, "halfway")
send(runner, {:task_started, self()})
receive do
:release_task -> {:ok, :done}
end
end,
%{group_id: "generation", group_name: "Generation"}
)
assert_receive {:task_started, task_pid}, 1_000
on_exit(fn ->
_ = BDS.Tasks.complete_task(running_task.id)
send(task_pid, :release_task)
end)
source =

View File

@@ -2,11 +2,14 @@ defmodule BDS.TasksTest do
use ExUnit.Case, async: false
setup do
cleanup_task_server()
original = Application.get_env(:bds, :tasks, [])
Application.put_env(:bds, :tasks, max_concurrent: 3, progress_throttle_ms: 250)
:ok = BDS.Tasks.clear_finished()
on_exit(fn ->
cleanup_task_server()
Application.put_env(:bds, :tasks, original)
_ = BDS.Tasks.clear_finished()
end)
@@ -131,74 +134,129 @@ defmodule BDS.TasksTest do
end
test "progress reports within 250ms throttle window are silently dropped" do
assert {:ok, task} = BDS.Tasks.register_external_task("fast progress")
runner = self()
assert {:ok, task} =
BDS.Tasks.submit_task("fast progress", fn report ->
send(runner, {:started, "fast progress", self()})
report.(0.25, "quarter")
report.(0.5, "half")
receive do
:release -> {:ok, :done}
end
end)
{"fast progress", worker_pid} = receive_started()
assert :ok = BDS.Tasks.report_progress(task.id, 0.25, "quarter")
assert wait_for_task(task.id, &(&1.progress == 0.25)).progress == 0.25
assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "half")
assert task_id = task.id
# The 250ms throttle has not elapsed, so progress stays at 0.25.
assert wait_for_task(task_id, & &1.progress == 0.25).progress == 0.25
assert wait_for_task(task.id, &(&1.progress == 0.25)).progress == 0.25
on_exit(fn -> BDS.Tasks.complete_task(task.id) end)
send(worker_pid, :release)
assert wait_for_task(task.id, &(&1.status == :completed)).status == :completed
end
test "progress report with value 1.0 bypasses the throttle" do
assert {:ok, task} = BDS.Tasks.register_external_task("completion progress")
runner = self()
assert :ok = BDS.Tasks.report_progress(task.id, 0.25, "quarter")
assert {:ok, task} =
BDS.Tasks.submit_task("completion progress", fn report ->
send(runner, {:started, "completion progress", self()})
report.(0.25, "quarter")
report.(1.0, "done")
receive do
:release -> {:ok, :done}
end
end)
{"completion progress", worker_pid} = receive_started()
# A completion report (1.0) must go through even if throttled.
assert :ok = BDS.Tasks.report_progress(task.id, 1.0, "done")
assert wait_for_task(task.id, &(&1.progress == 1.0)).progress == 1.0
assert wait_for_task(task.id, &(&1.message == "done")).message == "done"
on_exit(fn -> BDS.Tasks.complete_task(task.id) end)
send(worker_pid, :release)
assert wait_for_task(task.id, &(&1.status == :completed)).status == :completed
end
test "external tasks are registered as running and can report progress and complete" do
test "submitted tasks are registered as running and can report progress and complete" do
assert {:ok, task} =
BDS.Tasks.register_external_task("preview build", %{
BDS.Tasks.submit_task(
"preview build",
fn report ->
report.(0.5, "halfway")
receive do
:release -> {:ok, :done}
end
end,
%{
group_id: "generation",
group_name: "Generation"
})
}
)
assert task.status == :running
assert task.status == :pending
assert task.group_id == "generation"
assert task.group_name == "Generation"
assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "halfway")
progressed = wait_for_task(task.id, &(&1.progress == 0.5 and &1.message == "halfway"))
assert progressed.status == :running
assert :ok = BDS.Tasks.complete_task(task.id)
task_state = :sys.get_state(BDS.Tasks)
%{pid: worker_pid} = task_state.running[task.id]
send(worker_pid, :release)
assert wait_for_task(task.id, &(&1.status == :completed and &1.progress == 1.0)).status ==
:completed
end
test "status_snapshot exposes active task details for the desktop shell" do
runner = self()
assert {:ok, first} =
BDS.Tasks.register_external_task("preview build", %{
group_id: "generation",
group_name: "Generation"
})
BDS.Tasks.submit_task(
"preview build",
fn report ->
send(runner, {:started, "preview build", self()})
report.(0.5, "halfway")
receive do
:release -> {:ok, :done}
end
end,
%{group_id: "generation", group_name: "Generation"}
)
assert {:ok, second} =
BDS.Tasks.register_external_task("reindex text", %{
group_id: "search",
group_name: "Search"
})
BDS.Tasks.submit_task(
"reindex text",
fn _report ->
send(runner, {:started, "reindex text", self()})
receive do
:release -> {:ok, :done}
end
end,
%{group_id: "search", group_name: "Search"}
)
on_exit(fn ->
_ = BDS.Tasks.complete_task(first.id)
_ = BDS.Tasks.complete_task(second.id)
task_state = :sys.get_state(BDS.Tasks)
Enum.each([first.id, second.id], fn task_id ->
case task_state.running[task_id] do
%{pid: pid} -> send(pid, :release)
nil -> :ok
end
end)
end)
assert :ok = BDS.Tasks.report_progress(first.id, 0.5, "halfway")
_ = receive_started()
_ = receive_started()
snapshot = BDS.Tasks.status_snapshot()
@@ -250,12 +308,23 @@ defmodule BDS.TasksTest do
finished_task_ttl_ms: 1
)
assert {:ok, task} = BDS.Tasks.register_external_task("short lived")
assert {:ok, running} = BDS.Tasks.register_external_task("still running")
runner = self()
on_exit(fn -> _ = BDS.Tasks.complete_task(running.id) end)
assert {:ok, task} = BDS.Tasks.submit_task("short lived", fn _report -> {:ok, :done} end)
assert {:ok, running} =
BDS.Tasks.submit_task("still running", fn _report ->
send(runner, {:started, "still running", self()})
receive do
:release -> {:ok, :done}
end
end)
{"still running", worker_pid} = receive_started()
on_exit(fn -> send(worker_pid, :release) end)
assert :ok = BDS.Tasks.complete_task(task.id)
assert wait_for_task(task.id, &(&1.status == :completed)).status == :completed
Process.sleep(20)
@@ -273,15 +342,15 @@ defmodule BDS.TasksTest do
finished_task_ttl_ms: 50
)
assert {:ok, first} = BDS.Tasks.register_external_task("first finished")
assert {:ok, second} = BDS.Tasks.register_external_task("second finished")
assert {:ok, first} = BDS.Tasks.submit_task("first finished", fn _report -> {:ok, :done} end)
assert {:ok, second} = BDS.Tasks.submit_task("second finished", fn _report -> {:ok, :done} end)
assert :ok = BDS.Tasks.complete_task(first.id)
assert wait_for_task(first.id, &(&1.status == :completed)).status == :completed
first_timer = :sys.get_state(BDS.Tasks).finished_task_eviction_timer
assert is_reference(first_timer)
assert is_integer(Process.read_timer(first_timer))
assert :ok = BDS.Tasks.complete_task(second.id)
assert wait_for_task(second.id, &(&1.status == :completed)).status == :completed
second_timer = :sys.get_state(BDS.Tasks).finished_task_eviction_timer
assert second_timer == first_timer
@@ -336,4 +405,34 @@ defmodule BDS.TasksTest do
flunk("task did not reach expected state")
end
defp cleanup_task_server do
BDS.Tasks.list_tasks()
|> Enum.filter(&(&1.status in [:pending, :running]))
|> Enum.each(fn task ->
_ = BDS.Tasks.cancel_task(task.id)
end)
wait_until(fn ->
BDS.Tasks.list_tasks()
|> Enum.all?(&(&1.status not in [:pending, :running]))
end)
:ok = BDS.Tasks.clear_finished()
end
defp wait_until(predicate, attempts \\ 100)
defp wait_until(predicate, attempts) when attempts > 0 do
if predicate.() do
:ok
else
Process.sleep(20)
wait_until(predicate, attempts - 1)
end
end
defp wait_until(_predicate, 0) do
flunk("condition was not met")
end
end