Compare commits

...

23 Commits

Author SHA1 Message Date
655069a28a fix: fix issue #23 broken css for link insert 2026-07-18 10:33:15 +02:00
593f1646a1 fix: made the macos app smaller 2026-07-18 10:20:54 +02:00
c77450d497 fix: broken CSS in the bottom panel fixed 2026-07-18 09:05:31 +02:00
23f05b72f7 fix: issue #35 validation expected unrendered .lang translation variant routes; main-language lists now render main-language translations 2026-07-17 22:12:23 +02:00
dd53ca3fbc fix: issue #20 cmd-J panel tab strip visibility and output panel parity 2026-07-17 20:51:42 +02:00
6c9dd9605b fix: updated current line indicator for issue #32 2026-07-17 16:43:57 +02:00
46c239df56 feat: issue #32 markdown editor for TUI 2026-07-17 16:38:46 +02:00
2fd132e827 feat: TUI tags panels (cloud, manage, merge) wired to the shared tags backend (issue #34) 2026-07-17 14:50:25 +02:00
0f3f1efa08 feat: issue #25 implemented - cli 2026-07-17 14:26:14 +02:00
e4fa61ae07 fix: fix for building 2026-07-17 12:04:07 +02:00
8fe18d108f fix: bundle @rpath dylib deps (libsharpyuv) and fail verify_standalone on unresolvable @rpath references 2026-07-17 10:58:51 +02:00
b517672663 fix: insert link/media lands at the cursor and closes the overlay instead of breaking the editor 2026-07-17 10:58:51 +02:00
b016f6d812 fix: update_project_metadata keeps fields absent from attrs instead of resetting them 2026-07-17 08:57:20 +02:00
505527ae4f feat: TUI settings panel on key 6 with per-section preference editors backed by the same GUI settings backends (issue #29) 2026-07-17 08:38:26 +02:00
b6b1d16e54 feat: posts open in the rendered markdown preview by default, ctrl+e toggles to the editor 2026-07-16 22:22:17 +02:00
46fd6c1b85 feat: git sidebar shows the commit history in its lower half like the GUI (issue #30) 2026-07-16 22:10:27 +02:00
5433cb59ac feat: TUI git panel on key 7 with change list, scrollable diff, commit, pull and push (issue #30) 2026-07-16 21:59:59 +02:00
e027364c0e feat: vi-style / search in the TUI sidebar with tag:, category: and ISO-date filters (issue #31) 2026-07-16 21:42:19 +02:00
3675a26407 fix: local tui mode routes console logging to a file and disables download progress bars so stray output cannot scroll the TUI 2026-07-16 18:39:07 +02:00
9a1f301527 feat: bash-style tab completion in the TUI project path prompt (issue #28) 2026-07-16 18:27:09 +02:00
59333ac920 fix: set NO_WX before dependency boot and detect headless sessions per platform so the TUI fallback actually works (issue #33) 2026-07-16 18:13:15 +02:00
381deb417d feat: desktop mode falls back to the TUI when no graphical display is available (issue #33) 2026-07-16 17:46:36 +02:00
5801e49dc1 feat: TUI projects overlay to switch projects and open existing blog folders with automatic database rebuild (issue #28) 2026-07-16 17:26:19 +02:00
92 changed files with 11362 additions and 1684 deletions

View File

@@ -30,6 +30,7 @@ This document provides context and best practices for GitHub Copilot when workin
- you MUST run tests with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors - you MUST run tests with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors
- you MUST run build, test, credo, deps.audit and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests, clean credo, clean dependency audits and clean dialyzer results - you MUST run build, test, credo, deps.audit and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests, clean credo, clean dependency audits and clean dialyzer results
- on a headless Linux machine, you have to run tests with this command (if mix test complains about DISPLAX): xvfb-run mix test - on a headless Linux machine, you have to run tests with this command (if mix test complains about DISPLAX): xvfb-run mix test
- CSS precedence: Tailwind utilities and the ui-* kit (utilities.css) are in @layer and ALWAYS lose to the unlayered hand-written files (shell.css, panel.css, ...). Convention: hand-written CSS owns appearance, Tailwind utilities in templates own layout — never put display/flex-direction/size rules in hand-written CSS for elements whose layout the template controls, and define each selector in exactly one file.
--- ---

27
API.md
View File

@@ -40,6 +40,7 @@ local meta = bds.meta.get_project_metadata()
- [app.get_default_project_path](#appget_default_project_path) - [app.get_default_project_path](#appget_default_project_path)
- [app.get_system_language](#appget_system_language) - [app.get_system_language](#appget_system_language)
- [app.get_title_bar_metrics](#appget_title_bar_metrics) - [app.get_title_bar_metrics](#appget_title_bar_metrics)
- [app.log](#applog)
- [app.notify_renderer_ready](#appnotify_renderer_ready) - [app.notify_renderer_ready](#appnotify_renderer_ready)
- [app.open_folder](#appopen_folder) - [app.open_folder](#appopen_folder)
- [app.read_project_metadata](#appread_project_metadata) - [app.read_project_metadata](#appread_project_metadata)
@@ -202,6 +203,30 @@ nil -- or
local result = bds.app.get_title_bar_metrics() local result = bds.app.get_title_bar_metrics()
``` ```
### app.log
Append a line to the script output stream. Multiple arguments are joined with spaces. Output appears in the desktop app's Output panel (and on stdout in the CLI); Lua's global `print` is routed the same way.
**Parameters**
- text (string, required)
**Response specification**
- Return type: `boolean`
**Example response**
```lua
true
```
**Example call**
```lua
local result = bds.app.log("value")
```
### app.notify_renderer_ready ### app.notify_renderer_ready
Notify the host application that the renderer is ready. Notify the host application that the renderer is ready.
@@ -3443,7 +3468,7 @@ local result = bds.meta.sync_on_startup()
### meta.update_project_metadata ### meta.update_project_metadata
Update metadata for the current project. Update metadata for the current project. Keys omitted from updates keep their current values.
**Parameters** **Parameters**

223
TUI_EDITOR.md Normal file
View File

@@ -0,0 +1,223 @@
# TUI Editor — Implementation Plan (Issue #32)
Tracking issue: **#32** — "markdown, liquid and lua code editor with syntax
highlighting in the TUI".
Spec under change: `specs/tui.allium`.
Goal: a syntax-highlighted, word-wrapping TUI editor for posts (markdown
with `[[macro]]` syntax), templates (HTML + Liquid), and scripts (Lua),
replacing the current plain `ExRatatui.Widgets.Textarea` body.
## Decision: pure Elixir — no new Rust
`ExRatatui` already ships everything we need:
- `ExRatatui.CodeBlock.highlight/3` (`deps/ex_ratatui/lib/ex_ratatui/code_block.ex:103`)
returns `[%ExRatatui.Text.Line{}]` of styled spans via the bundled
syntect NIF. Supports Lua, Markdown, HTML, … out of the box.
- `ExRatatui.Widgets.CodeBlock` is read-only; we do **not** use it
directly for editing.
- `ExRatatui.Widget` protocol (`deps/ex_ratatui/lib/ex_ratatui/widget.ex:1`)
lets us implement a custom widget in pure Elixir that composes
primitives every frame. This is the seam we use.
- The editing engine stays Rust-owned
(`ExRatatui.textarea_new/0`, `textarea_handle_key/3`, `textarea_get_value/1`,
`textarea_cursor/1`, `textarea_line_count/1`). The custom widget only
re-renders the buffer through a styling + wrap pipeline.
- `ExRatatui.Widgets.Paragraph` accepts `[%Text.Line{}]` of styled spans
with `wrap: true` and a `scroll: {vertical, horizontal}` offset —
exactly the output shape we need.
The existing comment at `lib/bds/tui.ex:1451-1454` already pre-announces
this approach as "the planned MarkdownEditor custom widget".
Languages supported (matches GUI Monaco registrations in
`assets/js/monaco/languages.js`):
| Entity | Base lexer (NIF) | Elixir overlay |
|-----------------------|------------------|---------------------------------|
| post body | `markdown` | `[[macro …]]` recoloured |
| script body | `lua` | none |
| template body | `html` | `{{ … }}` and `{% … %}` recoloured |
Liquid is always HTML+Liquid in this codebase (no markdown+Liquid
templates exist). Plain text falls back to syntect's plain renderer.
## GUI styling parity (from `assets/js/monaco/theme.js` + `languages.js`)
- **macro** `[[ … ]]``keyword.macro` = `#C586C0`, **bold**.
- inside macro: `attribute.name` (e.g. `names`) → `#9CDCFE`,
`attribute.value` (e.g. `"x"`) → `#CE9178`.
- Liquid uses base vs-dark tokens; terminal approximation uses the same
base theme accent colours. No custom Liquid token colours overridden.
Use `{:rgb,197,134,192}` etc. via `ExRatatui.Style` (already supported
by `ExRatatui.CodeBlock.from_native/1`).
## Architecture
```
state.editor.textarea (unchanged, Rust-owned buffer/cursor/undo)
state.editor.language (:markdown_macros | :liquid | :lua | :text)
state.editor.preview? (unchanged ctrl+e toggle — default false for posts)
body_widget/3 edit branch: %BDS.UI.CodeEditor{textarea:, language:, theme:, wrap:, cursor_style:, line_highlight_style:, block:}
↳ impl ExRatatui.Widget.render:
1. content = ExRatatui.textarea_get_value(textarea)
2. {row, col} = ExRatatui.textarea_cursor(textarea)
3. lines = BDS.UI.CodeEditor.Highlight.highlight(content, language, theme)
4. apply current-line bg style into every span on lines[row]
5. cut the span at byte col on lines[row], splice cursor-style cell
6. top = vertical-scroll offset so cursor visual row stays in window (Latin-width count)
7. return [{%Paragraph{text: lines, wrap: true, scroll: {top,0}, block: block}, rect}]
```
`editor_key/1` in `lib/bds/tui.ex` keeps calling
`ExRatatui.textarea_handle_key/3` — no edit-model rewrite, the
textarea's own undo/redo/clipboard keeps working.
## Phases (test-first, per AGENTS.md)
### Phase 0 — Spec
Edit `specs/tui.allium`:
- `OpenEntry`: posts open in the **editor** (wrap on, highlighting on)
by default — not the rendered Markdown preview. New empty posts still
open in the editor (unchanged).
- Rename `WrappedPreview``EditorMode`: `ctrl+e` toggles editor ↔
read-only rendered-Markdown preview (via `ExRatatui.Widgets.Markdown`).
Drop the "textarea cannot wrap" caveat.
- Add `CodeEditorWrapping` rule: syntax highlighting + auto wrap +
highlighted current line. Explicitly **no line-number gutter**.
- Validate with `allium check specs/tui.allium`.
### Phase 1 — Red tests
New file `test/bds/ui/code_editor_test.exs`:
- `BDS.UI.CodeEditor.Highlight.highlight/3` for `:lua` colours a
`function … end` keyword with the theme's keyword colour.
- `:markdown_macros` recolours `[[gallery names="x"]]`:
- `[[`, `]]` and macro name → `{:rgb,197,134,192}` bold
- `names``{:rgb,156,220,254}`
- `"x"``{:rgb,206,145,120}`
- surrounding markdown still base-styled.
- `:liquid` recolours `{{ x }}` and `{% if x %}` over an HTML base;
`if` keyword distinct from identifiers.
- Widget emits a `%Paragraph{wrap: true, scroll:}`, never a bare `%Textarea`,
when language is one of the highlighted set.
Extended `test/bds/tui_test.exs`:
- Opening an existing post lands in the **editor** (`preview? == false`),
not the rendered preview. Replace the assertion in
`test "opening a post lands in the markdown preview, not the editor"`.
Test name/logic flips.
- Long line wraps across N visual rows in a 40-wide viewport
(assert via `CellSession.take_cells/1`).
- Cursor at the last visual row stays visible when scrolling a small
viewport (assert cursor-style cell appears in snapshot cells).
- Current line's cells have a distinct `bg` from non-current rows on
the same buffer.
- `ctrl+e` flips to preview and back; preview still rendered Markdown.
- `ctrl+s` persists textarea bytes unchanged regardless of highlight/wrap
(existing assertion still covers this — keep it green).
### Phase 2 — `lib/bds/ui/code_editor/highlight.ex` + `overlay.ex`
- `BDS.UI.CodeEditor.Highlight.highlight(content, language, theme)`
`[%ExRatatui.Text.Line{}]`:
- `:lua`, `:text` → pass straight through to
`ExRatatui.CodeBlock.highlight/3`.
- `:markdown_macros` → base highlight as `"markdown"`, then apply
`Overlay.markdown_macros/1` per source line.
- `:liquid` → base highlight as `"html"`, then apply
`Overlay.liquid/1` per source line.
- `BDS.UI.CodeEditor.Overlay`:
- `apply_ranges(line, ranges_with_styles)` — re-splits a `Line`'s
spans into a new span list, preserving the base styling outside
each range.
- `markdown_macro_ranges/1``~r/\[\[\s*[a-zA-Z][\w-]*[^]]*\]\]/s`.
- `macro_inner_ranges/1` — break `attr=` (attr-name) and quoted value
into separate styled sub-ranges inside a macro.
- `liquid_ranges/1``~r/(\{\{.*?\}\}|\{%-?\s*(\w+).*?-?%\})/s`,
with a sub-range for the tag keyword (`if`, `for`, `assign`, …).
- Cache results by `{content_hash, language, theme}` if profiling shows
re-tokenisation overhead on keystroke (Phase 2 stretch goal —
skip until a test says otherwise).
### Phase 3 — `lib/bds/ui/code_editor/widget.ex`
- `%BDS.UI.CodeEditor{}` struct fields: `textarea`, `language`,
`theme` (default `:base16_ocean_dark`, readable in dark terminals),
`wrap: true`, `cursor_style`, `line_highlight_style`, `block`, `scroll`.
- `defimpl ExRatatui.Widget` per the pipeline above.
- Cursor cell: re-style the single codepoint at `col` on source line
`row` into `cursor_style`. Paragraph wraps the same line; the styled
cell rides the wrap.
- Scroll math: count display rows by wrapping each source line into
`width` columns (Latin-width assumption — 1 char = 1 cell). Note the
limitation explicitly; CJK width is a follow-up if/when needed.
- Line highlight: apply `line_highlight_style` (a `bg:` colour) to
every span on the cursor's source line before cursor cut-in.
### Phase 4 — Wire into `lib/bds/tui.ex`
- `build_editor/4` (around `lib/bds/tui.ex:1968`): default
`preview? = false` for existing posts. New posts already open in
editor — unchanged.
- `body_widget/3` edit branch (`lib/bds/tui.ex:1466`): emit
`%BDS.UI.CodeEditor{}` instead of `%ExRatatui.Widgets.Textarea{}` for
entities that have a syntax highlighter (`:lua`, `:markdown_macros`,
`:liquid`). Plain text can keep `%Textarea{}` or feed `:text` through
the new widget — pick whichever keeps the test surface smaller.
- Remove the "wrap limitation" comment block at
`lib/bds/tui.ex:1451-1454`.
- `cycle_language/1` continues per post; cycle through the active
set (`:markdown_macros`, `:liquid`, `:lua`, `:text`) matching the
existing GUI editor language options.
- Preview branch (`body_widget/3` preview arm at `lib/bds/tui.ex:1455`)
stays on `ExRatatui.Widgets.Markdown` — unchanged.
- No new user-facing strings: the existing body title already flows
through `dgettext("ui", …)`. If a new toolbar/hint is added later,
it MUST go through gettext and provide de/fr/it/es translations.
### Phase 5 — Verify
- `mix test` (write to `/tmp/test_run.log` first, grep for failures —
per AGENTS.md).
- `mix credo --strict`
- `mix deps.audit --ignore-file .mix_audit.ignore`
- `mix dialyzer` — treat warnings as errors.
No bundle rebuild, no asset change, no NIF rebuild. Monaco still drives
the GUI editor; this change is TUI-only.
## Risks / limits
- **Per-frame NIF cost:** re-tokenises the whole buffer on every
keystroke. For typical post sizes (<50 KB) syntect is sub-ms. Add the
`{content_hash, language, theme}` cache only if a test shows it.
- **CJK cell width:** wrap math assumes 1 char = 1 cell. Fine for
de/fr/it/es. Note as TODO; not in scope for this phase.
- **Liquid mis-lexing inside HTML** is cosmetically possible (an HTML
attribute containing `{{`). Overlay wins after the base lexer; the
result is visually tolerable. A custom `.sublime-syntax` in the
dep would be the real fix — out of scope here per "stay on Elixir".
- **Cursor in a wrapped paragraph** rides the styled cell. Equivalent
to a cell-caret; GUI uses a real caret — acceptable TUI parity.
## Files touched (expected)
- `specs/tui.allium` — edited.
- `lib/bds/ui/code_editor/highlight.ex` — new.
- `lib/bds/ui/code_editor/overlay.ex` — new.
- `lib/bds/ui/code_editor/widget.ex` — new.
- `lib/bds/tui.ex` — edited (`build_editor`, `body_widget`, comment
removal, default `preview?`).
- `test/bds/ui/code_editor_test.exs` — new.
- `test/bds/tui_test.exs` — edited (flip the "opens in preview"
assertion, add wrap/cursor/current-line tests).
No changes to `mix.exs`, no new deps, no Rust, no assets.

View File

@@ -49,8 +49,7 @@
border-radius: 3px; border-radius: 3px;
} }
.editor-toolbar-button:hover, .editor-toolbar-button:hover {
.panel-tab:hover {
background: var(--vscode-toolbar-hoverBackground); background: var(--vscode-toolbar-hoverBackground);
} }

View File

@@ -377,6 +377,76 @@
font: inherit; font: inherit;
} }
.insert-modal-results {
overflow: auto;
padding: 8px 0;
}
.insert-modal-result-item {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
border: none;
background: transparent;
color: inherit;
padding: 10px 20px;
text-align: left;
cursor: pointer;
font: inherit;
}
.insert-modal-result-item:hover {
background: #252526;
}
.insert-modal-result-title {
color: #ffffff;
font-weight: 600;
}
.insert-modal-result-meta {
color: #9d9d9d;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.insert-modal-status {
padding: 16px 20px;
color: #9d9d9d;
}
.insert-modal-external {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px;
}
.insert-modal-external .insert-modal-input {
padding: 8px 10px;
border: 1px solid #3c3c3c;
border-radius: 4px;
background: #252526;
}
.insert-modal-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.insert-modal-label {
color: #9d9d9d;
font-size: 12px;
}
.insert-modal-submit {
align-self: flex-end;
}
/* Alert modal (errors) — reuses .confirm-delete-modal with a tone accent. */ /* Alert modal (errors) — reuses .confirm-delete-modal with a tone accent. */
.alert-modal-error { .alert-modal-error {

View File

@@ -8,14 +8,11 @@
display: none; display: none;
} }
.panel-tabs {
display: flex;
gap: 8px;
}
.panel-tabs { .panel-tabs {
display: flex; display: flex;
gap: 2px; gap: 2px;
align-items: stretch;
height: 100%;
} }
.panel-tab { .panel-tab {
@@ -246,6 +243,12 @@
background-color: var(--vscode-sideBar-background); background-color: var(--vscode-sideBar-background);
} }
/* .panel-entry (shell.css) forces flex-direction: column and, being unlayered,
beats the Tailwind row utilities on this button — force the row here. */
.task-group-toggle {
flex-direction: row;
}
.output-entry { .output-entry {
padding: 8px; padding: 8px;
border-bottom: none; border-bottom: none;

View File

@@ -402,24 +402,6 @@
border-bottom: 1px solid var(--vscode-panel-border); border-bottom: 1px solid var(--vscode-panel-border);
} }
.panel-tabs {
display: flex;
align-items: stretch;
height: 100%;
}
.panel-tab {
border: none;
background: transparent;
color: var(--vscode-descriptionForeground);
padding: 0 12px;
cursor: pointer;
}
.panel-tab.active {
color: var(--vscode-tab-activeForeground);
}
.panel-close { .panel-close {
background: transparent; background: transparent;
border: none; border: none;
@@ -778,11 +760,6 @@
padding-left: 18px; padding-left: 18px;
} }
.editor-toolbar {
display: flex;
gap: 10px;
}
.editor-meta { .editor-meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -794,13 +771,3 @@
padding: 16px; padding: 16px;
} }
.sidebar-header,
.assistant-header,
.panel-header {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
}

View File

@@ -19,6 +19,7 @@ import {
} from "../utils/shortcuts.js"; } from "../utils/shortcuts.js";
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js"; import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js"; import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
import { copyTextToClipboard } from "../utils/clipboard.js";
export const AppShell = { export const AppShell = {
mounted() { mounted() {
@@ -179,6 +180,10 @@ export const AppShell = {
} }
}); });
this.handleEvent("clipboard", ({ text }) => {
copyTextToClipboard(text || "");
});
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction); window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.addEventListener("keydown", this.handleShortcutKeyDown, true); window.addEventListener("keydown", this.handleShortcutKeyDown, true);
this.el.addEventListener("load", this.handleThumbnailLoad, true); this.el.addEventListener("load", this.handleThumbnailLoad, true);

View File

@@ -34,6 +34,20 @@ export const webKitTextAreaArrowCommand = (event) => {
return event.shiftKey ? `${command}Select` : command; return event.shiftKey ? `${command}Select` : command;
}; };
// Applying a remote value via setValue resets the cursor to the buffer start,
// which then makes follow-up actions (like link inserts targeting the current
// selection) land at position 1,1. Capture and restore the selection so the
// cursor survives server-driven reconciles; Monaco clamps out-of-range
// positions to the new content.
export const applyRemoteEditorValue = (editor, value) => {
const selections = editor.getSelections ? editor.getSelections() : null;
editor.setValue(value);
if (selections && selections.length > 0 && editor.setSelections) {
editor.setSelections(selections);
}
};
export const bridgeWebKitTextAreaArrowKey = (editor, event) => { export const bridgeWebKitTextAreaArrowKey = (editor, event) => {
if (!editor || !isMonacoInputArea(event.target)) { if (!editor || !isMonacoInputArea(event.target)) {
return false; return false;
@@ -92,8 +106,12 @@ export const MonacoEditor = {
if (this.editor.getValue() !== value) { if (this.editor.getValue() !== value) {
this.isApplyingRemoteUpdate = true; this.isApplyingRemoteUpdate = true;
this.editor.setValue(value);
this.isApplyingRemoteUpdate = false; try {
applyRemoteEditorValue(this.editor, value);
} finally {
this.isApplyingRemoteUpdate = false;
}
} }
this.lastKnownValue = value; this.lastKnownValue = value;

View File

@@ -0,0 +1,27 @@
// Copies text to the system clipboard, mirroring the old app's behavior:
// navigator.clipboard when available, with a hidden-textarea fallback for
// webviews that lack the async clipboard API.
export const copyTextToClipboard = async (text) => {
if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
await navigator.clipboard.writeText(text);
return;
}
if (typeof document === "undefined" || typeof document.createElement !== "function") {
return;
}
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.setAttribute("readonly", "");
textArea.style.position = "absolute";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
if (typeof document.execCommand === "function") {
document.execCommand("copy");
}
document.body.removeChild(textArea);
};

View File

@@ -1,5 +1,14 @@
import Config import Config
# Headless boots (BDS_MODE=server/tui, or desktop mode without a graphical
# display — issue #33) must neuter wx before the :desktop dependency
# application starts it and crashes the VM. Local tui mode additionally
# silences all other terminal writers (console logging goes to a file,
# model-download progress bars are disabled) — the TUI owns the terminal
# and every stray line scrolls its rendering up one row. Runtime config is
# the only hook that runs before dependency applications start.
BDS.Server.prepare_boot_env()
if config_env() == :prod do if config_env() == :prod do
database_path = database_path =
System.get_env("BDS_DATABASE_PATH") || System.get_env("BDS_DATABASE_PATH") ||

View File

@@ -12,6 +12,11 @@ defmodule BDS.Application do
[{BDS.Desktop.Server, []}, BDS.CliSync.Watcher, BDS.Server] [{BDS.Desktop.Server, []}, BDS.CliSync.Watcher, BDS.Server]
end end
# CLI mode (issue #25): a one-shot command run against the shared database.
# No HTTP listener, SSH daemon, window, or sync watcher — the CLI is the
# external writer the watcher in a concurrently running app listens for.
def mode_children(:cli, _env), do: []
# Local TUI: the headless server plus a TUI on the launching terminal, so # Local TUI: the headless server plus a TUI on the launching terminal, so
# a GUI (via tunnel) and the local terminal can work in parallel. Quitting # a GUI (via tunnel) and the local terminal can work in parallel. Quitting
# this TUI stops the whole VM — the terminal is the app in this mode. # this TUI stops the whole VM — the terminal is the app in this mode.
@@ -57,7 +62,7 @@ defmodule BDS.Application do
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor}, {Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
BDS.Scripting.JobSupervisor, BDS.Scripting.JobSupervisor,
BDS.Embeddings.Index BDS.Embeddings.Index
] ++ embedding_children() ++ mode_children(BDS.Server.mode(), current_env()) ] ++ embedding_children() ++ mode_children(boot_mode(), current_env())
opts = [strategy: :one_for_one, name: BDS.Supervisor] opts = [strategy: :one_for_one, name: BDS.Supervisor]
Supervisor.start_link(children, opts) Supervisor.start_link(children, opts)
@@ -77,6 +82,20 @@ defmodule BDS.Application do
Application.get_env(:bds, :current_env_override) || @compiled_env Application.get_env(:bds, :current_env_override) || @compiled_env
end end
# Desktop mode without a graphical display boots the TUI instead
# (issue #33); the log line explains the surprise mode switch.
defp boot_mode do
resolved = BDS.Server.mode()
effective = BDS.Server.effective_mode(resolved)
if effective != resolved do
require Logger
Logger.info("No graphical display available, starting the terminal UI instead")
end
effective
end
defp desktop_window_children do defp desktop_window_children do
if desktop_automation?() do if desktop_automation?() do
[] []

335
lib/bds/cli.ex Normal file
View File

@@ -0,0 +1,335 @@
defmodule BDS.CLI do
@moduledoc """
Command-line interface for managing content in a bDS2 workspace (issue #25).
Runs inside the release VM (`BDS_MODE=cli`) against the same settings and
cache database as the GUI/TUI application; mutations write
`BDS.CliSync.Notification` rows so a concurrently running app picks them
up through `BDS.CliSync.Watcher`.
The launcher script (`cli/bin/bds-cli` in the release, installable to
`~/.local/bin` via `BDS.CLI.Install`) passes the argv via `BDS_CLI_ARGV`
(unit-separator joined) because `bin/bds eval` cannot forward arguments.
"""
alias BDS.CLI.Commands
@argv_env "BDS_CLI_ARGV"
@argv_separator <<0x1F>>
@doc "Release entry point: run the command from `BDS_CLI_ARGV` and halt."
@spec main() :: no_return()
def main do
System.halt(run(env_argv()))
end
@doc "Decodes the unit-separator-joined argv from the launcher script."
@spec env_argv(String.t() | nil) :: [String.t()]
def env_argv(value \\ System.get_env(@argv_env))
def env_argv(nil), do: []
def env_argv(value), do: String.split(value, @argv_separator, trim: true)
@doc "Parses and executes an argv, returning the process exit code."
@spec run([String.t()]) :: non_neg_integer()
def run(argv) when is_list(argv) do
optimus = optimus()
case Optimus.parse(optimus, argv) do
{:ok, %Optimus.ParseResult{}} ->
print_help(optimus, [])
0
{:ok, subcommand_path, parse_result} ->
execute(subcommand_path, parse_result)
:help ->
print_help(optimus, [])
0
{:help, subcommand_path} ->
print_help(optimus, subcommand_path)
0
:version ->
IO.puts(version())
0
{:error, errors} ->
print_errors(optimus, [], errors)
1
{:error, subcommand_path, errors} ->
print_errors(optimus, subcommand_path, errors)
1
end
end
@doc "Executes a parsed subcommand, returning the process exit code."
@spec execute([atom()], Optimus.ParseResult.t()) :: non_neg_integer()
def execute(subcommand_path, parse_result) do
{:ok, _apps} = Application.ensure_all_started(:bds)
subcommand_path
|> Commands.dispatch(parse_result)
|> report()
end
defp report(:ok), do: 0
defp report({:ok, message}) when is_binary(message) do
IO.puts(message)
0
end
defp report({:error, message}) do
IO.puts(:stderr, "Error: " <> format_error(message))
1
end
defp format_error(message) when is_binary(message), do: message
defp format_error(%{message: message}) when is_binary(message), do: message
defp format_error(other), do: inspect(other)
@repair_parts ~w(post-links media-links thumbnails embeddings search)
@doc "The Optimus command definition (public for tests and help rendering)."
def optimus do
Optimus.new!(
name: "bds-cli",
description: "bDS2 workspace CLI",
about:
"Manages content in a bDS2 workspace using the same settings and " <>
"cache database as the desktop application.",
version: version(),
allow_unknown_args: false,
parse_double_dash: true,
subcommands: [
rebuild: [
name: "rebuild",
about: "Rebuild the caching database from the workspace files",
flags: [
incremental: [
long: "--incremental",
help:
"Run a metadata diff and auto-apply every difference from " <>
"file to database instead of a full rebuild"
]
]
],
repair: [
name: "repair",
about: "Run one of the standard repair tasks outside the full rebuild",
args: [
part: [
value_name: "PART",
help: "One of: " <> Enum.join(@repair_parts, ", "),
required: true,
parser: &parse_repair_part/1
]
]
],
render: [
name: "render",
about: "Render the blog from the current content",
flags: [
incremental: [
long: "--incremental",
help: "Validate the generated output and apply only the differences"
],
force: [
long: "--force",
help: "Full re-render ignoring (but updating) stored content hashes"
]
]
],
upload: [
name: "upload",
about: "Upload the rendered site to the configured host (rsync/scp)"
],
push: [
name: "push",
about: "Push the project repository to its origin"
],
pull: [
name: "pull",
about: "Pull the project repository and update the cache database"
],
post: [
name: "post",
about:
"Create a post from parameters or from JSON on stdin " <>
"(keys: title, content, excerpt, author, language, tags, categories, template)",
flags: [
stdin: [long: "--stdin", help: "Read post data as JSON from stdin"],
no_translate: [
long: "--no-translate",
help: "Skip automatic translation of the created post"
]
],
options: post_content_options()
],
media: [
name: "media",
about:
"Import an image with automatically generated title, alt text, " <>
"caption and translations",
args: [
file: [
value_name: "FILE",
help: "Path of the image file to import",
required: true
]
],
options: [
language: [
long: "--language",
value_name: "LANG",
help: "Language for the generated texts (defaults to the project main language)"
]
]
],
gallery: [
name: "gallery",
about:
"Create a gallery post and import all referenced images as new " <>
"media with generated texts and translations",
allow_unknown_args: true,
flags: [
stdin: [
long: "--stdin",
help: "Read gallery data as JSON from stdin (adds an \"images\" key to post keys)"
]
],
options: post_content_options()
],
config: [
name: "config",
about: "Read and write global preference values",
subcommands: [
get: [
name: "get",
about: "Print a preference value",
args: [key: [value_name: "KEY", help: "Preference key", required: true]]
],
set: [
name: "set",
about: "Set a preference value",
args: [
key: [value_name: "KEY", help: "Preference key", required: true],
value: [value_name: "VALUE", help: "New value", required: true]
]
],
list: [
name: "list",
about: "List all preference keys and values"
]
]
],
project: [
name: "project",
about: "Manage the projects registered in the cache database",
subcommands: [
list: [
name: "list",
about: "List all projects"
],
add: [
name: "add",
about: "Open a project folder and add it to the cache database",
args: [
path: [value_name: "PATH", help: "Project data folder", required: true]
],
options: [
name: [
long: "--name",
value_name: "NAME",
help: "Project name (defaults to the folder name)"
]
]
],
switch: [
name: "switch",
about: "Switch the active project",
args: [
project: [
value_name: "PROJECT",
help: "Project id, slug, or name",
required: true
]
]
]
]
],
tui: [
name: "tui",
about: "Open the app in TUI mode for interactive use"
],
lua: [
name: "lua",
about: "Run a utility (long-running task) Lua script from the database",
allow_unknown_args: true,
args: [
script: [
value_name: "SCRIPT",
help: "Script slug in the active project",
required: true
]
]
]
]
)
end
defp post_content_options do
[
title: [long: "--title", value_name: "TITLE", help: "Post title"],
content: [long: "--content", value_name: "MARKDOWN", help: "Post body (markdown)"],
excerpt: [long: "--excerpt", value_name: "TEXT", help: "Post excerpt"],
author: [long: "--author", value_name: "NAME", help: "Post author"],
language: [
long: "--language",
value_name: "LANG",
help: "Post language (auto-detected from the content when omitted)"
],
template: [long: "--template", value_name: "SLUG", help: "Template slug"],
tags: [long: "--tags", value_name: "TAGS", help: "Comma-separated tags"],
categories: [
long: "--categories",
value_name: "CATEGORIES",
help: "Comma-separated categories"
]
]
end
defp parse_repair_part(value) when value in @repair_parts, do: {:ok, value}
defp parse_repair_part(value),
do: {:error, "unknown repair part #{inspect(value)}; expected one of: #{Enum.join(@repair_parts, ", ")}"}
defp print_help(optimus, subcommand_path) do
optimus
|> Optimus.Help.help(subcommand_path, terminal_width())
|> Enum.each(&IO.puts/1)
end
defp print_errors(optimus, [], errors) do
optimus |> Optimus.Errors.format(errors) |> Enum.each(&IO.puts(:stderr, &1))
end
defp print_errors(optimus, subcommand_path, errors) do
optimus
|> Optimus.Errors.format(subcommand_path, errors)
|> Enum.each(&IO.puts(:stderr, &1))
end
defp terminal_width do
case Optimus.Term.width() do
{:ok, width} -> width
_other -> 80
end
end
defp version do
to_string(Application.spec(:bds, :vsn) || "dev")
end
end

696
lib/bds/cli/commands.ex Normal file
View File

@@ -0,0 +1,696 @@
defmodule BDS.CLI.Commands do
@moduledoc """
Implementations of the `bds-cli` subcommands (issue #25).
Every command runs synchronously against the engine modules the GUI uses
and returns `:ok`, `{:ok, message}`, or `{:error, message}`. Mutations
write `BDS.CliSync` notification rows so a concurrently running app
refreshes its state (see `specs/cli_sync.allium`).
"""
import Ecto.Query
alias BDS.AI
alias BDS.CliSync
alias BDS.Desktop.ShellLive.GalleryImport
alias BDS.Generation
alias BDS.Git
alias BDS.Maintenance
alias BDS.Media
alias BDS.Metadata
alias BDS.Posts
alias BDS.Posts.AutoTranslation
alias BDS.Projects
alias BDS.Publishing
alias BDS.Repo
alias BDS.Scripting
alias BDS.Scripts
alias BDS.Scripts.Script
alias BDS.Search
alias BDS.Settings
alias BDS.Tasks
@site_sections [:core, :single, :category, :tag, :date]
@bulk_entity_types ~w(post media script template)
@gallery_concurrency 2
@poll_interval_ms 250
@doc "Dispatches a parsed subcommand path to its implementation."
@spec dispatch([atom()], Optimus.ParseResult.t()) :: :ok | {:ok, String.t()} | {:error, term()}
def dispatch([:rebuild], %{flags: %{incremental: true}}),
do: with_project(&incremental_rebuild/1)
def dispatch([:rebuild], _parse_result), do: with_project(&full_rebuild/1)
def dispatch([:repair], %{args: %{part: part}}),
do: with_project(&repair(part, &1))
def dispatch([:render], %{flags: %{incremental: true, force: true}}),
do: {:error, "--incremental and --force are mutually exclusive"}
def dispatch([:render], %{flags: %{incremental: true}}),
do: with_project(&incremental_render/1)
def dispatch([:render], %{flags: flags}),
do: with_project(&full_render(&1, flags[:force] == true))
def dispatch([:upload], _parse_result), do: with_project(&upload/1)
def dispatch([:push], _parse_result) do
with_project(fn project ->
with {:ok, result} <- Git.push(project.id) do
print_git_output(result)
{:ok, "Pushed"}
end
end)
end
def dispatch([:pull], _parse_result) do
with_project(fn project ->
with {:ok, result} <- Git.pull(project.id) do
print_git_output(result)
# Update the cache database from the pulled files, like the app's
# incremental rebuild: file→db for every difference plus orphans.
incremental_rebuild(project)
end
end)
end
def dispatch([:post], %{flags: flags, options: options}) do
with_project(fn project ->
with {:ok, attrs} <- post_attrs(flags, options),
{:ok, attrs} <- ensure_language(attrs),
{:ok, post} <- create_post(project, attrs) do
unless flags[:no_translate], do: translate_post(post)
{:ok, "Created post #{post.id} (#{post.slug}, #{post.language})"}
end
end)
end
def dispatch([:media], %{args: %{file: file}, options: options}) do
with_project(fn project ->
with {:ok, metadata} <- Metadata.get_project_metadata(project.id),
:ok <- ensure_files_exist([file]) do
language = present(options[:language]) || metadata.main_language
targets = GalleryImport.translate_targets(metadata, language)
case GalleryImport.import_and_enrich(file, project.id, language, targets) do
{:ok, media, title} ->
:ok = notify("media", media.id, :created)
{:ok, "Imported media #{media.id} (#{title})"}
{:error, reason} ->
{:error, format_reason(reason)}
end
end
end)
end
def dispatch([:gallery], %{flags: flags, options: options, unknown: unknown}) do
with_project(fn project ->
with {:ok, attrs, images} <- gallery_attrs(flags, options, unknown),
:ok <- ensure_files_exist(images),
{:ok, attrs} <- ensure_language(attrs),
{:ok, post} <- create_post(project, attrs) do
import_gallery_images(project, post, images)
:ok = notify("media", "*", :created)
:ok = notify("post", post.id, :updated)
unless flags[:no_translate], do: translate_post(post)
{:ok, "Created gallery post #{post.id} (#{post.slug}) with #{length(images)} images"}
end
end)
end
def dispatch([:config, :get], %{args: %{key: key}}) do
case Settings.get_global_setting(key) do
nil -> {:error, "#{key} is not set"}
value -> {:ok, value}
end
end
def dispatch([:config, :set], %{args: %{key: key, value: value}}) do
with :ok <- Settings.put_global_setting(key, value) do
:ok = notify("setting", key, :updated)
{:ok, "#{key} = #{value}"}
end
end
def dispatch([:config, :list], _parse_result) do
Settings.list_global_settings()
|> Enum.each(fn {key, value} -> IO.puts("#{key}=#{value}") end)
:ok
end
def dispatch([:project, :list], _parse_result) do
Enum.each(Projects.list_projects(), fn project ->
marker = if project.is_active, do: "* ", else: " "
IO.puts("#{marker}#{project.id} #{project.slug} #{project.name}")
end)
:ok
end
def dispatch([:project, :add], %{args: %{path: path}, options: options}) do
expanded = Path.expand(path)
if File.dir?(expanded) do
name = present(options[:name]) || Path.basename(expanded)
with {:ok, project} <- Projects.create_project(%{name: name, data_path: expanded}) do
:ok = notify("project", project.id, :created)
{:ok, "Added project #{project.id} (#{project.slug}); switch with: project switch #{project.slug}"}
end
else
{:error, "#{expanded} is not a directory"}
end
end
def dispatch([:project, :switch], %{args: %{project: reference}}) do
with {:ok, project} <- resolve_project(reference),
{:ok, project} <- Projects.set_active_project(project.id) do
:ok = notify("project", project.id, :updated)
{:ok, "Active project: #{project.name}"}
else
{:error, :not_found} -> {:error, "No project matches #{inspect(reference)}"}
{:error, reason} -> {:error, format_reason(reason)}
end
end
def dispatch([:tui], _parse_result) do
{:error,
"The TUI is started by the bds-cli launcher script (BDS_MODE=tui); " <>
"it is not available through a direct BDS.CLI invocation"}
end
def dispatch([:lua], %{args: %{script: slug}, unknown: script_args}) do
with_project(fn project ->
case Repo.one(
from script in Script,
where: script.project_id == ^project.id and script.slug == ^slug
) do
nil ->
{:error, "No script with slug #{inspect(slug)} in the active project"}
%Script{kind: kind} when kind != :utility ->
{:error, "Script #{inspect(slug)} is a #{kind} script; only utility (task) scripts can be run"}
%Script{enabled: false} ->
{:error, "Script #{inspect(slug)} is disabled"}
script ->
run_lua_job(project, script, script_args)
end
end)
end
# ── rebuild ───────────────────────────────────────────────────────────────
defp full_rebuild(project) do
project.id
|> Maintenance.full_rebuild_steps()
|> Enum.reduce_while(:ok, fn step, :ok ->
IO.puts("==> #{step.name}")
case step.work.(progress_printer()) do
{:error, message} -> {:halt, {:error, message}}
_result -> {:cont, :ok}
end
end)
|> case do
:ok ->
notify_bulk_change()
{:ok, "Rebuild complete"}
error ->
error
end
end
defp incremental_rebuild(project) do
reporter = progress_printer()
{:ok, diff} = Maintenance.metadata_diff(project.id, on_progress: reporter)
items =
Enum.map(diff.diff_reports, &%{entity_type: &1.entity_type, entity_id: &1.entity_id})
orphans = Enum.map(diff.orphan_reports, &%{file_path: &1.file_path})
if items != [] do
{:ok, _repair} =
Maintenance.repair_metadata_diff(project.id, :file_to_db, items, on_progress: reporter)
end
if orphans != [] do
{:ok, _import} =
Maintenance.import_metadata_diff_orphans(project.id, orphans, on_progress: reporter)
end
if items != [] or orphans != [], do: notify_bulk_change()
{:ok,
"Applied #{length(items)} differences and imported #{length(orphans)} orphan files from the filesystem"}
end
# ── repair ────────────────────────────────────────────────────────────────
defp repair("post-links", project) do
:ok = Posts.rebuild_post_links(project.id, on_progress: progress_printer())
:ok = notify("post", "*", :updated)
{:ok, "Post links rebuilt"}
end
defp repair("media-links", project) do
{:ok, result} = Media.rebuild_media_links(project.id, on_progress: progress_printer())
:ok = notify("media", "*", :updated)
{:ok, "Media links rebuilt (#{result.links} links)"}
end
defp repair("thumbnails", project) do
result = Media.regenerate_missing_thumbnails(project.id, on_progress: progress_printer())
:ok = notify("media", "*", :updated)
{:ok, "Missing thumbnails regenerated (#{inspect(Map.get(result, :generated, 0))} generated)"}
end
defp repair("embeddings", project) do
case Maintenance.rebuild_embedding_index(project.id, on_progress: progress_printer()) do
{:error, message} -> {:error, message}
result -> {:ok, "Embedding index rebuilt (#{result.rebuilt_count} posts)"}
end
end
defp repair("search", project) do
:ok = Search.reindex_posts(project.id, on_progress: progress_printer())
:ok = Search.reindex_media(project.id, on_progress: progress_printer())
{:ok, "Search text reindexed"}
end
# ── render ────────────────────────────────────────────────────────────────
defp full_render(project, force?) do
render_opts = if force?, do: [force: true], else: []
Enum.each(@site_sections, fn section ->
IO.puts("==> Rendering #{section}")
{:ok, _result} =
Generation.render_site_section(
project.id,
section,
[on_progress: progress_printer()] ++ render_opts
)
end)
IO.puts("==> Building search index")
{:ok, _index} =
Generation.build_site_search_index(
project.id,
[on_progress: progress_printer()] ++ render_opts
)
{:ok, "Site rendered"}
end
defp incremental_render(project) do
{:ok, report} =
Generation.validate_site(project.id, @site_sections, on_progress: progress_printer())
{:ok, applied} =
Generation.apply_validation(project.id, report, on_progress: progress_printer())
{:ok,
"Validation applied (#{applied.rendered_url_count} rendered, " <>
"#{applied.deleted_url_count} deleted)"}
end
# ── upload ────────────────────────────────────────────────────────────────
defp upload(project) do
with {:ok, metadata} <- Metadata.get_project_metadata(project.id),
{:ok, credentials} <- upload_credentials(metadata.publishing_preferences),
{:ok, job} <- Publishing.upload_site(project.id, credentials) do
await_task(job.task_id, "Upload complete")
end
end
defp upload_credentials(prefs) when is_map(prefs) do
credentials = %{
ssh_host: Map.get(prefs, "ssh_host"),
ssh_user: Map.get(prefs, "ssh_user"),
ssh_remote_path: Map.get(prefs, "ssh_remote_path"),
ssh_mode: Map.get(prefs, "ssh_mode")
}
if Enum.all?(
[credentials.ssh_host, credentials.ssh_user, credentials.ssh_remote_path],
&is_binary/1
) do
{:ok, credentials}
else
{:error, "Publishing preferences are incomplete; configure the upload host in Settings"}
end
end
defp upload_credentials(_prefs),
do: {:error, "Publishing preferences are incomplete; configure the upload host in Settings"}
# ── post / gallery ────────────────────────────────────────────────────────
defp post_attrs(%{stdin: true}, _options) do
with {:ok, data} <- read_stdin_json() do
json_post_attrs(data)
end
end
defp post_attrs(_flags, options) do
case present(options[:title]) do
nil ->
{:error, "--title is required (or pass --stdin with JSON post data)"}
title ->
{:ok,
%{
title: title,
content: options[:content] || "",
excerpt: present(options[:excerpt]),
author: present(options[:author]),
language: present(options[:language]),
template_slug: present(options[:template]),
tags: split_list(options[:tags]),
categories: split_list(options[:categories])
}}
end
end
defp gallery_attrs(%{stdin: true}, _options, _unknown) do
with {:ok, data} <- read_stdin_json(),
{:ok, attrs} <- json_post_attrs(data) do
case Map.get(data, "images") do
images when is_list(images) and images != [] -> {:ok, attrs, images}
_other -> {:error, "JSON gallery data needs a non-empty \"images\" array"}
end
end
end
defp gallery_attrs(flags, options, unknown) do
case unknown do
[] ->
{:error, "Pass the image files as arguments (or use --stdin with JSON gallery data)"}
images ->
with {:ok, attrs} <- post_attrs(flags, options) do
{:ok, attrs, images}
end
end
end
defp json_post_attrs(data) when is_map(data) do
case present(Map.get(data, "title")) do
nil ->
{:error, "JSON post data needs a \"title\""}
title ->
{:ok,
%{
title: title,
content: Map.get(data, "content") || "",
excerpt: present(Map.get(data, "excerpt")),
author: present(Map.get(data, "author")),
language: present(Map.get(data, "language")),
template_slug: present(Map.get(data, "template")),
tags: json_list(Map.get(data, "tags")),
categories: json_list(Map.get(data, "categories"))
}}
end
end
defp json_post_attrs(_data), do: {:error, "JSON post data must be an object"}
defp read_stdin_json do
case IO.read(:stdio, :eof) do
:eof ->
{:error, "No JSON data on stdin"}
{:error, reason} ->
{:error, "Could not read stdin: #{inspect(reason)}"}
data ->
case Jason.decode(data) do
{:ok, decoded} -> {:ok, decoded}
{:error, error} -> {:error, "Invalid JSON on stdin: #{Exception.message(error)}"}
end
end
end
# Language auto-detection (issue #25): ask the configured AI endpoint —
# BDS.AI internally routes to the local model in airplane mode — and fall
# back to the offline search heuristic, informing the user (the CLI
# equivalent of the airplane-mode toast).
defp ensure_language(%{language: language} = attrs) when is_binary(language), do: {:ok, attrs}
defp ensure_language(attrs) do
text = String.trim("#{attrs.title}\n#{attrs.content}")
language =
case AI.detect_language(text) do
{:ok, %{language_code: language}} when is_binary(language) ->
language
_other ->
IO.puts("AI language detection unavailable; using the offline heuristic")
Search.detect_language(text)
end
{:ok, %{attrs | language: language}}
end
defp create_post(project, attrs) do
case Posts.create_post(Map.put(attrs, :project_id, project.id)) do
{:ok, post} ->
:ok = notify("post", post.id, :created)
{:ok, post}
{:error, %Ecto.Changeset{} = changeset} ->
{:error, changeset_message(changeset)}
end
end
# Automatic translation mirrors the GUI: schedule the background tasks and
# wait for them, so the CLI exits only when the work is done. When nothing
# gets scheduled (airplane mode without a local model, or a single-language
# blog) the user is told instead of a silent no-op.
defp translate_post(post) do
before_count = length(active_tasks())
:ok = AutoTranslation.maybe_schedule(post)
case length(active_tasks()) - before_count do
queued when queued > 0 ->
IO.puts("Waiting for #{queued} translation task(s)...")
await_active_tasks()
:ok = notify("post", post.id, :updated)
_none ->
IO.puts("Automatic translation was not scheduled (offline, unconfigured AI, or nothing to translate)")
end
:ok
end
defp import_gallery_images(project, post, images) do
{:ok, metadata} = Metadata.get_project_metadata(project.id)
language = post.language || metadata.main_language
parent = self()
runner =
Task.async(fn ->
GalleryImport.start(
images,
project.id,
post.id,
language,
@gallery_concurrency,
parent
)
end)
print_gallery_progress()
Task.await(runner, :infinity)
end
defp print_gallery_progress do
receive do
{:add_image_processed, title} ->
IO.puts("Imported #{title}")
print_gallery_progress()
{:add_image_error, path, reason} ->
IO.puts(:stderr, "Failed to import #{path}: #{format_reason(reason)}")
print_gallery_progress()
{:add_images_complete, count} ->
IO.puts("Processed #{count} image(s)")
:ok
end
end
defp ensure_files_exist(paths) do
case Enum.reject(paths, &File.regular?/1) do
[] -> :ok
missing -> {:error, "No such file: #{Enum.join(missing, ", ")}"}
end
end
# ── lua ───────────────────────────────────────────────────────────────────
# Utility ("task") scripts run with the managed-job execution budget
# (unlimited time/reductions) but synchronously — the CLI is the one
# waiting for the long-running activity.
defp run_lua_job(project, script, args) do
scripting_config = Application.fetch_env!(:bds, :scripting)
case Scripting.execute_project_script(
project.id,
Scripts.resolved_content(script),
script.entrypoint || "main",
args,
timeout: Keyword.get(scripting_config, :job_timeout, :infinity),
max_reductions: Keyword.get(scripting_config, :job_max_reductions, :none),
on_output: fn text -> IO.puts(text) end
) do
{:ok, nil} -> {:ok, "Script finished"}
{:ok, result} -> {:ok, "Script finished: #{format_reason(result)}"}
{:error, reason} -> {:error, "Script failed: #{format_reason(reason)}"}
end
end
# ── shared helpers ────────────────────────────────────────────────────────
defp with_project(fun) do
case Projects.get_active_project() do
nil -> {:error, "No active project selected; use: project switch <project>"}
project -> fun.(project)
end
end
defp resolve_project(reference) do
projects = Projects.list_projects()
found =
Enum.find(projects, fn project ->
reference in [project.id, project.slug, project.name]
end)
if found, do: {:ok, found}, else: {:error, :not_found}
end
defp await_task(task_id, success_message) do
case Tasks.get_task(task_id) do
nil ->
{:error, "Task disappeared before completion"}
%{status: :completed} ->
{:ok, success_message}
%{status: :failed} = task ->
{:error, format_reason(task.error || "task failed")}
%{message: message} = task ->
print_progress(task.progress || 0.0, message)
Process.sleep(@poll_interval_ms)
await_task(task_id, success_message)
end
end
defp active_tasks do
Enum.filter(Tasks.list_tasks(), &(&1.status in [:pending, :running]))
end
defp await_active_tasks do
case active_tasks() do
[] ->
:ok
_tasks ->
Process.sleep(@poll_interval_ms)
await_active_tasks()
end
end
@doc """
A 2-arity progress callback that prints deduplicated `[ 42%] message`
lines to stdout.
"""
def progress_printer do
&print_progress/2
end
defp print_progress(value, message) do
percent = value |> Kernel.*(100) |> round() |> min(100) |> max(0)
line = {percent, message}
if Process.get({__MODULE__, :last_progress}) != line do
Process.put({__MODULE__, :last_progress}, line)
if is_binary(message) and message != "" do
IO.puts("[#{String.pad_leading(Integer.to_string(percent), 3)}%] #{message}")
end
end
:ok
end
defp print_git_output(%{output: output}) when is_binary(output) and output != "" do
IO.puts(String.trim_trailing(output))
end
defp print_git_output(_result), do: :ok
defp notify(entity_type, entity_id, action) do
{:ok, _notification} = CliSync.cli_mutation_performed(entity_type, entity_id, action)
:ok
end
# Bulk maintenance touched an unknown set of rows; a wildcard notification
# per entity type makes a running app reload its lists.
defp notify_bulk_change do
Enum.each(@bulk_entity_types, &notify(&1, "*", :updated))
end
defp split_list(nil), do: []
defp split_list(value) when is_binary(value) do
value
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp json_list(value) when is_list(value), do: Enum.map(value, &to_string/1)
defp json_list(value) when is_binary(value), do: split_list(value)
defp json_list(_value), do: []
defp present(nil), do: nil
defp present(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
trimmed -> trimmed
end
end
defp changeset_message(%Ecto.Changeset{} = changeset) do
changeset
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
Enum.reduce(opts, message, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
|> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
end
defp format_reason(reason) when is_binary(reason), do: reason
defp format_reason(%{message: message}) when is_binary(message), do: message
defp format_reason(%{guidance: guidance}) when is_binary(guidance), do: guidance
defp format_reason(reason), do: inspect(reason)
end

57
lib/bds/cli/install.ex Normal file
View File

@@ -0,0 +1,57 @@
defmodule BDS.CLI.Install do
@moduledoc """
Installs the `bds-cli` launcher into `~/.local/bin` (issue #25), used by
the install buttons in the GUI and TUI settings.
The installed file is a two-line shim exec'ing the release's
`cli/bin/bds-cli` launcher (shipped via `rel/overlays`), so the CLI always
runs the same release — and therefore the same settings and cache
database — as the installed application.
"""
@launcher_name "bds-cli"
@doc """
Writes the `~/.local/bin/bds-cli` shim. Returns `{:ok, installed_path}`,
or `{:error, :no_release}` when not running from a packaged release (dev
runs have no launcher to point at).
"""
@spec install(keyword()) :: {:ok, Path.t()} | {:error, :no_release | File.posix()}
def install(opts \\ []) do
bin_dir = Keyword.get(opts, :bin_dir, Path.expand("~/.local/bin"))
with {:ok, launcher} <- launcher_path(Keyword.get(opts, :release_root)) do
installed = Path.join(bin_dir, @launcher_name)
with :ok <- File.mkdir_p(bin_dir),
:ok <- File.write(installed, shim_script(launcher)),
:ok <- File.chmod(installed, 0o755) do
{:ok, installed}
end
end
end
@doc """
The release's CLI launcher script, resolved from `RELEASE_ROOT` (set by
release scripts) or the code root dir (the release root when running a
release). `{:error, :no_release}` when neither contains the launcher.
"""
@spec launcher_path(Path.t() | nil) :: {:ok, Path.t()} | {:error, :no_release}
def launcher_path(release_root \\ nil) do
[release_root, System.get_env("RELEASE_ROOT"), to_string(:code.root_dir())]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.map(&Path.join([&1, "cli", "bin", @launcher_name]))
|> Enum.find(&File.regular?/1)
|> case do
nil -> {:error, :no_release}
path -> {:ok, path}
end
end
defp shim_script(launcher) do
"""
#!/bin/sh
exec "#{launcher}" "$@"
"""
end
end

View File

@@ -120,7 +120,7 @@ defmodule BDS.Desktop.ShellCommands do
"rebuild_embedding_index", "rebuild_embedding_index",
"Rebuild Embedding Index", "Rebuild Embedding Index",
"Embeddings", "Embeddings",
fn report -> rebuild_embedding_index_work(project, report) end fn report -> Maintenance.rebuild_embedding_index(project.id, on_progress: report) end
) )
end end
@@ -221,7 +221,7 @@ defmodule BDS.Desktop.ShellCommands do
defp dispatch("rebuild_database", project, _params) do defp dispatch("rebuild_database", project, _params) do
group_id = task_group_id("rebuild_database") group_id = task_group_id("rebuild_database")
attrs = %{group_id: group_id, group_name: "Maintenance"} attrs = %{group_id: group_id, group_name: "Maintenance"}
[first_step | remaining_steps] = rebuild_database_steps(project) [first_step | remaining_steps] = Maintenance.full_rebuild_steps(project.id)
{:ok, posts_task} = {:ok, posts_task} =
Tasks.submit_task(first_step.name, first_step.work, attrs) Tasks.submit_task(first_step.name, first_step.work, attrs)
@@ -692,102 +692,6 @@ defmodule BDS.Desktop.ShellCommands do
|> length() > 1 |> length() > 1
end end
defp rebuild_database_steps(project) do
[
%{
name: "Rebuild Posts From Files",
work: fn report ->
{:ok, posts} =
Maintenance.rebuild_from_filesystem(project.id, "post",
on_progress: report,
rebuild_embeddings: false
)
report.(1.0, "Post rebuild complete")
%{project_id: project.id, counts: %{posts: length(posts)}}
end
},
%{
name: "Rebuild Media From Files",
work: fn report ->
{:ok, media} =
Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report)
report.(1.0, "Media rebuild complete")
%{project_id: project.id, counts: %{media: length(media)}}
end
},
%{
name: "Rebuild Scripts From Files",
work: fn report ->
{:ok, scripts} =
Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report)
report.(1.0, "Script rebuild complete")
%{project_id: project.id, counts: %{scripts: length(scripts)}}
end
},
%{
name: "Rebuild Templates From Files",
work: fn report ->
{:ok, templates} =
Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report)
report.(1.0, "Template rebuild complete")
%{project_id: project.id, counts: %{templates: length(templates)}}
end
},
%{
name: "Rebuild Post Links",
work: fn report ->
:ok = Posts.rebuild_post_links(project.id, on_progress: report)
report.(1.0, "Post links rebuilt")
%{project_id: project.id}
end
},
%{
name: "Regenerate Missing Thumbnails",
work: fn report ->
result = BDS.Media.regenerate_missing_thumbnails(project.id, on_progress: report)
report.(1.0, "Missing thumbnails regenerated")
Map.put(result, :project_id, project.id)
end
},
%{
name: "Rebuild Embedding Index",
work: fn report -> rebuild_embedding_index_work(project, report) end
}
]
end
defp rebuild_embedding_index_work(project, report) do
case Embeddings.rebuild_project(project.id, on_progress: report) do
{:ok, rebuilt_post_ids} ->
report.(1.0, "Embedding index rebuilt")
%{
project_id: project.id,
rebuilt_post_ids: rebuilt_post_ids,
rebuilt_count: length(rebuilt_post_ids)
}
{:error, reason} ->
{:error, embedding_error_message(reason)}
end
end
defp embedding_error_message(reason) do
detail =
case reason do
message when is_binary(message) -> message
{:embedding_backend_unavailable, _inner} -> "the embedding service did not start"
other -> inspect(other)
end
"Could not build the embedding index: #{detail}. The model is downloaded on first use, " <>
"so check your internet connection — or turn off semantic similarity in Settings."
end
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok
defp run_rebuild_sequence(group_id, attrs, [step | remaining_steps]) do defp run_rebuild_sequence(group_id, attrs, [step | remaining_steps]) do

View File

@@ -182,6 +182,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:shell_overlay, nil) |> assign(:shell_overlay, nil)
|> assign(:git_run, nil) |> assign(:git_run, nil)
|> assign(:output_entries, []) |> assign(:output_entries, [])
|> assign(:collapsed_task_groups, MapSet.new())
|> assign(:panel_post_links, %{backlinks: [], outlinks: []}) |> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|> assign(:panel_git_entries, []) |> assign(:panel_git_entries, [])
|> assign(:auto_save_timers, %{}) |> assign(:auto_save_timers, %{})
@@ -226,6 +227,38 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, refresh_layout(socket, workbench)} {:noreply, refresh_layout(socket, workbench)}
end end
# Copy-all for the Output panel: entries are stored newest-first, so flip
# them back to chronological order before joining (old-app behavior).
def handle_event("copy_output", _params, socket) do
text =
socket.assigns.output_entries
|> Enum.reverse()
|> Enum.map_join("\n\n", & &1.message)
{:noreply, push_event(socket, "clipboard", %{text: text})}
end
def handle_event("toggle_task_group", %{"group" => group_id}, socket) do
collapsed = socket.assigns[:collapsed_task_groups] || MapSet.new()
collapsed =
if MapSet.member?(collapsed, group_id),
do: MapSet.delete(collapsed, group_id),
else: MapSet.put(collapsed, group_id)
{:noreply, assign(socket, :collapsed_task_groups, collapsed)}
end
def handle_event("cancel_task", %{"id" => task_id}, socket) do
_ = BDS.Tasks.cancel_task(task_id)
task_status =
BDS.Tasks.status_snapshot()
|> BDS.Desktop.ShellLive.TaskLocalization.localize_task_status(socket.assigns.page_language)
{:noreply, assign(socket, :task_status, task_status)}
end
def handle_event("open_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do def handle_event("open_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do
{:noreply, open_sidebar_item(socket, params, :preview)} {:noreply, open_sidebar_item(socket, params, :preview)}
end end
@@ -860,33 +893,66 @@ defmodule BDS.Desktop.ShellLive do
end end
defp import_blogmark(socket, project_id, url, title) do defp import_blogmark(socket, project_id, url, title) do
case Blogmark.receive_deep_link(project_id, url) do # Transform scripts run in the sandbox process; collect anything they
{:ok, %{post: post, toasts: toasts, errors: errors}} -> # print/log so it lands in the Output panel like in the old app.
socket {:ok, output_sink} = Agent.start_link(fn -> [] end)
|> reload_shell(socket.assigns.workbench)
|> open_sidebar_item(
%{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => post.slug
},
:pin
)
|> append_blogmark_toasts(title, toasts)
|> append_blogmark_errors(title, errors)
{:error, reason} -> sink = fn text -> Agent.update(output_sink, fn lines -> [text | lines] end) end
append_output_entry(socket, title, inspect(reason), url, "error")
try do
case Blogmark.receive_deep_link(project_id, url, on_output: sink) do
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
script_output = Agent.get(output_sink, &Enum.reverse/1)
socket
|> reload_shell(socket.assigns.workbench)
|> open_sidebar_item(
%{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => post.slug
},
:pin
)
|> append_blogmark_script_output(title, script_output)
|> append_blogmark_toasts(title, toasts)
|> append_blogmark_errors(title, errors)
|> open_output_panel_when(script_output != [] or errors != [])
{:error, reason} ->
append_output_entry(socket, title, inspect(reason), url, "error")
end
after
Agent.stop(output_sink)
end end
end end
defp append_blogmark_script_output(socket, title, lines) do
Enum.reduce(lines, socket, fn line, acc ->
append_output_entry(acc, title, line, nil, "info")
end)
end
defp append_blogmark_toasts(socket, title, toasts) do defp append_blogmark_toasts(socket, title, toasts) do
Enum.reduce(toasts, socket, fn message, acc -> Enum.reduce(toasts, socket, fn message, acc ->
append_output_entry(acc, title, message, nil, "info") append_output_entry(acc, title, message, nil, "info")
end) end)
end end
# Old-app parity: transform errors or streamed script output open the
# bottom panel on the Output tab so the user actually sees them.
defp open_output_panel_when(socket, true) do
workbench =
socket.assigns.workbench
|> Workbench.set_panel_visible(true)
|> Workbench.set_panel_tab(:output)
refresh_layout(socket, workbench)
end
defp open_output_panel_when(socket, false), do: socket
defp append_blogmark_errors(socket, title, errors) do defp append_blogmark_errors(socket, title, errors) do
Enum.reduce(errors, socket, fn %{slug: slug, reason: reason}, acc -> Enum.reduce(errors, socket, fn %{slug: slug, reason: reason}, acc ->
append_output_entry(acc, title, inspect(reason), slug, "error") append_output_entry(acc, title, inspect(reason), slug, "error")

View File

@@ -15,14 +15,7 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok @spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
def start(paths, project_id, post_id, language, concurrency_limit, parent) do def start(paths, project_id, post_id, language, concurrency_limit, parent) do
{:ok, metadata} = Metadata.get_project_metadata(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id)
main_language = metadata.main_language || language translate_targets = translate_targets(metadata, language)
blog_languages = metadata.blog_languages || []
translate_targets =
[main_language | blog_languages]
|> Enum.reject(&(&1 == language or is_nil(&1)))
|> Enum.uniq()
{in_flight, remaining} = Enum.split(paths, concurrency_limit) {in_flight, remaining} = Enum.split(paths, concurrency_limit)
tasks = tasks =
@@ -48,6 +41,36 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
send(parent, {:add_images_complete, length(paths)}) send(parent, {:add_images_complete, length(paths)})
end end
@doc """
The translation targets for AI-generated media texts: the project main
language plus all blog languages, minus the source `language`.
"""
def translate_targets(metadata, language) do
main_language = metadata.main_language || language
blog_languages = metadata.blog_languages || []
[main_language | blog_languages]
|> Enum.reject(&(&1 == language or is_nil(&1)))
|> Enum.uniq()
end
@doc """
Imports a single file and runs the same best-effort AI enrichment
(title/alt/caption plus translations) as the gallery pipeline, without
linking the media to a post. Used by the CLI `media` command (issue #25).
Returns `{:ok, media, display_title}` or `{:error, reason}`.
"""
def import_and_enrich(path, project_id, language, translate_targets) do
case Media.import_media(%{project_id: project_id, source_path: path}) do
{:ok, media} ->
{:ok, media, enrich_media(media, language, translate_targets)}
{:error, reason} ->
{:error, reason}
end
end
defp drain_tasks( defp drain_tasks(
[], [],
tasks, tasks,

View File

@@ -457,7 +457,7 @@
<div class="panel-tabs flex min-w-0 items-center overflow-x-auto"> <div class="panel-tabs flex min-w-0 items-center overflow-x-auto">
<%= for tab <- @panel_tabs do %> <%= for tab <- @panel_tabs do %>
<button <button
class={["panel-tab", "ui-tab", if(@workbench.panel.active_tab == tab, do: "ui-tab-active"), "inline-flex h-9 items-center px-3 text-xs uppercase tracking-wide", if(@workbench.panel.active_tab == tab, do: "active")]} class={["panel-tab", "ui-tab", if(@workbench.panel.active_tab == tab, do: "ui-tab-active"), "inline-flex items-center px-3 text-xs uppercase tracking-wide", if(@workbench.panel.active_tab == tab, do: "active")]}
type="button" type="button"
phx-click="select_panel_tab" phx-click="select_panel_tab"
phx-value-tab={tab} phx-value-tab={tab}

View File

@@ -20,6 +20,17 @@ defmodule BDS.Desktop.ShellLive.Notify do
:ok :ok
end end
@doc """
Sends an output entry to a specific LiveView process. Needed when the
caller runs in a different process than the shell — e.g. script output
callbacks invoked inside the sandboxed script process.
"""
@spec output_to(pid(), String.t(), String.t(), String.t() | nil, String.t()) :: :ok
def output_to(pid, title, message, detail, level) when is_pid(pid) do
send(pid, {:editor_output, title, message, detail, level})
:ok
end
@spec alert(String.t(), String.t()) :: :ok @spec alert(String.t(), String.t()) :: :ok
def alert(title, message) do def alert(title, message) do
send(self(), {:shell_alert, title, message}) send(self(), {:shell_alert, title, message})

View File

@@ -155,7 +155,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
ShellOverlayComponents.markdown_link(result.title, result.canonical_url)} ShellOverlayComponents.markdown_link(result.title, result.canonical_url)}
) )
socket assign(socket, :shell_overlay, nil)
end end
{%{kind: :insert_media}, %{type: :post, id: post_id}} -> {%{kind: :insert_media}, %{type: :post, id: post_id}} ->
@@ -172,7 +172,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
end end
Notify.parent({:post_editor_insert_content, post_id, syntax}) Notify.parent({:post_editor_insert_content, post_id, syntax})
socket assign(socket, :shell_overlay, nil)
end end
_other -> _other ->
@@ -197,10 +197,11 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
if details do if details do
Notify.parent({:post_editor_insert_content, post_id, details}) Notify.parent({:post_editor_insert_content, post_id, details})
assign(socket, :shell_overlay, nil)
else
socket
end end
socket
_other -> _other ->
socket socket
end end

View File

@@ -10,6 +10,7 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
alias BDS.PostLinks alias BDS.PostLinks
alias BDS.Posts alias BDS.Posts
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.UI.TaskGrouping
use Gettext, backend: BDS.Gettext use Gettext, backend: BDS.Gettext
@doc "Render the active panel tab body." @doc "Render the active panel tab body."
@@ -48,34 +49,122 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
end end
defp render_task_entries(assigns) do defp render_task_entries(assigns) do
entries = TaskGrouping.build_task_entries(Map.get(assigns.task_status, :tasks, []))
collapsed = Map.get(assigns, :collapsed_task_groups) || MapSet.new()
assigns =
assigns
|> assign(:task_entries, entries)
|> assign(:collapsed_task_groups, collapsed)
~H""" ~H"""
<%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %> <%= if Enum.empty?(@task_entries) do %>
<div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state"> <div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state">
<strong><%= dgettext("ui", "Tasks") %></strong> <strong><%= dgettext("ui", "Tasks") %></strong>
<span><%= dgettext("ui", "No background tasks running") %></span> <span><%= dgettext("ui", "No background tasks running") %></span>
</div> </div>
<% else %> <% else %>
<div class="task-list flex flex-col gap-2"> <div class="task-list flex flex-col gap-2">
<%= for task <- Map.get(@task_status, :tasks, []) do %> <%= for entry <- @task_entries do %>
<div class="panel-entry ui-panel-entry task-entry flex flex-col gap-2"> <%= case entry do %>
<div class="task-entry-header flex items-center justify-between gap-2"> <% {:single, task} -> %>
<strong><%= task.name %></strong> {render_task_row(assigns, task, false)}
<span class={"task-status task-status-#{task.status}"}><%= Map.get(task, :status_label, task.status |> to_string() |> String.capitalize()) %></span> <% {:group, group_id, group_name, tasks} -> %>
</div> {render_task_group(assigns, group_id, group_name, tasks)}
<span><%= task.message || task.group_name || "" %></span> <% end %>
<%= if is_number(task.progress) do %>
<div class="task-progress-row">
<progress max="1" value={task.progress}></progress>
<span><%= Map.get(task, :progress_label, progress_percent(task.progress)) %></span>
</div>
<% end %>
</div>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
""" """
end end
defp render_task_group(assigns, group_id, group_name, tasks) do
summary = TaskGrouping.summarize_task_group(tasks)
expanded = not MapSet.member?(assigns.collapsed_task_groups, group_id)
assigns =
assigns
|> assign(:group_id, group_id)
|> assign(:group_name, group_name)
|> assign(:group_tasks, tasks)
|> assign(:group_expanded, expanded)
|> assign(:group_meta, task_group_meta(summary))
~H"""
<div class="task-group flex flex-col">
<button
class="panel-entry ui-panel-entry task-entry task-group-toggle flex w-full items-center gap-2 text-left"
type="button"
phx-click="toggle_task_group"
phx-value-group={@group_id}
aria-expanded={to_string(@group_expanded)}
>
<span class="task-group-chevron">{if @group_expanded, do: "▾", else: "▸"}</span>
<strong class="task-group-title"><%= @group_name %> (<%= length(@group_tasks) %>)</strong>
<span class="task-group-meta text-muted text-xs"><%= @group_meta %></span>
</button>
<%= if @group_expanded do %>
<%= for task <- @group_tasks do %>
{render_task_row(assigns, task, true)}
<% end %>
<% end %>
</div>
"""
end
defp render_task_row(assigns, task, is_child) do
assigns =
assigns
|> assign(:task, task)
|> assign(:task_child?, is_child)
~H"""
<div class={["panel-entry", "ui-panel-entry", "task-entry", "flex flex-col gap-2", if(@task_child?, do: "ml-4")]}>
<div class="task-entry-header flex items-center justify-between gap-2">
<strong><%= @task.name %></strong>
<span class={"task-status task-status-#{@task.status}"}><%= Map.get(@task, :status_label, @task.status |> to_string() |> String.capitalize()) %></span>
</div>
<span><%= @task.message || @task.group_name || "" %></span>
<%= if is_number(@task.progress) do %>
<div class="task-progress-row">
<progress max="1" value={@task.progress}></progress>
<span><%= Map.get(@task, :progress_label, progress_percent(@task.progress)) %></span>
</div>
<% end %>
<%= if @task.status == :running do %>
<div class="task-entry-actions flex justify-end">
<button
class="ui-button ui-button-secondary inline-flex items-center px-2 py-1 text-xs"
data-testid="cancel-task-button"
type="button"
phx-click="cancel_task"
phx-value-id={@task.id}
>
<%= dgettext("ui", "Cancel") %>
</button>
</div>
<% end %>
</div>
"""
end
# "NN% · X Running · Y Pending" — the breakdown only lists non-zero counts,
# like the old app's summarizeTaskGroup rendering.
defp task_group_meta(summary) do
parts =
[]
|> maybe_append_breakdown(summary.running, dgettext("ui", "Running"))
|> maybe_append_breakdown(summary.pending, dgettext("ui", "Pending"))
suffix = if parts == [], do: "", else: " · " <> Enum.join(parts, " · ")
progress_percent(summary.progress) <> suffix
end
defp maybe_append_breakdown(parts, count, _label) when count <= 0, do: parts
defp maybe_append_breakdown(parts, count, label),
do: parts ++ ["#{count} #{label}"]
defp render_output_entries(assigns) do defp render_output_entries(assigns) do
~H""" ~H"""
<%= if Enum.empty?(@output_entries) do %> <%= if Enum.empty?(@output_entries) do %>
@@ -84,6 +173,16 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
<span><%= dgettext("ui", "No shell output yet") %></span> <span><%= dgettext("ui", "No shell output yet") %></span>
</div> </div>
<% else %> <% else %>
<div class="output-toolbar flex shrink-0 justify-end">
<button
class="ui-button ui-button-secondary inline-flex items-center px-2 py-1 text-xs"
data-testid="copy-output-button"
type="button"
phx-click="copy_output"
>
<%= dgettext("ui", "Copy") %>
</button>
</div>
<div class="output-list flex flex-col gap-2"> <div class="output-list flex flex-col gap-2">
<%= for entry <- @output_entries do %> <%= for entry <- @output_entries do %>
<div class={[ <div class={[

View File

@@ -117,7 +117,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
id: socket.assigns.post_id, id: socket.assigns.post_id,
content: content content: content
}) })
|> assign(:shell_overlay, nil)
{:ok, socket} {:ok, socket}
end end
@@ -316,18 +315,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
{:noreply, do_remove_list_value(socket, :categories, category)} {:noreply, do_remove_list_value(socket, :categories, category)}
end end
def handle_event("insert_content", %{"content" => content}, socket) do
socket =
socket
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{
id: socket.assigns.post_id,
content: content
})
|> assign(:shell_overlay, nil)
{:noreply, socket}
end
def handle_event("close_quick_actions", _params, socket) do def handle_event("close_quick_actions", _params, socket) do
socket = socket =
socket socket
@@ -892,7 +879,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end) end)
if map_size(attrs) == 0 do if map_size(attrs) == 0 do
assign(socket, :shell_overlay, nil) socket
else else
case Posts.update_post(post_id, attrs) do case Posts.update_post(post_id, attrs) do
{:ok, updated_post} -> {:ok, updated_post} ->
@@ -910,7 +897,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
) )
|> assign(:save_state, :dirty) |> assign(:save_state, :dirty)
|> assign(:dirty?, true) |> assign(:dirty?, true)
|> assign(:shell_overlay, nil)
|> build_data() |> build_data()
Notify.dirty(:post, post_id, true) Notify.dirty(:post, post_id, true)

