From e3e94ac2d9545d379d3816e75397ba88b05b6d6b Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 25 Jun 2026 15:42:32 +0200 Subject: [PATCH] fix: refactorings to remove anti-patterns --- AUDIT.md | 204 +++++++++ lib/bds/ai.ex | 12 +- lib/bds/desktop/automation.ex | 16 +- lib/bds/desktop/deep_link.ex | 4 +- lib/bds/desktop/main_window.ex | 18 +- lib/bds/desktop/shell_live.ex | 393 ++---------------- lib/bds/desktop/shell_live/chat_editor.ex | 4 +- lib/bds/desktop/shell_live/git_handler.ex | 126 ++++++ lib/bds/desktop/shell_live/import_editor.ex | 5 +- .../import_editor/analysis_state.ex | 8 +- .../shell_live/post_editor/list_values.ex | 5 +- .../shell_live/settings_editor/ai_settings.ex | 5 +- lib/bds/desktop/shell_live/socket_state.ex | 123 ++++++ lib/bds/desktop/shell_live/tab_actions.ex | 72 ++++ lib/bds/desktop/shell_live/url_state.ex | 78 ++++ lib/bds/desktop/shutdown.ex | 41 +- lib/bds/embeddings.ex | 34 ++ .../embeddings/dismissed_duplicate_pair.ex | 9 + lib/bds/embeddings/index.ex | 15 +- lib/bds/git.ex | 5 +- lib/bds/mcp/server.ex | 5 +- lib/bds/preview.ex | 5 +- lib/bds/scripting/capabilities/app_shell.ex | 5 +- lib/bds/search.ex | 103 ++--- lib/bds/tasks.ex | 76 +--- lib/bds/ui/sidebar.ex | 64 ++- test/bds/scripting/api_test.exs | 24 +- test/bds/tasks_test.exs | 173 ++++++-- 28 files changed, 1028 insertions(+), 604 deletions(-) create mode 100644 AUDIT.md create mode 100644 lib/bds/desktop/shell_live/git_handler.ex create mode 100644 lib/bds/desktop/shell_live/socket_state.ex create mode 100644 lib/bds/desktop/shell_live/tab_actions.ex create mode 100644 lib/bds/desktop/shell_live/url_state.ex diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..f6b6db9 --- /dev/null +++ b/AUDIT.md @@ -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 : #{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) diff --git a/lib/bds/ai.ex b/lib/bds/ai.ex index aa30ac5..63a798c 100644 --- a/lib/bds/ai.ex +++ b/lib/bds/ai.ex @@ -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 diff --git a/lib/bds/desktop/automation.ex b/lib/bds/desktop/automation.ex index 1d0644f..677c60a 100644 --- a/lib/bds/desktop/automation.ex +++ b/lib/bds/desktop/automation.ex @@ -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 diff --git a/lib/bds/desktop/deep_link.ex b/lib/bds/desktop/deep_link.ex index 94de96e..30fd61e 100644 --- a/lib/bds/desktop/deep_link.ex +++ b/lib/bds/desktop/deep_link.ex @@ -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 diff --git a/lib/bds/desktop/main_window.ex b/lib/bds/desktop/main_window.ex index c561c94..71269da 100644 --- a/lib/bds/desktop/main_window.ex +++ b/lib/bds/desktop/main_window.ex @@ -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 diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 6c9896d..9d46a2e 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -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()) diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index c4ee88c..276ddc9 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -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 diff --git a/lib/bds/desktop/shell_live/git_handler.ex b/lib/bds/desktop/shell_live/git_handler.ex new file mode 100644 index 0000000..8293463 --- /dev/null +++ b/lib/bds/desktop/shell_live/git_handler.ex @@ -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 \ No newline at end of file diff --git a/lib/bds/desktop/shell_live/import_editor.ex b/lib/bds/desktop/shell_live/import_editor.ex index f21d771..f00e708 100644 --- a/lib/bds/desktop/shell_live/import_editor.ex +++ b/lib/bds/desktop/shell_live/import_editor.ex @@ -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 diff --git a/lib/bds/desktop/shell_live/import_editor/analysis_state.ex b/lib/bds/desktop/shell_live/import_editor/analysis_state.ex index 657f9b6..4bd9b0e 100644 --- a/lib/bds/desktop/shell_live/import_editor/analysis_state.ex +++ b/lib/bds/desktop/shell_live/import_editor/analysis_state.ex @@ -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 diff --git a/lib/bds/desktop/shell_live/post_editor/list_values.ex b/lib/bds/desktop/shell_live/post_editor/list_values.ex index 565b609..f187621 100644 --- a/lib/bds/desktop/shell_live/post_editor/list_values.ex +++ b/lib/bds/desktop/shell_live/post_editor/list_values.ex @@ -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() diff --git a/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex b/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex index 12aab46..aebc666 100644 --- a/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex +++ b/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex @@ -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 diff --git a/lib/bds/desktop/shell_live/socket_state.ex b/lib/bds/desktop/shell_live/socket_state.ex new file mode 100644 index 0000000..545122f --- /dev/null +++ b/lib/bds/desktop/shell_live/socket_state.ex @@ -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 \ No newline at end of file diff --git a/lib/bds/desktop/shell_live/tab_actions.ex b/lib/bds/desktop/shell_live/tab_actions.ex new file mode 100644 index 0000000..f6c8b38 --- /dev/null +++ b/lib/bds/desktop/shell_live/tab_actions.ex @@ -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 diff --git a/lib/bds/desktop/shell_live/url_state.ex b/lib/bds/desktop/shell_live/url_state.ex new file mode 100644 index 0000000..0c9983d --- /dev/null +++ b/lib/bds/desktop/shell_live/url_state.ex @@ -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 diff --git a/lib/bds/desktop/shutdown.ex b/lib/bds/desktop/shutdown.ex index b77d1cd..ca1a424 100644 --- a/lib/bds/desktop/shutdown.ex +++ b/lib/bds/desktop/shutdown.ex @@ -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 diff --git a/lib/bds/embeddings.ex b/lib/bds/embeddings.ex index a9aa6b2..39b5af8 100644 --- a/lib/bds/embeddings.ex +++ b/lib/bds/embeddings.ex @@ -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 diff --git a/lib/bds/embeddings/dismissed_duplicate_pair.ex b/lib/bds/embeddings/dismissed_duplicate_pair.ex index 3b1533f..bdedcbc 100644 --- a/lib/bds/embeddings/dismissed_duplicate_pair.ex +++ b/lib/bds/embeddings/dismissed_duplicate_pair.ex @@ -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 diff --git a/lib/bds/embeddings/index.ex b/lib/bds/embeddings/index.ex index 88b6b9d..2fc508d 100644 --- a/lib/bds/embeddings/index.ex +++ b/lib/bds/embeddings/index.ex @@ -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 diff --git a/lib/bds/git.ex b/lib/bds/git.ex index 96af3a5..fa49cdb 100644 --- a/lib/bds/git.ex +++ b/lib/bds/git.ex @@ -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 diff --git a/lib/bds/mcp/server.ex b/lib/bds/mcp/server.ex index ccc9f85..aa6c786 100644 --- a/lib/bds/mcp/server.ex +++ b/lib/bds/mcp/server.ex @@ -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 diff --git a/lib/bds/preview.ex b/lib/bds/preview.ex index fb836bb..6930a80 100644 --- a/lib/bds/preview.ex +++ b/lib/bds/preview.ex @@ -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 diff --git a/lib/bds/scripting/capabilities/app_shell.ex b/lib/bds/scripting/capabilities/app_shell.ex index ac09b20..733e483 100644 --- a/lib/bds/scripting/capabilities/app_shell.ex +++ b/lib/bds/scripting/capabilities/app_shell.ex @@ -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 diff --git a/lib/bds/search.ex b/lib/bds/search.ex index 0cab9fa..5b6debc 100644 --- a/lib/bds/search.ex +++ b/lib/bds/search.ex @@ -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 diff --git a/lib/bds/tasks.ex b/lib/bds/tasks.ex index 1592fae..aa66e75 100644 --- a/lib/bds/tasks.ex +++ b/lib/bds/tasks.ex @@ -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)} diff --git a/lib/bds/ui/sidebar.ex b/lib/bds/ui/sidebar.ex index 5560f1b..7eff33c 100644 --- a/lib/bds/ui/sidebar.ex +++ b/lib/bds/ui/sidebar.ex @@ -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 diff --git a/test/bds/scripting/api_test.exs b/test/bds/scripting/api_test.exs index 300c067..39aa90c 100644 --- a/test/bds/scripting/api_test.exs +++ b/test/bds/scripting/api_test.exs @@ -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 = diff --git a/test/bds/tasks_test.exs b/test/bds/tasks_test.exs index c295c0c..1b931c5 100644 --- a/test/bds/tasks_test.exs +++ b/test/bds/tasks_test.exs @@ -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