View File

@@ -206,11 +206,17 @@ defmodule BDS.Desktop.ShellLive.ScriptEditor do
%Script{} = script -> %Script{} = script ->
draft = current_draft(socket.assigns, script) draft = current_draft(socket.assigns, script)
# The sandbox runs in its own process, so streamed script output is
# routed back to this LiveView explicitly.
live_view = self()
title = dgettext("ui", "Scripts")
case Scripting.execute_project_script( case Scripting.execute_project_script(
script.project_id, script.project_id,
draft["content"] || "", draft["content"] || "",
draft["entrypoint"] || "main", draft["entrypoint"] || "main",
[] [],
on_output: fn text -> Notify.output_to(live_view, title, text, nil, "info") end
) do ) do
{:ok, result} -> {:ok, result} ->
notify_output(socket, dgettext("ui", "Scripts"), inspect(result)) notify_output(socket, dgettext("ui", "Scripts"), inspect(result))

View File

@@ -69,6 +69,15 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
{:noreply, socket} {:noreply, socket}
end end
def handle_event("install_cli_tool", _params, socket) do
case BDS.UI.SettingsForm.run_action("install_cli") do
{:ok, message} -> LiveToast.send_toast(:info, message)
{:error, message} -> LiveToast.send_toast(:error, message)
end
{:noreply, socket}
end
def handle_event("change_settings_search", %{"query" => query}, socket) do def handle_event("change_settings_search", %{"query" => query}, socket) do
socket = socket =
socket socket
@@ -302,7 +311,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
query, query,
~w(mcp claude copilot gemini opencode mistral codex agent server) ~w(mcp claude copilot gemini opencode mistral codex agent server)
), ),
data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem)), data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem cli)),
supported_languages: @supported_languages, supported_languages: @supported_languages,
protected_categories: ManagedCategories.protected_categories() protected_categories: ManagedCategories.protected_categories()
} }

View File

@@ -107,6 +107,24 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
defp do_save_ai(socket, reload, append_output) do defp do_save_ai(socket, reload, append_output) do
attrs = ai_attrs(socket.assigns) attrs = ai_attrs(socket.assigns)
case save_attrs(attrs) do
:ok ->
LiveToast.send_toast(:info, dgettext("ui", "AI settings saved."))
socket
|> assign(:settings_editor_ai_draft, %{})
|> assign(:offline_mode, attrs.offline_mode)
|> reload.(socket.assigns.workbench)
{:error, reason} ->
surface_ai_error(socket, reload, append_output, reason)
end
end
# The full save pipeline as a pure function so the TUI settings form
# (BDS.UI.SettingsForm) drives the exact same writes as the GUI editor.
@spec save_attrs(map()) :: :ok | {:error, term()}
def save_attrs(attrs) do
with :ok <- with :ok <-
put_endpoint_preferences( put_endpoint_preferences(
:online, :online,
@@ -156,15 +174,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
attrs.offline_chat_images attrs.offline_chat_images
), ),
:ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do :ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do
LiveToast.send_toast(:info, dgettext("ui", "AI settings saved.")) :ok
socket
|> assign(:settings_editor_ai_draft, %{})
|> assign(:offline_mode, attrs.offline_mode)
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
surface_ai_error(socket, reload, append_output, reason)
end end
end end

View File

@@ -381,6 +381,15 @@
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_embedding_index"><%= dgettext("ui", "Rebuild Embedding Index") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_embedding_index"><%= dgettext("ui", "Rebuild Embedding Index") %></button>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= dgettext("ui", "Open Data Folder") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= dgettext("ui", "Open Data Folder") %></button>
</div> </div>
<div class="setting-row">
<div class="setting-info">
<label class="setting-label"><%= dgettext("ui", "Command Line Tool") %></label>
<p class="setting-description"><%= dgettext("ui", "Install the bds-cli tool to ~/.local/bin; it uses the same settings and cache database as the app") %></p>
</div>
<div class="setting-control">
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="install_cli_tool" phx-target={@myself}><%= dgettext("ui", "Install CLI Tool") %></button>
</div>
</div>
</div> </div>
<% end %> <% end %>
</div> </div>

View File

@@ -7,7 +7,6 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
alias BDS.{Repo, Tags} alias BDS.{Repo, Tags}
alias BDS.Desktop.ShellLive.Notify alias BDS.Desktop.ShellLive.Notify
alias BDS.Posts.Post
alias BDS.Tags.Tag alias BDS.Tags.Tag
alias BDS.Templates.Template alias BDS.Templates.Template
use Gettext, backend: BDS.Gettext use Gettext, backend: BDS.Gettext
@@ -16,12 +15,8 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
@tags_sections ~w(cloud manage merge) @tags_sections ~w(cloud manage merge)
@colour_presets ~w( # Shared with the TUI tags panel (issue #34) via BDS.UI.TagsPanel.
#ef4444 #f97316 #f59e0b #eab308 #84cc16 @colour_presets BDS.UI.TagsPanel.colour_presets()
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
#ec4899 #64748b
)
@spec colour_presets() :: [String.t()] @spec colour_presets() :: [String.t()]
def colour_presets, do: @colour_presets def colour_presets, do: @colour_presets
@@ -439,12 +434,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
defp current_tab_meta(_assigns), do: %{} defp current_tab_meta(_assigns), do: %{}
defp tag_counts(project_id) do defp tag_counts(project_id), do: BDS.UI.TagsPanel.tag_counts(project_id)
Repo.all(from post in Post, where: post.project_id == ^project_id, select: post.tags)
|> List.flatten()
|> Enum.reject(&is_nil/1)
|> Enum.reduce(%{}, fn tag, acc -> Map.update(acc, tag, 1, &(&1 + 1)) end)
end
defp blank_to_nil(nil), do: nil defp blank_to_nil(nil), do: nil

View File

@@ -32,6 +32,11 @@ defmodule BDS.Embeddings.Backends.Neural do
* Everywhere else — and as a fallback when EMLX is unavailable or explicitly * Everywhere else — and as a fallback when EMLX is unavailable or explicitly
disabled — it runs on optimised native CPU via XLA (`compiler: EXLA`). disabled — it runs on optimised native CPU via XLA (`compiler: EXLA`).
Packaged releases contain only the backend that can run on their platform
(`runtime:` conditional deps in mix.exs): macOS releases ship EMLX and no
EXLA, Linux/Windows ship EXLA and no EMLX (MLX is Apple-only). The selected
backend is therefore started on demand here, never assumed at boot.
The accelerator can be pinned with `config :bds, :embeddings, accelerator:` The accelerator can be pinned with `config :bds, :embeddings, accelerator:`
to `:auto` (default), `:emlx`, or `:exla`. to `:auto` (default), `:emlx`, or `:exla`.
""" """
@@ -171,7 +176,20 @@ defmodule BDS.Embeddings.Backends.Neural do
@doc false @doc false
@spec current_accelerator() :: :emlx | :exla @spec current_accelerator() :: :emlx | :exla
def current_accelerator do def current_accelerator do
select_accelerator(configured_accelerator(), emlx_available?(), apple_silicon?()) case select_accelerator(
configured_accelerator(),
emlx_available?(),
exla_available?(),
apple_silicon?()
) do
:none ->
raise RuntimeError,
"no embeddings accelerator available: neither the EMLX (Apple GPU) nor the " <>
"EXLA (CPU) application could be started on this machine"
accelerator ->
accelerator
end
end end
@doc """ @doc """
@@ -179,22 +197,43 @@ defmodule BDS.Embeddings.Backends.Neural do
Prefer the Apple GPU (EMLX) under `:auto` only when it is both available and Prefer the Apple GPU (EMLX) under `:auto` only when it is both available and
running on Apple Silicon; honour an explicit `:emlx`/`:exla` request, but running on Apple Silicon; honour an explicit `:emlx`/`:exla` request, but
degrade a forced `:emlx` to EXLA when EMLX is not loaded so a misconfigured degrade to the other backend when the requested one is not available so a
host still gets working CPU inference instead of crashing. misconfigured host still gets working inference instead of crashing. Only
one backend ships per platform (macOS releases exclude EXLA, Linux/Windows
exclude EMLX), so the unavailable side is the norm in production, not an
edge case.
""" """
@spec select_accelerator(:auto | :emlx | :exla, boolean(), boolean()) :: :emlx | :exla @spec select_accelerator(:auto | :emlx | :exla, boolean(), boolean(), boolean()) ::
def select_accelerator(:exla, _emlx_available?, _apple_silicon?), do: :exla :emlx | :exla | :none
def select_accelerator(:emlx, true, _apple_silicon?), do: :emlx def select_accelerator(_configured, false, false, _apple_silicon?), do: :none
def select_accelerator(:emlx, false, _apple_silicon?), do: :exla def select_accelerator(:exla, _emlx?, true, _apple_silicon?), do: :exla
def select_accelerator(:auto, true, true), do: :emlx def select_accelerator(:exla, true, false, _apple_silicon?), do: :emlx
def select_accelerator(:auto, _emlx_available?, _apple_silicon?), do: :exla def select_accelerator(:emlx, true, _exla?, _apple_silicon?), do: :emlx
def select_accelerator(:emlx, false, true, _apple_silicon?), do: :exla
def select_accelerator(:auto, true, _exla?, true), do: :emlx
def select_accelerator(:auto, _emlx?, true, _apple_silicon?), do: :exla
def select_accelerator(:auto, true, false, _apple_silicon?), do: :emlx
defp configured_accelerator do defp configured_accelerator do
config() |> Keyword.get(:accelerator, @default_accelerator) config() |> Keyword.get(:accelerator, @default_accelerator)
end end
# The backend apps are runtime: false deps on the platforms that can't run
# them, so releases exclude one of them entirely and neither is auto-started
# in dev/test. Availability therefore means "the app actually starts here":
# ensure_all_started errors when the app is absent (or its NIF can't load).
# Starting the app is also exactly what the chosen backend needs before
# building the serving.
defp emlx_available? do defp emlx_available? do
Code.ensure_loaded?(EMLX) and Code.ensure_loaded?(EMLX.Backend) started?(:emlx) and Code.ensure_loaded?(EMLX.Backend)
end
defp exla_available? do
started?(:exla)
end
defp started?(app) do
match?({:ok, _apps}, Application.ensure_all_started(app))
end end
defp apple_silicon? do defp apple_silicon? do

View File

@@ -178,7 +178,7 @@ defmodule BDS.Generation do
build_core_outputs( build_core_outputs(
plan, plan,
data.published_list_posts, render_data.main_list_posts,
render_data.localized_list_posts_by_language, render_data.localized_list_posts_by_language,
on_output on_output
) ++ ) ++
@@ -274,7 +274,7 @@ defmodule BDS.Generation do
end end
defp full_localized_archive_outputs(plan, render_data, builder) do defp full_localized_archive_outputs(plan, render_data, builder) do
main_outputs = builder.(plan, render_data.data.post_index, [plan.language]) main_outputs = builder.(plan, render_data.main_post_index, [plan.language])
language_outputs = language_outputs =
Enum.flat_map(additional_languages(plan), fn language -> Enum.flat_map(additional_languages(plan), fn language ->
@@ -286,11 +286,8 @@ defmodule BDS.Generation do
end end
defp core_sitemap_outputs(plan, data) do defp core_sitemap_outputs(plan, data) do
published_route_posts =
suppress_subtree_translation_variants(data.published_route_posts, additional_languages(plan))
{_sitemap_content, sitemap_to_write, _expected_paths, _timestamp_checks} = {_sitemap_content, sitemap_to_write, _expected_paths, _timestamp_checks} =
build_validation_sitemap_artifacts(plan, data, published_route_posts, %{}, nil) build_validation_sitemap_artifacts(plan, data, data.published_posts, %{}, nil)
[{"sitemap.xml", sitemap_to_write}] [{"sitemap.xml", sitemap_to_write}]
end end
@@ -321,17 +318,13 @@ defmodule BDS.Generation do
{:ok, generated_files_list} = list_generated_files(project_id) {:ok, generated_files_list} = list_generated_files(project_id)
generated_file_updated_at = generated_file_updated_at_map(generated_files_list) generated_file_updated_at = generated_file_updated_at_map(generated_files_list)
additional_languages = additional_languages(plan)
published_route_posts =
suppress_subtree_translation_variants(data.published_route_posts, additional_languages)
{sitemap_content, sitemap_to_write, additional_expected_paths, {sitemap_content, sitemap_to_write, additional_expected_paths,
additional_post_timestamp_checks} = additional_post_timestamp_checks} =
build_validation_sitemap_artifacts( build_validation_sitemap_artifacts(
plan, plan,
data, data,
published_route_posts, data.published_posts,
generated_file_updated_at, generated_file_updated_at,
on_progress on_progress
) )
@@ -350,7 +343,7 @@ defmodule BDS.Generation do
post_timestamp_checks: post_timestamp_checks:
build_post_timestamp_checks( build_post_timestamp_checks(
data.project_data_dir, data.project_data_dir,
published_route_posts, data.published_posts,
generated_file_updated_at generated_file_updated_at
) ++ additional_post_timestamp_checks, ) ++ additional_post_timestamp_checks,
additional_expected_paths: additional_expected_paths additional_expected_paths: additional_expected_paths
@@ -647,53 +640,15 @@ defmodule BDS.Generation do
defp build_outputs(plan, on_output \\ nil) do defp build_outputs(plan, on_output \\ nil) do
plan = with_render_context(plan) plan = with_render_context(plan)
data = generation_data(plan) render_data = validation_render_data(plan)
published_translations = flattened_generation_translations(data.translations_by_post) data = render_data.data
translations_by_post_language = translation_lookup_map(published_translations)
translatable_published_posts =
Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
translatable_published_list_posts =
Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
localized_posts_by_language =
additional_languages(plan)
|> Enum.map(fn language ->
{language,
resolve_posts_for_language(
translatable_published_posts,
language,
translations_by_post_language,
plan.language
)}
end)
|> Map.new()
localized_list_posts_by_language =
additional_languages(plan)
|> Enum.map(fn language ->
{language,
resolve_posts_for_language(
translatable_published_list_posts,
language,
translations_by_post_language,
plan.language
)}
end)
|> Map.new()
localized_post_indexes =
localized_list_posts_by_language
|> Enum.map(fn {language, posts} -> {language, build_generation_post_index(posts)} end)
|> Map.new()
core_outputs = core_outputs =
if :core in plan.sections do if :core in plan.sections do
build_core_outputs( build_core_outputs(
plan, plan,
data.published_list_posts, render_data.main_list_posts,
localized_list_posts_by_language, render_data.localized_list_posts_by_language,
on_output on_output
) )
else else
@@ -706,8 +661,8 @@ defmodule BDS.Generation do
plan_render_target(plan), plan_render_target(plan),
plan.language, plan.language,
data.published_posts, data.published_posts,
translations_by_post_language, render_data.translations_by_post_language,
localized_posts_by_language, render_data.localized_posts_by_language,
on_output on_output
) )
else else
@@ -720,8 +675,8 @@ defmodule BDS.Generation do
plan_render_target(plan), plan_render_target(plan),
plan.language, plan.language,
data.published_posts, data.published_posts,
translations_by_post_language, render_data.translations_by_post_language,
localized_posts_by_language, render_data.localized_posts_by_language,
on_output on_output
) )
else else
@@ -729,7 +684,12 @@ defmodule BDS.Generation do
end end
archive_outputs = archive_outputs =
build_archive_outputs(plan, data.post_index, localized_post_indexes, on_output) build_archive_outputs(
plan,
render_data.main_post_index,
render_data.localized_post_indexes,
on_output
)
urls = urls =
(core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs) (core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs)
@@ -770,14 +730,14 @@ defmodule BDS.Generation do
defp build_validation_sitemap_artifacts( defp build_validation_sitemap_artifacts(
plan, plan,
data, data,
published_route_posts, published_posts,
generated_file_updated_at, generated_file_updated_at,
on_progress on_progress
) do ) do
main_paths = main_paths =
build_validation_route_paths( build_validation_route_paths(
plan, plan,
published_route_posts, published_posts,
data.published_list_posts, data.published_list_posts,
data.post_index, data.post_index,
nil nil
@@ -1111,12 +1071,26 @@ defmodule BDS.Generation do
{language, build_generation_post_index(posts)} {language, build_generation_post_index(posts)}
end) end)
# Main-language lists must show the main-language translation of a
# foreign-language post when one exists, mirroring the canonical variant
# lookup single-page renders do. Resolved here (not in generation_data)
# because subtree resolution above needs the raw posts' ids intact.
main_list_posts =
resolve_posts_for_language(
data.published_list_posts,
plan.language,
translations_by_post_language,
plan.language
)
%{ %{
data: data, data: data,
translations_by_post_language: translations_by_post_language, translations_by_post_language: translations_by_post_language,
localized_posts_by_language: localized_posts_by_language, localized_posts_by_language: localized_posts_by_language,
localized_list_posts_by_language: localized_list_posts_by_language, localized_list_posts_by_language: localized_list_posts_by_language,
localized_post_indexes: localized_post_indexes localized_post_indexes: localized_post_indexes,
main_list_posts: main_list_posts,
main_post_index: build_generation_post_index(main_list_posts)
} }
end end
@@ -1125,7 +1099,7 @@ defmodule BDS.Generation do
main_root = main_root =
if targeted_plan.request_root_routes do if targeted_plan.request_root_routes do
posts = build_list_posts(plan, data.published_list_posts, nil) posts = build_list_posts(plan, render_data.main_list_posts, nil)
build_root_outputs(plan, plan.language, posts) build_root_outputs(plan, plan.language, posts)
else else
[] []
@@ -1228,7 +1202,7 @@ defmodule BDS.Generation do
end end
defp build_targeted_archive_outputs(plan, render_data, targeted_plan, builder) do defp build_targeted_archive_outputs(plan, render_data, targeted_plan, builder) do
main_outputs = builder.(plan, render_data.data.post_index, targeted_plan, [plan.language]) main_outputs = builder.(plan, render_data.main_post_index, targeted_plan, [plan.language])
language_outputs = language_outputs =
Enum.flat_map(additional_languages(plan), fn language -> Enum.flat_map(additional_languages(plan), fn language ->

View File

@@ -74,8 +74,8 @@ defmodule BDS.Generation.Data do
|> Enum.uniq_by(& &1.id) |> Enum.uniq_by(& &1.id)
|> Enum.sort_by(&{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)}) |> Enum.sort_by(&{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)})
{published_route_posts, translations_by_post} = translations_by_post =
build_generation_route_posts( build_generation_translations(
plan.project_id, plan.project_id,
project_data_dir, project_data_dir,
published_posts, published_posts,
@@ -87,7 +87,6 @@ defmodule BDS.Generation.Data do
project_data_dir: project_data_dir, project_data_dir: project_data_dir,
published_posts: published_posts, published_posts: published_posts,
published_list_posts: published_list_posts, published_list_posts: published_list_posts,
published_route_posts: published_route_posts,
translations_by_post: translations_by_post, translations_by_post: translations_by_post,
post_index: build_generation_post_index(published_list_posts) post_index: build_generation_post_index(published_list_posts)
} }
@@ -121,18 +120,13 @@ defmodule BDS.Generation.Data do
post_language = String.downcase(to_string(post.language || "")) post_language = String.downcase(to_string(post.language || ""))
effective_language = if post_language == "", do: main, else: post_language effective_language = if post_language == "", do: main, else: post_language
cond do if effective_language == target do
is_binary(Map.get(post, :translation_source_slug)) -> post
post else
case Map.get(translations_by_post_language, {post.id, target_language}) do
effective_language == target -> nil -> post
post translation -> build_localized_subtree_variant(post, translation)
end
true ->
case Map.get(translations_by_post_language, {post.id, target_language}) do
nil -> post
translation -> build_localized_subtree_variant(post, translation)
end
end end
end) end)
end end
@@ -231,7 +225,7 @@ defmodule BDS.Generation.Data do
read_snapshot(full_path, fallback_post, &build_post_snapshot/2) read_snapshot(full_path, fallback_post, &build_post_snapshot/2)
end end
defp build_generation_route_posts( defp build_generation_translations(
project_id, project_id,
project_data_dir, project_data_dir,
published_posts, published_posts,
@@ -268,17 +262,7 @@ defmodule BDS.Generation.Data do
end) end)
|> Map.new(fn {post_id, translations} -> {post_id, Enum.reverse(translations)} end) |> Map.new(fn {post_id, translations} -> {post_id, Enum.reverse(translations)} end)
route_posts = translations_by_post
Enum.flat_map(published_posts, fn post ->
variants =
translations_by_post
|> Map.get(post.id, [])
|> Enum.map(&build_published_translation_variant(post, &1))
[post | variants]
end)
{route_posts, translations_by_post}
end end
defp published_translation_snapshot(project_data_dir, %Translation{} = translation) do defp published_translation_snapshot(project_data_dir, %Translation{} = translation) do
@@ -357,31 +341,6 @@ defmodule BDS.Generation.Data do
} }
end end
defp build_published_translation_variant(post, translation) do
%{
id: translation.id,
project_id: post.project_id,
title: translation.title,
slug: "#{post.slug}.#{translation.language}",
excerpt: translation.excerpt,
content: nil,
status: :published,
author: post.author,
created_at: post.created_at,
updated_at: translation.updated_at,
published_at: translation.published_at || post.published_at,
file_path: translation.file_path,
tags: post.tags,
categories: post.categories,
template_slug: post.template_slug,
language: translation.language,
do_not_translate: post.do_not_translate,
translation_source_slug: post.slug,
translation_canonical_language: post.language,
translation_file_path: translation.file_path
}
end
defp build_localized_subtree_variant(post, translation) do defp build_localized_subtree_variant(post, translation) do
%{ %{
post post

View File

@@ -22,16 +22,6 @@ defmodule BDS.Generation.Outputs do
def route_post_output_path(post, ""), do: post_output_path(post) def route_post_output_path(post, ""), do: post_output_path(post)
def route_post_output_path(post, route_language), do: post_output_path(post, route_language) def route_post_output_path(post, route_language), do: post_output_path(post, route_language)
@spec suppress_subtree_translation_variants([map()], [String.t()]) :: [map()]
def suppress_subtree_translation_variants(route_posts, additional_languages) do
subtree_languages = MapSet.new(additional_languages)
Enum.reject(route_posts, fn post ->
is_binary(Map.get(post, :translation_source_slug)) and
MapSet.member?(subtree_languages, to_string(post.language))
end)
end
@spec build_validation_route_paths(map(), [map()], [map()], map(), String.t() | nil) :: [ @spec build_validation_route_paths(map(), [map()], [map()], map(), String.t() | nil) :: [
String.t() String.t()
] ]

View File

@@ -28,15 +28,15 @@ defmodule BDS.Generation.Validation do
@spec build_post_timestamp_checks(String.t(), [map()], map()) :: [map()] @spec build_post_timestamp_checks(String.t(), [map()], map()) :: [map()]
def build_post_timestamp_checks( def build_post_timestamp_checks(
project_data_dir, project_data_dir,
published_route_posts, published_posts,
generated_file_updated_at generated_file_updated_at
) do ) do
build_timestamp_checks( build_timestamp_checks(
project_data_dir, project_data_dir,
published_route_posts, published_posts,
generated_file_updated_at, generated_file_updated_at,
&BDS.Generation.Paths.post_output_path/1, &BDS.Generation.Paths.post_output_path/1,
fn post -> Map.get(post, :translation_file_path) || post.file_path end & &1.file_path
) )
end end

View File

@@ -70,17 +70,20 @@ defmodule BDS.MacBundle do
frameworks = Path.join(contents, "Frameworks") frameworks = Path.join(contents, "Frameworks")
rel = Path.join(resources, "rel") rel = Path.join(resources, "rel")
File.rm_rf!(app) # Validate before rm_rf! so a refused build leaves any previous bundle intact.
Enum.each([macos, resources, frameworks], &File.mkdir_p!/1) with :ok <- BDS.ReleasePackaging.validate_release_source(release_dir) do
File.rm_rf!(app)
Enum.each([macos, resources, frameworks], &File.mkdir_p!/1)
with {:ok, _} <- File.cp_r(release_dir, rel), with {:ok, _} <- File.cp_r(release_dir, rel),
:ok <- File.cp(icns_path, Path.join(resources, "#{@icon_name}.icns")), :ok <- File.cp(icns_path, Path.join(resources, "#{@icon_name}.icns")),
:ok <- File.write(Path.join(contents, "Info.plist"), info_plist(version: version)), :ok <- File.write(Path.join(contents, "Info.plist"), info_plist(version: version)),
:ok <- File.write(Path.join(contents, "PkgInfo"), "APPL????"), :ok <- File.write(Path.join(contents, "PkgInfo"), "APPL????"),
:ok <- write_launcher(Path.join(macos, @executable)), :ok <- write_launcher(Path.join(macos, @executable)),
:ok <- maybe_relocate_dylibs(rel, frameworks, opts), :ok <- maybe_relocate_dylibs(rel, frameworks, opts),
:ok <- maybe_codesign(app, opts) do :ok <- maybe_codesign(app, opts) do
{:ok, app} {:ok, app}
end
end end
end end
@@ -130,8 +133,10 @@ defmodule BDS.MacBundle do
@doc """ @doc """
Fail the build unless the bundle is self-contained: no Mach-O under the `.app` Fail the build unless the bundle is self-contained: no Mach-O under the `.app`
may reference a Homebrew/local (`/opt/homebrew`, `/usr/local`) path. This is a may reference a Homebrew/local (`/opt/homebrew`, `/usr/local`) path, and every
hard requirement — the app must run on a Mac without Homebrew installed. `@rpath/` dependency must resolve — via the binary's own `LC_RPATH`s — to a
file inside the bundle. This is a hard requirement — the app must run on a
Mac without Homebrew installed.
""" """
@spec verify_standalone(String.t()) :: @spec verify_standalone(String.t()) ::
:ok | {:error, {:external_refs, [{String.t(), String.t()}]}} :ok | {:error, {:external_refs, [{String.t(), String.t()}]}}
@@ -141,7 +146,8 @@ defmodule BDS.MacBundle do
|> macho_files() |> macho_files()
|> Enum.flat_map(fn file -> |> Enum.flat_map(fn file ->
external_refs(file, ["-L"], &Dylibs.parse_otool/1) ++ external_refs(file, ["-L"], &Dylibs.parse_otool/1) ++
external_refs(file, ["-l"], &Dylibs.parse_rpaths/1) external_refs(file, ["-l"], &Dylibs.parse_rpaths/1) ++
unresolved_rpath_refs(file, app)
end) end)
if offenders == [], do: :ok, else: {:error, {:external_refs, offenders}} if offenders == [], do: :ok, else: {:error, {:external_refs, offenders}}
@@ -154,6 +160,50 @@ defmodule BDS.MacBundle do
end end
end end
# An @rpath/ dep is an offender unless one of its rpath-resolved candidates
# exists inside the app — a dangling one (Homebrew's libwebp referencing
# @rpath/libsharpyuv.0.dylib with no copied sibling) crashes only at runtime
# on the user's machine, so it must fail the build here. A dylib's own
# LC_ID_DYLIB shows up in `otool -L` like a dep (precompiled NIFs such as
# hnswlib_nif.so ship an @rpath/ self id with no rpaths at all); dyld never
# resolves a file's id when loading that file, so self references are skipped.
defp unresolved_rpath_refs(file, app) do
root = Path.expand(app)
case otool_parse(file, ["-L"], &Dylibs.parse_otool/1) do
{:ok, deps} ->
deps
|> Enum.filter(&String.starts_with?(&1, "@rpath/"))
|> Enum.reject(&(Path.basename(&1) == Path.basename(file)))
|> Enum.reject(&rpath_dep_resolves?(file, &1, root))
|> Enum.map(&{file, &1})
_error ->
[]
end
end
defp rpath_dep_resolves?(file, dep, root) do
case otool_parse(file, ["-l"], &Dylibs.parse_rpaths/1) do
{:ok, rpaths} ->
dep
|> Dylibs.resolve_rpath_dep(rpaths, Path.dirname(file))
|> Enum.any?(fn candidate ->
String.starts_with?(candidate, root <> "/") and File.exists?(candidate)
end)
_error ->
false
end
end
defp otool_parse(file, args, parse) do
case System.cmd("otool", args ++ [file], stderr_to_stdout: true) do
{out, 0} -> {:ok, parse.(out)}
{out, status} -> {:error, {:otool_failed, status, out}}
end
end
@doc """ @doc """
Compute a `../`-style relative path from `from_dir` to `to` (both absolute), Compute a `../`-style relative path from `from_dir` to `to` (both absolute),
e.g. the path from the wx NIF's directory up to `Contents/Frameworks`. e.g. the path from the wx NIF's directory up to `Contents/Frameworks`.

View File

@@ -103,16 +103,43 @@ defmodule BDS.MacBundle.Dylibs do
end end
@doc """ @doc """
Build `{old, new}` rewrites for a binary's `otool -L` dependency list: every Resolve an `@rpath/<name>` dependency to absolute candidate paths using the
external dep becomes `<prefix>/<basename>`. System (`/usr/lib`, `/System`) and referencing binary's `LC_RPATH` list, the way dyld would: each rpath entry is
already-relocatable (`@rpath`/`@loader_path`/`@executable_path`) deps are tried in order with `@loader_path` expanded to the binary's own directory.
dropped. Rewriting by basename — not by the exact source path — is what makes `@executable_path` rpaths are skipped (not resolvable from a library), and
this catch every absolute spelling of the same library. non-`@rpath` deps yield no candidates. Homebrew links keg siblings this way
(`@rpath/libsharpyuv.0.dylib` + rpath `@loader_path/../lib` in libwebp), so
these deps are just as external as absolute `/opt/homebrew` ones.
""" """
@spec relink_changes([String.t()], String.t()) :: [{String.t(), String.t()}] @spec resolve_rpath_dep(String.t(), [String.t()], String.t()) :: [String.t()]
def relink_changes(deps, prefix) when is_list(deps) do def resolve_rpath_dep("@rpath/" <> name, rpaths, loader_dir) when is_list(rpaths) do
rpaths
|> Enum.map(fn
"@loader_path" <> rest -> Path.expand(loader_dir <> rest)
"@executable_path" <> _rest -> nil
absolute -> absolute
end)
|> Enum.reject(&is_nil/1)
|> Enum.map(&Path.join(&1, name))
end
def resolve_rpath_dep(_dep, _rpaths, _loader_dir), do: []
@doc """
Build `{old, new}` rewrites for a binary's `otool -L` dependency list: every
external dep becomes `<prefix>/<basename>`, and every `@rpath/<name>` dep
whose basename is in `bundled` (it was copied into Frameworks) is rewritten
the same way. System (`/usr/lib`, `/System`) and other already-relocatable
deps are dropped. Rewriting by basename — not by the exact source path — is
what makes this catch every absolute spelling of the same library.
"""
@spec relink_changes([String.t()], String.t(), [String.t()]) :: [{String.t(), String.t()}]
def relink_changes(deps, prefix, bundled \\ []) when is_list(deps) do
deps deps
|> Enum.filter(&external?/1) |> Enum.filter(fn
"@rpath/" <> name -> name in bundled
dep -> external?(dep)
end)
|> Enum.map(fn dep -> {dep, prefix <> "/" <> Path.basename(dep)} end) |> Enum.map(fn dep -> {dep, prefix <> "/" <> Path.basename(dep)} end)
end end
@@ -135,9 +162,10 @@ defmodule BDS.MacBundle.Dylibs do
with {:ok, externals} <- collect(nifs, %{}) do with {:ok, externals} <- collect(nifs, %{}) do
reals = materialize(externals, frameworks_dir) reals = materialize(externals, frameworks_dir)
bundled = Map.keys(externals)
with :ok <- relink_each(reals, fn _real -> "@loader_path" end, set_id: true), with :ok <- relink_each(reals, fn _real -> "@loader_path" end, bundled, set_id: true),
:ok <- relink_each(nifs, prefix_for, set_id: false) do :ok <- relink_each(nifs, prefix_for, bundled, set_id: false) do
{:ok, reals} {:ok, reals}
end end
end end
@@ -147,10 +175,9 @@ defmodule BDS.MacBundle.Dylibs do
# basename — different absolute spellings of the same leaf collapse here). # basename — different absolute spellings of the same leaf collapse here).
defp collect(binaries, acc) do defp collect(binaries, acc) do
Enum.reduce_while(binaries, {:ok, acc}, fn bin, {:ok, acc} -> Enum.reduce_while(binaries, {:ok, acc}, fn bin, {:ok, acc} ->
case otool(bin) do case external_deps(bin) do
{:ok, deps} -> {:ok, deps} ->
deps deps
|> Enum.filter(&external?/1)
|> Enum.reduce_while({:ok, acc}, fn dep, {:ok, acc} -> |> Enum.reduce_while({:ok, acc}, fn dep, {:ok, acc} ->
base = Path.basename(dep) base = Path.basename(dep)
@@ -174,6 +201,42 @@ defmodule BDS.MacBundle.Dylibs do
end) end)
end end
# A binary's external deps: absolute Homebrew/local references, plus any
# @rpath/ reference that resolves (via the binary's own LC_RPATHs) to an
# existing Homebrew/local file. @rpath deps resolving inside the release tree
# (vix/exla-style precompiled NIFs) are relocatable as-is and stay untouched.
defp external_deps(bin) do
with {:ok, deps} <- otool(bin) do
direct = Enum.filter(deps, &external?/1)
case resolve_external_rpath_deps(bin, deps) do
{:ok, resolved} -> {:ok, direct ++ resolved}
error -> error
end
end
end
defp resolve_external_rpath_deps(bin, deps) do
case Enum.filter(deps, &String.starts_with?(&1, "@rpath/")) do
[] ->
{:ok, []}
rpath_deps ->
with {:ok, search_paths} <- rpaths(bin) do
resolved =
rpath_deps
|> Enum.map(fn dep ->
dep
|> resolve_rpath_dep(search_paths, Path.dirname(bin))
|> Enum.find(fn candidate -> external?(candidate) and File.exists?(candidate) end)
end)
|> Enum.reject(&is_nil/1)
{:ok, resolved}
end
end
end
# Copy each external once (deduped by device+inode, which follows symlinks to # Copy each external once (deduped by device+inode, which follows symlinks to
# the real file); additional basenames for the same file become symlinks so # the real file); additional basenames for the same file become symlinks so
# dyld loads a single image. Returns the real (non-symlink) copies. # dyld loads a single image. Returns the real (non-symlink) copies.
@@ -206,12 +269,12 @@ defmodule BDS.MacBundle.Dylibs do
{stat.major_device, stat.inode} {stat.major_device, stat.inode}
end end
defp relink_each(binaries, prefix_for, opts) do defp relink_each(binaries, prefix_for, bundled, opts) do
set_id? = Keyword.fetch!(opts, :set_id) set_id? = Keyword.fetch!(opts, :set_id)
Enum.reduce_while(binaries, :ok, fn binary, :ok -> Enum.reduce_while(binaries, :ok, fn binary, :ok ->
with :ok <- maybe_set_id(binary, set_id?), with :ok <- maybe_set_id(binary, set_id?),
:ok <- relink(binary, prefix_for.(binary)) do :ok <- relink(binary, prefix_for.(binary), bundled) do
{:cont, :ok} {:cont, :ok}
else else
error -> {:halt, error} error -> {:halt, error}
@@ -225,9 +288,9 @@ defmodule BDS.MacBundle.Dylibs do
run("install_name_tool", id_args(binary, "@loader_path/" <> Path.basename(binary))) run("install_name_tool", id_args(binary, "@loader_path/" <> Path.basename(binary)))
end end
defp relink(binary, prefix) do defp relink(binary, prefix, bundled) do
with {:ok, deps} <- otool(binary), with {:ok, deps} <- otool(binary),
:ok <- apply_changes(binary, change_args(binary, relink_changes(deps, prefix))) do :ok <- apply_changes(binary, change_args(binary, relink_changes(deps, prefix, bundled))) do
strip_external_rpaths(binary) strip_external_rpaths(binary)
end end
end end

View File

@@ -142,4 +142,106 @@ defmodule BDS.Maintenance do
{:ok, %{diff_reports: diff_reports, orphan_reports: orphan_reports}} {:ok, %{diff_reports: diff_reports, orphan_reports: orphan_reports}}
end end
@doc """
The canonical full-rebuild sequence, shared by the GUI "Rebuild Database"
command and the CLI `rebuild` command so the two can never drift. Each step
is `%{name: String.t(), work: (report -> result)}` where `report` is a
2-arity progress callback and a `{:error, message}` result marks failure.
"""
def full_rebuild_steps(project_id) when is_binary(project_id) do
[
%{
name: "Rebuild Posts From Files",
work: fn report ->
{:ok, posts} =
rebuild_from_filesystem(project_id, "post",
on_progress: report,
rebuild_embeddings: false
)
report.(1.0, "Post rebuild complete")
%{project_id: project_id, counts: %{posts: length(posts)}}
end
},
%{
name: "Rebuild Media From Files",
work: fn report ->
{:ok, media} = rebuild_from_filesystem(project_id, "media", on_progress: report)
report.(1.0, "Media rebuild complete")
%{project_id: project_id, counts: %{media: length(media)}}
end
},
%{
name: "Rebuild Scripts From Files",
work: fn report ->
{:ok, scripts} = rebuild_from_filesystem(project_id, "script", on_progress: report)
report.(1.0, "Script rebuild complete")
%{project_id: project_id, counts: %{scripts: length(scripts)}}
end
},
%{
name: "Rebuild Templates From Files",
work: fn report ->
{:ok, templates} = rebuild_from_filesystem(project_id, "template", on_progress: report)
report.(1.0, "Template rebuild complete")
%{project_id: project_id, counts: %{templates: length(templates)}}
end
},
%{
name: "Rebuild Post Links",
work: fn report ->
:ok = BDS.Posts.rebuild_post_links(project_id, on_progress: report)
report.(1.0, "Post links rebuilt")
%{project_id: project_id}
end
},
%{
name: "Regenerate Missing Thumbnails",
work: fn report ->
result = BDS.Media.regenerate_missing_thumbnails(project_id, on_progress: report)
report.(1.0, "Missing thumbnails regenerated")
Map.put(result, :project_id, project_id)
end
},
%{
name: "Rebuild Embedding Index",
work: fn report -> rebuild_embedding_index(project_id, on_progress: report) end
}
]
end
@doc """
Rebuilds the embedding index for the project, mapping backend failures to a
user-facing message (`{:error, message}`).
"""
def rebuild_embedding_index(project_id, opts \\ []) when is_binary(project_id) do
on_progress = progress_callback(opts)
case Embeddings.rebuild_project(project_id, on_progress: on_progress) do
{:ok, rebuilt_post_ids} ->
on_progress.(1.0, "Embedding index rebuilt")
%{
project_id: project_id,
rebuilt_post_ids: rebuilt_post_ids,
rebuilt_count: length(rebuilt_post_ids)
}
{:error, reason} ->
{:error, embedding_error_message(reason)}
end
end
defp embedding_error_message(reason) do
detail =
case reason do
message when is_binary(message) -> message
{:embedding_backend_unavailable, _inner} -> "the embedding service did not start"
other -> inspect(other)
end
"Could not build the embedding index: #{detail}. The model is downloaded on first use, " <>
"so check your internet connection — or turn off semantic similarity in Settings."
end
end end

View File

@@ -49,6 +49,11 @@ defmodule BDS.Metadata do
@typedoc "Attribute map for `update_project_metadata/2`." @typedoc "Attribute map for `update_project_metadata/2`."
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} @type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
@spec supported_pico_themes() :: [String.t()]
def supported_pico_themes do
["default" | @supported_pico_themes |> MapSet.delete("default") |> Enum.sort()]
end
@spec get_project_metadata(String.t()) :: {:ok, metadata_state()} @spec get_project_metadata(String.t()) :: {:ok, metadata_state()}
def get_project_metadata(project_id) do def get_project_metadata(project_id) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
@@ -313,6 +318,8 @@ defmodule BDS.Metadata do
} }
end end
# Keys absent from attrs are left out entirely, so the merge in
# update_project_metadata/2 keeps their current values (partial update).
defp normalize_project_metadata_attrs(attrs, project) do defp normalize_project_metadata_attrs(attrs, project) do
%{ %{
name: attr(attrs, :name) || project.name, name: attr(attrs, :name) || project.name,
@@ -328,6 +335,26 @@ defmodule BDS.Metadata do
semantic_similarity_enabled: attr(attrs, :semantic_similarity_enabled) || false, semantic_similarity_enabled: attr(attrs, :semantic_similarity_enabled) || false,
blog_languages: normalize_language_list(attr(attrs, :blog_languages) || []) blog_languages: normalize_language_list(attr(attrs, :blog_languages) || [])
} }
|> Map.take(present_attr_keys(attrs))
end
defp present_attr_keys(attrs) do
Enum.filter(
[
:name,
:description,
:public_url,
:main_language,
:default_author,
:max_posts_per_page,
:image_import_concurrency,
:blogmark_category,
:pico_theme,
:semantic_similarity_enabled,
:blog_languages
],
fn key -> Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key)) end
)
end end
defp normalize_category_settings(settings) do defp normalize_category_settings(settings) do

View File

@@ -56,7 +56,8 @@ defmodule BDS.ReleasePackaging do
app_release_source = Keyword.fetch!(opts, :app_release_source) app_release_source = Keyword.fetch!(opts, :app_release_source)
mcp_release_source = Keyword.fetch!(opts, :mcp_release_source) mcp_release_source = Keyword.fetch!(opts, :mcp_release_source)
with :ok <- reset_output(metadata), with :ok <- validate_release_source(app_release_source),
:ok <- reset_output(metadata),
:ok <- copy_release(app_release_source, metadata.app_root), :ok <- copy_release(app_release_source, metadata.app_root),
:ok <- copy_release(mcp_release_source, metadata.resources_root), :ok <- copy_release(mcp_release_source, metadata.resources_root),
:ok <- write_manifest(metadata), :ok <- write_manifest(metadata),
@@ -65,6 +66,20 @@ defmodule BDS.ReleasePackaging do
end end
end end
@doc """
Rejects half-assembled releases. A failed `mix release` run can leave `lib/`
copied but no `releases/<version>/` (no `env.sh`, no boot scripts) — packaging
that produces an app that dies on launch. Refuse it up front instead.
"""
def validate_release_source(release_dir) do
if release_dir |> Path.join("releases/*/env.sh") |> Path.wildcard() == [] do
{:error,
{:incomplete_release, "#{release_dir} has no releases/*/env.sh — did `mix release` fail?"}}
else
:ok
end
end
defp normalize_platform(platform) when platform in [:macos, :linux, :windows], do: platform defp normalize_platform(platform) when platform in [:macos, :linux, :windows], do: platform
defp normalize_platform(:darwin), do: :macos defp normalize_platform(:darwin), do: :macos

View File

@@ -50,6 +50,14 @@ defmodule BDS.Scripting.ApiDocs do
params: [], params: [],
returns: "table | nil" returns: "table | nil"
}, },
%{
module: "app",
name: "log",
description:
"Append a line to the script output stream. Multiple arguments are joined with spaces. Output appears in the desktop app's Output panel (and on stdout in the CLI); Lua's global `print` is routed the same way.",
params: [%{name: "text", type: "string", required: true}],
returns: "boolean"
},
%{ %{
module: "app", module: "app",
name: "notify_renderer_ready", name: "notify_renderer_ready",
@@ -762,7 +770,8 @@ defmodule BDS.Scripting.ApiDocs do
%{ %{
module: "meta", module: "meta",
name: "update_project_metadata", name: "update_project_metadata",
description: "Update metadata for the current project.", description:
"Update metadata for the current project. Keys omitted from updates keep their current values.",
params: [%{name: "updates", type: "table", required: true}], params: [%{name: "updates", type: "table", required: true}],
returns: "ProjectMetadata | nil" returns: "ProjectMetadata | nil"
}, },

View File

@@ -49,6 +49,7 @@ defmodule BDS.Scripting.Capabilities do
get_system_language: zero_or_one_arg(fn _args -> I18n.current_ui_locale() end), get_system_language: zero_or_one_arg(fn _args -> I18n.current_ui_locale() end),
get_default_project_path: zero_or_one_arg(fn _args -> project_path(project_id) end), get_default_project_path: zero_or_one_arg(fn _args -> project_path(project_id) end),
get_title_bar_metrics: zero_or_one_arg(fn _args -> title_bar_metrics(opts) end), get_title_bar_metrics: zero_or_one_arg(fn _args -> title_bar_metrics(opts) end),
log: BDS.Scripting.Lua.output_function(opts, [true]),
notify_renderer_ready: zero_or_one_arg(fn _args -> notify_renderer_ready(opts) end), notify_renderer_ready: zero_or_one_arg(fn _args -> notify_renderer_ready(opts) end),
open_folder: one_arg(fn folder_path -> open_folder(folder_path, opts) end), open_folder: one_arg(fn folder_path -> open_folder(folder_path, opts) end),
read_project_metadata: one_arg(fn folder_path -> read_project_metadata(folder_path) end), read_project_metadata: one_arg(fn folder_path -> read_project_metadata(folder_path) end),

View File

@@ -6,16 +6,20 @@ defmodule BDS.Scripting.Lua do
and opt-in. and opt-in.
""" """
require Logger
@type source :: String.t() @type source :: String.t()
@type entrypoint :: String.t() @type entrypoint :: String.t()
@type args :: [term()] @type args :: [term()]
@type progress_event :: map() @type progress_event :: map()
@type progress_callback :: (progress_event() -> any()) @type progress_callback :: (progress_event() -> any())
@type output_callback :: (String.t() -> any())
@type execution_option :: @type execution_option ::
{:timeout, non_neg_integer() | :infinity} {:timeout, non_neg_integer() | :infinity}
| {:max_reductions, pos_integer() | :none} | {:max_reductions, pos_integer() | :none}
| {:spawn_opts, [term()]} | {:spawn_opts, [term()]}
| {:on_progress, progress_callback()} | {:on_progress, progress_callback()}
| {:on_output, output_callback()}
| {:capabilities, map()} | {:capabilities, map()}
@callback validate(source()) :: :ok | {:error, term()} @callback validate(source()) :: :ok | {:error, term()}
@@ -48,12 +52,65 @@ defmodule BDS.Scripting.Lua do
end end
end end
@doc """
Returns the sink script output (`print`, `bds.app.log`) is routed to: the
`:on_output` callback when given, otherwise a Logger-based default so script
output never leaks onto the console (where it would corrupt the TUI).
"""
@spec output_sink(keyword()) :: output_callback()
def output_sink(opts) when is_list(opts) do
case Keyword.get(opts, :on_output) do
callback when is_function(callback, 1) ->
callback
_other ->
fn text -> Logger.debug("script output: #{text}") end
end
end
@doc """
Builds the Lua-callable function shared by the global `print` override and
the `bds.app.log` capability: joins all arguments with spaces and forwards
the line to the output sink.
"""
@spec output_function(keyword(), [term()]) :: (list(), lua_state() -> {list(), lua_state()})
def output_function(opts, return_values \\ []) when is_list(opts) and is_list(return_values) do
sink = output_sink(opts)
fn args, current_state ->
line =
args
|> :luerl.decode_list(current_state)
|> Enum.map_join(" ", &format_lua_value/1)
sink.(line)
:luerl.encode_list(return_values, current_state)
end
end
@doc "Formats a decoded Lua value the way Lua's `tostring` would present it."
@spec format_lua_value(term()) :: String.t()
def format_lua_value(nil), do: "nil"
def format_lua_value(true), do: "true"
def format_lua_value(false), do: "false"
def format_lua_value(value) when is_binary(value), do: value
def format_lua_value(value) when is_float(value) do
if Float.round(value, 0) == value,
do: "#{trunc(value)}.0",
else: to_string(value)
end
def format_lua_value(value) when is_integer(value), do: Integer.to_string(value)
def format_lua_value(value), do: inspect(value)
defp initial_state(opts) do defp initial_state(opts) do
state = :luerl_sandbox.init() state = :luerl_sandbox.init()
capabilities = Keyword.get(opts, :capabilities, %{}) capabilities = Keyword.get(opts, :capabilities, %{})
with {:ok, state} <- :luerl.set_table_keys_dec(["bds"], %{}, state), with {:ok, state} <- :luerl.set_table_keys_dec(["bds"], %{}, state),
{:ok, state} <- install_progress_callback(state, Keyword.get(opts, :on_progress)), {:ok, state} <- install_progress_callback(state, Keyword.get(opts, :on_progress)),
{:ok, state} <- install_print_override(state, opts),
{:ok, state} <- install_capabilities(state, capabilities) do {:ok, state} <- install_capabilities(state, capabilities) do
{:ok, state} {:ok, state}
end end
@@ -85,6 +142,15 @@ defmodule BDS.Scripting.Lua do
defp install_progress_callback(_state, callback), defp install_progress_callback(_state, callback),
do: {:error, {:invalid_progress_callback, callback}} do: {:error, {:invalid_progress_callback, callback}}
# Replaces the built-in `print` (which would write straight to the BEAM
# console) with a function routed to the output sink.
defp install_print_override(state, opts) do
case :luerl.set_table_keys_dec(["print"], output_function(opts), state) do
{:ok, next_state} -> {:ok, next_state}
error -> {:error, {:print_override_install_failed, error}}
end
end
defp install_capabilities(state, capabilities) when capabilities in [%{}, []], do: {:ok, state} defp install_capabilities(state, capabilities) when capabilities in [%{}, []], do: {:ok, state}
defp install_capabilities(state, capabilities) when is_map(capabilities) do defp install_capabilities(state, capabilities) when is_map(capabilities) do

View File

@@ -13,29 +13,139 @@ defmodule BDS.Server do
@doc """ @doc """
Resolves the boot mode from the `BDS_MODE` environment variable: Resolves the boot mode from the `BDS_MODE` environment variable:
`"server"` (headless + SSH daemon), `"tui"` (headless + SSH daemon + `"server"` (headless + SSH daemon), `"tui"` (headless + SSH daemon +
a TUI attached to the launching terminal), anything else is desktop. a TUI attached to the launching terminal), `"cli"` (headless one-shot
command run — no SSH daemon, no HTTP listener, no watcher; issue #25),
anything else is desktop.
""" """
@spec mode(String.t() | nil) :: :desktop | :server | :tui @spec mode(String.t() | nil) :: :desktop | :server | :tui | :cli
def mode(value \\ System.get_env("BDS_MODE")) def mode(value \\ System.get_env("BDS_MODE"))
def mode(value) when is_binary(value) do def mode(value) when is_binary(value) do
case String.downcase(value) do case String.downcase(value) do
"server" -> :server "server" -> :server
"tui" -> :tui "tui" -> :tui
"cli" -> :cli
_other -> :desktop _other -> :desktop
end end
end end
def mode(nil), do: :desktop def mode(nil), do: :desktop
@doc """
The mode the app actually boots in: the resolved `BDS_MODE`, except that
desktop mode falls back to the TUI when no graphical session is available
(issue #33). Starting the app anywhere then still opens a UI — graphical
when possible, the terminal one otherwise.
Per platform: non-Darwin unix needs `DISPLAY` or `WAYLAND_DISPLAY` to run
wx — that check also covers plain ssh sessions, while `ssh -X` with a
forwarded display keeps the GUI. macOS never sets `DISPLAY`, so there an
ssh session (`SSH_CONNECTION`/`SSH_CLIENT`/`SSH_TTY`) is the headless
signal and a local launch always has a graphical session. Windows always
boots the GUI.
"""
@spec effective_mode(:desktop | :server | :tui | :cli, {atom(), atom()}, %{
optional(String.t()) => String.t()
}) :: :desktop | :server | :tui | :cli
def effective_mode(mode \\ mode(), os_type \\ :os.type(), env \\ System.get_env())
def effective_mode(:desktop, {:unix, :darwin}, env) do
if env_any?(env, ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]), do: :tui, else: :desktop
end
def effective_mode(:desktop, {:unix, _os}, env) do
if env_any?(env, ["DISPLAY", "WAYLAND_DISPLAY"]), do: :desktop, else: :tui
end
def effective_mode(mode, _os_type, _env), do: mode
defp env_any?(env, vars), do: Enum.any?(vars, &(env[&1] not in [nil, ""]))
@doc """
Prepares the OS environment for the given effective boot mode and returns
it. The `:desktop` dependency application boots wx inside its own
`Application.start/2` — before any BDS supervisor runs — and that wx load
crashes the whole VM on a display-less system. For every non-desktop mode
this sets `NO_WX=1`, elixir-desktop's own switch that turns all of its wx
calls into no-ops, so the dependency starts inert and the TUI fallback
(issue #33) can actually boot.
In tui mode the app additionally owns the launching terminal, so every
other terminal writer is silenced: the default (console) logger handler
is replaced with a rotating file handler at `tui_log_file/0`, and
Bumblebee's model-download progress bar (raw stdout writes) is disabled.
Each stray line would otherwise scroll the TUI's alternate screen up one
row and corrupt the rendering. Desktop and server modes keep logging to
the terminal, and SSH-served TUI sessions are unaffected either way —
their cells travel over the SSH channel, away from the server console.
Called from `config/runtime.exs`: runtime config is the only hook that
runs before dependency applications start, in both `mix run` and releases.
"""
@spec prepare_boot_env(:desktop | :server | :tui | :cli, (-> :ok)) ::
:desktop | :server | :tui | :cli
def prepare_boot_env(mode \\ effective_mode(), redirect_logs \\ &redirect_logging_to_file/0) do
if mode != :desktop, do: System.put_env("NO_WX", "1")
# CLI mode owns stdout for command output the same way the local TUI owns
# the terminal: console logging goes to the rotating file instead.
if mode in [:tui, :cli] do
Application.put_env(:bumblebee, :progress_bar_enabled, false)
redirect_logs.()
end
mode
end
@doc """
Log file that replaces console logging in local tui mode: a
machine-specific, regenerable artifact, so it lives in the private app
dir (PrivateArtifactsLiveInOsAppDir). Overridable via `:bds, :tui_log_file`.
"""
@spec tui_log_file() :: Path.t()
def tui_log_file do
Application.get_env(:bds, :tui_log_file) ||
Path.join([BDS.Projects.private_dir(), "logs", "bds.log"])
end
@doc """
Replaces the default (console) logger handler with a rotating file
handler writing to `tui_log_file/0`, keeping the configured level,
filters, and Elixir formatter. See `prepare_boot_env/2`.
"""
@spec redirect_logging_to_file() :: :ok
def redirect_logging_to_file do
log_file = tui_log_file()
File.mkdir_p!(Path.dirname(log_file))
with {:ok, handler} <- :logger.get_handler_config(:default) do
:ok = :logger.remove_handler(:default)
:ok =
:logger.add_handler(
:default,
:logger_std_h,
handler
|> Map.take([:level, :filters, :filter_default, :formatter])
|> Map.put(:config, %{
file: String.to_charlist(log_file),
max_no_bytes: 10_000_000,
max_no_files: 5
})
)
end
:ok
end
@doc """ @doc """
Whether the HTTP endpoint requires the desktop webview auth token. Only Whether the HTTP endpoint requires the desktop webview auth token. Only
desktop mode does: in server/tui mode the endpoint stays loopback-only desktop mode does: in server/tui mode the endpoint stays loopback-only
and clients arrive through the key-authenticated SSH tunnel, which the and clients arrive through the key-authenticated SSH tunnel, which the
per-boot webview token would otherwise lock out. per-boot webview token would otherwise lock out.
""" """
@spec desktop_auth_required?(:desktop | :server | :tui) :: boolean() @spec desktop_auth_required?(:desktop | :server | :tui | :cli) :: boolean()
def desktop_auth_required?(mode \\ mode()) def desktop_auth_required?(mode \\ effective_mode())
def desktop_auth_required?(:desktop), do: true def desktop_auth_required?(:desktop), do: true
def desktop_auth_required?(_mode), do: false def desktop_auth_required?(_mode), do: false

View File

@@ -3,10 +3,17 @@ defmodule BDS.Settings do
Persistence layer for global application settings stored as key-value pairs in the database. Persistence layer for global application settings stored as key-value pairs in the database.
""" """
import Ecto.Query, only: [from: 2]
alias BDS.Persistence alias BDS.Persistence
alias BDS.Repo alias BDS.Repo
alias BDS.Settings.Setting alias BDS.Settings.Setting
@spec list_global_settings() :: [{String.t(), String.t()}]
def list_global_settings do
Repo.all(from setting in Setting, order_by: setting.key, select: {setting.key, setting.value})
end
@spec get_global_setting(String.t()) :: String.t() | nil @spec get_global_setting(String.t()) :: String.t() | nil
def get_global_setting(key) do def get_global_setting(key) do
case Repo.get(Setting, key) do case Repo.get(Setting, key) do

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
defmodule BDS.UI.CodeEditor.Highlight do
@moduledoc """
Syntax highlighting for the TUI code editor (issue #32).
Wraps `ExRatatui.CodeBlock.highlight/3` with the editor's language
set: base lexing via syntect, plus pure-Elixir overlays for the
syntaxes syntect does not know — post macros (`[[gallery names="x"]]`)
over the markdown base and Liquid tags (`{{ … }}` / `{% … %}`) over
the HTML base. Mirrors the GUI Monaco language registrations in
`assets/js/monaco/languages.js`.
"""
alias BDS.UI.CodeEditor.Overlay
alias ExRatatui.Text.Line
alias ExRatatui.Widgets.CodeBlock
@type language :: :markdown_macros | :liquid | :lua | :text
@doc """
Highlights `content` for `language` using `theme`, returning one
`%ExRatatui.Text.Line{}` per source line.
The returned line count always matches `String.split(content, "\\n")`
(syntect drops trailing empty lines, but the textarea's line count is
authoritative for the cursor row, so they are padded back). Span
contents never contain newline characters.
"""
@spec highlight(String.t(), language(), CodeBlock.theme()) :: [Line.t()]
def highlight(content, language, theme) when is_binary(content) do
content
|> base_highlight(language, theme)
|> sanitize_lines()
|> pad_lines(source_line_count(content))
|> apply_overlay(language)
end
defp base_highlight(content, :markdown_macros, theme),
do: ExRatatui.CodeBlock.highlight(content, "markdown", theme)
defp base_highlight(content, :liquid, theme),
do: ExRatatui.CodeBlock.highlight(content, "html", theme)
defp base_highlight(content, :lua, theme),
do: ExRatatui.CodeBlock.highlight(content, "lua", theme)
defp base_highlight(content, :text, theme),
do: ExRatatui.CodeBlock.highlight(content, nil, theme)
# syntect appends the line terminator to the last span of a line;
# spans fed to `ExRatatui.Widgets.Paragraph` must not contain "\n".
defp sanitize_lines(lines) do
Enum.map(lines, fn %Line{spans: spans} = line ->
spans =
spans
|> Enum.map(&%{&1 | content: String.replace(&1.content, ~r/[\r\n]/, "")})
|> Enum.reject(&(&1.content == ""))
%{line | spans: spans}
end)
end
defp source_line_count(content), do: content |> String.split("\n") |> length()
defp pad_lines(lines, count) when length(lines) >= count, do: lines
defp pad_lines(lines, count),
do: lines ++ List.duplicate(%Line{}, count - length(lines))
defp apply_overlay(lines, :markdown_macros), do: Enum.map(lines, &Overlay.markdown_macros/1)
defp apply_overlay(lines, :liquid), do: Enum.map(lines, &Overlay.liquid/1)
defp apply_overlay(lines, _language), do: lines
end

View File

@@ -0,0 +1,225 @@
defmodule BDS.UI.CodeEditor.Overlay do
@moduledoc """
Re-styles `%ExRatatui.Text.Line{}` spans for the syntaxes the base
syntect lexer does not know: post macros (`[[gallery names="x"]]`) and
Liquid tags (`{{ … }}` / `{% … %}`).
Ranges are byte-based `{start, length, %Style{}}` triples applied with
patch semantics — the overlay's non-nil fields override the base span
style, so the theme background survives. Overlay colours mirror the
GUI Monaco theme (`assets/js/monaco/theme.js`): macro accents are the
custom `keyword.macro` / `attribute.name` / `attribute.value` rules,
Liquid accents are the vs-dark keyword/string/number base tokens.
"""
alias ExRatatui.Style
alias ExRatatui.Text.Line
alias ExRatatui.Text.Span
@type styled_range :: {non_neg_integer(), non_neg_integer(), Style.t()}
@macro_style %Style{fg: {:rgb, 197, 134, 192}, modifiers: [:bold]}
@macro_attr_style %Style{fg: {:rgb, 156, 220, 254}}
@macro_value_style %Style{fg: {:rgb, 206, 145, 120}}
@liquid_keyword_style %Style{fg: {:rgb, 86, 156, 214}}
@liquid_string_style %Style{fg: {:rgb, 206, 145, 120}}
@liquid_number_style %Style{fg: {:rgb, 181, 206, 168}}
@macro_pattern ~r/\[\[\s*[a-zA-Z][\w-]*[^]]*\]\]/
@macro_opener ~r/\A\[\[\s*[a-zA-Z][\w-]*/
@macro_attr_name ~r/[a-zA-Z][\w-]*(?=\s*=)/
@macro_quoted_value ~r/"[^"]*"/
@macro_bare_value ~r/[^\s=\[\]"]++(?!\s*=)/
@liquid_tag ~r/\{\{-?.*?-?\}\}|\{%-?.*?-?%\}/
@liquid_open ~r/\A\{[\{%]-?/
@liquid_close ~r/-?[\}%][\}%]\z/
@liquid_keywords ~r/\b(?:assign|capture|case|comment|cycle|decrement|echo|elsif|else|endcase|endcapture|endif|endfor|endunless|endcomment|for|if|include|increment|liquid|paginate|raw|render|tablerow|unless|when|true|false|nil|blank|empty|contains)\b/
@liquid_filter ~r/\|\s*[a-zA-Z_][\w-]*/
@liquid_string ~r/"[^"]*"|'[^']*'/
@liquid_number ~r/\b\d+(?:\.\d+)?\b/
@doc """
Recolours every `[[macro]]` on the line, preserving the base styling
of the surrounding markdown.
"""
@spec markdown_macros(Line.t()) :: Line.t()
def markdown_macros(%Line{} = line) do
apply_ranges(line, markdown_macro_ranges(line_text(line)))
end
@doc """
Recolours every Liquid output/tag expression on the line, preserving
the base styling of the surrounding HTML.
"""
@spec liquid(Line.t()) :: Line.t()
def liquid(%Line{} = line) do
apply_ranges(line, liquid_ranges(line_text(line)))
end
@doc """
Byte ranges of every macro on `text`: `[[` + name and `]]` in the
macro style, attribute names and values in their accent styles.
"""
@spec markdown_macro_ranges(String.t()) :: [styled_range()]
def markdown_macro_ranges(text) do
text
|> scan(@macro_pattern)
|> Enum.flat_map(fn {start, len} ->
macro = binary_part(text, start, len)
macro
|> macro_inner_ranges()
|> Enum.map(fn {inner_start, inner_len, style} ->
{start + inner_start, inner_len, style}
end)
end)
end
@doc """
Byte ranges (relative to the start of `macro`, including its `[[` and
`]]`) of the styled sub-parts of a single macro match. Quoted values
are masked before scanning for attribute names and bare values so
nothing matches inside a quoted string.
"""
@spec macro_inner_ranges(String.t()) :: [styled_range()]
def macro_inner_ranges(macro) do
[{0, opener_len}] = Regex.run(@macro_opener, macro, return: :index)
inner = binary_part(macro, opener_len, byte_size(macro) - opener_len - 2)
quoted = scan(inner, @macro_quoted_value)
masked = mask(inner, quoted)
inner_ranges =
with_style(quoted, @macro_value_style) ++
(masked |> scan(@macro_attr_name) |> with_style(@macro_attr_style)) ++
(masked |> scan(@macro_bare_value) |> with_style(@macro_value_style))
[{0, opener_len, @macro_style}, {byte_size(macro) - 2, 2, @macro_style}] ++
offset_ranges(inner_ranges, opener_len)
end
@doc """
Byte ranges of every Liquid expression on `text`: the `{{`/`}}` and
`{%`/`%}` delimiters, tag keywords and filters in the keyword style,
strings and numbers in their accent styles. Strings are masked before
scanning so keywords/numbers inside them keep the string style.
"""
@spec liquid_ranges(String.t()) :: [styled_range()]
def liquid_ranges(text) do
text
|> scan(@liquid_tag)
|> Enum.flat_map(fn {start, len} ->
tag = binary_part(text, start, len)
[{0, open_len}] = Regex.run(@liquid_open, tag, return: :index)
[{close_start, close_len}] = Regex.run(@liquid_close, tag, return: :index)
inner = binary_part(tag, open_len, close_start - open_len)
strings = scan(inner, @liquid_string)
masked = mask(inner, strings)
inner_ranges =
with_style(strings, @liquid_string_style) ++
(masked |> scan(@liquid_keywords) |> with_style(@liquid_keyword_style)) ++
(masked |> scan(@liquid_filter) |> with_style(@liquid_keyword_style)) ++
(masked |> scan(@liquid_number) |> with_style(@liquid_number_style))
[{start, open_len, @liquid_keyword_style}, {start + close_start, close_len, @liquid_keyword_style}] ++
offset_ranges(inner_ranges, start + open_len)
end)
end
@doc """
Re-splits a line's spans at the given byte ranges, patch-merging each
range's style over the base span style it covers. Overlapping ranges
resolve to the one starting earliest (strings/quotes therefore win
over keywords matched inside them).
"""
@spec apply_ranges(Line.t(), [styled_range()]) :: Line.t()
def apply_ranges(%Line{} = line, []), do: line
def apply_ranges(%Line{spans: spans} = line, ranges) do
ranges = Enum.sort_by(ranges, &elem(&1, 0))
{new_spans, _offset} =
Enum.map_reduce(spans, 0, fn span, offset ->
{split_span(span, offset, ranges), offset + byte_size(span.content)}
end)
%{line | spans: new_spans |> List.flatten() |> Enum.reject(&(&1.content == ""))}
end
@doc """
Patch-merges `patch` over `base`: non-nil colours override, modifiers
union. Used for overlay accents, the cursor cell and the current-line
highlight so the theme background/foreground survives underneath.
"""
@spec merge_style(Style.t(), Style.t()) :: Style.t()
def merge_style(%Style{} = base, %Style{} = patch) do
%Style{
fg: patch.fg || base.fg,
bg: patch.bg || base.bg,
underline_color: patch.underline_color || base.underline_color,
modifiers: Enum.uniq(base.modifiers ++ patch.modifiers)
}
end
defp split_span(%Span{} = span, span_start, ranges) do
span_end = span_start + byte_size(span.content)
cuts =
ranges
|> Enum.flat_map(fn {range_start, range_len, _style} ->
range_end = range_start + range_len
if range_end > span_start and range_start < span_end do
[max(range_start, span_start), min(range_end, span_end)]
else
[]
end
end)
|> Enum.filter(&(&1 > span_start and &1 < span_end))
|> Enum.uniq()
|> Enum.sort()
[span_start | cuts ++ [span_end]]
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [segment_start, segment_end] ->
content = binary_part(span.content, segment_start - span_start, segment_end - segment_start)
style =
case Enum.find(ranges, &covers?(&1, segment_start, segment_end)) do
nil -> span.style
{_start, _len, patch} -> merge_style(span.style, patch)
end
%Span{content: content, style: style}
end)
end
defp covers?({range_start, range_len, _style}, segment_start, segment_end) do
segment_start >= range_start and segment_end <= range_start + range_len
end
defp line_text(line), do: Enum.map_join(line.spans, & &1.content)
defp scan(text, pattern) do
pattern
|> Regex.scan(text, return: :index)
|> Enum.map(fn [{start, len}] -> {start, len} end)
end
defp with_style(ranges, style), do: Enum.map(ranges, fn {start, len} -> {start, len, style} end)
defp offset_ranges(ranges, offset),
do: Enum.map(ranges, fn {start, len, style} -> {start + offset, len, style} end)
# Blanks out the given byte ranges with spaces so later scans cannot
# match inside them (offsets stay intact).
defp mask(text, ranges) do
Enum.reduce(ranges, text, fn {start, len}, acc ->
binary_part(acc, 0, start) <>
String.duplicate(" ", len) <>
binary_part(acc, start + len, byte_size(acc) - start - len)
end)
end
end

View File

@@ -0,0 +1,205 @@
defmodule BDS.UI.CodeEditor do
@moduledoc """
Syntax-highlighted, word-wrapping code editor widget (issue #32).
The editing engine stays Rust-owned (`ExRatatui.textarea_*` — buffer,
cursor, undo/redo, clipboard); this widget only re-renders the buffer
every frame through a styling + wrap pipeline:
1. read the buffer and cursor off the textarea reference
2. highlight with `BDS.UI.CodeEditor.Highlight`
3. patch the current-line background into the cursor's source line
4. cut the span at the cursor column and splice in a cursor-styled
cell (the cell-caret rides the paragraph's wrapping)
5. compute the vertical scroll offset so the cursor's visual row
stays inside the window
6. emit a wrapping `ExRatatui.Widgets.Paragraph`
Fields:
* `:textarea` — the Rust textarea reference (required)
* `:language` — `:markdown_macros | :liquid | :lua | :text`
* `:theme` — syntect theme, default `:base16_ocean_dark`
* `:wrap` — soft-wrap long lines, default `true`
* `:cursor_style` — patch style for the cell under the cursor
* `:line_highlight_style` — patch style for the cursor's source line
* `:block` — optional `%ExRatatui.Widgets.Block{}` container
* `:scroll` — previous `{vertical, horizontal}` offset; the render
adjusts it minimally so the cursor stays visible
There is deliberately no line-number gutter. The wrap math assumes
Latin cell widths (1 char = 1 cell) — wide graphemes (CJK, emoji)
can scroll slightly off; noted as a TODO, out of scope for now.
"""
alias ExRatatui.Style
alias ExRatatui.Widgets.Block
alias ExRatatui.Widgets.CodeBlock
@type language :: BDS.UI.CodeEditor.Highlight.language()
@type t :: %__MODULE__{
textarea: reference() | nil,
language: language(),
theme: CodeBlock.theme(),
wrap: boolean(),
cursor_style: Style.t(),
line_highlight_style: Style.t(),
block: Block.t() | nil,
scroll: {non_neg_integer(), non_neg_integer()}
}
defstruct textarea: nil,
language: :text,
theme: :base16_ocean_dark,
wrap: true,
cursor_style: %Style{bg: :cyan},
line_highlight_style: %Style{},
block: nil,
scroll: {0, 0}
end
defimpl ExRatatui.Widget, for: BDS.UI.CodeEditor do
alias BDS.UI.CodeEditor
alias BDS.UI.CodeEditor.Highlight
alias BDS.UI.CodeEditor.Overlay
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Text.Line
alias ExRatatui.Text.Span
alias ExRatatui.Widgets.Block
alias ExRatatui.Widgets.Paragraph
def render(%CodeEditor{textarea: nil} = editor, %Rect{} = rect) do
[{%Paragraph{text: "", wrap: editor.wrap, block: editor.block}, rect}]
end
def render(%CodeEditor{} = editor, %Rect{} = rect) do
content = ExRatatui.textarea_get_value(editor.textarea)
{row, col} = ExRatatui.textarea_cursor(editor.textarea)
source_lines = String.split(content, "\n")
inner = inner_rect(rect, editor.block)
width = max(inner.width, 1)
height = max(inner.height, 1)
lines =
content
|> Highlight.highlight(editor.language, editor.theme)
|> highlight_current_line(row, editor.line_highlight_style)
|> splice_cursor(source_lines, row, col, editor)
top = scroll_top(source_lines, row, col, width, height, elem(editor.scroll, 0))
[
{%Paragraph{
text: lines,
wrap: editor.wrap,
scroll: {top, 0},
block: editor.block
}, rect}
]
end
defp inner_rect(rect, nil), do: rect
defp inner_rect(rect, %Block{borders: borders}) do
left = if border?(borders, :left), do: 1, else: 0
right = if border?(borders, :right), do: 1, else: 0
top = if border?(borders, :top), do: 1, else: 0
bottom = if border?(borders, :bottom), do: 1, else: 0
%Rect{
x: rect.x + left,
y: rect.y + top,
width: max(rect.width - left - right, 1),
height: max(rect.height - top - bottom, 1)
}
end
defp border?(borders, side), do: :all in borders or side in borders
defp highlight_current_line(lines, row, %Style{} = style) do
if style == %Style{} do
lines
else
List.update_at(lines, row, fn %Line{} = line ->
%{line | spans: Enum.map(line.spans, &%{&1 | style: Overlay.merge_style(&1.style, style)})}
end)
end
end
defp splice_cursor(lines, source_lines, row, col, editor) do
List.update_at(lines, row, fn %Line{} = line ->
if col >= cursor_line_length(source_lines, row) do
append_cursor_cell(line, editor)
else
splice_cursor_cell(line, col, editor.cursor_style)
end
end)
end
defp cursor_line_length(source_lines, row) do
source_lines |> Enum.at(row, "") |> String.length()
end
# Past the last character there is no span to cut into — append a
# cursor cell instead. The line highlight bleeds through when the
# cursor style leaves the background alone.
defp append_cursor_cell(%Line{} = line, editor) do
style = Overlay.merge_style(editor.line_highlight_style, editor.cursor_style)
%{line | spans: line.spans ++ [%Span{content: " ", style: style}]}
end
defp splice_cursor_cell(%Line{} = line, col, cursor_style) do
{new_spans, _offset} =
Enum.map_reduce(line.spans, 0, fn span, offset ->
len = String.length(span.content)
if col >= offset and col < offset + len do
{cut_span(span, col - offset, cursor_style), offset + len}
else
{[span], offset + len}
end
end)
%{line | spans: List.flatten(new_spans)}
end
defp cut_span(span, at, cursor_style) do
[
%Span{content: String.slice(span.content, 0, at), style: span.style},
%Span{
content: String.slice(span.content, at, 1),
style: Overlay.merge_style(span.style, cursor_style)
},
%Span{content: String.slice(span.content, at + 1, String.length(span.content)), style: span.style}
]
|> Enum.reject(&(&1.content == ""))
end
# Latin-width assumption (1 char = 1 cell): wide graphemes wrap
# differently than counted here. TODO: CJK/emoji cell widths.
defp scroll_top(source_lines, row, col, width, height, prev_top) do
visual_row =
source_lines
|> Enum.take(row)
|> Enum.map(&visual_rows(&1, width))
|> Enum.sum()
|> Kernel.+(div(col, width))
cond do
visual_row < prev_top -> visual_row
visual_row >= prev_top + height -> visual_row - height + 1
true -> prev_top
end
|> max(0)
end
defp visual_rows(line, width) do
line
|> String.length()
|> max(1)
|> then(&div(&1 + width - 1, width))
end
end

502
lib/bds/ui/settings_form.ex Normal file
View File

@@ -0,0 +1,502 @@
defmodule BDS.UI.SettingsForm do
@moduledoc """
Renderer-agnostic preferences forms (issue #29).
Exposes each settings section of the GUI settings editor as a flat list
of typed fields (`:text`, `:bool`, `:enum`, `:info`, `:action`) that the
TUI can render and edit generically. `save/3` writes through the same backends
as the GUI editor — `BDS.Metadata`, `BDS.Settings`, `BDS.AI` and
`BDS.MCP.AgentConfig` — so both frontends operate on the same
preferences.
"""
use Gettext, backend: BDS.Gettext
import Ecto.Query
alias BDS.Desktop.ShellLive.SettingsEditor.AISettings
alias BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories
alias BDS.Desktop.ShellLive.SettingsEditor.MCPConfig
alias BDS.I18n
alias BDS.MCP.AgentConfig
alias BDS.Metadata
alias BDS.Projects
alias BDS.Repo
alias BDS.Settings
alias BDS.Templates.Template
@type field :: %{
key: String.t(),
label: String.t(),
type: :text | :bool | :enum | :info | :action,
value: term(),
options: [String.t()]
}
@type form :: %{section: String.t(), title: String.t(), fields: [field()]}
# ── Load ─────────────────────────────────────────────────────────────────
@spec load(String.t(), String.t()) :: form()
def load("project", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
form("project", dgettext("ui", "Project"), [
text("name", dgettext("ui", "Name"), metadata.name),
text("description", dgettext("ui", "Description"), metadata.description),
text("public_url", dgettext("ui", "Public URL"), metadata.public_url),
enum(
"main_language",
dgettext("ui", "Main Language"),
metadata.main_language || "en",
supported_language_codes()
),
text("default_author", dgettext("ui", "Default Author"), metadata.default_author),
text(
"max_posts_per_page",
dgettext("ui", "Posts per Page"),
Integer.to_string(metadata.max_posts_per_page)
),
text(
"image_import_concurrency",
dgettext("ui", "Image Import Concurrency"),
Integer.to_string(metadata.image_import_concurrency)
),
enum(
"blogmark_category",
dgettext("ui", "Blogmark Category"),
metadata.blogmark_category || List.first(metadata.categories) || "article",
metadata.categories
),
text(
"blog_languages",
dgettext("ui", "Blog Languages (comma-separated)"),
Enum.join(metadata.blog_languages, ", ")
),
bool(
"semantic_similarity_enabled",
dgettext("ui", "Semantic Similarity"),
metadata.semantic_similarity_enabled
)
])
end
def load("editor", _project_id) do
stored = editor_settings()
form("editor", dgettext("ui", "Editor"), [
enum("default_mode", dgettext("ui", "Default Editor Mode"), stored["default_mode"], [
"wysiwyg",
"markdown",
"preview"
]),
enum("diff_view_style", dgettext("ui", "Diff View Style"), stored["diff_view_style"], [
"inline",
"side-by-side"
]),
bool("wrap_long_lines", dgettext("ui", "Wrap Long Lines"), stored["wrap_long_lines"]),
bool(
"hide_unchanged_regions",
dgettext("ui", "Hide Unchanged Regions"),
stored["hide_unchanged_regions"]
)
])
end
def load("content", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
post_templates = [""] ++ template_slugs(project_id, :post)
list_templates = [""] ++ template_slugs(project_id, :list)
category_fields =
metadata
|> ManagedCategories.category_rows()
|> Enum.flat_map(fn row ->
[
info("cat:#{row.name}", "── " <> row.name, ""),
text("cat:#{row.name}:title", dgettext("ui", "Title"), row.title),
bool("cat:#{row.name}:render_in_lists", dgettext("ui", "Render in Lists"), row.render_in_lists),
bool("cat:#{row.name}:show_title", dgettext("ui", "Show Title"), row.show_title),
enum(
"cat:#{row.name}:post_template_slug",
dgettext("ui", "Post Template"),
row.post_template_slug || "",
post_templates
),
enum(
"cat:#{row.name}:list_template_slug",
dgettext("ui", "List Template"),
row.list_template_slug || "",
list_templates
)
]
end)
form(
"content",
dgettext("ui", "Content"),
[text("new_category", dgettext("ui", "New Category"), "")] ++ category_fields
)
end
def load("ai", _project_id) do
stored = AISettings.ai_form(%{})
form("ai", dgettext("ui", "AI"), [
bool("offline_mode", dgettext("ui", "Airplane Mode"), stored["offline_mode"]),
text("system_prompt", dgettext("ui", "System Prompt"), stored["system_prompt"]),
text("online_url", dgettext("ui", "Online Endpoint URL"), stored["online_url"]),
text("online_api_key", dgettext("ui", "Online API Key"), stored["online_api_key"]),
text("online_chat_model", dgettext("ui", "Online Chat Model"), stored["online_chat_model"]),
bool("online_chat_tools", dgettext("ui", "Online Chat Tool Calls"), stored["online_chat_tools"]),
bool(
"online_chat_disable_reasoning",
dgettext("ui", "Online Chat Disable Reasoning"),
stored["online_chat_disable_reasoning"]
),
text("online_title_model", dgettext("ui", "Online Title Model"), stored["online_title_model"]),
text(
"online_image_analysis_model",
dgettext("ui", "Online Image Model"),
stored["online_image_analysis_model"]
),
bool("online_chat_images", dgettext("ui", "Online Image Support"), stored["online_chat_images"]),
text("offline_url", dgettext("ui", "Offline Endpoint URL"), stored["offline_url"]),
text("offline_api_key", dgettext("ui", "Offline API Key"), stored["offline_api_key"]),
text("offline_chat_model", dgettext("ui", "Offline Chat Model"), stored["offline_chat_model"]),
bool("offline_chat_tools", dgettext("ui", "Offline Chat Tool Calls"), stored["offline_chat_tools"]),
bool(
"offline_chat_disable_reasoning",
dgettext("ui", "Offline Chat Disable Reasoning"),
stored["offline_chat_disable_reasoning"]
),
text("offline_title_model", dgettext("ui", "Offline Title Model"), stored["offline_title_model"]),
text(
"offline_image_analysis_model",
dgettext("ui", "Offline Image Model"),
stored["offline_image_analysis_model"]
),
bool("offline_chat_images", dgettext("ui", "Offline Image Support"), stored["offline_chat_images"])
])
end
def load("technology", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
form("technology", dgettext("ui", "Technology"), [
bool(
"semantic_similarity_enabled",
dgettext("ui", "Semantic Similarity"),
metadata.semantic_similarity_enabled
)
])
end
def load("publishing", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
prefs = metadata.publishing_preferences
form("publishing", dgettext("ui", "Publishing"), [
text("ssh_host", dgettext("ui", "SSH Host"), Map.get(prefs, "ssh_host", "")),
text("ssh_user", dgettext("ui", "SSH User"), Map.get(prefs, "ssh_user", "")),
text("ssh_remote_path", dgettext("ui", "SSH Remote Path"), Map.get(prefs, "ssh_remote_path", "")),
enum("ssh_mode", dgettext("ui", "SSH Mode"), Map.get(prefs, "ssh_mode", "scp") || "scp", [
"scp",
"rsync"
])
])
end
def load("data", project_id) do
project = Projects.get_project(project_id)
data_path = if project, do: Projects.project_data_dir(project), else: ""
form("data", dgettext("ui", "Data"), [
info("data_path", dgettext("ui", "Data Folder"), data_path),
info(
"data_hint",
dgettext("ui", "Maintenance"),
dgettext("ui", "Rebuild and maintenance commands are available under the : prompt.")
),
action(
"install_cli",
dgettext("ui", "Command Line Tool"),
dgettext("ui", "Install CLI Tool")
)
])
end
def load("mcp", _project_id) do
fields =
Enum.map(MCPConfig.mcp_rows(), fn row ->
if row.supported? do
bool("mcp:#{row.id}", row.label, row.configured?)
else
info("mcp:#{row.id}", row.label, dgettext("ui", "not supported yet"))
end
end)
form("mcp", dgettext("ui", "MCP"), fields)
end
def load("style", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
current = if metadata.pico_theme in [nil, ""], do: "default", else: metadata.pico_theme
form("style", dgettext("ui", "Style"), [
enum("pico_theme", dgettext("ui", "Theme"), current, Metadata.supported_pico_themes())
])
end
def load(section, _project_id) do
form(section, section, [])
end
# ── Save ─────────────────────────────────────────────────────────────────
@spec save(String.t(), String.t(), %{String.t() => term()}) :: :ok | {:error, term()}
def save("project", project_id, values) do
save_project_metadata(project_id, %{
name: blank_to_nil(values["name"]),
description: blank_to_nil(values["description"]),
public_url: blank_to_nil(values["public_url"]),
main_language: blank_to_nil(values["main_language"]),
default_author: blank_to_nil(values["default_author"]),
max_posts_per_page: parse_integer(values["max_posts_per_page"], 50),
image_import_concurrency: parse_integer(values["image_import_concurrency"], 4),
blogmark_category: blank_to_nil(values["blogmark_category"]),
blog_languages: split_languages(values["blog_languages"]),
semantic_similarity_enabled: values["semantic_similarity_enabled"] == true
})
end
def save("editor", _project_id, values) do
with :ok <- Settings.put_global_setting("ui.preferred_editor_mode", values["default_mode"]),
:ok <- Settings.put_global_setting("ui.git_diff_view_style", values["diff_view_style"]),
:ok <-
Settings.put_global_setting(
"ui.git_diff_word_wrap",
boolean_string(values["wrap_long_lines"])
) do
Settings.put_global_setting(
"ui.git_diff_hide_unchanged_regions",
boolean_string(values["hide_unchanged_regions"])
)
end
end
def save("content", project_id, values) do
with :ok <- maybe_add_category(project_id, values["new_category"]) do
values
|> category_settings_by_name()
|> Enum.reduce_while(:ok, fn {category, settings}, _acc ->
case Metadata.update_category_settings(project_id, category, settings) do
{:ok, _metadata} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
end
def save("ai", _project_id, values) do
AISettings.save_attrs(%{
online_url: blank_to_nil(values["online_url"]),
online_api_key: blank_to_nil(values["online_api_key"]),
online_chat_model: blank_to_nil(values["online_chat_model"]),
online_chat_tools: values["online_chat_tools"] == true,
online_chat_disable_reasoning: values["online_chat_disable_reasoning"] == true,
online_title_model: blank_to_nil(values["online_title_model"]),
online_image_analysis_model: blank_to_nil(values["online_image_analysis_model"]),
online_chat_images: values["online_chat_images"] == true,
offline_url: blank_to_nil(values["offline_url"]),
offline_api_key: blank_to_nil(values["offline_api_key"]),
offline_mode: values["offline_mode"] == true,
offline_chat_model: blank_to_nil(values["offline_chat_model"]),
offline_chat_tools: values["offline_chat_tools"] == true,
offline_chat_disable_reasoning: values["offline_chat_disable_reasoning"] == true,
offline_title_model: blank_to_nil(values["offline_title_model"]),
offline_image_analysis_model: blank_to_nil(values["offline_image_analysis_model"]),
offline_chat_images: values["offline_chat_images"] == true,
system_prompt: to_string(values["system_prompt"] || "")
})
end
def save("technology", project_id, values) do
save_project_metadata(project_id, %{
semantic_similarity_enabled: values["semantic_similarity_enabled"] == true
})
end
def save("publishing", project_id, values) do
case Metadata.set_publishing_preferences(project_id, %{
ssh_host: blank_to_nil(values["ssh_host"]),
ssh_user: blank_to_nil(values["ssh_user"]),
ssh_remote_path: blank_to_nil(values["ssh_remote_path"]),
ssh_mode: values["ssh_mode"] || "scp"
}) do
{:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason}
end
end
def save("mcp", _project_id, values) do
MCPConfig.mcp_rows()
|> Enum.filter(& &1.supported?)
|> Enum.reduce_while(:ok, fn row, _acc ->
case toggle_mcp_agent(row, Map.get(values, "mcp:#{row.id}", row.configured?)) do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
def save("style", project_id, values) do
save_project_metadata(project_id, %{pico_theme: values["pico_theme"]})
end
def save(_section, _project_id, _values), do: :ok
# ── Actions ──────────────────────────────────────────────────────────────
@doc """
Runs an `:action` field (issue #25) and returns a localized result
message for the invoking frontend (GUI toast or TUI status line).
"""
@spec run_action(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def run_action("install_cli") do
case BDS.CLI.Install.install() do
{:ok, path} ->
{:ok, dgettext("ui", "CLI tool installed to %{path}", path: path)}
{:error, :no_release} ->
{:error, dgettext("ui", "The CLI tool can only be installed from the packaged application")}
{:error, reason} ->
{:error, dgettext("ui", "Installing the CLI tool failed: %{reason}", reason: inspect(reason))}
end
end
def run_action(_key), do: {:error, dgettext("ui", "Unknown settings action")}
# ── Helpers ──────────────────────────────────────────────────────────────
defp form(section, title, fields), do: %{section: section, title: title, fields: fields}
defp text(key, label, value),
do: %{key: key, label: label, type: :text, value: to_string(value || ""), options: []}
defp bool(key, label, value),
do: %{key: key, label: label, type: :bool, value: value == true, options: []}
defp enum(key, label, value, options),
do: %{key: key, label: label, type: :enum, value: to_string(value || ""), options: options}
defp info(key, label, value),
do: %{key: key, label: label, type: :info, value: value, options: []}
defp action(key, label, caption),
do: %{key: key, label: label, type: :action, value: caption, options: []}
defp editor_settings do
%{
"default_mode" => Settings.get_global_setting("ui.preferred_editor_mode") || "markdown",
"diff_view_style" => Settings.get_global_setting("ui.git_diff_view_style") || "inline",
"wrap_long_lines" => Settings.get_global_setting("ui.git_diff_word_wrap") == "true",
"hide_unchanged_regions" =>
Settings.get_global_setting("ui.git_diff_hide_unchanged_regions") == "true"
}
end
defp supported_language_codes, do: Enum.map(I18n.supported_languages(), & &1.code)
defp template_slugs(project_id, kind) do
Repo.all(
from template in Template,
where: template.project_id == ^project_id and template.kind == ^kind,
order_by: [asc: template.slug],
select: template.slug
)
end
defp save_project_metadata(project_id, overrides) do
case Metadata.update_project_metadata(project_id, overrides) do
{:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp maybe_add_category(project_id, name) do
case String.trim(to_string(name || "")) do
"" ->
:ok
category ->
case Metadata.add_category(project_id, category) do
{:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
defp category_settings_by_name(values) do
values
|> Enum.flat_map(fn {key, value} ->
case String.split(key, ":", parts: 3) do
["cat", name, property] -> [{name, property, value}]
_other -> []
end
end)
|> Enum.group_by(fn {name, _property, _value} -> name end)
|> Enum.map(fn {name, entries} ->
{name, Map.new(entries, fn {_name, property, value} -> category_setting(property, value) end)}
end)
end
defp category_setting("title", value), do: {:title, blank_to_nil(value)}
defp category_setting("render_in_lists", value), do: {:render_in_lists, value == true}
defp category_setting("show_title", value), do: {:show_title, value == true}
defp category_setting("post_template_slug", value), do: {:post_template_slug, blank_to_nil(value)}
defp category_setting("list_template_slug", value), do: {:list_template_slug, blank_to_nil(value)}
defp toggle_mcp_agent(%{configured?: configured?}, wanted) when wanted == configured?, do: :ok
defp toggle_mcp_agent(%{id: agent_id}, true) do
case AgentConfig.add_to_config(agent_id, install_root: Application.app_dir(:bds)) do
{:ok, _payload} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp toggle_mcp_agent(%{id: agent_id}, _wanted) do
case AgentConfig.remove_from_config(agent_id) do
{:ok, _payload} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp split_languages(value) do
value
|> to_string()
|> String.split(~r/[,\s]+/, trim: true)
|> Enum.uniq()
end
defp parse_integer(value, fallback) do
case Integer.parse(to_string(value || "")) do
{parsed, _rest} -> parsed
:error -> fallback
end
end
defp boolean_string(true), do: "true"
defp boolean_string(_value), do: "false"
defp blank_to_nil(nil), do: nil
defp blank_to_nil(value) do
case String.trim(to_string(value)) do
"" -> nil
trimmed -> trimmed
end
end
end

View File

@@ -363,6 +363,7 @@ defmodule BDS.UI.Sidebar do
|> maybe_where_search(filters.search) |> maybe_where_search(filters.search)
|> maybe_where_year(filters.year) |> maybe_where_year(filters.year)
|> maybe_where_month(filters.month) |> maybe_where_month(filters.month)
|> maybe_where_day(filters.day)
|> maybe_where_all_tags(filters.tags) |> maybe_where_all_tags(filters.tags)
|> maybe_where_all_categories(filters.categories) |> maybe_where_all_categories(filters.categories)
end end
@@ -424,6 +425,25 @@ defmodule BDS.UI.Sidebar do
) )
end end
# Day-of-month filter used by the TUI's ISO-date search (yyyy-mm-dd);
# always combined with year and month by the caller.
defp maybe_where_day(query, nil), do: query
defp maybe_where_day(query, day) do
day_str = String.pad_leading(to_string(day), 2, "0")
where(
query,
[p],
fragment(
"strftime('%d', datetime(COALESCE(?, ?) / 1000, 'unixepoch')) = ?",
p.published_at,
p.updated_at,
^day_str
)
)
end
defp maybe_where_all_tags(query, []), do: query defp maybe_where_all_tags(query, []), do: query
defp maybe_where_all_tags(query, tags), do: where_all_in_json_array(query, tags, :tags) defp maybe_where_all_tags(query, tags), do: where_all_in_json_array(query, tags, :tags)
@@ -609,6 +629,7 @@ defmodule BDS.UI.Sidebar do
|> maybe_where_media_search(filters.search) |> maybe_where_media_search(filters.search)
|> maybe_where_media_year(filters.year) |> maybe_where_media_year(filters.year)
|> maybe_where_media_month(filters.month) |> maybe_where_media_month(filters.month)
|> maybe_where_media_day(filters.day)
|> maybe_where_all_media_tags(filters.tags) |> maybe_where_all_media_tags(filters.tags)
end end
@@ -667,6 +688,22 @@ defmodule BDS.UI.Sidebar do
) )
end end
defp maybe_where_media_day(query, nil), do: query
defp maybe_where_media_day(query, day) do
day_str = String.pad_leading(to_string(day), 2, "0")
where(
query,
[m],
fragment(
"strftime('%d', datetime(? / 1000, 'unixepoch')) = ?",
m.updated_at,
^day_str
)
)
end
defp maybe_where_all_media_tags(query, []), do: query defp maybe_where_all_media_tags(query, []), do: query
defp maybe_where_all_media_tags(query, tags), defp maybe_where_all_media_tags(query, tags),
@@ -1028,6 +1065,7 @@ defmodule BDS.UI.Sidebar do
search: normalize_string(BDS.MapUtils.attr(params, :search)), search: normalize_string(BDS.MapUtils.attr(params, :search)),
year: normalize_integer(BDS.MapUtils.attr(params, :year)), year: normalize_integer(BDS.MapUtils.attr(params, :year)),
month: normalize_integer(BDS.MapUtils.attr(params, :month)), month: normalize_integer(BDS.MapUtils.attr(params, :month)),
day: normalize_integer(BDS.MapUtils.attr(params, :day)),
tags: normalize_string_list(BDS.MapUtils.attr(params, :tags)), tags: normalize_string_list(BDS.MapUtils.attr(params, :tags)),
categories: normalize_string_list(BDS.MapUtils.attr(params, :categories)), categories: normalize_string_list(BDS.MapUtils.attr(params, :categories)),
display_limit: display_limit:
@@ -1045,6 +1083,7 @@ defmodule BDS.UI.Sidebar do
search: nil, search: nil,
year: nil, year: nil,
month: nil, month: nil,
day: nil,
tags: [], tags: [],
categories: [], categories: [],
display_limit: @default_page_size display_limit: @default_page_size
@@ -1052,8 +1091,8 @@ defmodule BDS.UI.Sidebar do
end end
defp filter_active?(filters) do defp filter_active?(filters) do
present?(filters.search) or not is_nil(filters.year) or filters.tags != [] or present?(filters.search) or not is_nil(filters.year) or not is_nil(filters.day) or
filters.categories != [] filters.tags != [] or filters.categories != []
end end
defp post_search_blob(post) do defp post_search_blob(post) do

240
lib/bds/ui/tags_panel.ex Normal file
View File

@@ -0,0 +1,240 @@
defmodule BDS.UI.TagsPanel do
@moduledoc """
Renderer-agnostic tag management core (issue #34).
Exposes the same tag operations the GUI tags editor
(`BDS.Desktop.ShellLive.TagsEditor`) performs — create, rename, colour,
post template, delete, merge, sync-from-posts — as plain functions with
localized result messages, so the TUI tags panel and the GUI operate on
the same `BDS.Tags` backend.
"""
import Ecto.Query
use Gettext, backend: BDS.Gettext
alias BDS.Posts.Post
alias BDS.Repo
alias BDS.Tags
alias BDS.Tags.Tag
alias BDS.Templates.Template
@colour_presets ~w(
#ef4444 #f97316 #f59e0b #eab308 #84cc16
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
#ec4899 #64748b
)
@type tag_entry :: %{
id: String.t(),
name: String.t(),
color: String.t() | nil,
post_template_slug: String.t() | nil,
count: non_neg_integer()
}
@spec colour_presets() :: [String.t()]
def colour_presets, do: @colour_presets
@doc "All project tags with their post usage counts, plus template slugs."
@spec load(String.t()) :: %{tags: [tag_entry()], templates: [String.t()]}
def load(project_id) do
counts = tag_counts(project_id)
tags =
project_id
|> Tags.list_tags()
|> Enum.map(fn tag ->
%{
id: tag.id,
name: tag.name,
color: tag.color,
post_template_slug: tag.post_template_slug,
count: Map.get(counts, tag.name, 0)
}
end)
%{tags: tags, templates: template_slugs(project_id)}
end
@doc "How many posts use each tag name."
@spec tag_counts(String.t()) :: %{String.t() => non_neg_integer()}
def tag_counts(project_id) do
Repo.all(from post in Post, where: post.project_id == ^project_id, select: post.tags)
|> List.flatten()
|> Enum.reject(&is_nil/1)
|> Enum.reduce(%{}, fn tag, acc -> Map.update(acc, tag, 1, &(&1 + 1)) end)
end
@spec post_count(String.t(), String.t()) :: non_neg_integer()
def post_count(project_id, tag_name), do: Map.get(tag_counts(project_id), tag_name, 0)
@spec create(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def create(project_id, name) do
case Tags.create_tag(%{project_id: project_id, name: name}) do
{:ok, tag} -> {:ok, dgettext("ui", "Tag %{name} created", name: tag.name)}
{:error, reason} -> {:error, error_message(reason)}
end
end
@spec rename(String.t(), String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def rename(project_id, tag_name, new_name) do
with {:ok, tag} <- fetch(project_id, tag_name) do
normalized = String.trim(to_string(new_name || ""))
cond do
normalized == "" ->
{:error, dgettext("ui", "Tag name cannot be empty")}
normalized == tag.name ->
{:ok, dgettext("ui", "Tag unchanged")}
true ->
case Tags.rename_tag(tag.id, normalized) do
{:ok, renamed} ->
{:ok, dgettext("ui", "Tag renamed to %{name}", name: renamed.name)}
{:error, reason} ->
{:error, error_message(reason)}
end
end
end
end
@doc "Advances the tag colour through the presets (last preset wraps to none)."
@spec cycle_color(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def cycle_color(project_id, tag_name) do
with {:ok, tag} <- fetch(project_id, tag_name) do
next = next_in_cycle(@colour_presets, tag.color)
case Tags.update_tag(tag.id, %{color: next}) do
{:ok, updated} ->
{:ok,
dgettext("ui", "Colour of %{name}: %{color}",
name: updated.name,
color: updated.color || dgettext("ui", "none")
)}
{:error, reason} ->
{:error, error_message(reason)}
end
end
end
@doc "Advances the tag post template through the project templates (wraps to none)."
@spec cycle_template(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def cycle_template(project_id, tag_name) do
with {:ok, tag} <- fetch(project_id, tag_name) do
next = next_in_cycle(template_slugs(project_id), tag.post_template_slug)
case Tags.update_tag(tag.id, %{post_template_slug: next}) do
{:ok, updated} ->
{:ok,
dgettext("ui", "Template of %{name}: %{template}",
name: updated.name,
template: updated.post_template_slug || dgettext("ui", "default")
)}
{:error, reason} ->
{:error, error_message(reason)}
end
end
end
@spec delete(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def delete(project_id, tag_name) do
with {:ok, tag} <- fetch(project_id, tag_name) do
case Tags.delete_tag(tag.id) do
{:ok, _deleted} -> {:ok, dgettext("ui", "Tag %{name} deleted", name: tag_name)}
{:error, reason} -> {:error, error_message(reason)}
end
end
end
@doc "Merges the named tags into `target_name` (which must be one of them)."
@spec merge(String.t(), [String.t()], String.t()) :: {:ok, String.t()} | {:error, String.t()}
def merge(project_id, source_names, target_name) do
tags =
Repo.all(
from tag in Tag, where: tag.project_id == ^project_id and tag.name in ^source_names
)
target = Enum.find(tags, &(&1.name == target_name))
sources = Enum.reject(tags, &(&1.name == target_name))
cond do
target == nil ->
{:error, dgettext("ui", "The merge target must be one of the marked tags")}
sources == [] ->
{:error, dgettext("ui", "Mark at least two tags to merge")}
true ->
case Tags.merge_tags(Enum.map(sources, & &1.id), target.id) do
{:ok, _merged} ->
{:ok,
dgettext("ui", "Merged %{count} tags into %{name}",
count: length(sources),
name: target.name
)}
{:error, reason} ->
{:error, error_message(reason)}
end
end
end
@spec sync(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def sync(project_id) do
case Tags.sync_tags_from_posts(project_id) do
{:ok, tags} ->
{:ok, dgettext("ui", "Tags synced from posts (%{count} total)", count: length(tags))}
{:error, reason} ->
{:error, error_message(reason)}
end
end
defp fetch(project_id, tag_name) do
case Repo.get_by(Tag, project_id: project_id, name: tag_name) do
nil -> {:error, dgettext("ui", "Tag %{name} not found", name: tag_name)}
tag -> {:ok, tag}
end
end
defp template_slugs(project_id) do
Repo.all(
from template in Template,
where: template.project_id == ^project_id,
order_by: [asc: template.slug],
select: template.slug
)
end
# nil → first option → … → last option → nil
defp next_in_cycle([], _current), do: nil
defp next_in_cycle(options, current) do
case Enum.find_index(options, &(&1 == current)) do
nil -> List.first(options)
index when index == length(options) - 1 -> nil
index -> Enum.at(options, index + 1)
end
end
defp error_message(reason) when is_binary(reason), do: reason
defp error_message(:not_found), do: dgettext("ui", "Tag not found")
defp error_message(%Ecto.Changeset{} = changeset) do
changeset
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
Enum.reduce(opts, message, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
|> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
end
defp error_message(reason), do: inspect(reason)
end

View File

@@ -0,0 +1,91 @@
defmodule BDS.UI.TaskGrouping do
@moduledoc """
Groups task snapshot entries for the bottom panel's Tasks tab, mirroring the
old app's `taskGrouping.ts`: tasks sharing a `group_id` collapse into one
group entry (ordered by first appearance), everything else stays a single
entry. Groups summarize to per-status counts plus an aggregate progress in
the 0.0..1.0 range.
"""
@type task :: map()
@type entry :: {:single, task()} | {:group, String.t(), String.t(), [task()]}
@type summary :: %{
total: non_neg_integer(),
running: non_neg_integer(),
pending: non_neg_integer(),
completed: non_neg_integer(),
failed: non_neg_integer(),
cancelled: non_neg_integer(),
progress: float()
}
@spec build_task_entries([task()]) :: [entry()]
def build_task_entries(tasks) when is_list(tasks) do
{singles, groups} =
tasks
|> Enum.with_index()
|> Enum.reduce({[], %{}}, fn {task, index}, {singles, groups} ->
case Map.get(task, :group_id) do
nil ->
{[{index, task} | singles], groups}
group_id ->
groups =
Map.update(groups, group_id, {index, Map.get(task, :group_name) || group_id, [task]}, fn {first_index, name, grouped} ->
{first_index, name, [task | grouped]}
end)
{singles, groups}
end
end)
single_entries = Enum.map(singles, fn {index, task} -> {index, {:single, task}} end)
group_entries =
Enum.map(groups, fn {group_id, {index, name, grouped}} ->
{index, {:group, group_id, name, Enum.reverse(grouped)}}
end)
(single_entries ++ group_entries)
|> Enum.sort_by(fn {index, _entry} -> index end)
|> Enum.map(fn {_index, entry} -> entry end)
end
@spec summarize_task_group([task()]) :: summary()
def summarize_task_group(tasks) when is_list(tasks) do
base = %{total: 0, running: 0, pending: 0, completed: 0, failed: 0, cancelled: 0, progress: 0.0}
summary =
Enum.reduce(tasks, base, fn task, acc ->
status = Map.get(task, :status)
acc
|> Map.update!(:total, &(&1 + 1))
|> count_status(status)
|> Map.update!(:progress, &(&1 + progress_contribution(task)))
end)
if summary.total > 0 do
%{summary | progress: summary.progress / summary.total}
else
summary
end
end
defp count_status(acc, status) when status in [:running, :pending, :completed, :failed, :cancelled],
do: Map.update!(acc, status, &(&1 + 1))
defp count_status(acc, _status), do: acc
# Finished tasks count as fully done no matter their outcome, pending tasks
# contribute nothing yet (old-app getProgressContribution).
defp progress_contribution(%{status: status}) when status in [:completed, :failed, :cancelled],
do: 1.0
defp progress_contribution(%{status: :pending}), do: 0.0
defp progress_contribution(%{progress: progress}) when is_number(progress),
do: progress |> max(0.0) |> min(1.0)
defp progress_contribution(_task), do: 0.0
end

37
mix.exs
View File

@@ -2,6 +2,8 @@ defmodule BDS.MixProject do
use Mix.Project use Mix.Project
def project do def project do
ensure_xla_compiler_flags()
[ [
app: :bds, app: :bds,
version: "0.1.0", version: "0.1.0",
@@ -15,6 +17,22 @@ defmodule BDS.MixProject do
] ]
end end
# XLA 0.9.x headers (pulled by exla) illegally specialize `std::is_signed`,
# which Apple clang 17+/libc++ rejects with a hard `-Winvalid-specialization`
# error, breaking the exla NIF build. Disable just that diagnostic on macOS so
# the C++ compile succeeds. Guarded to Darwin so the Linux/gcc build path is
# untouched. Appends rather than overwrites any CFLAGS the caller already set.
defp ensure_xla_compiler_flags do
if :os.type() == {:unix, :darwin} do
flag = "-Wno-invalid-specialization"
current = System.get_env("CFLAGS", "")
unless String.contains?(current, flag) do
System.put_env("CFLAGS", String.trim(flag <> " " <> current))
end
end
end
def application do def application do
[ [
extra_applications: [:logger, :wx, :ssl], extra_applications: [:logger, :wx, :ssl],
@@ -32,6 +50,7 @@ defmodule BDS.MixProject do
{:ecto_sqlite3, "~> 0.24"}, {:ecto_sqlite3, "~> 0.24"},
{:luerl, "~> 1.5"}, {:luerl, "~> 1.5"},
{:jason, "~> 1.4"}, {:jason, "~> 1.4"},
{:optimus, "~> 0.5"},
{:mdex, "~> 0.13"}, {:mdex, "~> 0.13"},
{:liquex, "~> 0.13.1"}, {:liquex, "~> 0.13.1"},
{:plug, "~> 1.18"}, {:plug, "~> 1.18"},
@@ -43,11 +62,21 @@ defmodule BDS.MixProject do
{:ex_ratatui, "~> 0.11"}, {:ex_ratatui, "~> 0.11"},
{:image, "~> 0.67"}, {:image, "~> 0.67"},
{:nx, "~> 0.10"}, {:nx, "~> 0.10"},
{:exla, "~> 0.10"}, # Only one ML inference backend ships per platform; NIF releases are
# host-built, so the build OS is the target OS. EXLA (283 MB XLA CPU
# payload) never runs in the macOS app — EMLX always wins on Apple
# Silicon — but `runtime: false` alone can't exclude it there because
# the `image` dep declares exla as an optional application, which drags
# it into the release whenever it exists in the prod dep tree. Scoping
# it to dev/test on macOS keeps it fully out of macOS releases while
# Linux/Windows ship it as their production inference backend.
{:exla, "~> 0.10", if(macos?(), do: [only: [:dev, :test]], else: [])},
# Apple Silicon GPU (Metal) acceleration for embedding inference. Ships # Apple Silicon GPU (Metal) acceleration for embedding inference. Ships
# precompiled MLX binaries; the Neural backend prefers it on arm64 macOS # precompiled MLX binaries; the Neural backend prefers it on arm64 macOS
# and falls back to EXLA-CPU elsewhere (SPECGAPS A1-14c). # and falls back to EXLA-CPU elsewhere (SPECGAPS A1-14c). Apple-only, so
{:emlx, "~> 0.2.0"}, # non-macOS releases exclude it (runtime: false deps stay out of
# releases — nothing pulls emlx in optionally, unlike exla above).
{:emlx, "~> 0.2.0", runtime: macos?()},
{:bumblebee, "~> 0.6.3"}, {:bumblebee, "~> 0.6.3"},
{:hnswlib, "~> 0.1.7"}, {:hnswlib, "~> 0.1.7"},
{:stemex, "~> 0.2.1"}, {:stemex, "~> 0.2.1"},
@@ -102,4 +131,6 @@ defmodule BDS.MixProject do
] ]
] ]
end end
defp macos?, do: :os.type() == {:unix, :darwin}
end end

View File

@@ -62,6 +62,7 @@
"nx_image": {:hex, :nx_image, "0.1.2", "0c6e3453c1dc30fc80c723a54861204304cebc8a89ed3b806b972c73ee5d119d", [:mix], [{:nx, "~> 0.4", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "9161863c42405ddccb6dbbbeae078ad23e30201509cc804b3b3a7c9e98764b81"}, "nx_image": {:hex, :nx_image, "0.1.2", "0c6e3453c1dc30fc80c723a54861204304cebc8a89ed3b806b972c73ee5d119d", [:mix], [{:nx, "~> 0.4", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "9161863c42405ddccb6dbbbeae078ad23e30201509cc804b3b3a7c9e98764b81"},
"nx_signal": {:hex, :nx_signal, "0.2.0", "e1ca0318877b17c81ce8906329f5125f1e2361e4c4235a5baac8a95ee88ea98e", [:mix], [{:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "7247e5e18a177a59c4cb5355952900c62fdeadeb2bad02a9a34237b68744e2bb"}, "nx_signal": {:hex, :nx_signal, "0.2.0", "e1ca0318877b17c81ce8906329f5125f1e2361e4c4235a5baac8a95ee88ea98e", [:mix], [{:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "7247e5e18a177a59c4cb5355952900c62fdeadeb2bad02a9a34237b68744e2bb"},
"oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"}, "oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"},
"optimus": {:hex, :optimus, "0.6.1", "b493bdc7ee71035fdf8cbfbf158b1863e2d622b65c3906153e4042815c901fca", [:mix], [], "hexpm", "c0db4107a51f5af94de8b05e4208333ebb8016a3bfdbcd74df6e5c99829db17f"},
"phoenix": {:hex, :phoenix, "1.8.8", "ada3d761359274178180c0e992ef0c2b536bd7c3bd75ebba94acbf39ab4347fe", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f0c843037bd2e7012fc1d1ec9574dfa6972b7e3d09e9b77fd23aa283af0aa994"}, "phoenix": {:hex, :phoenix, "1.8.8", "ada3d761359274178180c0e992ef0c2b536bd7c3bd75ebba94acbf39ab4347fe", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f0c843037bd2e7012fc1d1ec9574dfa6972b7e3d09e9b77fd23aa283af0aa994"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18 #: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:288 #: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:566 #: lib/bds/ui/sidebar.ex:586
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archive" msgid "Archive"
msgstr "Archiv" msgstr "Archiv"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18 #: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:288 #: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:566 #: lib/bds/ui/sidebar.ex:586
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archive" msgid "Archive"
msgstr "" msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18 #: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:288 #: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:566 #: lib/bds/ui/sidebar.ex:586
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archive" msgid "Archive"
msgstr "Archivo" msgstr "Archivo"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18 #: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:288 #: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:566 #: lib/bds/ui/sidebar.ex:586
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archive" msgid "Archive"
msgstr "Archives" msgstr "Archives"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18 #: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:288 #: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:566 #: lib/bds/ui/sidebar.ex:586
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archive" msgid "Archive"
msgstr "Archivio" msgstr "Archivio"

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ msgstr ""
#: lib/bds/rendering/labels.ex:18 #: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:288 #: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:566 #: lib/bds/ui/sidebar.ex:586
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archive" msgid "Archive"
msgstr "" msgstr ""

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

23
rel/overlays/cli/bin/bds-cli Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/sh
# bds-cli: workspace CLI for bDS2 (issue #25). Runs commands inside the
# release VM against the same settings and cache database as the app.
set -e
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
RELEASE_ROOT="$(cd "$SELF_DIR/../.." && pwd)"
export RELEASE_ROOT
# The TUI owns the terminal, so it boots the app instead of the one-shot CLI.
if [ "$1" = "tui" ]; then
BDS_MODE=tui exec "$RELEASE_ROOT/bin/bds" start
fi
# `bin/bds eval` cannot forward arguments, so the argv travels in an
# environment variable, joined with the ASCII unit separator (0x1f).
US="$(printf '\037')"
ARGV=""
for arg in "$@"; do
ARGV="${ARGV}${arg}${US}"
done
BDS_CLI_ARGV="$ARGV" BDS_MODE=cli exec "$RELEASE_ROOT/bin/bds" eval "BDS.CLI.main()"

View File

@@ -47,6 +47,9 @@ for await (const line of rl) {
assistant_visible: !hasClass("[data-testid='assistant-shell']", "is-hidden"), assistant_visible: !hasClass("[data-testid='assistant-shell']", "is-hidden"),
assistant_width: document.querySelector("[data-testid='assistant-shell']")?.getBoundingClientRect().width ?? 0, assistant_width: document.querySelector("[data-testid='assistant-shell']")?.getBoundingClientRect().width ?? 0,
panel_visible: !hasClass(".panel-shell", "is-hidden"), panel_visible: !hasClass(".panel-shell", "is-hidden"),
panel_tabs_height: document.querySelector(".panel-tabs")?.getBoundingClientRect().height ?? 0,
panel_tab_labels: texts(".panel-tab", (node) => node.textContent.trim()),
panel_active_tab: text(".panel-tab.active"),
editor_title: text("[data-testid='editor-title']"), editor_title: text("[data-testid='editor-title']"),
activity_labels: texts("[data-testid='activity-button']", (node) => node.getAttribute("aria-label")), activity_labels: texts("[data-testid='activity-button']", (node) => node.getAttribute("aria-label")),
sidebar_sections: texts("[data-testid='sidebar-section-title']", (node) => node.textContent.trim()), sidebar_sections: texts("[data-testid='sidebar-section-title']", (node) => node.textContent.trim()),

175
specs/cli.allium Normal file
View File

@@ -0,0 +1,175 @@
-- allium: 1
-- Workspace CLI tool (issue #25)
-- Scope: extension (Bucket G — MCP + Automation)
-- Distilled from: lib/bds/cli.ex, lib/bds/cli/commands.ex, lib/bds/cli/install.ex,
-- rel/overlays/cli/bin/bds-cli
entity CliInvocation {
command: rebuild | repair | render | upload | push | pull | post | media
| gallery | config | project | tui | lua
incremental: Boolean
force: Boolean
exit_code: Integer
-- Parsed by Optimus: subcommands, options, flags, and auto-generated
-- help/version output. Unknown commands and invalid options exit 1
-- with formatted errors on stderr.
}
surface CliSurface {
facing _: CliRuntime
provides:
CliCommandExecuted(command)
CliInstallRequested()
}
invariant SharedDatabase {
-- The CLI boots the application in BDS_MODE=cli: the same repo,
-- settings, and cache database as the GUI/TUI app — but with no
-- HTTP listener, no SSH daemon, no window, and no sync watcher.
-- Console logging is redirected to the rotating log file so stdout
-- carries only command output.
}
invariant CliWritesNotify {
-- Every CLI mutation (post/media/gallery creation, config set,
-- project add/switch, bulk rebuild/repair) inserts DbNotification
-- rows (from_cli: true) so a concurrently running app picks the
-- change up through its sync watcher (see cli_sync.allium). Bulk
-- maintenance uses wildcard entity ids per entity type.
}
rule ExitCode {
when: CliCommandExecuted(command)
-- Success prints the result (stdout) and exits 0; any error prints
-- to stderr and exits 1.
requires: CliInvocation.command = command
ensures: CliInvocation.exit_code.updated()
}
rule RebuildFull {
when: CliCommandExecuted(command: rebuild)
requires: not CliInvocation.incremental
-- The same step sequence as the GUI "Rebuild Database"
-- (BDS.Maintenance.full_rebuild_steps — posts, media, scripts,
-- templates, post links, thumbnails, embedding index), run
-- synchronously with progress on stdout.
ensures: CacheDatabaseRebuilt()
}
rule RebuildIncremental {
when: CliCommandExecuted(command: rebuild)
requires: CliInvocation.incremental
-- Metadata diff, then auto-apply file→db for every difference and
-- import every orphan file.
ensures: CacheDatabaseRebuilt()
}
rule Repair {
when: CliCommandExecuted(command: repair)
-- Subcommand argument selects the repair part: post-links,
-- media-links, thumbnails, embeddings, or search — the standard
-- rebuild tasks outside the full rebuild.
ensures: RepairTaskCompleted()
}
rule Render {
when: CliCommandExecuted(command: render)
-- Default: render all site sections plus the search index.
-- --incremental: validate the generated output and apply only the
-- differences (targeted render + extra-file deletion + calendar).
-- --force: full re-render ignoring (but updating) content hashes.
requires: not (CliInvocation.incremental and CliInvocation.force)
ensures: SiteRendered()
}
rule Upload {
when: CliCommandExecuted(command: upload)
-- Uses the project publishing preferences (ssh host/user/path/mode)
-- exactly like the app's upload, and waits for the publish job.
ensures: SiteUploaded()
}
rule GitSync {
when: CliCommandExecuted(command: push | pull)
-- push: git push of the project repository to origin.
-- pull: git pull --ff-only, then the incremental cache update
-- (metadata diff auto-apply + orphan import) so the database
-- reflects the pulled files.
ensures: RepositorySynchronized()
}
rule CreatePost {
when: CliCommandExecuted(command: post)
-- Post data from parameters or JSON on stdin (LLM-friendly).
-- Language is auto-detected when missing: the configured AI
-- endpoint (airplane mode routes to the local model), falling back
-- to the offline heuristic with a printed notice — the CLI
-- equivalent of the airplane-mode toast. After creation the same
-- auto-translation as the GUI is scheduled and awaited; when
-- nothing can be scheduled the user is told.
ensures: PostCreated()
}
rule CreateMedia {
when: CliCommandExecuted(command: media)
-- Imports the image, then best-effort AI enrichment: generated
-- title, alt text, caption, and translations to all configured
-- blog languages (the gallery pipeline without post linking).
ensures: MediaImported()
}
rule CreateGallery {
when: CliCommandExecuted(command: gallery)
-- Creates the post, then runs the shared gallery import pipeline
-- for every referenced image: import, mandatory post link, AI
-- enrichment, translations; finally the post auto-translation.
ensures: PostCreated()
ensures: MediaImported()
}
rule Preferences {
when: CliCommandExecuted(command: config)
-- get/set/list of the global settings store shared with the app.
ensures: PreferencesAccessed()
}
rule Projects {
when: CliCommandExecuted(command: project)
-- list, add <path> (register a folder in the cache database), and
-- switch <id|slug|name> (change the active project).
ensures: ProjectRegistryUpdated()
}
rule Tui {
when: CliCommandExecuted(command: tui)
-- Handled by the launcher script: exec the release in BDS_MODE=tui
-- so the interactive terminal UI owns the terminal.
ensures: TuiStarted()
}
rule RunLuaTask {
when: CliCommandExecuted(command: lua)
-- Runs a utility ("task", long-running) script from the database by
-- slug in the active project, with the managed-job execution budget
-- (unlimited time/reductions) but synchronously. Macro and
-- transform scripts are rejected.
ensures: ScriptExecuted()
}
rule InstallLauncher {
when: CliInstallRequested()
-- Install buttons in the GUI settings (Data section) and the TUI
-- settings (data form action field) both call
-- BDS.UI.SettingsForm.run_action("install_cli"): a shim exec'ing
-- the release's cli/bin/bds-cli launcher is written to
-- ~/.local/bin/bds-cli. Outside a packaged release the action
-- reports that installing requires the packaged application.
ensures: LauncherInstalled()
}
invariant LauncherArgv {
-- bin/bds eval cannot forward arguments, so the launcher passes the
-- argv in BDS_CLI_ARGV joined with the ASCII unit separator (0x1f)
-- and the release evaluates BDS.CLI.main().
}

View File

@@ -241,7 +241,12 @@ invariant NativeAcceleratedExecution {
-- On Apple Silicon the model runs on the Apple GPU via EMLX (MLX/Metal, -- On Apple Silicon the model runs on the Apple GPU via EMLX (MLX/Metal,
-- params placed on the EMLX.Backend GPU device); everywhere else, and as a -- params placed on the EMLX.Backend GPU device); everywhere else, and as a
-- fallback when EMLX is unavailable, it runs on optimised native CPU via -- fallback when EMLX is unavailable, it runs on optimised native CPU via
-- EXLA. Selection is `accelerator: :auto | :emlx | :exla` (default :auto). -- EXLA. Selection is `accelerator: :auto | :emlx | :exla` (default :auto);
-- a request for an unavailable backend degrades to the other one.
-- Packaged releases ship only the backend their platform can run (runtime:
-- conditional deps): macOS releases exclude EXLA, Linux/Windows releases
-- exclude the Apple-only EMLX. The selected backend is started on demand
-- by the Neural backend, never assumed started at boot.
-- Neighbour search is HNSW (hnswlib). -- Neighbour search is HNSW (hnswlib).
} }

View File

@@ -113,6 +113,17 @@ invariant MultiLanguageRoutes {
-- Each language subtree gets its own feeds and archives -- Each language subtree gets its own feeds and archives
} }
invariant LanguageVariantSelection {
-- Every language tree renders each post in its own language when a
-- matching variant exists, falling back to the post's original language.
-- This applies uniformly to single pages, list pages (home/pagination,
-- category/tag/date archives), and feeds:
-- Main tree: a post whose language differs from main_language renders
-- its main_language translation when one exists (canonical variant).
-- Additional-language trees: posts resolve to that language's
-- translation; do_not_translate posts are excluded from those trees.
}
invariant CanonicalBaseUrlConfigured { invariant CanonicalBaseUrlConfigured {
for generation in SiteGenerations: for generation in SiteGenerations:
generation.base_url != "" generation.base_url != ""

View File

@@ -249,6 +249,8 @@ invariant PanelTabFallback {
-- Tasks tab: last 10 tasks, newest first, with progress/cancel. -- Tasks tab: last 10 tasks, newest first, with progress/cancel.
-- Tasks with shared group_id are collapsible groups showing aggregate progress. -- Tasks with shared group_id are collapsible groups showing aggregate progress.
-- Output tab: log entries with copy-all button. -- Output tab: log entries with copy-all button.
-- The panel opens on Output automatically when a blogmark transform fails
-- or a transform script writes output.
-- Post Links tab: backlinks (posts linking here) + outlinks (posts linked from here). -- Post Links tab: backlinks (posts linking here) + outlinks (posts linked from here).
-- Each entry clickable, opens linked post as pinned tab. -- Each entry clickable, opens linked post as pinned tab.
-- Git Log tab: file-level git history for active post/media (up to 50 entries). -- Git Log tab: file-level git history for active post/media (up to 50 entries).

View File

@@ -110,6 +110,12 @@ surface ScriptRuntimeSurface {
-- Progress reporting is cooperative and flows through the supplied -- Progress reporting is cooperative and flows through the supplied
-- progress sink rather than ambient global side effects. -- progress sink rather than ambient global side effects.
@guarantee ScriptOutputRouting
-- print(…) calls and the explicit bds.app.log(…) API append lines to a
-- host-routed script output stream: the desktop app's Output panel,
-- stdout in the CLI, and the host logger when no sink is attached —
-- never the ambient console.
@guarantee BatchCancellation @guarantee BatchCancellation
-- Managed utility and transform jobs can be cancelled by the host -- Managed utility and transform jobs can be cancelled by the host
-- operator boundary. -- operator boundary.

View File

@@ -30,7 +30,24 @@ surface ServerRuntimeSurface {
rule ModeResolution { rule ModeResolution {
when: ApplicationStarted(mode) when: ApplicationStarted(mode)
-- BDS_MODE environment variable decides the mode once per boot -- BDS_MODE environment variable decides the mode once per boot.
-- Fallback (issue #33): resolved desktop mode boots as tui when no
-- graphical session is available, so the app always opens a UI
-- rather than crashing wx; a log line records the switch. Headless
-- means: non-Darwin unix without DISPLAY/WAYLAND_DISPLAY (plain ssh
-- included; ssh -X with a forwarded display keeps the GUI), or macOS
-- inside an ssh session (SSH_CONNECTION/SSH_CLIENT/SSH_TTY — macOS
-- never sets DISPLAY). Explicit server/tui modes and Windows are
-- never rewritten. Because the :desktop dependency boots wx in its
-- own application start, config/runtime.exs sets NO_WX=1 for every
-- non-desktop effective mode (BDS.Server.prepare_boot_env) so the
-- dependency starts inert before any BDS supervisor runs. In tui
-- mode prepare_boot_env also silences every other terminal writer —
-- the default logger handler moves to a rotating file in the private
-- app dir and Bumblebee's model-download progress bar is disabled —
-- because the TUI owns the terminal and each stray console line
-- scrolls the alternate screen up one row. Desktop/server modes and
-- SSH-served TUI sessions keep normal console logging.
ensures: BootMode.created(kind: mode) ensures: BootMode.created(kind: mode)
} }

View File

@@ -4,7 +4,7 @@
-- Distilled from: lib/bds/tui.ex, lib/bds/ui/sidebar.ex, lib/bds/ui/post_editor/*.ex -- Distilled from: lib/bds/tui.ex, lib/bds/ui/sidebar.ex, lib/bds/ui/post_editor/*.ex
entity TuiState { entity TuiState {
view: posts | media | templates | scripts | tags view: posts | media | templates | scripts | tags | settings | git
focus: sidebar | editor focus: sidebar | editor
selected_index: Integer selected_index: Integer
editing_post: Boolean editing_post: Boolean
@@ -33,9 +33,12 @@ rule SidebarNavigation {
rule OpenEntry { rule OpenEntry {
when: TuiKeyPressed(code: "enter") when: TuiKeyPressed(code: "enter")
-- Posts open in the editor (title + textarea seeded from the -- Posts open in the editor by default (title + textarea seeded from
-- persisted form). Images open in the terminal image preview. -- the persisted form; syntax highlighting and word wrap on; ctrl+e
-- Other entities report that terminal editing is not available yet. -- switches to the rendered preview). New empty posts created with
-- "n" open in the editor as well. Images open in the terminal image
-- preview. Other entities report that terminal editing is not
-- available yet.
ensures: TuiState.editing_post.updated() ensures: TuiState.editing_post.updated()
} }
@@ -47,14 +50,27 @@ rule SaveAndPublish {
ensures: PostPersisted() ensures: PostPersisted()
} }
rule WrappedPreview { rule EditorMode {
when: TuiKeyPressed(code: "e", modifiers: "ctrl") when: TuiKeyPressed(code: "e", modifiers: "ctrl")
-- The editing textarea cannot soft-wrap (upstream ratatui-textarea -- ctrl+e toggles between the syntax-highlighted editor (the default
-- limitation), so ctrl+e toggles a read-only word-wrapped Markdown -- mode when opening a post) and the read-only rendered-Markdown
-- preview of the current draft; keys in preview never edit content. -- preview (rendered through tui-markdown so headings/emphasis/lists
-- are styled); keys in preview never edit content, and esc returns
-- to the sidebar from either mode.
ensures: PreviewToggled() ensures: PreviewToggled()
} }
rule CodeEditorWrapping {
when: TuiState.editing_post.updated()
-- The post editor renders the buffer with syntax highlighting
-- (markdown with [[macro]] accents for posts, HTML+Liquid for
-- templates, Lua for scripts), soft-wraps long lines to the
-- viewport width, keeps the cursor's visual row visible while
-- scrolling, and highlights the cursor's source line. There is no
-- line-number gutter.
ensures: TuiState.updated()
}
rule AiQuickAction { rule AiQuickAction {
when: TuiKeyPressed(code: "g", modifiers: "ctrl") when: TuiKeyPressed(code: "g", modifiers: "ctrl")
-- One-shot AI post analysis (title/excerpt suggestions). Gated by -- One-shot AI post analysis (title/excerpt suggestions). Gated by
@@ -63,6 +79,39 @@ rule AiQuickAction {
ensures: AiSuggestionsRequestedOrRefused() ensures: AiSuggestionsRequestedOrRefused()
} }
rule ProjectSwitcher {
when: TuiKeyPressed(code: "p")
-- "p" opens the projects overlay: all projects from the caching
-- database with the active one marked; enter activates the selected
-- project (BDS.Projects.set_active_project) and reloads the sidebar.
-- "o" switches to a folder-path prompt with bash-style tab
-- completion: tab completes a unique directory (trailing slash
-- appended), an ambiguous prefix completes to the longest common
-- prefix and lists the candidate folders; only directories are
-- offered, dotfolders only for a dot-prefixed input, and a leading
-- "~" expands to the home directory. A typed path is validated
-- to be an existing directory, then opened as a project
-- (BDS.Projects.create_project with data_path, named after the
-- folder) and activated; a full database rebuild is queued
-- automatically through the rebuild_database shell command so the
-- folder's content is loaded into the caching database. Invalid
-- paths keep the prompt open and report the error.
ensures: ProjectSwitchedOrOpened()
}
rule SidebarSearch {
when: TuiKeyPressed(code: "/")
-- "/" in sidebar focus opens a vi-style search prompt in the status
-- line that live-filters the current view through the same
-- BDS.UI.Sidebar filter params the GUI sidebar uses (posts, pages,
-- media). Plain words are a text search, "tag:x" and "category:x"
-- filter like the GUI chips, and an ISO date (yyyy-mm-dd) narrows
-- to that day — the day filter extends the shared sidebar queries.
-- Tokens combine with AND. Enter keeps the filter (shown appended
-- to the sidebar title), esc clears it; filters are per view.
ensures: SidebarFiltered()
}
rule CommandPrompt { rule CommandPrompt {
when: TuiKeyPressed(code: ":") when: TuiKeyPressed(code: ":")
-- Vi-style prompt: ":" opens a filterable list of the parameterless -- Vi-style prompt: ":" opens a filterable list of the parameterless
@@ -77,6 +126,62 @@ rule CommandPrompt {
ensures: CommandListShownOrExecuted() ensures: CommandListShownOrExecuted()
} }
rule SettingsPanel {
when: TuiKeyPressed(code: "6")
-- "6" opens the settings panel (issue #29), matching the GUI panel
-- numbering: the sidebar lists the same preference sections as the
-- GUI settings editor (Project, Editor, Content, AI, Technology,
-- Publishing, Data, MCP, Style — from BDS.UI.Sidebar's settings
-- view). Enter on a section opens a section-specific editor in the
-- main area: a generic typed-field form from BDS.UI.SettingsForm.
-- Enter edits a text field in a status-line prompt, toggles a
-- boolean, or cycles an enum through its options; read-only info
-- rows are skipped by the selection. ctrl+s saves the section
-- through the same backends as the GUI editor (BDS.Metadata,
-- BDS.Settings, BDS.AI, BDS.MCP.AgentConfig) and reloads the form;
-- esc cancels the prompt, then closes the form. Category removal
-- and AI model discovery remain GUI-only.
ensures: SettingsFormShownEditedOrSaved()
}
rule TagsPanel {
when: TuiKeyPressed(code: "5")
-- "5" opens the tags view (issue #34), matching the GUI panel
-- numbering. The sidebar lists the same three sections as the GUI
-- tags editor (Tag Cloud, Create / Edit, Merge Tags — from
-- BDS.UI.Sidebar's tags nav view); enter opens the section in the
-- main area, all backed by BDS.UI.TagsPanel over the same BDS.Tags
-- operations as the GUI editor. Cloud lists tags with their post
-- usage counts ordered by usage; manage is alphabetical with "n"
-- (create prompt), enter (rename prompt), "c" (cycle colour through
-- the shared presets), "t" (cycle post template), "d" then "y"
-- (delete with post-count confirmation; any other key cancels) and
-- "s" (sync tags from post tags); merge marks tags with space and
-- "m" merges the marked tags into the selected one (at least two,
-- target included). Text prompts live in the status line; esc
-- cancels the prompt, then closes the panel. Domain tag events
-- reload an open panel, keeping it in sync with GUI and CLI writes.
ensures: TagsPanelShownOrEdited()
}
rule GitPanel {
when: TuiKeyPressed(code: "7")
-- "7" opens the git panel (issue #30) for content sync: the sidebar
-- lists the changed files from git status (code + path, branch with
-- ahead/behind counts in the title) with the commit history in its
-- lower half, like the GUI (short hash + subject; ↑ marks commits
-- that still need a push, ↓ remote-only ones), the main area shows a
-- scrollable whole-folder diff (staged + unstaged, capped) paged
-- with pgup/pgdn; enter on a file jumps the diff to it. "c" opens a
-- status-line commit prompt (git add -A + commit, empty messages
-- rejected), "u" pulls (ff-only) and "s" pushes — both run off the
-- UI process and report back as a status toast. Every action is
-- BDS.Git, the same backend as the GUI git panel. A project folder
-- that is not a git repository shows a message and refuses the
-- actions.
ensures: GitPanelShownOrSynced()
}
rule ReportPanels { rule ReportPanels {
when: ShellTaskCompleted(route: "metadata_diff" | "site_validation") when: ShellTaskCompleted(route: "metadata_diff" | "site_validation")
-- Completed metadata diff and site validation tasks open a -- Completed metadata diff and site validation tasks open a

401
test/bds/cli_test.exs Normal file
View File

@@ -0,0 +1,401 @@
defmodule BDS.CLITest do
use ExUnit.Case, async: false
import Ecto.Query
import ExUnit.CaptureIO
alias BDS.CLI
alias BDS.CLI.Commands
alias BDS.CliSync.Notification
alias BDS.Repo
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Repo.delete_all(Notification)
temp_dir = Path.join(System.tmp_dir!(), "bds-cli-test-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, temp_dir: temp_dir}
end
defp create_active_project(temp_dir, name \\ "CLI Test") do
{:ok, project} = BDS.Projects.create_project(%{name: name, data_path: temp_dir})
{:ok, project} = BDS.Projects.set_active_project(project.id)
project
end
defp notifications do
Repo.all(Notification)
end
describe "argv decoding" do
test "splits on the unit separator and drops empties" do
assert CLI.env_argv("post" <> <<0x1F>> <> "--title" <> <<0x1F>> <> "Hello" <> <<0x1F>>) ==
["post", "--title", "Hello"]
assert CLI.env_argv(nil) == []
assert CLI.env_argv("") == []
end
end
describe "parser" do
test "parses every subcommand" do
optimus = CLI.optimus()
assert {:ok, [:rebuild], %{flags: %{incremental: true}}} =
Optimus.parse(optimus, ["rebuild", "--incremental"])
assert {:ok, [:repair], %{args: %{part: "post-links"}}} =
Optimus.parse(optimus, ["repair", "post-links"])
assert {:error, [:repair], _errors} = Optimus.parse(optimus, ["repair", "nonsense"])
assert {:ok, [:render], %{flags: %{force: true, incremental: false}}} =
Optimus.parse(optimus, ["render", "--force"])
assert {:ok, [:upload], _result} = Optimus.parse(optimus, ["upload"])
assert {:ok, [:push], _result} = Optimus.parse(optimus, ["push"])
assert {:ok, [:pull], _result} = Optimus.parse(optimus, ["pull"])
assert {:ok, [:post], %{options: %{title: "Hi", tags: "a,b"}}} =
Optimus.parse(optimus, ["post", "--title", "Hi", "--tags", "a,b"])
assert {:ok, [:media], %{args: %{file: "img.jpg"}, options: %{language: "de"}}} =
Optimus.parse(optimus, ["media", "img.jpg", "--language", "de"])
assert {:ok, [:gallery], %{options: %{title: "Trip"}, unknown: ["a.jpg", "b.jpg"]}} =
Optimus.parse(optimus, ["gallery", "--title", "Trip", "a.jpg", "b.jpg"])
assert {:ok, [:config, :get], %{args: %{key: "ui.theme"}}} =
Optimus.parse(optimus, ["config", "get", "ui.theme"])
assert {:ok, [:config, :set], %{args: %{key: "k", value: "v"}}} =
Optimus.parse(optimus, ["config", "set", "k", "v"])
assert {:ok, [:config, :list], _result} = Optimus.parse(optimus, ["config", "list"])
assert {:ok, [:project, :list], _result} = Optimus.parse(optimus, ["project", "list"])
assert {:ok, [:project, :add], %{args: %{path: "/tmp/x"}, options: %{name: "X"}}} =
Optimus.parse(optimus, ["project", "add", "/tmp/x", "--name", "X"])
assert {:ok, [:project, :switch], %{args: %{project: "blog"}}} =
Optimus.parse(optimus, ["project", "switch", "blog"])
assert {:ok, [:tui], _result} = Optimus.parse(optimus, ["tui"])
assert {:ok, [:lua], %{args: %{script: "cleanup"}, unknown: ["arg1"]}} =
Optimus.parse(optimus, ["lua", "cleanup", "arg1"])
end
test "help exits 0 and unknown commands exit 1" do
{help_code, help_output} = with_io(fn -> CLI.run(["--help"]) end)
assert help_code == 0
assert help_output =~ "rebuild"
assert help_output =~ "gallery"
{code, _output} = with_io(:stderr, fn -> CLI.run(["frobnicate"]) end)
assert code == 1
end
end
describe "config commands" do
test "set, get, and list operate on the global settings store", %{temp_dir: _temp_dir} do
assert {:ok, "cli.test.key = hello"} =
Commands.dispatch([:config, :set], %{args: %{key: "cli.test.key", value: "hello"}})
assert {:ok, "hello"} =
Commands.dispatch([:config, :get], %{args: %{key: "cli.test.key"}})
{:ok, output} = with_io(fn -> Commands.dispatch([:config, :list], %{}) end)
assert output =~ "cli.test.key=hello"
assert {:error, message} =
Commands.dispatch([:config, :get], %{args: %{key: "cli.test.missing"}})
assert message =~ "not set"
assert Enum.any?(
notifications(),
&(&1.entity_type == "setting" and &1.entity_id == "cli.test.key" and
&1.from_cli == true)
)
end
end
describe "project commands" do
test "add, switch, and list manage the project registry", %{temp_dir: temp_dir} do
assert {:ok, added_message} =
Commands.dispatch([:project, :add], %{
args: %{path: temp_dir},
options: %{name: "CLI Added"}
})
assert added_message =~ "Added project"
project =
Enum.find(BDS.Projects.list_projects(), &(&1.name == "CLI Added"))
assert project
assert {:ok, "Active project: CLI Added"} =
Commands.dispatch([:project, :switch], %{args: %{project: project.slug}})
{:ok, output} = with_io(fn -> Commands.dispatch([:project, :list], %{}) end)
assert output =~ "CLI Added"
assert output =~ "* " <> project.id
assert {:error, message} =
Commands.dispatch([:project, :switch], %{args: %{project: "no-such-project"}})
assert message =~ "No project matches"
entity_types = Enum.map(notifications(), & &1.entity_type)
assert "project" in entity_types
end
test "add rejects a missing directory" do
assert {:error, message} =
Commands.dispatch([:project, :add], %{
args: %{path: "/nonexistent/bds-cli"},
options: %{}
})
assert message =~ "not a directory"
end
end
describe "post command" do
test "creates a post from options and writes a cli notification", %{temp_dir: temp_dir} do
create_active_project(temp_dir)
{{:ok, message}, output} =
with_io(fn ->
Commands.dispatch([:post], %{
flags: %{no_translate: true},
options: %{
title: "From the CLI",
content: "Hello from the command line",
language: "en",
tags: "cli,test",
categories: "notes"
}
})
end)
assert message =~ "Created post"
assert output == "" or output =~ "translation"
post =
Repo.one!(
from post in BDS.Posts.Post, where: post.title == "From the CLI"
)
assert post.tags == ["cli", "test"]
assert post.categories == ["notes"]
assert post.language == "en"
assert post.status == :draft
assert Enum.any?(
notifications(),
&(&1.entity_type == "post" and &1.entity_id == post.id and &1.action == :created)
)
end
test "creates a post from JSON on stdin", %{temp_dir: temp_dir} do
create_active_project(temp_dir)
json =
Jason.encode!(%{
"title" => "Stdin Post",
"content" => "Body from stdin",
"language" => "en",
"tags" => ["json", "cli"]
})
{{:ok, message}, _output} =
with_io([input: json], fn ->
Commands.dispatch([:post], %{flags: %{stdin: true, no_translate: true}, options: %{}})
end)
assert message =~ "Created post"
post = Repo.one!(from post in BDS.Posts.Post, where: post.title == "Stdin Post")
assert post.tags == ["json", "cli"]
end
test "requires a title", %{temp_dir: temp_dir} do
create_active_project(temp_dir)
assert {:error, message} = Commands.dispatch([:post], %{flags: %{}, options: %{}})
assert message =~ "--title is required"
end
end
describe "media command" do
test "imports a file and reports enrichment best-effort", %{temp_dir: temp_dir} do
create_active_project(temp_dir)
source = Path.join(temp_dir, "cli-image.png")
File.write!(source, "fake png bytes")
{result, _output} =
with_io(fn ->
Commands.dispatch([:media], %{args: %{file: source}, options: %{}})
end)
assert {:ok, message} = result
assert message =~ "Imported media"
assert Enum.any?(notifications(), &(&1.entity_type == "media" and &1.action == :created))
end
test "rejects a missing file", %{temp_dir: temp_dir} do
create_active_project(temp_dir)
assert {:error, message} =
Commands.dispatch([:media], %{
args: %{file: Path.join(temp_dir, "missing.png")},
options: %{}
})
assert message =~ "No such file"
end
end
describe "rebuild command" do
test "incremental rebuild diffs and applies from the filesystem", %{temp_dir: temp_dir} do
create_active_project(temp_dir)
{result, _output} =
with_io(fn ->
Commands.dispatch([:rebuild], %{flags: %{incremental: true}})
end)
assert {:ok, message} = result
assert message =~ "differences"
end
end
describe "gallery and tui guards" do
test "gallery without images errors", %{temp_dir: temp_dir} do
create_active_project(temp_dir)
assert {:error, message} =
Commands.dispatch([:gallery], %{
flags: %{},
options: %{title: "Trip"},
unknown: []
})
assert message =~ "image files"
end
test "tui is delegated to the launcher script" do
assert {:error, message} = Commands.dispatch([:tui], %{})
assert message =~ "launcher"
end
end
describe "lua command" do
test "rejects unknown and non-utility scripts", %{temp_dir: temp_dir} do
project = create_active_project(temp_dir)
assert {:error, message} =
Commands.dispatch([:lua], %{args: %{script: "missing"}, unknown: []})
assert message =~ "No script"
{:ok, script} =
BDS.Scripts.create_script(%{
project_id: project.id,
title: "Macro Script",
kind: :macro,
content: "function render() return \"x\" end",
entrypoint: "render"
})
assert {:error, macro_message} =
Commands.dispatch([:lua], %{args: %{script: script.slug}, unknown: []})
assert macro_message =~ "macro"
end
test "runs a utility script synchronously", %{temp_dir: temp_dir} do
project = create_active_project(temp_dir)
{:ok, script} =
BDS.Scripts.create_script(%{
project_id: project.id,
title: "Task Script",
kind: :utility,
content: "function main() return \"done-42\" end",
entrypoint: "main"
})
assert {:ok, message} =
Commands.dispatch([:lua], %{args: %{script: script.slug}, unknown: []})
assert message =~ "done-42"
end
end
describe "full rebuild step sharing" do
test "the CLI and GUI share the canonical full-rebuild sequence" do
names = Enum.map(BDS.Maintenance.full_rebuild_steps("project-id"), & &1.name)
assert names == [
"Rebuild Posts From Files",
"Rebuild Media From Files",
"Rebuild Scripts From Files",
"Rebuild Templates From Files",
"Rebuild Post Links",
"Regenerate Missing Thumbnails",
"Rebuild Embedding Index"
]
end
end
describe "installer" do
test "installs a shim pointing at the release launcher", %{temp_dir: temp_dir} do
release_root = Path.join(temp_dir, "release")
launcher = Path.join([release_root, "cli", "bin", "bds-cli"])
File.mkdir_p!(Path.dirname(launcher))
File.write!(launcher, "#!/bin/sh\n")
bin_dir = Path.join(temp_dir, "local-bin")
assert {:ok, installed} =
BDS.CLI.Install.install(bin_dir: bin_dir, release_root: release_root)
assert installed == Path.join(bin_dir, "bds-cli")
content = File.read!(installed)
assert content =~ launcher
assert content =~ ~s(exec ")
assert Bitwise.band(File.stat!(installed).mode, 0o111) != 0
end
test "reports no_release outside a packaged release", %{temp_dir: temp_dir} do
assert {:error, :no_release} =
BDS.CLI.Install.install(
bin_dir: Path.join(temp_dir, "bin"),
release_root: Path.join(temp_dir, "not-a-release")
)
end
end
describe "settings form action" do
test "the data section exposes the install action and run_action handles it" do
fields = BDS.UI.SettingsForm.load("data", "nonexistent-project").fields
action = Enum.find(fields, &(&1.type == :action))
assert action.key == "install_cli"
# Outside a packaged release the action reports the error message.
assert {:error, message} = BDS.UI.SettingsForm.run_action("install_cli")
assert message =~ "packaged application"
assert {:error, _unknown} = BDS.UI.SettingsForm.run_action("nope")
end
end
end

View File

@@ -129,6 +129,31 @@ defmodule BDS.Desktop.AutomationTest do
assert automation_process_counts() == baseline assert automation_process_counts() == baseline
end end
@tag timeout: 120_000
test "cmd-J panel tab strip is visible and switches between tasks and output (issue #20)" do
{:ok, session} = Automation.start_session()
on_exit(fn ->
Automation.stop_session(session)
end)
assert :ok = Automation.press(session, "Meta+J")
snapshot = await(session, & &1.panel_visible)
assert snapshot.panel_visible == true
# The tab strip must offer both tabs and be tall enough to actually see
# and hit them — issue #20 was the strip collapsing to an unusable sliver.
assert "Tasks" in snapshot.panel_tab_labels
assert "Output" in snapshot.panel_tab_labels
assert snapshot.panel_tabs_height >= 20
assert :ok = Automation.click(session, "button[phx-value-tab='output']")
snapshot = await(session, &(&1.panel_active_tab == "Output"))
assert snapshot.panel_active_tab == "Output"
end
@tag timeout: 120_000 @tag timeout: 120_000
test "automation dispatches native menu actions into the liveview shell" do test "automation dispatches native menu actions into the liveview shell" do
{:ok, session} = Automation.start_session() {:ok, session} = Automation.start_session()

View File

@@ -907,6 +907,120 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-id="#{created_post.id}") assert html =~ ~s(data-tab-id="#{created_post.id}")
end end
test "tasks panel shows a cancel button for running tasks and cancels them" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
{:ok, task} =
BDS.Tasks.submit_task("Long Running Task", fn _report ->
receive do
:finish -> :ok
after
30_000 -> :ok
end
end)
send(view.pid, :refresh_task_status)
html = render_click(view, "select_panel_tab", %{"tab" => "tasks"})
assert html =~ "Long Running Task"
assert html =~ ~s(data-testid="cancel-task-button")
view
|> element(~s{[data-testid="cancel-task-button"]})
|> render_click()
assert Enum.find(BDS.Tasks.list_tasks(), &(&1.id == task.id)).status == :cancelled
end
test "tasks panel groups tasks by group_id and toggles the group collapsed" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
blocking = fn _report ->
receive do
:finish -> :ok
after
30_000 -> :ok
end
end
{:ok, _t1} = BDS.Tasks.submit_task("First Grouped", blocking, %{group_id: "grp", group_name: "Batch"})
{:ok, _t2} = BDS.Tasks.submit_task("Second Grouped", blocking, %{group_id: "grp", group_name: "Batch"})
send(view.pid, :refresh_task_status)
html = render_click(view, "select_panel_tab", %{"tab" => "tasks"})
# One group row for both tasks, expanded by default (task names also
# appear in the status bar, hence the <strong> row scoping)
assert html =~ "Batch (2)"
assert html =~ "<strong>First Grouped</strong>"
assert html =~ "<strong>Second Grouped</strong>"
html = render_click(view, "toggle_task_group", %{"group" => "grp"})
assert html =~ "Batch (2)"
refute html =~ "<strong>First Grouped</strong>"
refute html =~ "<strong>Second Grouped</strong>"
html = render_click(view, "toggle_task_group", %{"group" => "grp"})
assert html =~ "<strong>First Grouped</strong>"
assert html =~ "<strong>Second Grouped</strong>"
end
test "output panel copy button pushes the full log text to the clipboard" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
send(view.pid, {:editor_output, "First", "line one", nil, "info"})
send(view.pid, {:editor_output, "Second", "line two", nil, "error"})
html = render_click(view, "select_panel_tab", %{"tab" => "output"})
assert html =~ ~s(data-testid="copy-output-button")
view
|> element(~s{[data-testid="copy-output-button"]})
|> render_click()
# Chronological order (oldest first), entries joined by a blank line.
assert_push_event(view, "clipboard", %{text: "line one\n\nline two"})
end
test "blogmark transform output lands in the output panel and auto-opens it", %{
project: project
} do
{:ok, _script} =
Scripts.create_and_publish_script(%{
project_id: project.id,
title: "Logging Transform",
kind: :transform,
entrypoint: "main",
content:
"function main(data, ctx) bds.app.log('transform ran') print('still ran') return data end"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
url =
"bds2://new-post?title=" <>
URI.encode_www_form("Logged Blogmark") <>
"&url=" <> URI.encode_www_form("https://example.com/logged")
send(view.pid, {:blogmark_deep_link, url})
_html = render(view)
assigns = :sys.get_state(view.pid).socket.assigns
messages = Enum.map(assigns.output_entries, & &1.message)
assert "transform ran" in messages
assert "still ran" in messages
# Like the old app: noteworthy script output opens the panel on Output.
assert assigns.workbench.panel.visible == true
assert assigns.workbench.panel.active_tab == :output
end
test "blogmark deep link with a project_id switches to that project and imports there", test "blogmark deep link with a project_id switches to that project and imports there",
%{project: active} do %{project: active} do
{:ok, other} = {:ok, other} =
@@ -3427,6 +3541,115 @@ defmodule BDS.Desktop.ShellLiveTest do
assert output_html =~ "Automatic AI actions stay gated by airplane mode" assert output_html =~ "Automatic AI actions stay gated by airplane mode"
end end
test "selecting an internal insert link result pushes the link and closes the overlay", %{
project: project
} do
{:ok, target} =
Posts.create_post(%{project_id: project.id, title: "Link Target", content: "Target body"})
{:ok, post} =
Posts.create_post(%{project_id: project.id, title: "Editing Post", content: "Body"})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => "draft"
})
html =
view
|> element("[phx-click='open_overlay'][phx-value-kind='insert_link']")
|> render_click()
assert html =~ "insert-modal"
render_click(view, "overlay_select_result", %{"id" => target.id})
assert_push_event(view, "post-editor-insert-content", %{id: _id, content: content})
assert content =~ "Link Target"
refute render(view) =~ "insert-modal"
end
test "inserting an external link pushes the link and closes the overlay", %{project: project} do
{:ok, post} =
Posts.create_post(%{project_id: project.id, title: "Editing Post", content: "Body"})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => "draft"
})
html =
view
|> element("[phx-click='open_overlay'][phx-value-kind='insert_link']")
|> render_click()
assert html =~ "insert-modal"
render_click(view, "overlay_set_tab", %{"tab" => "external"})
render_change(view, "overlay_update_form", %{
"overlay" => %{"url" => "https://example.com", "text" => "Example"}
})
render_click(view, "overlay_insert_external", %{})
assert_push_event(view, "post-editor-insert-content", %{content: "[Example](https://example.com)"})
refute render(view) =~ "insert-modal"
end
test "selecting an insert media result pushes the syntax and closes the overlay", %{
project: project
} do
temp_dir =
Path.join(System.tmp_dir!(), "bds-shell-live-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
media_source_path = Path.join(temp_dir, "insert-media.jpg")
File.write!(media_source_path, "fake image body")
{:ok, media} =
Media.import_media(%{
project_id: project.id,
source_path: media_source_path,
title: "Insert Media"
})
{:ok, post} =
Posts.create_post(%{project_id: project.id, title: "Editing Post", content: "Body"})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => "draft"
})
html =
view
|> element("[phx-click='open_overlay'][phx-value-kind='insert_media']")
|> render_click()
assert html =~ "insert-modal"
render_click(view, "overlay_set_search", %{"overlay" => %{"query" => "Insert"}})
render_click(view, "overlay_select_result", %{"id" => media.id})
assert_push_event(view, "post-editor-insert-content", %{content: content})
assert content =~ "bds-media://#{media.id}"
refute render(view) =~ "insert-modal"
end
test "ai suggestions overlay fetches async results for posts when online", %{project: project} do test "ai suggestions overlay fetches async results for posts when online", %{project: project} do
Application.put_env(:bds, :test_pid, self()) Application.put_env(:bds, :test_pid, self())
@@ -5943,6 +6166,38 @@ defmodule BDS.Desktop.ShellLiveTest do
assert reloaded.version == 1 assert reloaded.version == 1
end end
test "script editor run streams print and bds.app.log output to the output panel", %{
project: project
} do
{:ok, script} =
Scripts.create_script(%{
project_id: project.id,
title: "Output Test",
kind: :utility,
content:
"function main() print('from-print') bds.app.log('from-log') return 'done' end"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
render_click(view, "pin_sidebar_item", %{
"route" => "scripts",
"id" => script.id,
"title" => script.title,
"subtitle" => "draft"
})
view
|> element(".scripts-run-button")
|> render_click()
entries = :sys.get_state(view.pid).socket.assigns.output_entries
messages = Enum.map(entries, & &1.message)
assert "from-print" in messages
assert "from-log" in messages
end
test "script editor check syntax validates lua content without saving", %{project: project} do test "script editor check syntax validates lua content without saving", %{project: project} do
{:ok, script} = {:ok, script} =
Scripts.create_script(%{ Scripts.create_script(%{

View File

@@ -38,29 +38,45 @@ defmodule BDS.Embeddings.Backends.NeuralTest do
end end
describe "accelerator selection (NativeAcceleratedExecution)" do describe "accelerator selection (NativeAcceleratedExecution)" do
# select_accelerator(configured, emlx_available?, exla_available?, apple_silicon?)
test "auto prefers Apple GPU (EMLX) when available on Apple Silicon" do test "auto prefers Apple GPU (EMLX) when available on Apple Silicon" do
assert Neural.select_accelerator(:auto, true, true) == :emlx assert Neural.select_accelerator(:auto, true, true, true) == :emlx
end end
test "auto falls back to EXLA-CPU off Apple Silicon" do test "auto falls back to EXLA-CPU off Apple Silicon" do
assert Neural.select_accelerator(:auto, true, false) == :exla assert Neural.select_accelerator(:auto, true, true, false) == :exla
end end
test "auto falls back to EXLA-CPU when EMLX is unavailable" do test "auto falls back to EXLA-CPU when EMLX is unavailable" do
assert Neural.select_accelerator(:auto, false, true) == :exla assert Neural.select_accelerator(:auto, false, true, true) == :exla
end
test "auto uses EMLX when EXLA is not shipped (pruned macOS bundle)" do
assert Neural.select_accelerator(:auto, true, false, true) == :emlx
assert Neural.select_accelerator(:auto, true, false, false) == :emlx
end end
test "explicit :exla is honoured even on Apple Silicon with EMLX present" do test "explicit :exla is honoured even on Apple Silicon with EMLX present" do
assert Neural.select_accelerator(:exla, true, true) == :exla assert Neural.select_accelerator(:exla, true, true, true) == :exla
end
test "explicit :exla degrades to EMLX when EXLA is unavailable" do
assert Neural.select_accelerator(:exla, true, false, true) == :emlx
end end
test "explicit :emlx is honoured when available" do test "explicit :emlx is honoured when available" do
assert Neural.select_accelerator(:emlx, true, true) == :emlx assert Neural.select_accelerator(:emlx, true, true, true) == :emlx
assert Neural.select_accelerator(:emlx, true, false) == :emlx assert Neural.select_accelerator(:emlx, true, true, false) == :emlx
end end
test "explicit :emlx degrades to EXLA when EMLX is unavailable" do test "explicit :emlx degrades to EXLA when EMLX is unavailable" do
assert Neural.select_accelerator(:emlx, false, true) == :exla assert Neural.select_accelerator(:emlx, false, true, true) == :exla
end
test "no accelerator available selects :none" do
assert Neural.select_accelerator(:auto, false, false, true) == :none
assert Neural.select_accelerator(:exla, false, false, false) == :none
assert Neural.select_accelerator(:emlx, false, false, false) == :none
end end
test "defn options map each accelerator to its native compiler" do test "defn options map each accelerator to its native compiler" do

View File

@@ -1473,6 +1473,123 @@ defmodule BDS.GenerationTest do
) )
end end
test "validate_site reports no missing pages for main-language translations of foreign-language posts",
%{project: project, temp_dir: temp_dir} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog",
main_language: "de",
blog_languages: ["de", "en"]
})
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "English Original",
content: "English body",
language: "en"
})
created_at = DateTime.to_unix(~U[2026-04-15 12:00:00Z])
Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^post.id),
set: [created_at: created_at, updated_at: created_at]
)
assert {:ok, _translation} =
Posts.upsert_post_translation(post.id, "de", %{
title: "Deutsches Original",
content: "Deutscher Inhalt"
})
assert {:ok, published_post} = Posts.publish_post(post.id)
assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:core, :single])
assert {:ok, report} = BDS.Generation.validate_site(project.id, [:core, :single])
assert report.missing_url_paths == []
assert report.extra_url_paths == []
assert report.updated_post_url_paths == []
canonical_html =
File.read!(
Path.join([temp_dir, "html", BDS.Generation.post_output_path(published_post)])
)
english_html =
File.read!(
Path.join([temp_dir, "html", BDS.Generation.post_output_path(published_post, "en")])
)
assert canonical_html =~ "Deutscher Inhalt"
assert english_html =~ "English body"
sitemap_xml = File.read!(Path.join([temp_dir, "html", "sitemap.xml"]))
refute sitemap_xml =~ "english-original.de"
end
test "main-language list pages and feeds use main-language translations of foreign-language posts",
%{project: project, temp_dir: temp_dir} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog",
main_language: "de",
blog_languages: ["de", "en"]
})
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "English List Original",
content: "English list body",
language: "en",
categories: ["notes"],
tags: ["Elixir"]
})
created_at = DateTime.to_unix(~U[2026-04-15 12:00:00Z])
Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^post.id),
set: [created_at: created_at, updated_at: created_at]
)
assert {:ok, _translation} =
Posts.upsert_post_translation(post.id, "de", %{
title: "Deutsche Listenfassung",
content: "Deutscher Listeninhalt"
})
assert {:ok, _published_post} = Posts.publish_post(post.id)
assert {:ok, _result} =
BDS.Generation.generate_site(project.id, [:core, :single, :category, :tag, :date])
read_output = fn segments -> File.read!(Path.join([temp_dir, "html" | segments])) end
for segments <- [
["index.html"],
["rss.xml"],
["atom.xml"],
["category", "notes", "index.html"],
["tag", "Elixir", "index.html"],
["2026", "index.html"]
] do
html = read_output.(segments)
assert html =~ "Deutsche Listenfassung", "expected translated title in #{Path.join(segments)}"
refute html =~ "English List Original", "unexpected original title in #{Path.join(segments)}"
end
for segments <- [
["en", "index.html"],
["en", "rss.xml"],
["en", "category", "notes", "index.html"]
] do
html = read_output.(segments)
assert html =~ "English List Original", "expected original title in #{Path.join(segments)}"
refute html =~ "Deutsche Listenfassung", "unexpected translated title in #{Path.join(segments)}"
end
end
test "validate_site follows old language subtree expectations and combined sitemap output", %{ test "validate_site follows old language subtree expectations and combined sitemap output", %{
project: project, project: project,
temp_dir: temp_dir temp_dir: temp_dir

View File

@@ -7,6 +7,19 @@ defmodule BDS.MacBundleTest do
alias BDS.MacBundle.Dylibs alias BDS.MacBundle.Dylibs
alias BDS.MacBundle.Icon alias BDS.MacBundle.Icon
# Give a copied keg dylib a controlled id so verify_standalone only sees the
# reference under test, not the keg's absolute LC_ID_DYLIB.
defp fix_id!(dylib, id \\ nil) do
{_out, 0} =
System.cmd(
"install_name_tool",
Dylibs.id_args(dylib, id || "@loader_path/" <> Path.basename(dylib)),
stderr_to_stdout: true
)
:ok
end
# Parse "#RRGGBB" into {r, g, b} for property assertions in tests. # Parse "#RRGGBB" into {r, g, b} for property assertions in tests.
defp rgb("#" <> hex) do defp rgb("#" <> hex) do
{r, g, b} = {String.slice(hex, 0, 2), String.slice(hex, 2, 2), String.slice(hex, 4, 2)} {r, g, b} = {String.slice(hex, 0, 2), String.slice(hex, 2, 2), String.slice(hex, 4, 2)}
@@ -176,6 +189,116 @@ defmodule BDS.MacBundleTest do
end end
end end
describe "Dylibs.resolve_rpath_dep/3" do
test "expands @loader_path rpaths against the referencing binary's directory" do
assert Dylibs.resolve_rpath_dep(
"@rpath/libsharpyuv.0.dylib",
["@loader_path/../lib", "/opt/homebrew/lib"],
"/opt/homebrew/opt/webp/lib"
) == [
"/opt/homebrew/opt/webp/lib/libsharpyuv.0.dylib",
"/opt/homebrew/lib/libsharpyuv.0.dylib"
]
end
test "resolves in-tree loader-relative rpaths (vix/exla precompiled layout)" do
assert Dylibs.resolve_rpath_dep(
"@rpath/libvips.42.dylib",
["@loader_path/precompiled_libvips/lib"],
"/app/rel/lib/vix-0.38.0/priv"
) == ["/app/rel/lib/vix-0.38.0/priv/precompiled_libvips/lib/libvips.42.dylib"]
end
test "skips @executable_path rpaths (not resolvable from a library) and non-@rpath deps" do
assert Dylibs.resolve_rpath_dep(
"@rpath/libfoo.dylib",
["@executable_path/../Frameworks"],
"/any/dir"
) == []
assert Dylibs.resolve_rpath_dep("/usr/lib/libSystem.B.dylib", ["@loader_path"], "/d") == []
end
end
describe "Dylibs.relink_changes/3 (@rpath deps)" do
test "rewrites @rpath deps whose basename was bundled, leaves the rest" do
deps = [
"/opt/homebrew/opt/webp/lib/libwebp.7.dylib",
"@rpath/libsharpyuv.0.dylib",
"@rpath/libvips.42.dylib",
"/usr/lib/libSystem.B.dylib"
]
assert Dylibs.relink_changes(deps, "@loader_path", ["libsharpyuv.0.dylib"]) == [
{"/opt/homebrew/opt/webp/lib/libwebp.7.dylib", "@loader_path/libwebp.7.dylib"},
{"@rpath/libsharpyuv.0.dylib", "@loader_path/libsharpyuv.0.dylib"}
]
end
end
describe "MacBundle.verify_standalone/1 (@rpath resolution gate)" do
@describetag :macos_tools
# Regression for the July 2026 crash: Homebrew's libwebp references its
# sibling as @rpath/libsharpyuv.0.dylib (rpath @loader_path/../lib). A copy
# in Frameworks without the sibling must fail verification; a copy whose
# rpath resolves inside the tree must pass.
test "fails on an @rpath dep that does not resolve inside the bundle and passes when it does" do
keg_webp = "/opt/homebrew/opt/webp/lib/libwebp.7.dylib"
keg_sharpyuv = "/opt/homebrew/opt/webp/lib/libsharpyuv.0.dylib"
if File.exists?(keg_webp) and File.exists?(keg_sharpyuv) do
tmp = Path.join(System.tmp_dir!(), "bds-rpath-#{System.unique_integer([:positive])}")
on_exit(fn -> File.rm_rf!(tmp) end)
# Broken layout: libwebp in Frameworks, libsharpyuv missing entirely.
broken = Path.join(tmp, "broken/Contents/Frameworks")
File.mkdir_p!(broken)
File.cp!(keg_webp, Path.join(broken, "libwebp.7.dylib"))
File.chmod!(Path.join(broken, "libwebp.7.dylib"), 0o644)
fix_id!(Path.join(broken, "libwebp.7.dylib"))
assert {:error, {:external_refs, offenders}} =
MacBundle.verify_standalone(Path.join(tmp, "broken"))
assert Enum.any?(offenders, fn {_file, ref} -> ref == "@rpath/libsharpyuv.0.dylib" end)
# Healthy layout: @loader_path/../lib resolves next to the library, so
# placing the sibling there satisfies the reference (vix-style in-tree
# resolution must not be flagged). libsharpyuv keeps an @rpath/ self id
# — precompiled NIF deps (hnswlib, libvips, libmlx) ship exactly that,
# and a library's own LC_ID_DYLIB must never count as unresolved.
healthy = Path.join(tmp, "healthy/Contents/pkg/lib")
File.mkdir_p!(healthy)
File.cp!(keg_webp, Path.join(healthy, "libwebp.7.dylib"))
File.cp!(keg_sharpyuv, Path.join(healthy, "libsharpyuv.0.dylib"))
Enum.each(["libwebp.7.dylib", "libsharpyuv.0.dylib"], fn base ->
File.chmod!(Path.join(healthy, base), 0o644)
end)
fix_id!(Path.join(healthy, "libwebp.7.dylib"))
fix_id!(Path.join(healthy, "libsharpyuv.0.dylib"), "@rpath/libsharpyuv.0.dylib")
# Match the precompiled-NIF shape exactly: @rpath/ self id with NO
# LC_RPATH left to resolve it (hnswlib_nif.so ships like this).
sharpyuv = Path.join(healthy, "libsharpyuv.0.dylib")
{rpath_out, 0} = System.cmd("otool", ["-l", sharpyuv], stderr_to_stdout: true)
Enum.each(Dylibs.parse_rpaths(rpath_out), fn rpath ->
{_out, 0} =
System.cmd("install_name_tool", ["-delete_rpath", rpath, sharpyuv],
stderr_to_stdout: true
)
end)
assert MacBundle.verify_standalone(Path.join(tmp, "healthy")) == :ok
else
:ok
end
end
end
describe "Dylibs.parse_rpaths/1" do describe "Dylibs.parse_rpaths/1" do
test "extracts LC_RPATH paths from otool -l output, ignoring other load commands" do test "extracts LC_RPATH paths from otool -l output, ignoring other load commands" do
output = """ output = """
@@ -313,6 +436,8 @@ defmodule BDS.MacBundleTest do
File.write!(Path.join(release, "bin/bds"), "#!/bin/sh\necho fake release\n") File.write!(Path.join(release, "bin/bds"), "#!/bin/sh\necho fake release\n")
File.mkdir_p!(Path.join(release, "lib/wx-2.4/priv")) File.mkdir_p!(Path.join(release, "lib/wx-2.4/priv"))
File.write!(Path.join(release, "lib/wx-2.4/priv/wxe_driver.so"), "fake-nif") File.write!(Path.join(release, "lib/wx-2.4/priv/wxe_driver.so"), "fake-nif")
File.mkdir_p!(Path.join(release, "releases/0.1.0"))
File.write!(Path.join(release, "releases/0.1.0/env.sh"), "# env")
icns = Path.join(tmp, "AppIcon.icns") icns = Path.join(tmp, "AppIcon.icns")
File.write!(icns, "fake-icns") File.write!(icns, "fake-icns")
@@ -366,5 +491,28 @@ defmodule BDS.MacBundleTest do
assert File.exists?(Path.join(app, "Contents/Resources/rel/bin/bds")) assert File.exists?(Path.join(app, "Contents/Resources/rel/bin/bds"))
assert File.exists?(Path.join(app, "Contents/Resources/AppIcon.icns")) assert File.exists?(Path.join(app, "Contents/Resources/AppIcon.icns"))
end end
test "refuses a half-assembled release and keeps the previous bundle", %{app: app} do
# A failed `mix release` leaves lib/ but no releases/<version>/env.sh.
tmp = Path.join(System.tmp_dir!(), "bds-macbundle-broken-#{System.unique_integer([:positive])}")
broken = Path.join(tmp, "rel")
File.mkdir_p!(Path.join(broken, "bin"))
File.mkdir_p!(Path.join(broken, "lib/wx-2.4/priv"))
on_exit(fn -> File.rm_rf!(tmp) end)
assert {:error, {:incomplete_release, message}} =
MacBundle.assemble(
release_dir: broken,
output_dir: Path.dirname(app),
icns_path: Path.join(tmp, "missing.icns"),
version: "0.1.0",
skip_dylibs: true,
skip_codesign: true
)
assert message =~ "mix release"
# the refused build must not have wiped the existing bundle
assert File.exists?(Path.join(app, "Contents/Resources/rel/bin/bds"))
end
end end
end end

View File

@@ -54,6 +54,27 @@ defmodule BDS.MetadataTest do
assert loaded.blog_languages == ["de", "fr"] assert loaded.blog_languages == ["de", "fr"]
end end
test "update_project_metadata keeps fields that are absent from the attrs", %{
project: project
} do
assert {:ok, _metadata} =
BDS.Metadata.update_project_metadata(project.id, %{
description: "Keep me",
public_url: "https://example.com",
default_author: "Writer",
blog_languages: ["de"]
})
assert {:ok, metadata} =
BDS.Metadata.update_project_metadata(project.id, %{pico_theme: "blue"})
assert metadata.pico_theme == "blue"
assert metadata.description == "Keep me"
assert metadata.public_url == "https://example.com"
assert metadata.default_author == "Writer"
assert metadata.blog_languages == ["de"]
end
test "update_project_metadata keeps committed database changes when filesystem flush fails", %{ test "update_project_metadata keeps committed database changes when filesystem flush fails", %{
project: project, project: project,
temp_dir: temp_dir temp_dir: temp_dir

View File

@@ -15,6 +15,8 @@ defmodule BDS.ReleasePackagingTest do
File.mkdir_p!(Path.join(mcp_release, "mcp/bin")) File.mkdir_p!(Path.join(mcp_release, "mcp/bin"))
File.write!(Path.join(app_release, "bin/bds"), "app-release") File.write!(Path.join(app_release, "bin/bds"), "app-release")
File.write!(Path.join(mcp_release, "mcp/bin/bds-mcp"), "mcp-release") File.write!(Path.join(mcp_release, "mcp/bin/bds-mcp"), "mcp-release")
File.mkdir_p!(Path.join(app_release, "releases/0.1.0"))
File.write!(Path.join(app_release, "releases/0.1.0/env.sh"), "# env")
on_exit(fn -> File.rm_rf(base_dir) end) on_exit(fn -> File.rm_rf(base_dir) end)
@@ -66,6 +68,23 @@ defmodule BDS.ReleasePackagingTest do
} }
end end
test "package refuses a half-assembled release (no releases/*/env.sh)", context do
# A failed `mix release` can leave lib/ copied but no releases/<version>/;
# packaging that produces an app that dies on launch.
File.rm_rf!(Path.join(context.app_release, "releases"))
assert {:error, {:incomplete_release, message}} =
ReleasePackaging.package(
platform: :macos,
version: "0.1.0",
output_dir: context.output_dir,
app_release_source: context.app_release,
mcp_release_source: context.mcp_release
)
assert message =~ "mix release"
end
test "package creates a platform archive for redistribution", context do test "package creates a platform archive for redistribution", context do
assert {:ok, metadata} = assert {:ok, metadata} =
ReleasePackaging.package( ReleasePackaging.package(

View File

@@ -34,6 +34,57 @@ defmodule BDS.Scripting.LuaTest do
assert_receive {:progress, %{"phase" => "write", "current" => 2, "total" => 2}} assert_receive {:progress, %{"phase" => "write", "current" => 2, "total" => 2}}
end end
test "streams bds.app.log output through the on_output callback" do
parent = self()
source = """
function main()
bds.app.log('fetching')
bds.app.log('done', 42)
return 'ok'
end
"""
callback = fn text -> send(parent, {:script_output, text}) end
assert {:ok, "ok"} =
BDS.Scripting.execute_project_script("project-id", source, "main", [],
on_output: callback
)
assert_receive {:script_output, "fetching"}
assert_receive {:script_output, "done 42"}
end
test "routes print calls through the on_output callback" do
parent = self()
source = """
function main()
print('hello', 'world')
return 'ok'
end
"""
callback = fn text -> send(parent, {:script_output, text}) end
assert {:ok, "ok"} = BDS.Scripting.execute(source, "main", [], on_output: callback)
assert_receive {:script_output, "hello world"}
end
test "log and print fall back to a silent sink without an on_output callback" do
source = """
function main()
bds.app.log('note')
print('hello')
return 'ok'
end
"""
assert {:ok, "ok"} = BDS.Scripting.execute_project_script("project-id", source, "main", [])
end
test "sandbox blocks os.execute" do test "sandbox blocks os.execute" do
source = "function main() os.execute('echo hacked') end" source = "function main() os.execute('echo hacked') end"

View File

@@ -23,6 +23,157 @@ defmodule BDS.ServerTest do
end end
end end
describe "effective_mode/3" do
test "desktop stays desktop when a display is available" do
assert Server.effective_mode(:desktop, {:unix, :linux}, %{"DISPLAY" => ":0"}) == :desktop
assert Server.effective_mode(:desktop, {:unix, :linux}, %{"WAYLAND_DISPLAY" => "wayland-0"}) ==
:desktop
end
test "desktop falls back to the TUI on unix without a display" do
assert Server.effective_mode(:desktop, {:unix, :linux}, %{}) == :tui
assert Server.effective_mode(:desktop, {:unix, :linux}, %{"DISPLAY" => ""}) == :tui
assert Server.effective_mode(:desktop, {:unix, :freebsd}, %{"WAYLAND_DISPLAY" => ""}) == :tui
end
test "macOS keeps the GUI locally but falls back to the TUI over ssh" do
# macOS has no DISPLAY variable; a local launch always has a
# graphical session, an ssh session never does.
assert Server.effective_mode(:desktop, {:unix, :darwin}, %{}) == :desktop
assert Server.effective_mode(:desktop, {:unix, :darwin}, %{
"SSH_CONNECTION" => "10.0.0.5 52000 10.0.0.9 22"
}) == :tui
assert Server.effective_mode(:desktop, {:unix, :darwin}, %{"SSH_TTY" => "/dev/ttys003"}) ==
:tui
assert Server.effective_mode(:desktop, {:unix, :darwin}, %{"SSH_CLIENT" => "10.0.0.5"}) ==
:tui
assert Server.effective_mode(:desktop, {:unix, :darwin}, %{"SSH_CONNECTION" => ""}) ==
:desktop
end
test "linux over ssh with a forwarded display keeps the GUI" do
env = %{"SSH_CONNECTION" => "10.0.0.5 52000 10.0.0.9 22", "DISPLAY" => "localhost:10.0"}
assert Server.effective_mode(:desktop, {:unix, :linux}, env) == :desktop
end
test "Windows never falls back" do
assert Server.effective_mode(:desktop, {:win32, :nt}, %{}) == :desktop
assert Server.effective_mode(:desktop, {:win32, :nt}, %{"SSH_CONNECTION" => "10.0.0.5"}) ==
:desktop
end
test "explicit server and tui modes are untouched" do
assert Server.effective_mode(:server, {:unix, :linux}, %{}) == :server
assert Server.effective_mode(:tui, {:unix, :linux}, %{"DISPLAY" => ":0"}) == :tui
assert Server.effective_mode(:server, {:unix, :darwin}, %{"DISPLAY" => ":0"}) == :server
end
end
describe "prepare_boot_env/1" do
setup do
original = System.get_env("NO_WX")
System.delete_env("NO_WX")
on_exit(fn ->
if original, do: System.put_env("NO_WX", original), else: System.delete_env("NO_WX")
end)
:ok
end
test "non-desktop modes disable wx so the :desktop dep boots inert" do
assert Server.prepare_boot_env(:tui, fn -> :ok end) == :tui
assert System.get_env("NO_WX") == "1"
System.delete_env("NO_WX")
assert Server.prepare_boot_env(:server, fn -> :ok end) == :server
assert System.get_env("NO_WX") == "1"
end
test "desktop mode leaves wx enabled" do
assert Server.prepare_boot_env(:desktop, fn -> :ok end) == :desktop
assert System.get_env("NO_WX") == nil
end
test "tui mode silences terminal writers; desktop and server modes keep them" do
original = Application.get_env(:bumblebee, :progress_bar_enabled)
on_exit(fn ->
if original == nil do
Application.delete_env(:bumblebee, :progress_bar_enabled)
else
Application.put_env(:bumblebee, :progress_bar_enabled, original)
end
end)
parent = self()
assert Server.prepare_boot_env(:tui, fn -> send(parent, :redirected) end) == :tui
assert Application.get_env(:bumblebee, :progress_bar_enabled) == false
assert_received :redirected
Server.prepare_boot_env(:desktop, fn -> send(parent, :redirected) end)
Server.prepare_boot_env(:server, fn -> send(parent, :redirected) end)
refute_received :redirected
end
end
describe "tui logging redirect" do
setup do
dir = Path.join(System.tmp_dir!(), "bds-tui-log-#{System.unique_integer([:positive])}")
log_file = Path.join(dir, "bds.log")
Application.put_env(:bds, :tui_log_file, log_file)
on_exit(fn ->
Application.delete_env(:bds, :tui_log_file)
File.rm_rf(dir)
end)
{:ok, log_file: log_file}
end
test "tui_log_file defaults to the private app dir and honors the override", %{
log_file: log_file
} do
assert Server.tui_log_file() == log_file
Application.delete_env(:bds, :tui_log_file)
assert Server.tui_log_file() == Path.join([BDS.Projects.private_dir(), "logs", "bds.log"])
end
test "redirect_logging_to_file swaps the default handler to a rotating file handler", %{
log_file: log_file
} do
{:ok, original} = :logger.get_handler_config(:default)
on_exit(fn ->
_ = :logger.remove_handler(:default)
:ok =
:logger.add_handler(
:default,
original.module,
Map.take(original, [:level, :filters, :filter_default, :formatter, :config])
)
end)
assert Server.redirect_logging_to_file() == :ok
{:ok, handler} = :logger.get_handler_config(:default)
assert handler.module == :logger_std_h
assert handler.config.file == String.to_charlist(log_file)
assert handler.config.max_no_bytes > 0
# The swap must keep the Elixir log formatter, not fall back to OTP's.
assert handler.formatter == original.formatter
end
end
describe "ensure_ssh_dir!/1" do describe "ensure_ssh_dir!/1" do
setup do setup do
dir = Path.join(System.tmp_dir!(), "bds-ssh-test-#{System.unique_integer([:positive])}") dir = Path.join(System.tmp_dir!(), "bds-ssh-test-#{System.unique_integer([:positive])}")

View File

@@ -38,14 +38,18 @@ defmodule BDS.TUITest do
state state
end end
defp screen_text(state, width \\ 100, height \\ 32) do defp screen_cells(state, width \\ 100, height \\ 32) do
session = CellSession.new(width, height) session = CellSession.new(width, height)
frame = %ExRatatui.Frame{width: width, height: height} frame = %ExRatatui.Frame{width: width, height: height}
:ok = CellSession.draw(session, BDS.TUI.render(state, frame)) :ok = CellSession.draw(session, BDS.TUI.render(state, frame))
snapshot = CellSession.take_cells(session) snapshot = CellSession.take_cells(session)
:ok = CellSession.close(session) :ok = CellSession.close(session)
snapshot.cells snapshot.cells
end
defp screen_text(state, width \\ 100, height \\ 32) do
state
|> screen_cells(width, height)
|> Enum.group_by(& &1.row) |> Enum.group_by(& &1.row)
|> Enum.sort_by(fn {row, _} -> row end) |> Enum.sort_by(fn {row, _} -> row end)
|> Enum.map_join("\n", fn {_row, cells} -> |> Enum.map_join("\n", fn {_row, cells} ->
@@ -53,7 +57,23 @@ defmodule BDS.TUITest do
end) end)
end end
defp key(code, modifiers \\ []), do: %Key{code: code, kind: "press", modifiers: modifiers} # The cell where `text` starts on screen (used to assert per-cell styles).
defp cell_at_text(cells, text) do
{_row, row_cells} =
cells
|> Enum.group_by(& &1.row)
|> Enum.find(fn {_row, row_cells} ->
row_cells |> Enum.sort_by(& &1.col) |> Enum.map_join("", & &1.symbol) |> String.contains?(text)
end)
sorted = Enum.sort_by(row_cells, & &1.col)
joined = Enum.map_join(sorted, & &1.symbol)
{byte_offset, _len} = :binary.match(joined, text)
char_offset = joined |> binary_part(0, byte_offset) |> String.length()
Enum.at(sorted, char_offset)
end
defp key(code, modifiers), do: %Key{code: code, kind: "press", modifiers: modifiers}
defp press(state, code, modifiers \\ []) do defp press(state, code, modifiers \\ []) do
{:noreply, state} = BDS.TUI.handle_event(key(code, modifiers), state) {:noreply, state} = BDS.TUI.handle_event(key(code, modifiers), state)
@@ -68,13 +88,76 @@ defmodule BDS.TUITest do
assert text =~ "Hello TUI" assert text =~ "Hello TUI"
end end
test "navigates and opens a post in the editor", %{post: post} do test "opening a post lands in the editor with the raw source", %{post: post} do
{:ok, _} =
BDS.Posts.update_post(post.id, %{content: "# Big Heading\n\nsome **bold** words"})
state = mount!() |> press("enter") state = mount!() |> press("enter")
assert state.focus == :editor assert state.focus == :editor
assert state.editor.post.id == post.id assert state.editor.post.id == post.id
assert ExRatatui.textarea_get_value(state.editor.textarea) == "terminal body" refute state.editor.preview?
assert screen_text(state) =~ "terminal body"
# The editor shows the raw markdown source.
assert screen_text(state) =~ "**bold**"
# ctrl+e flips to the rendered markdown preview: inline markers are
# consumed by the styling.
state = press(state, "e", ["ctrl"])
assert state.editor.preview?
text = screen_text(state)
assert text =~ "Big Heading"
assert text =~ "some bold words"
refute text =~ "**bold**"
# and back to the editor.
state = press(state, "e", ["ctrl"])
refute state.editor.preview?
assert screen_text(state) =~ "**bold**"
end
test "the editor word-wraps long lines", %{post: post} do
long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD"
{:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line})
state = mount!() |> press("enter")
refute state.editor.preview?
# The tail of a long line wraps onto visible rows without any
# horizontal scrolling.
assert screen_text(state, 80, 32) =~ "FINALWORD"
end
test "the cursor cell stays visible when the cursor sits below the viewport", %{post: post} do
content = Enum.map_join(1..30, "\n", &"line #{&1}")
{:ok, _} = BDS.Posts.update_post(post.id, %{content: content})
state = mount!() |> press("enter")
# The cursor opens at the end of the buffer — source row 29 of 30.
assert ExRatatui.textarea_cursor(state.editor.textarea) == {29, 7}
cells = screen_cells(state)
# The cursor cell is the single space carrying the cursor background
# inside the editor area (the sidebar selection also uses a cyan bg).
assert Enum.any?(cells, &(&1.bg == :cyan and &1.symbol == " " and &1.col >= 34))
end
test "the cursor line has a distinct background from the other lines", %{post: post} do
{:ok, _} = BDS.Posts.update_post(post.id, %{content: "first\nsecond\nthird"})
state = mount!() |> press("enter")
# Move the cursor from the end of the buffer onto the first line.
state = state |> press("up") |> press("up")
assert ExRatatui.textarea_cursor(state.editor.textarea) == {0, 5}
cells = screen_cells(state)
# A fixed dark grey — readable against the light syntax colours in
# any terminal palette (the named :dark_gray is palette-dependent).
assert cell_at_text(cells, "first").bg == {:rgb, 52, 61, 70}
refute cell_at_text(cells, "second").bg == {:rgb, 52, 61, 70}
end end
test "ctrl+s persists textarea edits", %{post: post} do test "ctrl+s persists textarea edits", %{post: post} do
@@ -95,13 +178,16 @@ defmodule BDS.TUITest do
assert state.editor.post.status == :published assert state.editor.post.status == :published
end end
test "n creates a new draft post", %{project: project} do test "n creates a new draft post and starts in the editor, not the preview", %{
project: project
} do
count_before = post_count(project.id) count_before = post_count(project.id)
state = mount!() |> press("n") state = mount!() |> press("n")
assert post_count(project.id) == count_before + 1 assert post_count(project.id) == count_before + 1
assert state.focus == :editor assert state.focus == :editor
assert state.editor.post.status == :draft assert state.editor.post.status == :draft
refute state.editor.preview?
end end
test "esc returns focus to the sidebar" do test "esc returns focus to the sidebar" do
@@ -109,23 +195,18 @@ defmodule BDS.TUITest do
assert state.focus == :sidebar assert state.focus == :sidebar
end end
test "ctrl+e toggles a word-wrapped preview of the draft", %{post: post} do test "the preview word-wraps long lines", %{post: post} do
long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD" long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD"
{:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line}) {:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line})
state = mount!() |> press("enter") |> press("e", ["ctrl"]) state = mount!() |> press("enter") |> press("e", ["ctrl"])
assert state.editor.preview? assert state.editor.preview?
# The textarea cannot soft-wrap, so the tail of a long line is off-screen
# there; the preview renders through the Markdown widget, which wraps.
assert screen_text(state, 80, 32) =~ "FINALWORD" assert screen_text(state, 80, 32) =~ "FINALWORD"
state = press(state, "e", ["ctrl"])
refute state.editor.preview?
end end
test "keys in preview mode do not edit the content" do test "keys in preview mode do not edit the content" do
state = mount!() |> press("enter") |> press("e", ["ctrl"]) state = mount!() |> press("enter") |> press("e", ["ctrl"])
assert state.editor.preview?
before = ExRatatui.textarea_get_value(state.editor.textarea) before = ExRatatui.textarea_get_value(state.editor.textarea)
state = press(state, "x") state = press(state, "x")
@@ -133,11 +214,12 @@ defmodule BDS.TUITest do
refute state.editor.dirty refute state.editor.dirty
end end
test "esc in preview returns to editing, not to the sidebar" do test "esc returns to the sidebar from both preview and editor" do
state = mount!() |> press("enter") |> press("e", ["ctrl"]) |> press("esc") state = mount!() |> press("enter") |> press("esc")
assert state.focus == :sidebar
refute state.editor.preview? state = state |> press("enter") |> press("e", ["ctrl"]) |> press("esc")
assert state.focus == :editor assert state.focus == :sidebar
end end
test "entity_changed refreshes the sidebar", %{project: project} do test "entity_changed refreshes the sidebar", %{project: project} do
@@ -224,7 +306,7 @@ defmodule BDS.TUITest do
end end
test "up/down move the selection" do test "up/down move the selection" do
state = mount_with_executor!() |> press(":") |> press("down") |> press("enter") _state = mount_with_executor!() |> press(":") |> press("down") |> press("enter")
assert_receive {:executed, action, %{}} assert_receive {:executed, action, %{}}
assert is_binary(action) assert is_binary(action)
@@ -416,6 +498,671 @@ defmodule BDS.TUITest do
end end
end end
describe "projects overlay" do
defp type(state, text) do
text |> String.graphemes() |> Enum.reduce(state, &press(&2, &1))
end
defp move_to(state, index) do
moves = index - state.projects.selected
code = if moves >= 0, do: "down", else: "up"
Enum.reduce(List.duplicate(code, abs(moves)), state, &press(&2, &1))
end
test "'p' opens the overlay listing all projects with the active one selected", %{
project: project
} do
state = mount!() |> press("p")
assert state.projects.mode == :list
active = Enum.at(state.projects.projects, state.projects.selected)
assert active.id == project.id
text = screen_text(state, 140, 40)
assert text =~ project.name
end
test "esc closes the overlay" do
state = mount!() |> press("p") |> press("esc")
assert state.projects == nil
end
test "enter switches the active project and reloads the sidebar", %{project: project} do
other_dir = Path.join(System.tmp_dir!(), "bds-tui-other-#{System.unique_integer([:positive])}")
File.mkdir_p!(other_dir)
on_exit(fn -> File.rm_rf(other_dir) end)
{:ok, other} = BDS.Projects.create_project(%{name: "Other Blog", data_path: other_dir})
{:ok, _} =
BDS.Posts.create_post(%{project_id: other.id, title: "Other Post", content: "x"})
state = mount!() |> press("p")
index = Enum.find_index(state.projects.projects, &(&1.id == other.id))
state = state |> move_to(index) |> press("enter")
assert state.projects == nil
assert state.project_id == other.id
assert BDS.Projects.get_active_project().id == other.id
assert screen_text(state) =~ "Other Post"
# switching back must not churn: selecting the active project is a no-op
{:ok, _} = BDS.Projects.set_active_project(project.id)
end
test "'o' plus a valid path opens the folder as a project and queues a rebuild" do
parent = self()
import_dir =
Path.join(System.tmp_dir!(), "bds-tui-import-#{System.unique_integer([:positive])}")
File.mkdir_p!(import_dir)
on_exit(fn -> File.rm_rf(import_dir) end)
state =
mount!(
command_executor: fn action, params ->
send(parent, {:executed, action, params})
{:ok, %{kind: "task_queued", title: "Rebuild", message: "queued"}}
end
)
|> press("p")
|> press("o")
assert state.projects.mode == :open
state = state |> type(import_dir) |> press("enter")
assert state.projects == nil
imported = Enum.find(BDS.Projects.list_projects(), &(&1.data_path == import_dir))
assert imported != nil
assert BDS.Projects.get_active_project().id == imported.id
assert state.project_id == imported.id
assert_receive {:executed, "rebuild_database", %{}}
assert state.task_polling
end
test "an invalid path keeps the prompt open and reports the error" do
parent = self()
count_before = length(BDS.Projects.list_projects())
state =
mount!(command_executor: fn action, params -> send(parent, {:executed, action, params}) end)
|> press("p")
|> press("o")
|> type("/nonexistent-bds-folder-xyz")
|> press("enter")
assert state.projects.mode == :open
assert state.status != nil
assert length(BDS.Projects.list_projects()) == count_before
refute_receive {:executed, _action, _params}, 50
end
test "esc in the path prompt returns to the project list" do
state = mount!() |> press("p") |> press("o") |> press("esc")
assert state.projects.mode == :list
end
end
describe "sidebar search" do
setup %{project: project} do
{:ok, tagged} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Tagged Post",
content: "x",
tags: ["elixir"],
categories: ["news"]
})
{:ok, tagged: tagged}
end
defp item_titles(state) do
for {:item, item} <- state.items, do: to_string(item[:title] || item[:name])
end
defp search(state, query) do
state |> press("/") |> type(query)
end
test "'/' opens the prompt and typing filters the list live" do
state = mount!() |> search("tagged")
assert state.search != nil
assert item_titles(state) == ["Tagged Post"]
assert screen_text(state) =~ "/tagged"
end
test "tag: filters by tag" do
state = mount!() |> search("tag:elixir")
assert item_titles(state) == ["Tagged Post"]
end
test "category: filters by category" do
state = mount!() |> search("category:news")
assert item_titles(state) == ["Tagged Post"]
end
test "an ISO date shows the posts from that date", %{post: post} do
on_ms =
DateTime.new!(~D[2026-07-04], ~T[10:00:00], "Etc/UTC") |> DateTime.to_unix(:millisecond)
BDS.Repo.update_all(
from(p in BDS.Posts.Post, where: p.id == ^post.id),
set: [updated_at: on_ms]
)
state = mount!() |> search("2026-07-04")
assert item_titles(state) == ["Hello TUI"]
end
test "tokens combine: tag plus text must both match" do
state = mount!() |> search("tag:elixir hello")
assert item_titles(state) == []
end
test "enter keeps the filter and shows it in the sidebar title" do
state = mount!() |> search("tag:elixir") |> press("enter")
assert state.search == nil
assert item_titles(state) == ["Tagged Post"]
assert screen_text(state) =~ "/tag:elixir"
end
test "esc clears the filter and restores the full list" do
state = mount!() |> search("tag:elixir") |> press("esc")
assert state.search == nil
assert "Hello TUI" in item_titles(state)
assert "Tagged Post" in item_titles(state)
end
test "the filter applies per view and works for media", %{project: project} do
media_file = Path.join(System.tmp_dir!(), "bds-tui-media-#{System.unique_integer([:positive])}.txt")
File.write!(media_file, "media body")
on_exit(fn -> File.rm(media_file) end)
{:ok, _media} =
BDS.Media.import_media(%{
project_id: project.id,
source_path: media_file,
title: "Findable Media"
})
state = mount!() |> press("2") |> search("findable") |> press("enter")
assert item_titles(state) == ["Findable Media"]
# posts view keeps its own (empty) filter
state = press(state, "1")
assert "Hello TUI" in item_titles(state)
end
end
describe "path completion" do
setup do
base = Path.join(System.tmp_dir!(), "bds-tui-tab-#{System.unique_integer([:positive])}")
for dir <- ["blog-one", "blog-two", "alpha-unique", ".secret"] do
File.mkdir_p!(Path.join(base, dir))
end
File.write!(Path.join(base, "blog-notes.txt"), "not a folder")
on_exit(fn -> File.rm_rf(base) end)
{:ok, base: base}
end
defp prompt!(path) do
mount!() |> press("p") |> press("o") |> type(path)
end
test "tab completes a unique directory prefix with a trailing slash", %{base: base} do
state = prompt!(Path.join(base, "alph")) |> press("tab")
assert state.projects.input == Path.join(base, "alpha-unique") <> "/"
assert state.projects.matches == []
end
test "tab completes ambiguous prefixes to the longest common prefix and lists candidates",
%{base: base} do
state = prompt!(Path.join(base, "bl")) |> press("tab")
# Files are not offered — this is a folder picker.
assert state.projects.input == Path.join(base, "blog-")
assert state.projects.matches == ["blog-one", "blog-two"]
assert screen_text(state, 140, 40) =~ "blog-one"
end
test "candidates are cleared as soon as typing continues", %{base: base} do
state = prompt!(Path.join(base, "bl")) |> press("tab") |> press("o")
assert state.projects.matches == []
end
test "hidden folders are not offered unless the prefix starts with a dot", %{base: base} do
state = prompt!(base <> "/") |> press("tab")
refute ".secret" in state.projects.matches
state = prompt!(Path.join(base, ".se")) |> press("tab")
assert state.projects.input == Path.join(base, ".secret") <> "/"
end
test "tab with no matches leaves the input unchanged", %{base: base} do
input = Path.join(base, "zzz")
state = prompt!(input) |> press("tab")
assert state.projects.input == input
assert state.projects.matches == []
end
test "a leading ~ expands to the home directory" do
state = prompt!("~/") |> press("tab")
assert String.starts_with?(state.projects.input, System.user_home!() <> "/")
end
end
describe "git view" do
defp git!(dir, args) do
{output, 0} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
output
end
defp init_repo!(dir) do
git!(dir, ["init"])
git!(dir, ["config", "user.email", "tui@test"])
git!(dir, ["config", "user.name", "TUI Test"])
File.write!(Path.join(dir, "tracked.md"), "original\n")
git!(dir, ["add", "-A"])
git!(dir, ["commit", "-m", "initial"])
end
defp modify!(dir, file, content), do: File.write!(Path.join(dir, file), content)
test "'7' opens the git view with changed files and a scrollable diff", %{project: project} do
dir = BDS.Projects.project_data_dir(project)
init_repo!(dir)
modify!(dir, "tracked.md", "changed\n")
File.write!(Path.join(dir, "new-file.md"), "brand new\n")
state = mount!() |> press("7")
assert state.view == "git"
labels = for {:item, item} <- state.items, do: item.label
assert "M tracked.md" in labels
assert "U new-file.md" in labels
assert state.git != nil
assert Enum.any?(state.git.lines, &String.starts_with?(&1, "diff --git"))
assert screen_text(state, 140, 40) =~ "diff --git"
end
test "pgdn/pgup scroll the diff", %{project: project} do
dir = BDS.Projects.project_data_dir(project)
init_repo!(dir)
modify!(dir, "tracked.md", Enum.map_join(1..60, "\n", &"line #{&1}") <> "\n")
state = mount!() |> press("7") |> press("pagedown")
assert state.git.scroll > 0
state = press(state, "pageup")
assert state.git.scroll == 0
end
test "enter on a changed file jumps the diff to that file", %{project: project} do
dir = BDS.Projects.project_data_dir(project)
init_repo!(dir)
File.write!(Path.join(dir, "zz-later.md"), "z\n")
git!(dir, ["add", "-A"])
git!(dir, ["commit", "-m", "second file"])
modify!(dir, "tracked.md", "changed\n")
modify!(dir, "zz-later.md", "changed too\n")
state = mount!() |> press("7")
index = Enum.find_index(state.items, fn {:item, item} -> item.id == "zz-later.md" end)
state = %{state | selected: index} |> press("enter")
line = Enum.at(state.git.lines, state.git.scroll)
assert line =~ "diff --git"
assert line =~ "zz-later.md"
end
test "'c' opens the commit prompt and enter commits everything", %{project: project} do
dir = BDS.Projects.project_data_dir(project)
init_repo!(dir)
modify!(dir, "tracked.md", "changed\n")
state = mount!() |> press("7") |> press("c")
assert state.git_commit != nil
assert screen_text(state) =~ "Commit message:"
state = state |> type("content sync")
assert screen_text(state) =~ "content sync"
state = press(state, "enter")
assert state.git_commit == nil
assert {:ok, %{files: []}} = BDS.Git.status(project.id)
assert state.items == []
assert git!(dir, ["log", "-1", "--format=%s"]) =~ "content sync"
end
test "an empty commit message is rejected", %{project: project} do
dir = BDS.Projects.project_data_dir(project)
init_repo!(dir)
modify!(dir, "tracked.md", "changed\n")
state = mount!() |> press("7") |> press("c") |> press("enter")
assert state.git_commit == nil
assert state.status != nil
assert {:ok, %{files: [_change]}} = BDS.Git.status(project.id)
end
test "'u' pulls asynchronously and reports the result", %{project: project} do
dir = BDS.Projects.project_data_dir(project)
init_repo!(dir)
state = mount!() |> press("7") |> press("u")
assert state.busy
# No remote is configured, so the streamed result is an error toast.
assert_receive {:git_result, :pull, {:error, _reason}}, 5_000
{:noreply, state} = BDS.TUI.handle_info({:git_result, :pull, {:error, {:git_failed, "no remote"}}}, state)
refute state.busy
assert state.status =~ "no remote"
end
test "'s' pushes asynchronously", %{project: project} do
dir = BDS.Projects.project_data_dir(project)
init_repo!(dir)
state = mount!() |> press("7") |> press("s")
assert state.busy
assert_receive {:git_result, :push, {:error, _reason}}, 5_000
end
test "the sidebar shows the commit history in its lower half", %{project: project} do
dir = BDS.Projects.project_data_dir(project)
init_repo!(dir)
state = mount!() |> press("7")
assert [entry | _] = state.sidebar.history_entries
assert entry.subject == "initial"
text = screen_text(state, 140, 40)
assert text =~ "History"
# No remote exists, so the commit is local-only and marked for push.
assert text =~ "#{entry.short_hash} initial"
# A commit from the panel shows up in the history immediately.
modify!(dir, "tracked.md", "changed\n")
state = state |> press("r") |> press("c") |> type("sync commit") |> press("enter")
assert screen_text(state, 140, 40) =~ "sync commit"
end
test "git keys in a non-repo project only report", %{project: project} do
state = mount!() |> press("7")
assert state.view == "git"
assert state.git == nil
assert state.items == []
assert screen_text(state, 140, 40) =~ "Not a git repository."
state = press(state, "c")
assert state.git_commit == nil
assert state.status != nil
state = press(state, "u")
refute state.busy
refute_receive {:git_result, _op, _result}, 100
_ = project
end
end
describe "settings panel (issue #29)" do
defp open_section(state, title) do
index =
Enum.find_index(state.items, fn
{:item, item} -> item.title == title
_other -> false
end)
moves = index - state.selected
direction = if moves < 0, do: "up", else: "down"
1..abs(moves)//1
|> Enum.reduce(state, fn _step, acc -> press(acc, direction) end)
|> press("enter")
end
defp select_field(state, key) do
index = Enum.find_index(state.settings_form.fields, &(&1.key == key))
moves = index - state.settings_form.selected
direction = if moves < 0, do: "up", else: "down"
Enum.reduce(1..abs(moves)//1, state, fn _step, acc -> press(acc, direction) end)
end
test "6 opens the settings view and lists the sections" do
state = mount!() |> press("6")
assert state.view == "settings"
text = screen_text(state)
assert text =~ "Project"
assert text =~ "Publishing"
assert text =~ "Style"
end
test "enter opens a section form; editing a text field persists on ctrl+s", %{
project: project
} do
state = mount!() |> press("6") |> open_section("Project")
assert state.settings_form != nil
assert state.settings_form.section == "project"
assert screen_text(state, 140, 40) =~ "Posts per Page"
state = select_field(state, "name") |> press("enter")
assert state.settings_form.input != nil
# The prompt is seeded with the current value; replace it.
state =
Enum.reduce(1..3, state, fn _n, acc -> press(acc, "backspace") end)
|> type("TUI Prefs")
|> press("enter")
assert state.settings_form.input == nil
assert state.settings_form.dirty
state = press(state, "s", ["ctrl"])
refute state.settings_form.dirty
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert metadata.name == "TUI Prefs"
end
test "enter toggles booleans and cycles enums", %{project: project} do
state = mount!() |> press("6") |> open_section("Publishing")
state = select_field(state, "ssh_mode")
assert current_field(state).value == "scp"
state = press(state, "enter")
assert current_field(state).value == "rsync"
state = state |> select_field("ssh_host") |> press("enter") |> type("example.org") |> press("enter")
state = press(state, "s", ["ctrl"])
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert metadata.publishing_preferences["ssh_mode"] == "rsync"
assert metadata.publishing_preferences["ssh_host"] == "example.org"
# Booleans toggle in place.
state = state |> press("esc") |> open_section("Technology")
state = select_field(state, "semantic_similarity_enabled")
refute current_field(state).value
state = press(state, "enter")
assert current_field(state).value
end
test "esc in a prompt cancels only the prompt; esc again closes the form" do
state = mount!() |> press("6") |> open_section("Publishing")
state = select_field(state, "ssh_host") |> press("enter")
assert state.settings_form.input != nil
state = press(state, "esc")
assert state.settings_form != nil
assert state.settings_form.input == nil
state = press(state, "esc")
assert state.settings_form == nil
assert state.view == "settings"
end
defp current_field(state),
do: Enum.at(state.settings_form.fields, state.settings_form.selected)
end
describe "tags panel (issue #34)" do
defp open_tags_section(state, section) do
state = press(state, "5")
index =
Enum.find_index(state.items, fn
{:item, %{id: id}} -> id == "tags-" <> section
_other -> false
end)
assert index != nil
press(%{state | selected: index}, "enter")
end
defp type_text(state, text) do
text
|> String.graphemes()
|> Enum.reduce(state, fn char, acc -> press(acc, char) end)
end
defp tag_names(project_id) do
project_id |> BDS.Tags.list_tags() |> Enum.map(& &1.name)
end
test "the sidebar entries open the panel sections in the main area", %{project: project} do
{:ok, _tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "elixir"})
state = open_tags_section(mount!(), "cloud")
assert state.tags_panel.section == "cloud"
assert screen_text(state) =~ "elixir (0)"
state = press(state, "esc")
assert state.tags_panel == nil
state = open_tags_section(state, "manage")
assert state.tags_panel.section == "manage"
state = open_tags_section(press(state, "esc"), "merge")
assert state.tags_panel.section == "merge"
assert screen_text(state) =~ "[ ] elixir (0)"
end
test "manage creates a tag through the n prompt", %{project: project} do
state = open_tags_section(mount!(), "manage")
state = press(state, "n")
assert state.tags_panel.input.kind == :new
state = state |> type_text("neu") |> press("enter")
assert "neu" in tag_names(project.id)
assert state.tags_panel.input == nil
assert state.status =~ "neu"
end
test "manage renames the selected tag through the enter prompt", %{project: project} do
{:ok, _tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "alpha"})
state = open_tags_section(mount!(), "manage")
state = press(state, "enter")
assert state.tags_panel.input.kind == :rename
assert state.tags_panel.input.value == "alpha"
state =
Enum.reduce(1..5, state, fn _n, acc -> press(acc, "backspace") end)
_state = state |> type_text("beta") |> press("enter")
assert tag_names(project.id) == ["beta"]
end
test "manage cycles the tag colour with c", %{project: project} do
{:ok, tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "colored"})
state = open_tags_section(mount!(), "manage")
_state = press(state, "c")
updated = BDS.Repo.get!(BDS.Tags.Tag, tag.id)
assert updated.color == List.first(BDS.UI.TagsPanel.colour_presets())
end
test "manage deletes only after y confirmation", %{project: project} do
{:ok, _tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "doomed"})
state = open_tags_section(mount!(), "manage")
# Any other key cancels the pending delete.
state = state |> press("d") |> press("x")
assert state.tags_panel.confirm_delete == nil
assert "doomed" in tag_names(project.id)
state = state |> press("d")
assert state.tags_panel.confirm_delete == "doomed"
state = press(state, "y")
assert tag_names(project.id) == []
assert state.status =~ "doomed"
end
test "merge marks tags with space and merges them into the selection", %{project: project} do
{:ok, _one} = BDS.Tags.create_tag(%{project_id: project.id, name: "one"})
{:ok, _two} = BDS.Tags.create_tag(%{project_id: project.id, name: "two"})
{:ok, tagged_post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Tagged",
content: "body",
tags: ["one"]
})
state = open_tags_section(mount!(), "merge")
# Mark "one", move to "two", merge — "one" is folded into "two".
_state = state |> press(" ") |> press("down") |> press("m")
assert tag_names(project.id) == ["two"]
assert BDS.Posts.get_post!(tagged_post.id).tags == ["two"]
end
test "manage syncs tags from post tags with s", %{project: project} do
{:ok, _post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "With Tags",
content: "body",
tags: ["from-post"]
})
state = open_tags_section(mount!(), "manage")
state = press(state, "s")
assert "from-post" in tag_names(project.id)
assert Enum.any?(state.tags_panel.tags, &(&1.name == "from-post"))
end
end
test "local tui mode stops the VM when the app exits" do test "local tui mode stops the VM when the app exits" do
parent = self() parent = self()
state = mount!(stop_vm_on_exit: true, stop_fun: fn -> send(parent, :vm_stopped) end) state = mount!(stop_vm_on_exit: true, stop_fun: fn -> send(parent, :vm_stopped) end)

View File

@@ -0,0 +1,199 @@
defmodule BDS.UI.CodeEditorTest do
use ExUnit.Case, async: true
alias BDS.UI.CodeEditor
alias BDS.UI.CodeEditor.Highlight
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Text.Line
alias ExRatatui.Widgets.Paragraph
alias ExRatatui.Widgets.Textarea
@theme :base16_ocean_dark
@rect %Rect{x: 0, y: 0, width: 40, height: 10}
defp find_span(line, text) do
Enum.find(line.spans, fn span -> String.contains?(span.content, text) end)
end
defp editor_widget(content, opts \\ []) do
textarea = ExRatatui.textarea_new()
:ok = ExRatatui.textarea_insert_str(textarea, content)
widget = %CodeEditor{
textarea: textarea,
language: Keyword.get(opts, :language, :markdown_macros),
cursor_style: Keyword.get(opts, :cursor_style, %Style{bg: :cyan}),
line_highlight_style: Keyword.get(opts, :line_highlight_style, %Style{}),
block: Keyword.get(opts, :block),
scroll: Keyword.get(opts, :scroll, {0, 0})
}
{widget, textarea}
end
defp render(widget, rect \\ @rect) do
[{paragraph, ^rect}] = ExRatatui.Widget.render(widget, rect)
paragraph
end
describe "Highlight.highlight/3 — :lua" do
test "colours keywords with the theme keyword colour, distinct from identifiers" do
[first, _middle, last] =
Highlight.highlight("local function foo()\n return 1\nend", :lua, @theme)
keyword = find_span(first, "function")
assert keyword.style.fg != nil
assert find_span(last, "end").style.fg == keyword.style.fg
refute find_span(first, "foo").style.fg == keyword.style.fg
end
end
describe "Highlight.highlight/3 — :markdown_macros" do
test "recolours [[macro]] syntax with the GUI accent colours" do
[line] = Highlight.highlight(~s([[gallery names="x"]]), :markdown_macros, @theme)
opener = find_span(line, "[[gallery")
assert opener.style.fg == {:rgb, 197, 134, 192}
assert :bold in opener.style.modifiers
closer = find_span(line, "]]")
assert closer.style.fg == {:rgb, 197, 134, 192}
assert :bold in closer.style.modifiers
assert find_span(line, "names").style.fg == {:rgb, 156, 220, 254}
assert find_span(line, ~s("x")).style.fg == {:rgb, 206, 145, 120}
end
test "keeps the base markdown styling outside macros" do
content = ~s(before [[gallery names="x"]] after)
[overlayed] = Highlight.highlight(content, :markdown_macros, @theme)
[base] = ExRatatui.CodeBlock.highlight(content, "markdown", @theme)
base_style = find_span(base, "before").style
assert find_span(overlayed, "before").style == base_style
assert find_span(overlayed, "after").style == base_style
end
test "highlights the macro over a styled markdown base" do
[line] = Highlight.highlight("some **bold** [[macro]] words", :markdown_macros, @theme)
# Bold markdown keeps its base styling while the macro is recoloured.
assert :bold in find_span(line, "bold").style.modifiers
assert find_span(line, "[[macro").style.fg == {:rgb, 197, 134, 192}
assert find_span(line, "]]").style.fg == {:rgb, 197, 134, 192}
end
end
describe "Highlight.highlight/3 — :liquid" do
test "recolours output tags over the HTML base" do
[line] = Highlight.highlight("<div>{{ x }}</div>", :liquid, @theme)
assert find_span(line, "{{").style.fg == {:rgb, 86, 156, 214}
assert find_span(line, "}}").style.fg == {:rgb, 86, 156, 214}
# The HTML base styling survives outside the tag.
assert find_span(line, "div").style.fg != nil
end
test "recolours the tag keyword distinctly from identifiers" do
[line] = Highlight.highlight("{% if x %}y{% endif %}", :liquid, @theme)
keyword = find_span(line, "if")
assert keyword.style.fg == {:rgb, 86, 156, 214}
assert find_span(line, "endif").style.fg == keyword.style.fg
refute find_span(line, " x ").style.fg == keyword.style.fg
end
end
describe "Highlight.highlight/3 — line bookkeeping" do
test "empty content yields a single empty line" do
assert [%Line{spans: []}] = Highlight.highlight("", :markdown_macros, @theme)
end
test "trailing empty lines are padded back to the textarea's line count" do
lines = Highlight.highlight("a\n\n", :text, @theme)
assert length(lines) == 3
end
test "spans never contain newline characters" do
lines = Highlight.highlight("a\nb\n", :text, @theme)
for line <- lines, span <- line.spans do
refute String.contains?(span.content, "\n")
end
end
end
describe "ExRatatui.Widget rendering" do
test "emits a wrapping Paragraph, never a bare Textarea, for every highlighted language" do
for language <- [:markdown_macros, :liquid, :lua, :text] do
{widget, _textarea} = editor_widget("hello", language: language)
paragraph = render(widget)
assert %Paragraph{wrap: true, scroll: {_top, 0}} = paragraph
refute match?(%Textarea{}, paragraph)
end
end
test "splices the cursor cell into the character under the cursor" do
{widget, textarea} = editor_widget("hello")
:ok = ExRatatui.textarea_handle_key(textarea, "home", [])
%Paragraph{text: [line | _rest]} = render(widget)
cursor_span = Enum.find(line.spans, &(&1.style.bg == :cyan))
assert cursor_span.content == "h"
assert Enum.map_join(line.spans, & &1.content) == "hello"
end
test "appends a cursor cell when the cursor sits at the end of the line" do
{widget, _textarea} = editor_widget("hello")
%Paragraph{text: [line | _rest]} = render(widget)
cursor_span = List.last(line.spans)
assert cursor_span.content == " "
assert cursor_span.style.bg == :cyan
end
test "highlights the cursor line with the line highlight style" do
{widget, _textarea} =
editor_widget("ab\ncd",
language: :text,
cursor_style: %Style{},
line_highlight_style: %Style{bg: :dark_gray}
)
%Paragraph{text: lines} = render(widget)
# The cursor opens at the end of the second line.
assert Enum.all?(Enum.at(lines, 1).spans, &(&1.style.bg == :dark_gray))
assert Enum.all?(Enum.at(lines, 0).spans, &(&1.style.bg != :dark_gray))
end
test "scrolls so the cursor visual row stays inside the window" do
content = Enum.map_join(1..30, "\n", &"line #{&1}")
rect = %Rect{x: 0, y: 0, width: 20, height: 5}
{widget, _textarea} = editor_widget(content, rect: rect)
# The cursor opens on source row 29: 30 visual rows, window height 5.
assert %Paragraph{scroll: {25, 0}} = render(widget, rect)
end
test "wrapped source lines count towards the cursor's visual row" do
rect = %Rect{x: 0, y: 0, width: 4, height: 2}
{widget, _textarea} = editor_widget("abcdefghij", rect: rect)
# 10 characters in a 4-wide window wrap to 3 visual rows; the cursor
# at the end of the line lands on the third.
assert %Paragraph{scroll: {1, 0}} = render(widget, rect)
end
test "keeps the scroll offset at zero while the cursor is visible" do
{widget, _textarea} = editor_widget("a\nb\nc")
assert %Paragraph{scroll: {0, 0}} = render(widget)
end
end
end

View File

@@ -0,0 +1,200 @@
defmodule BDS.UI.SettingsFormTest do
use ExUnit.Case, async: false
alias BDS.UI.SettingsForm
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, :manual) end)
temp_dir = Path.join(System.tmp_dir!(), "bds-settings-form-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "Prefs", data_path: temp_dir})
{:ok, _} = BDS.Projects.set_active_project(project.id)
%{project: project}
end
defp field(form, key), do: Enum.find(form.fields, &(&1.key == key))
defp values(form, overrides) do
form.fields
|> Map.new(fn f -> {f.key, f.value} end)
|> Map.merge(overrides)
end
describe "project" do
test "load exposes the project metadata as typed fields", %{project: project} do
form = SettingsForm.load("project", project.id)
assert field(form, "name").value == "Prefs"
assert field(form, "name").type == :text
assert field(form, "main_language").type == :enum
assert "de" in field(form, "main_language").options
assert field(form, "semantic_similarity_enabled").type == :bool
end
test "save writes through Metadata and keeps untouched values", %{project: project} do
{:ok, _} = BDS.Metadata.update_project_metadata(project.id, %{pico_theme: "jade"})
form = SettingsForm.load("project", project.id)
:ok =
SettingsForm.save(
"project",
project.id,
values(form, %{
"name" => "Renamed",
"max_posts_per_page" => "25",
"blog_languages" => "en, de",
"semantic_similarity_enabled" => true
})
)
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert metadata.name == "Renamed"
assert metadata.max_posts_per_page == 25
assert metadata.blog_languages == ["en", "de"]
assert metadata.semantic_similarity_enabled
assert metadata.pico_theme == "jade"
end
end
describe "editor" do
test "round-trips the global editor settings", %{project: project} do
form = SettingsForm.load("editor", project.id)
assert field(form, "default_mode").type == :enum
:ok =
SettingsForm.save(
"editor",
project.id,
values(form, %{"default_mode" => "preview", "wrap_long_lines" => true})
)
assert BDS.Settings.get_global_setting("ui.preferred_editor_mode") == "preview"
assert BDS.Settings.get_global_setting("ui.git_diff_word_wrap") == "true"
reloaded = SettingsForm.load("editor", project.id)
assert field(reloaded, "default_mode").value == "preview"
assert field(reloaded, "wrap_long_lines").value == true
end
end
describe "content" do
test "edits category settings and adds new categories", %{project: project} do
form = SettingsForm.load("content", project.id)
assert field(form, "cat:aside:show_title").type == :bool
:ok =
SettingsForm.save(
"content",
project.id,
values(form, %{
"new_category" => "linkroll",
"cat:article:render_in_lists" => false,
"cat:article:title" => "Articles"
})
)
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert "linkroll" in metadata.categories
assert metadata.category_settings["article"]["render_in_lists"] == false
assert metadata.category_settings["article"]["title"] == "Articles"
end
end
describe "technology" do
test "toggles semantic similarity without wiping the project", %{project: project} do
form = SettingsForm.load("technology", project.id)
refute field(form, "semantic_similarity_enabled").value
:ok =
SettingsForm.save(
"technology",
project.id,
values(form, %{"semantic_similarity_enabled" => true})
)
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert metadata.semantic_similarity_enabled
assert metadata.name == "Prefs"
end
end
describe "publishing" do
test "saves the SSH preferences", %{project: project} do
form = SettingsForm.load("publishing", project.id)
assert field(form, "ssh_mode").options == ["scp", "rsync"]
:ok =
SettingsForm.save(
"publishing",
project.id,
values(form, %{"ssh_host" => "example.org", "ssh_mode" => "rsync"})
)
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert metadata.publishing_preferences["ssh_host"] == "example.org"
assert metadata.publishing_preferences["ssh_mode"] == "rsync"
end
end
describe "ai" do
test "loads the AI form and saves prompt plus airplane mode", %{project: project} do
form = SettingsForm.load("ai", project.id)
assert field(form, "offline_mode").type == :bool
assert field(form, "system_prompt").type == :text
:ok =
SettingsForm.save(
"ai",
project.id,
values(form, %{"system_prompt" => "be terse", "offline_mode" => true})
)
assert BDS.Settings.get_global_setting("ai.system_prompt") == "be terse"
assert BDS.AI.airplane_mode?()
end
end
describe "style" do
test "applies a theme without wiping other metadata", %{project: project} do
form = SettingsForm.load("style", project.id)
assert "jade" in field(form, "pico_theme").options
:ok = SettingsForm.save("style", project.id, values(form, %{"pico_theme" => "jade"}))
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert metadata.pico_theme == "jade"
assert metadata.name == "Prefs"
end
end
describe "data" do
test "shows the data folder read-only and save is a no-op", %{project: project} do
form = SettingsForm.load("data", project.id)
data_field = field(form, "data_path")
assert data_field.type == :info
assert data_field.value =~ "bds-settings-form"
assert SettingsForm.save("data", project.id, %{}) == :ok
end
end
describe "mcp" do
test "lists the agents with supported ones as toggles", %{project: project} do
form = SettingsForm.load("mcp", project.id)
claude = field(form, "mcp:claude_code")
assert claude.type == :bool
assert is_boolean(claude.value)
gemini = field(form, "mcp:gemini_cli")
assert gemini.type == :info
end
end
end

View File

@@ -400,6 +400,50 @@ defmodule BDS.UI.ShellTest do
assert status == 0, output assert status == 0, output
end end
test "monaco hook preserves cursor and selection when applying a remote value" do
script = """
import assert from "node:assert/strict";
class FakeTextArea {}
globalThis.HTMLTextAreaElement = FakeTextArea;
const { applyRemoteEditorValue } = await import("./assets/js/hooks/monaco_editor.js");
const selections = [{ startLineNumber: 3, startColumn: 5, endLineNumber: 3, endColumn: 9 }];
const calls = [];
const editor = {
getSelections: () => selections,
setValue: (value) => calls.push(["setValue", value]),
setSelections: (restored) => calls.push(["setSelections", restored])
};
applyRemoteEditorValue(editor, "line one\\nline two\\nline three");
assert.deepEqual(calls, [
["setValue", "line one\\nline two\\nline three"],
["setSelections", selections]
]);
// Editors without selections still get the value applied.
const bareCalls = [];
const bareEditor = {
getSelections: () => null,
setValue: (value) => bareCalls.push(["setValue", value]),
setSelections: () => bareCalls.push(["setSelections"])
};
applyRemoteEditorValue(bareEditor, "fresh");
assert.deepEqual(bareCalls, [["setValue", "fresh"]]);
"""
{output, status} =
System.cmd("node", ["--input-type=module", "--eval", script],
cd: "/Users/gb/Projects/bDS2",
stderr_to_stdout: true
)
assert status == 0, output
end
test "monaco ESM build config emits served worker bundles" do test "monaco ESM build config emits served worker bundles" do
mix_exs = File.read!("/Users/gb/Projects/bDS2/mix.exs") mix_exs = File.read!("/Users/gb/Projects/bDS2/mix.exs")
config = File.read!("/Users/gb/Projects/bDS2/config/config.exs") config = File.read!("/Users/gb/Projects/bDS2/config/config.exs")

View File

@@ -175,6 +175,47 @@ defmodule BDS.UI.SidebarTest do
assert Enum.map(import_view.items, & &1.title) == ["New Import", "Old Import"] assert Enum.map(import_view.items, & &1.title) == ["New Import", "Old Import"]
end end
test "posts and media can be filtered to a single ISO date", %{
project: project,
temp_dir: temp_dir
} do
on_ms =
DateTime.new!(~D[2026-07-04], ~T[10:00:00], "Etc/UTC") |> DateTime.to_unix(:millisecond)
off_ms =
DateTime.new!(~D[2026-07-05], ~T[10:00:00], "Etc/UTC") |> DateTime.to_unix(:millisecond)
assert {:ok, on_post} = Posts.create_post(%{project_id: project.id, title: "On Date"})
assert {:ok, off_post} = Posts.create_post(%{project_id: project.id, title: "Off Date"})
update_post_sidebar_row(on_post.id, updated_at: on_ms)
update_post_sidebar_row(off_post.id, updated_at: off_ms)
view = Sidebar.view(project.id, "posts", %{year: 2026, month: 7, day: 4})
assert titles_in_section(view, "Drafts") == ["On Date"]
assert view.filters.has_active_filters
on_file = Path.join(temp_dir, "on.txt")
off_file = Path.join(temp_dir, "off.txt")
File.write!(on_file, "on")
File.write!(off_file, "off")
assert {:ok, on_media} =
Media.import_media(%{project_id: project.id, source_path: on_file, title: "Media On"})
assert {:ok, off_media} =
Media.import_media(%{
project_id: project.id,
source_path: off_file,
title: "Media Off"
})
update_media_sidebar_row(on_media.id, updated_at: on_ms)
update_media_sidebar_row(off_media.id, updated_at: off_ms)
media_view = Sidebar.view(project.id, "media", %{year: 2026, month: 7, day: 4})
assert Enum.map(media_view.items, & &1.title) == ["Media On"]
end
defp titles_in_section(view, title) do defp titles_in_section(view, title) do
view.sections view.sections
|> Enum.find(&(&1.title == title)) |> Enum.find(&(&1.title == title))

View File

@@ -0,0 +1,78 @@
defmodule BDS.UI.TaskGroupingTest do
use ExUnit.Case, async: true
alias BDS.UI.TaskGrouping
defp task(id, status, opts \\ %{}) do
%{
id: id,
name: Map.get(opts, :name, "Task #{id}"),
status: status,
progress: Map.get(opts, :progress),
message: nil,
group_id: Map.get(opts, :group_id),
group_name: Map.get(opts, :group_name)
}
end
describe "build_task_entries/1" do
test "tasks without a group stay single entries in order" do
entries = TaskGrouping.build_task_entries([task("a", :running), task("b", :completed)])
assert [{:single, %{id: "a"}}, {:single, %{id: "b"}}] = entries
end
test "tasks with a group_id are grouped by first-seen order" do
entries =
TaskGrouping.build_task_entries([
task("a", :running, %{group_id: "g1", group_name: "Import"}),
task("b", :running),
task("c", :pending, %{group_id: "g1", group_name: "Import"}),
task("d", :completed, %{group_id: "g2", group_name: "Upload"})
])
assert [
{:group, "g1", "Import", [%{id: "a"}, %{id: "c"}]},
{:single, %{id: "b"}},
{:group, "g2", "Upload", [%{id: "d"}]}
] = entries
end
test "group label falls back to the group_id when no group_name is set" do
assert [{:group, "g1", "g1", [_]}] =
TaskGrouping.build_task_entries([task("a", :running, %{group_id: "g1"})])
end
end
describe "summarize_task_group/1" do
test "counts statuses and averages progress contributions" do
summary =
TaskGrouping.summarize_task_group([
task("a", :running, %{progress: 0.5}),
task("b", :pending),
task("c", :completed)
])
assert summary.total == 3
assert summary.running == 1
assert summary.pending == 1
assert summary.completed == 1
assert summary.failed == 0
assert summary.cancelled == 0
# contributions: 0.5 (running) + 0 (pending) + 1.0 (completed)
assert_in_delta summary.progress, 0.5, 0.001
end
test "finished failures and cancellations count as full progress" do
summary = TaskGrouping.summarize_task_group([task("a", :failed), task("b", :cancelled)])
assert summary.progress == 1.0
assert summary.failed == 1
assert summary.cancelled == 1
end
test "empty group summarizes to zero" do
assert %{total: 0, progress: +0.0} = TaskGrouping.summarize_task_group([])
end
end
end