Compare commits

...

116 Commits

Author SHA1 Message Date
d7e30b94cb chore: cleanup of tmp files for test 2026-05-28 22:48:48 +02:00
f1265ee326 fix: broken CSS for metadata diff 2026-05-28 22:41:57 +02:00
c5e09e7316 fix: A1-7 implement 4-level template lookup cascade (post→tag→category→default) 2026-05-28 22:38:35 +02:00
1ae6152da7 fix: A1-15 add PreviewDraftOverlay and GenerationPublishedOnly invariants to specs 2026-05-28 22:27:08 +02:00
0305d80051 fix: A1-6 preview prefers draft content over published files for on-demand rendering 2026-05-28 22:24:07 +02:00
a021fc45cd chore: ignore tmp/ folder 2026-05-28 22:21:47 +02:00
fceb995c7c chore: update allium spec for clearer wording towards embedding model 2026-05-28 22:21:36 +02:00
e58d68e73e fix: A1-6 implement on-demand rendering in preview server per spec 2026-05-28 22:20:39 +02:00
0f30221907 fix: A1-5 implement post editor auto-save after 3000ms idle, on tab switch, and on unmount 2026-05-28 21:45:42 +02:00
d423b6db98 chore: claude md and gitignore for tmp 2026-05-28 21:41:37 +02:00
3adb4407a0 chore: removed tmp/ 2026-05-28 21:41:21 +02:00
05923f255b fix: A1-4 omit doNotTranslate from frontmatter when false per spec 2026-05-28 21:36:10 +02:00
ff89d78ab4 fix: A1-3 publish_post deletes old file when post path changes 2026-05-28 21:15:58 +02:00
e2c92cb90d fix: A1-2 delete translation files from disk when parent post is deleted 2026-05-28 18:47:27 +02:00
82ce445c44 fix: A1-1 implement archived→draft/published transitions, wire archive/unarchive into post editor quick actions, complete all i18n translations 2026-05-28 18:39:52 +02:00
f99e139fa5 feat: added a gallery quick action and fleshed out builtin macros 2026-05-28 17:19:49 +02:00
1914b05f39 chore: tend to allium spec to align with code 2026-05-28 13:36:55 +02:00
b09b14cc03 fix: fixes to rendering in the AI chat 2026-05-28 11:21:03 +02:00
721b1ae626 fix: styling for a2ui surfaces was missing 2026-05-28 11:03:22 +02:00
f7a4a9512c fix: persist a2ui surfaces in the database for chats to re-hydrate on
opening an old chat, unless manually dismissed
2026-05-27 20:13:33 +02:00
141c2bfc89 removed fixed codesmell document 2026-05-27 19:20:43 +02:00
a5ac74db91 fix(style): add missing @impl true to all handle_call clauses in Publishing GenServer (CSM-036) 2026-05-27 19:19:50 +02:00
beca4d992f fix(docs): document UILocale process-dict invariant and add enforcement tests (CSM-035) 2026-05-27 19:16:42 +02:00
9e6d93a4b3 fix(safety): replace File.read! with File.read and error-tuple handling in preview_assets and templates (CSM-034) 2026-05-27 19:10:13 +02:00
e29dfb490a fix(perf): replace Enum.each + individual inserts with preloaded keys and batch upsert in embeddings (CSM-033) 2026-05-27 19:03:21 +02:00
f2b340ba86 fix(style): replace Map.get with dot access and pattern matching where keys are guaranteed (CSM-032) 2026-05-27 18:33:42 +02:00
d18e0ef7f2 fix(rendering): replace inline try/rescue with with-chains and safe_liquex_render helpers (CSM-031) 2026-05-27 18:12:23 +02:00
2d796cee83 updated specgaps for A3-1 2026-05-27 17:56:19 +02:00
b052d59376 fix(fs): handle File.mkdir_p errors and remove bang variants in sidecars and release packaging (CSM-030) 2026-05-11 20:25:06 +02:00
4a089b0856 fix(perf): bind length/1 to variables before loop bodies in route paths and sidebar (CSM-029) 2026-05-11 20:18:56 +02:00
2632649cdc fix(rendering): replace raising read_template_file with try_read in macro templates (CSM-028) 2026-05-11 20:09:49 +02:00
782511d523 fix(templates): replace equality check with pattern matching in rewrite_template_file (CSM-027) 2026-05-11 20:06:15 +02:00
1cb59d7a78 chore: update the spec gaps with decisionm points 2026-05-11 12:19:16 +02:00
9844f3555a chore: analyse specs against code 2026-05-11 11:56:34 +02:00
99dc1c2216 chore: remove redundant export-only tests, add test audit procedure
Deleted chat_editor_test.exs and import_editor_test.exs which only
checked function_exported?/Code.ensure_loaded? without exercising any
behavior — both components are already tested via LiveView rendering
in shell_live_test.exs and import_shell_live_test.exs respectively.

Added TESTAUDIT.md documenting the procedure for periodic test suite
audits to catch non-behavioral tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 10:35:24 +02:00
71fb99af16 chore: unit tests adaption to idea of gate test 2026-05-11 10:21:00 +02:00
0808b27057 chore: moved from delay-based tests to deterministic gated server tests
for chat
2026-05-11 10:20:46 +02:00
ac4f5a3580 fix: eliminate TOCTOU race in template file system by reading directly instead of checking existence (CSM-026) 2026-05-11 09:26:54 +02:00
43a435f35d fix: derive pagefind language prefixes from project settings (CSM-025)
Replace hardcoded ["de/", "fr/", "it/", "es/"] with prefixes computed
from plan.blog_languages, so arbitrary language codes work correctly.
Also mark CSM-024 as fixed (done in CSM-005).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 09:19:31 +02:00
7b383d31ab fix: decompose monolithic functions into focused helpers (CSM-023)
Break up SRP violations in Templates.do_update_template/2 and
Capabilities.for_project/2 by extracting domain-specific builder
functions and single-responsibility private helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 09:15:48 +02:00
09df925e9b chore: allow git push 2026-05-11 09:10:08 +02:00
a4ecbabc21 fix: return error tuples instead of silent {:ok, ""} in execute_macro (CSM-022)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 09:07:44 +02:00
2be43ca06d fix: replace cond blocks with pattern matching and case (CSM-021)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 09:04:56 +02:00
d231f42363 chore: allowed git add and ecto migrate 2026-05-10 12:37:24 +02:00
7c00279b9d fix: flatten nested case blocks with with chains (CSM-020)
Replace deeply nested case expressions with flat with chains in
import_definitions, publishing, and templates modules. Also replaced
Repo.update!() with Repo.update() in the publishing update_job handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 12:36:42 +02:00
b6f9cf58e1 fix: add @spec to all public functions across 24 modules (CSM-019)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 11:40:42 +02:00
3f77488e33 fix: add @moduledoc to all public modules (CSM-018)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 11:30:08 +02:00
5c17751d55 fix: fixed CSM-017 2026-05-09 17:33:51 +02:00
e4452ca504 chore: unit tests for CSM-016 2026-05-09 17:18:43 +02:00
ce80f28e60 fix: fix CSM-016 for real (previous commit was 015) 2026-05-09 17:18:32 +02:00
f1de11a205 fix: fixed CSM-016 2026-05-09 16:57:59 +02:00
ff219fd110 chore: unit test for csm-014 2026-05-09 16:38:18 +02:00
de7ea12c9c fix: fixed CSM-014 2026-05-09 16:38:00 +02:00
1beffe6b07 chore: unit test for csm-013 2026-05-09 15:10:24 +02:00
999632dbe7 fix: fixed CSM-013 2026-05-09 15:10:04 +02:00
44b88056e3 fix: fix CSM-012 2026-05-09 15:04:10 +02:00
35b3818d58 chore: unit test for CSM-011 2026-05-09 14:52:48 +02:00
37db52c024 fix: fixed CSM-011 2026-05-09 14:52:29 +02:00
f1445120fc fix: fix CSM-010 2026-05-09 14:31:44 +02:00
14dfbd8829 fix: fixed CSM-009 2026-05-09 14:22:56 +02:00
24e9e9a022 fix: fixed CSM-008 2026-05-09 14:17:32 +02:00
88c689ee55 fix: fixed CSM-007 2026-05-09 10:41:28 +02:00
e5429f7265 fix: implement CSM-006 2026-05-08 20:39:50 +02:00
93a4159c31 fix: fix CSM-006 2026-05-08 20:26:08 +02:00
06d80e2924 fix: tests for CSM-005 2026-05-08 20:09:18 +02:00
291dff697c fix: fix CSM-005 2026-05-08 20:09:02 +02:00
9944b70ab1 fix: fixed CSM-004 2026-05-08 19:52:59 +02:00
723b8c6433 fix: worked on CSM-003 2026-05-07 21:49:59 +02:00
92334256cf fix: fix CSM-002 2026-05-07 16:52:53 +02:00
d3f45ba0dd fix: CSM-001 done 2026-05-06 19:33:54 +02:00
3ce6010b87 updated codesmell file with new analysis 2026-05-06 18:48:00 +02:00
f704aba288 chore: added a new code smell todo 2026-05-05 20:44:58 +02:00
5282fcd241 chore: update docs 2026-05-04 13:11:23 +02:00
7756d9f83c feat: last gaps on tailwind migration 2026-05-04 13:00:42 +02:00
4ab0bc7b4e feat: gaps in tailwind migration closed 2026-05-04 12:27:07 +02:00
eca89e51d2 feat: phase 5 of tailwind migration 2026-05-04 12:02:13 +02:00
8e715eec8b feat: phase 4 of tailwind migration 2026-05-04 11:39:31 +02:00
35017f9793 feat: p hase 3 of tailwind migration 2026-05-04 11:12:17 +02:00
b17e9cc3f8 feat: rework of the full CSS machine to tailwind and modular CSS
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 10:15:01 +02:00
6b6c985187 fix: better styling for docs 2026-05-04 07:01:43 +02:00
cb46b45cda feat: added doc rendering 2026-05-04 06:47:28 +02:00
43a4610ce7 chore: noise in tests 2026-05-04 06:18:06 +02:00
4de8492c4f feat: complete change to gettext from homebrew i18n solution 2026-05-03 22:28:25 +02:00
4bee8cf1db fix: proper menu translation 2026-05-03 19:31:26 +02:00
dbb93a66f6 chore: moved a bunch of bridges out of the ShellLive module into its own
module
2026-05-03 19:09:24 +02:00
483c13aaa3 chore: more of the overlay and sidebar stuff 2026-05-03 18:56:10 +02:00
f3d8fbcbdc chore: moved more code out of ShellLive into their own files 2026-05-03 18:54:32 +02:00
c16afa4c00 chore: switched MiscEditor to LiveComponent 2026-05-03 18:21:48 +02:00
0f193929da chore: converted import editor to LiveComponent 2026-05-03 17:57:43 +02:00
fa76cdf11d chore: converted ai chat to a live component 2026-05-03 17:20:52 +02:00
98243cbd16 feat: some more work on completing AI translation features 2026-05-03 15:32:54 +02:00
657ed58e80 fix: provide target language for AI suggestions 2026-05-03 14:50:20 +02:00
556f33711f fix: fixed media quick actions usage for images 2026-05-03 14:24:59 +02:00
5bc2b4a338 chore: convert media to live component 2026-05-03 13:09:27 +02:00
9f17954ce3 overlay for post suggestions uses AI now 2026-05-03 12:42:03 +02:00
b9797809aa fix: metadata and excerpt collapsible 2026-05-03 11:38:14 +02:00
4fee1a6333 chore: converted post editor to live component 2026-05-03 11:32:43 +02:00
0075f25ef7 chore: converted scripts and templates to live components 2026-05-03 10:00:22 +02:00
8d7e7419d4 chore: convert menu editor to live component 2026-05-03 09:35:49 +02:00
ce54e973ad chore: converted preferences editor to live component 2026-05-03 09:19:27 +02:00
6c7fde6b95 changed tags editor into live component 2026-05-03 09:03:54 +02:00
eb8f5698e3 feat: just some work on tcp handling and god modules 2026-05-03 08:45:31 +02:00
2be751400d fix: parity in behaviour for scripts, templates and posts 2026-05-02 19:50:13 +02:00
73e066c330 fix: tag editor hopefully working and fixes to test runner 2026-05-02 11:24:51 +02:00
a4ea24faa2 fix: fixed import action for orphaned files 2026-05-02 11:08:33 +02:00
45040f9f66 fix: fixed duplicate elements in ai chat 2026-05-02 10:55:14 +02:00
4cf0f5281b feat: fill missing translations implemented 2026-05-02 10:33:19 +02:00
24f114c24e fix: made menus more stable and verified and hooke up stuff that got lost 2026-05-02 09:37:24 +02:00
07fab7d1ab feat: delete buttons on sidebar entries 2026-05-02 09:15:54 +02:00
c118412f56 fix: handling of tab titles on restore 2026-05-02 09:03:03 +02:00
e0f13e325b fix: tab titles on ai chats on reload 2026-05-02 08:47:27 +02:00
631ceb0521 fix: still fighting crashes on close and weird AI chat behaviou 2026-05-02 08:40:36 +02:00
7db8f6d36b fix: more work on chat and chat titles 2026-05-01 23:34:37 +02:00
c495a2ed0a fix: A2UI now behaves better 2026-05-01 23:15:04 +02:00
64a5eb525d fix: count_posts paginated before aggregation 2026-05-01 22:56:29 +02:00
fef722c4c9 fix: some more work on close-crashing 2026-05-01 22:45:36 +02:00
312 changed files with 69650 additions and 15886 deletions

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "phoenix",
"runtimeExecutable": "mix",
"runtimeArgs": ["phx.server"],
"port": 4000
}
]
}

View File

@@ -0,0 +1,2 @@
- [Fix all test failures](feedback_fix_all_failures.md) — Never dismiss failures as pre-existing or flaky; investigate and fix
- [Debug targeted](feedback_targeted_debugging.md) — Analyze the code and fix; don't brute-force with repeated suite runs

View File

@@ -0,0 +1,13 @@
---
name: Fix all test failures including flaky ones
description: Never dismiss test failures as pre-existing or flaky — investigate root cause and stabilize
type: feedback
---
All test failures must be fixed, even if they appear unrelated to current changes. The test suite was clean before, so any failure is my responsibility.
Flaky tests are deeper problems waiting to surface. Running a test in isolation and seeing it pass is never enough — must find out why it was flaky in the full suite run and make it stable.
**Why:** Dismissing failures as "pre-existing" or "flaky" is wrong. Flaky tests indicate real issues (race conditions, test pollution, shared state) that will bite harder later.
**How to apply:** After making changes, if any test fails: investigate the root cause, fix it, and verify it passes reliably in the full suite. Never stash, never skip, never re-run and hope. Never dismiss ordering-dependent failures — find and fix the shared state or race condition.

View File

@@ -0,0 +1,11 @@
---
name: Debug targeted, don't brute-force
description: When investigating flaky tests, analyze the code and fix — don't brute-force with repeated full suite runs
type: feedback
---
If you already know which test is failing and why, fix it. Don't waste time running the full suite 20 times hoping to capture output.
**Why:** It's slow, wasteful, and avoids thinking. Analyze the code, understand the race, fix it.
**How to apply:** When a test fails, read the test, understand what it does, identify the root cause, and fix it. Only re-run to verify the fix, not to gather more data you already have.

View File

@@ -0,0 +1,22 @@
{
"permissions": {
"allow": [
"Bash(mix compile *)",
"Bash(mix test *)",
"Bash(mix dialyzer *)",
"Bash(mix ecto.migrate)",
"Bash(git add *)",
"Bash(git push *)",
"Bash(git -C /Users/gb/Projects/bDS2 status)",
"Bash(git status *)",
"Bash(mix assets.deploy)",
"Bash(mix phx.server)",
"mcp__Claude_Preview__preview_start",
"mcp__Claude_in_Chrome__navigate",
"mcp__Claude_in_Chrome__computer",
"mcp__Claude_in_Chrome__browser_batch",
"mcp__Claude_in_Chrome__javascript_tool",
"Bash(allium check *)"
]
}
}

3
.gitignore vendored
View File

@@ -3,10 +3,11 @@
/deps/ /deps/
/dist/ /dist/
/doc/ /doc/
/tmp/
/.elixir_ls/ /.elixir_ls/
/erl_crash.dump /erl_crash.dump
/node_modules/ /node_modules/
/priv/data/*.db /priv/data/*.db
/priv/data/*.db-shm /priv/data/*.db-shm
/priv/data/*.db-wal /priv/data/*.db-wal
*.ez *.eztmp/

View File

@@ -20,6 +20,7 @@ This document provides context and best practices for GitHub Copilot when workin
- you must use ecto to generate migrations and snapshots - you must use ecto to generate migrations and snapshots
- on MacOS we use native menus and you have to hook them into the intercept for new menu items - on MacOS we use native menus and you have to hook them into the intercept for new menu items
- there are two areas of localization, you sometimes need both (menus for example) - there are two areas of localization, you sometimes need both (menus for example)
- localization is done with elixier gettext and you need mix gettext.extract to update translation files
- all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast - all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast
- metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other. - metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other.
- if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database - if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database
@@ -112,6 +113,7 @@ This document provides context and best practices for GitHub Copilot when workin
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback - For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
- English fallback is allowed only when the requested locale is unsupported by available locale files - English fallback is allowed only when the requested locale is unsupported by available locale files
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras - The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
- When adding new `msgid` entries, you MUST provide translations for ALL supported locales (de, fr, it, es) — empty `msgstr` values are not acceptable
> **No hardcoded user-facing text. No exceptions.** > **No hardcoded user-facing text. No exceptions.**

4275
API.md

File diff suppressed because it is too large Load Diff

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

474
DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,474 @@
# bDS2 User Guide
## In this article
- [Who this guide is for](#who-this-guide-is-for)
- [How bDS2 works](#how-bds2-works)
- [Getting started](#getting-started)
- [Understanding the interface](#understanding-the-interface)
- [Working with posts](#working-with-posts)
- [Working with pages](#working-with-pages)
- [Working with media](#working-with-media)
- [Working with translations](#working-with-translations)
- [Using macros](#using-macros)
- [Using scripting](#using-scripting)
- [Using the AI assistant](#using-the-ai-assistant)
- [Organizing with tags](#organizing-with-tags)
- [Using blogmarks](#using-blogmarks)
- [Importing from WordPress (WXR)](#importing-from-wordpress-wxr)
- [Using Git (Source Control)](#using-git-source-control)
- [Configuring settings](#configuring-settings)
- [Checking and repairing metadata](#checking-and-repairing-metadata)
- [Managing templates](#managing-templates)
- [Generating and publishing](#generating-and-publishing)
- [Typical editorial workflows](#typical-editorial-workflows)
- [Working fully offline](#working-fully-offline)
- [Troubleshooting and recovery](#troubleshooting-and-recovery)
- [Team conventions](#team-conventions)
## Who this guide is for
This guide is for people who use bDS2 day to day to create, edit, organize, translate, generate, and publish blog content. It is written for editors, content managers, and project owners who need reliable guidance on what each part of the application does and how to use it safely.
If you need implementation notes, project architecture, or development setup, use the repository README. This guide stays focused on end-user operation and editorial decisions.
### Key takeaways
- bDS2 documentation should help with real editorial work, not only isolated clicks.
- Each chapter explains purpose first, then usage.
- Safe content handling and recoverability matter throughout the application.
[↑ Back to In this article](#in-this-article)
---
## How bDS2 works
bDS2 is a local-first writing and publishing workspace. You can draft, revise, structure, preview, and publish content on your machine without depending on constant internet access. Optional remote Git synchronization and AI-assisted workflows extend that model, but they do not replace it.
Three states matter in day-to-day work. A draft is your in-progress state. Publishing marks a local content state as published inside your project. A Git commit creates a recoverable snapshot that can be reviewed, synchronized, and restored. These actions are related, but they are not the same operation.
The recommended sequence remains simple: edit in draft, publish when the content is ready, then commit immediately. That is the safest pattern for protecting work and keeping project history understandable.
### Key takeaways
- bDS2 is designed for local reliability first.
- Publish and commit are different actions and both matter.
- The safe default lifecycle is: Draft -> Publish -> Commit.
[↑ Back to In this article](#in-this-article)
---
## Getting started
Before you begin editorial work, confirm that the project context is correct. Open bDS2 and select the right project. If this is a new project, create it and define its identity early, including project name and description.
Next, open Settings and verify the project data path and Public Base URL. The data path should match your backup strategy. The Public Base URL should be set early because sitemap and feed generation depend on it.
Finally, define language and author defaults. These defaults reduce repetitive edits and keep output consistent when multiple contributors work in the same project.
### Key takeaways
- Set project identity, data location, and Public Base URL at the beginning.
- Configure language and author defaults before regular editing starts.
- Early setup decisions reduce later cleanup.
[↑ Back to In this article](#in-this-article)
---
## Understanding the interface
The bDS2 interface is organized around workflows rather than isolated forms. The Activity Bar on the left moves between major areas such as Posts, Pages, Media, Tags, Import, Source Control, and Settings. The Sidebar changes with the active area and helps with filtering, selection, and navigation. The Editor area is where most work happens and supports tabbed editing for content, configuration, and analysis views.
The bottom panel and status area matter during longer operations such as imports, rebuild actions, metadata scans, and media work. Toasts provide quick feedback. The Output panel provides deeper detail when something needs attention.
Tab behavior is optimized for quick scanning and focused editing. Single click often opens a transient tab. Double click or explicit actions pin a tab for longer work.
### Key takeaways
- Use the Activity Bar for section-level context switching.
- Use the Sidebar for finding and narrowing content.
- Pin tabs when you move from inspection to editing.
[↑ Back to In this article](#in-this-article)
---
## Working with posts
The Posts section is for chronological content such as articles, notes, and recurring updates. In most editorial teams, Posts are the primary outward-facing stream.
A post combines title, body content, category, tags, excerpt, and status. Titles establish topic. Body content carries the narrative. Categories provide broad structure. Tags support finer discovery. Status should be used intentionally so collaborative workflows stay clear.
A reliable post workflow is: draft to completion, review structure and metadata, preview the result, publish when editorially ready, then commit immediately.
When you want help refining post metadata, use Quick Actions in the post editor and review AI suggestions for title, summary, and slug. Treat this as editorial assistance, not an automatic rewrite.
### Key takeaways
- Use Posts for date-oriented and regularly updated content.
- Categories and tags serve different purposes: broad grouping versus precise discovery.
- Publish only when editorially ready, then commit right away.
[↑ Back to In this article](#in-this-article)
---
## Working with pages
Pages are for durable, non-chronological content such as About, Contact, legal notices, and other structural information. Use Pages when content should stay stable in navigation and should not be interpreted as part of a time-based feed.
Because pages are revisited over longer periods, naming consistency matters. Keep titles and slugs predictable, avoid unnecessary structural churn, and follow your project navigation conventions.
The working pattern is similar to posts: draft, review, preview, publish, commit. The difference is editorial intent: pages prioritize clarity and long-term maintainability over release cadence.
### Key takeaways
- Use Pages for stable structural content.
- Keep titles and slugs consistent for maintainability.
- Apply the same safe lifecycle: Draft -> Publish -> Commit.
[↑ Back to In this article](#in-this-article)
---
## Working with media
The Media section is where you import, describe, and maintain assets used by posts and pages. It is not only a file list; it is also where accessibility and descriptive quality are enforced through metadata.
When importing media, add metadata while context is still fresh. Alt text should describe meaning for accessibility. Captions should support reader understanding. Media tags should help later retrieval and reuse.
You can also drag image files into the post editor or paste screenshots from the clipboard. bDS2 imports the image into the media library, links it to the current post, and inserts the Markdown image at the cursor position.
### Key takeaways
- Media management includes metadata quality, not only file import.
- Add alt text and captions during import, not as a postponed task.
- Commit content and related media in the same change when possible.
[↑ Back to In this article](#in-this-article)
---
## Working with translations
bDS2 supports translating both posts and media metadata into multiple languages. Translations are stored separately from canonical content so localized variants do not drift into unrelated records.
### Post translations
Each post has a canonical language and can have translations for additional languages. Translations keep their own title, excerpt, and content, while canonical metadata such as category, tags, slug, and publish state stays centralized.
The post editor shows the current language, existing translations, and missing languages. Posts marked Do Not Translate are excluded from automatic translation and from alternate language trees during site generation.
Published translation body content follows the same filesystem rule as published posts: the body lives in the file, not in the database.
### Media translations
Media items can have translated title, alt text, and caption values per language. The binary asset stays shared; only descriptive text varies by language.
### Automatic translation cascade
When blog languages are configured, bDS2 can fill missing translations for posts and linked media. Automatic translation respects airplane mode and the configured AI runtime. If an automatic action cannot run in the current AI mode, bDS2 reports that through the UI instead of silently inventing a result.
### Key takeaways
- Post translations store title, excerpt, and content separately from the canonical post.
- Media translations store translated descriptive text while the asset stays shared.
- Automatic translation keeps posts and linked media aligned across configured languages.
- Do Not Translate excludes content from multi-language workflows.
[↑ Back to In this article](#in-this-article)
---
## Using macros
Macros let you insert dynamic content blocks directly inside Markdown by using `[[macro_name ...]]` syntax. bDS2 expands these macros during preview and generated output using local assets only.
Built-in macros include YouTube, Vimeo, gallery, photo archive, and tag cloud helpers. Use them when you want reusable rich blocks without dropping into raw HTML.
### Key takeaways
- Macros are inserted directly in Markdown and expanded during preview and publishing.
- Use macro parameters to control behavior without leaving the editor.
- Built-in macros remain the first choice for common embedded content blocks.
[↑ Back to In this article](#in-this-article)
---
## Using scripting
Scripts in bDS2 are Lua files stored in your project's `scripts/` directory. Published scripts are written as `.lua` files with frontmatter metadata, so they stay portable and Git-reviewable.
Each script has a Kind (`macro`, `transform`, or `utility`) and an Entrypoint. Utility and transform scripts typically default to `main`. Macro scripts default to `render`.
### Transform scripts
Transform scripts run during blogmark import to normalize or enrich incoming post data before the post is created. The entrypoint receives a post table and can optionally receive a context table.
```lua
function main(post, context)
local title = (post.title or ""):gsub("^%s+", ""):gsub("%s+$", "")
if title ~= "" and not title:match("^%[Clipped%]") then
post.title = "[Clipped] " .. title
end
post.categories = { "Inbox", "Research" }
return post
end
```
`context.source` identifies the import source. `context.url` contains the original bookmarked URL when that information exists.
### Macro scripts
Macro scripts let you create custom `[[macro_name ...]]` blocks that expand during preview and generation. The entrypoint receives a context table and the current post table.
```lua
function render(context, post)
local params = context.params or {}
local title = (post and post.title) or "Unknown"
local label = params.label or ""
return {
html = "<p>" .. title .. ": " .. label .. "</p>"
}
end
```
Built-in macros take priority over custom Lua macros that reuse the same slug.
### API access
Lua scripts can call the application API through `bds`. The in-app API tab is rendered from the live Lua capability map, and [API.md](API.md) is generated from the same source.
```lua
local result = bds.posts.get("post-id")
```
### Key takeaways
- Scripts in bDS2 are Lua files, not Python files.
- Published scripts are stored as `.lua` files with frontmatter metadata.
- `main` is the usual entrypoint for utility and transform scripts; `render` is the usual entrypoint for macros.
- The scripting API is documented with Lua examples and kept in sync with the live runtime.
[↑ Back to In this article](#in-this-article)
---
## Using the AI assistant
The AI assistant is integrated into bDS2 to help with editorial tasks such as search, analysis, metadata suggestions, translation, and structured content inspection.
The assistant works on your project data. Depending on configuration, requests can run against the configured online endpoint or the airplane-mode endpoint. Automatic AI actions remain gated by airplane mode rules in the app, and bDS2 surfaces status through toasts and the Output area instead of silently bypassing that policy.
The assistant can present results as text, tables, cards, charts, metrics, lists, forms, and tabbed views. Ask plainly for the result you need, or request a specific presentation when that helps your workflow.
### Key takeaways
- The assistant works with your project content and metadata.
- AI configuration can be online or airplane-mode based, depending on your setup.
- Automatic AI actions respect airplane mode and report availability through the UI.
- Ask for a table, chart, list, or form when a specific shape is useful.
[↑ Back to In this article](#in-this-article)
---
## Organizing with tags
Tags are your precision taxonomy tool. Over time, even well-managed projects accumulate near-duplicate tags, naming inconsistencies, and labels that no longer help readers or editors. Use the Tags area to keep taxonomy useful.
After significant taxonomy cleanup, create a focused commit that captures the change clearly.
### Key takeaways
- Tags improve discovery only if naming stays consistent.
- Merge and rename operations should be deliberate and reviewed.
- Commit taxonomy changes in focused snapshots.
[↑ Back to In this article](#in-this-article)
---
## Using blogmarks
Blogmarks provide a quick way to save links from the browser directly into bDS2 as new posts. Generate the bookmarklet from Settings, add it to your browser bar, and click it when you want to capture a page into the current project.
Transform scripts can normalize incoming blogmark posts before creation. Use them for title cleanup, default tags, or source-specific formatting.
### Key takeaways
- Blogmarks turn the browser into a one-click content capture tool.
- Generate the bookmarklet from Settings and add it to your browser bar.
- Use transform scripts to enrich incoming posts automatically.
[↑ Back to In this article](#in-this-article)
---
## Importing from WordPress (WXR)
The Import section supports structured migration from WordPress exports. Treat import as a staged workflow: analyze first, adjust mappings, then execute. For larger sites, iterative passes are usually safer than a single rigid import.
### Key takeaways
- Treat WXR import as analyze, adjust, execute.
- Iterative passes are safer than one large import.
- Validate representative output before committing migrated content.
[↑ Back to In this article](#in-this-article)
---
## Using Git (Source Control)
Source Control in bDS2 is the foundation for reliable recovery and collaboration. Publishing marks local editorial state, but Git commits provide durable history.
In a normal cycle, synchronize first, complete editorial changes, publish when ready, commit with a specific message, then push when you want to share the result.
### Key takeaways
- Git provides recoverable history; publishing alone does not.
- A stable rhythm is: sync, edit, publish, commit, push.
- Specific commit messages improve teamwork and recovery.
[↑ Back to In this article](#in-this-article)
---
## Configuring settings
Settings define how the project behaves. Project settings control identity, paths, public URL context, and render languages. Editor settings shape day-to-day working defaults. AI settings are optional and should enhance, not define, your editorial workflow.
Maintenance actions such as rebuilds and diff scans are repair tools for specific situations, not part of routine editing.
### Key takeaways
- Settings affect long-term consistency across the project.
- Optional integrations should not replace the core workflow.
- Rebuild actions are corrective tools, not daily habits.
[↑ Back to In this article](#in-this-article)
---
## Checking and repairing metadata
Over time, metadata stored in the database and metadata stored on disk can drift apart, especially after external edits, merges, or file operations. The Metadata Diff tool detects these inconsistencies and lets you repair them without rebuilding everything.
The scan covers posts, media, scripts, and templates. Results are grouped by entity type, and field pills let you focus on one kind of difference at a time.
Use DB to File when the database is correct. Use File to DB when the filesystem is correct.
### Key takeaways
- Metadata Diff compares database records against files on disk.
- Field pills help you bulk-repair one difference type at a time.
- Use it after external changes, not as part of routine editing.
[↑ Back to In this article](#in-this-article)
---
## Managing templates
Templates control the Liquid layout used when bDS2 generates HTML pages. Template kinds determine where they are used: `post`, `list`, `not-found`, and `partial`.
Templates are stored as files with frontmatter metadata in the project data directory, so they are portable and Git-reviewable.
### Key takeaways
- Templates define the generated HTML layout.
- Four template kinds cover page, list, not-found, and reusable partial rendering.
- Templates are filesystem-backed and Git-friendly.
[↑ Back to In this article](#in-this-article)
---
## Generating and publishing
Publishing in bDS2 is a staged process: publish content locally, generate or validate-and-apply site changes, commit the result, then deploy when ready.
Full generation builds the entire static site. Site validation detects missing, extra, and updated routes so bDS2 can re-render only what changed. This is the practical incremental workflow for most daily editorial changes.
When blog languages are configured, generation produces language-aware route trees, per-language feeds, and alternate language metadata.
### Key takeaways
- Full generation produces the complete site.
- Validate and Apply is the efficient daily workflow for incremental publishing.
- Public Base URL must be set before generation.
- Commit generated output before deploying for recoverability.
[↑ Back to In this article](#in-this-article)
---
## Typical editorial workflows
Short link posts benefit from a lightweight workflow: create, add concise context, classify, preview once, publish, commit. Long-form articles benefit from a fuller cycle: draft thoroughly, add media, review metadata, preview carefully, publish, commit content and media together.
Across both patterns, the safety baseline stays the same: Draft -> Publish -> Commit.
### Key takeaways
- Use a lightweight workflow for short notes and links.
- Use a fuller workflow for long-form content with media.
- Keep the same safety baseline in both cases.
[↑ Back to In this article](#in-this-article)
---
## Working fully offline
bDS2 is designed so core editorial work can continue without network access. You can create and revise content, manage metadata, preview locally, and publish within local project state while offline.
When AI is involved, airplane mode determines which automatic actions are allowed and which endpoint class is used. Keep local commits frequent even when you are not pushing to a remote.
### Key takeaways
- Core editing and publishing workflows work offline.
- Local commits still matter when no remote is available.
- Reconnect and synchronize in a controlled order.
[↑ Back to In this article](#in-this-article)
---
## Troubleshooting and recovery
If content looks correct locally but is missing for collaborators, the usual cause is that changes were published but not committed and pushed. Check repository status, create a commit, then push to the expected remote.
If content lists or references become inconsistent after manual file changes, start with Metadata Diff. If broader inconsistency remains, use rebuild tools to realign database and filesystem state.
### Key takeaways
- Most missing remote content issues are commit or push gaps.
- Metadata Diff is the first repair tool after external file changes.
- Frequent meaningful commits are the strongest safety net.
[↑ Back to In this article](#in-this-article)
---
## Team conventions
Shared conventions reduce ambiguity and merge friction. Teams should agree on category definitions, tag naming rules, publish-readiness criteria, and commit message patterns.
A practical minimum rule is simple: any content considered published should be committed promptly.
### Key takeaways
- Explicit conventions improve speed and reduce avoidable conflict.
- Start with a small rule set and enforce it consistently.
- Minimum standard: published content should be committed promptly.
[↑ Back to In this article](#in-this-article)

166
README.md
View File

@@ -1,52 +1,122 @@
# bDS2 # bDS2
bDS2 is the Elixir rewrite of bDS, the offline-first desktop blogging workspace in [../bDS](/Users/gb/Projects/bDS). The repository now contains a substantial BEAM application: Ecto persistence, filesystem-backed content workflows, rendering/generation/publishing pipelines, AI and MCP integrations, and a bundled desktop shell served by the Elixir runtime. bDS2 is the Elixir rewrite of bDS, the offline-first desktop blogging workspace. It is no longer just a rewrite scaffold: the repository now contains the main desktop runtime, Ecto persistence, filesystem-backed content workflows, rendering and publishing pipelines, Lua scripting, AI and MCP integration, and a Phoenix LiveView shell embedded in a native desktop window.
The Allium specifications in [specs/](/Users/gb/Projects/bDS2/specs) remain the behavioral contract for the rewrite. For current implementation status and the parity roadmap, see [PLAN.md](/Users/gb/Projects/bDS2/PLAN.md). The Allium specs in [specs/](/Users/gb/Projects/bDS2/specs) remain the behavioral contract for the rewrite. For end-user operation, see [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md). For the scripting surface, see [API.md](/Users/gb/Projects/bDS2/API.md).
## Scope ## Current Status
The rewrite aims to preserve the product behavior of bDS while replacing the technical stack. The major architectural rework is in place.
Behaviour that should remain stable includes: - The desktop UI is served by Phoenix LiveView inside the desktop shell rather than by a separate handwritten frontend runtime.
- Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static).
- Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP.
- Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model.
- Offline-first editorial workflows. The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices.
- Filesystem-backed content with stable frontmatter, media sidecars, templates, scripts, and menu formats.
- Project, post, media, translation, tag, template, generation, preview, publishing, AI, and MCP workflows.
- Generated site output, search behavior, metadata synchronization, and rebuild behavior where those are part of the product contract.
The following are intentionally not part of the behavioral contract: ## Architecture Overview
- The implementation language. ### Runtime
- Desktop container or UI framework.
- ORM choice.
- Internal state management, concurrency model, or runtime libraries.
## Scripting Direction [BDS.Application](/Users/gb/Projects/bDS2/lib/bds/application.ex) is the supervision root. It starts the Phoenix endpoint, database, preview and publishing workers, task supervisors, scripting jobs, and the desktop server/window adapters.
bDS2 should use Lua as its user-facing scripting language. At a high level, the stack is:
The reason is host fit, not language fashion: Lua has a better embedding story for the BEAM than Python does, while still being small, expressive, and suitable for user-authored macros, transforms, and utility scripts. The current direction is: - Native windowing through the `:desktop` integration.
- Phoenix endpoint and LiveView shell for the actual app UI.
- Ecto + SQLite for indexed state, editor state, and app data.
- Filesystem-backed project data for published content, media, sidecars, scripts, templates, generated output, and rebuild workflows.
- Lua script files as the persisted user script format. ### Desktop Shell
- A BEAM-hosted execution boundary with explicit host capabilities instead of unrestricted runtime access.
- Bounded but long-running script execution for user-authored code, with explicit progress reporting through host APIs.
The initial runtime baseline in this repository uses a dedicated Elixir scripting boundary with a Luerl-backed Lua adapter. The goal is to keep scripting integration native to the BEAM while making sandboxing and host capability exposure explicit at the application boundary. The desktop workbench lives under [lib/bds/desktop/](/Users/gb/Projects/bDS2/lib/bds/desktop). The main screen is [BDS.Desktop.ShellLive](/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex), with feature-specific editors and sidebar logic under [lib/bds/desktop/shell_live/](/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live).
This keeps the scripting surface lightweight and aligned with the Elixir host application. Python remains a possible integration boundary for specialized tasks, but it is no longer the default scripting model for the rewrite. If you are tracing UI behavior, start there first:
## Repository Layout - LiveView event routing, workbench state, overlays, and menu handling live in the desktop shell modules.
- HEEx templates under the same tree now own most common layout and state styling.
- Monaco remains a vendor drop under [priv/ui/monaco/](/Users/gb/Projects/bDS2/priv/ui/monaco).
- [mix.exs](/Users/gb/Projects/bDS2/mix.exs): Mix project definition. ### Domain Modules
- [config/](/Users/gb/Projects/bDS2/config): Elixir and Ecto configuration.
- [lib/](/Users/gb/Projects/bDS2/lib): application bootstrap and shared runtime modules. Most application behavior lives under [lib/bds/](/Users/gb/Projects/bDS2/lib/bds):
- [priv/repo/](/Users/gb/Projects/bDS2/priv/repo): Ecto migrations.
- [specs/](/Users/gb/Projects/bDS2/specs): Allium specs distilled from the existing bDS product and being normalized for implementation-agnostic use. - posts, media, tags, templates, scripts, and project settings
- metadata, frontmatter, sidecars, rebuild, and maintenance
- rendering, generation, preview, and publishing
- AI runtimes, chat tooling, embeddings, and MCP
- scripting capabilities and generated API docs
The repo has been pushed toward smaller feature-focused modules rather than one large mixed runtime. For new work, prefer finding the owning feature module instead of adding more behavior to broad catch-all files.
### Storage Model
The database is important, but it is not the whole source of truth.
- Ecto models hold app state, indexes, editor state, and workflow data.
- The filesystem holds published content artifacts and sidecar metadata that must stay stable and reviewable.
- Rebuild and metadata-diff flows exist because database state and filesystem state are expected to stay in sync.
When you change persisted behavior, think in both directions: database writes and filesystem writes/readback.
## Localization And i18n
Localization now has two separate layers, and confusing them causes bugs.
### 1. UI Localization
UI chrome, menus, dashboard text, editor labels, and toasts use Gettext through [BDS.Gettext](/Users/gb/Projects/bDS2/lib/bds/gettext.ex) and the `ui` domain. Locale normalization lives in [BDS.I18n](/Users/gb/Projects/bDS2/lib/bds/i18n.ex), and the desktop shell binds the active UI locale through [BDS.Desktop.UILocale](/Users/gb/Projects/bDS2/lib/bds/desktop/ui_locale.ex).
In practice, this is the language of the app itself.
### 2. Render Localization
Rendered site output uses a separate locale flow. Archive labels, pagination text, template-facing render strings, and generated site language handling use the `render` Gettext domain and the project's `main_language` and `blog_languages` settings.
In practice, this is the language of the blog output, not necessarily the UI.
### 3. Content Translation
Posts and media have translation-aware workflows. Post translations and media metadata translations are modeled explicitly, and generation/preview/publishing use the project's configured languages when building output.
Relevant translation resources live under:
- [priv/gettext/](/Users/gb/Projects/bDS2/priv/gettext) for Gettext catalogs
- [priv/i18n/](/Users/gb/Projects/bDS2/priv/i18n) for additional locale data used by the app
If you touch i18n-sensitive behavior, check whether the change belongs to UI locale, render locale, or content translation. They are related, but they are not interchangeable.
## Frontend And Assets
Frontend source now follows the Phoenix asset layout:
- [assets/css/](/Users/gb/Projects/bDS2/assets/css) for Tailwind-based CSS modules
- [assets/js/](/Users/gb/Projects/bDS2/assets/js) for LiveView hooks, bridges, Monaco integration, and UI helpers
- [priv/static/assets/](/Users/gb/Projects/bDS2/priv/static/assets) for generated outputs
The rule of thumb is simple:
- common layout, spacing, state, and typography belong in HEEx and small shared UI primitives
- authored CSS stays for tokens and desktop-specific selectors
- JavaScript stays focused on LiveView hooks, editor integration, drag/drop, and browser APIs
## Repository Map
- [mix.exs](/Users/gb/Projects/bDS2/mix.exs): Mix project definition, aliases, releases, and dependencies
- [config/](/Users/gb/Projects/bDS2/config): runtime, dev, test, and asset configuration
- [lib/bds/](/Users/gb/Projects/bDS2/lib/bds): core application modules
- [lib/bds/desktop/](/Users/gb/Projects/bDS2/lib/bds/desktop): desktop endpoint, shell, menus, controllers, and window integration
- [assets/](/Users/gb/Projects/bDS2/assets): Tailwind and esbuild source
- [priv/repo/](/Users/gb/Projects/bDS2/priv/repo): Ecto migrations and snapshots
- [priv/gettext/](/Users/gb/Projects/bDS2/priv/gettext): UI and render translation catalogs
- [specs/](/Users/gb/Projects/bDS2/specs): Allium behavior specs
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md): end-user guide
- [API.md](/Users/gb/Projects/bDS2/API.md): generated scripting API reference
## macOS Development Setup ## macOS Development Setup
If you are setting up a new macOS machine, start with the toolchain. If you are setting up a new macOS machine, install the toolchain first.
### 1. Install Xcode Command Line Tools ### 1. Install Xcode Command Line Tools
@@ -56,8 +126,6 @@ xcode-select --install
### 2. Install Homebrew ### 2. Install Homebrew
If Homebrew is not already installed:
```bash ```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
``` ```
@@ -69,36 +137,28 @@ brew update
brew install erlang elixir sqlite brew install erlang elixir sqlite
``` ```
Verify the installation: ### 4. Fetch Dependencies And Set Up The App
```bash
elixir --version
mix --version
sqlite3 --version
```
### 4. Fetch Dependencies
```bash ```bash
cd /Users/gb/Projects/bDS2 cd /Users/gb/Projects/bDS2
mix deps.get mix setup
mix assets.setup
``` ```
### 5. Create the Local Database ## Development Workflow
```bash Useful commands:
mix ecto.create
mix ecto.migrate
```
### 6. Run Tests
```bash ```bash
mix compile --warnings-as-errors
mix test mix test
mix dialyzer
mix assets.build
``` ```
## Development Notes Notes for developers:
- Use `mix test` for validation during development. - Specs in [specs/](/Users/gb/Projects/bDS2/specs) define the intended product behavior.
- The application behavior is defined by the Allium specs in [specs/](/Users/gb/Projects/bDS2/specs). - [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md) is for end users, not implementation details.
- Use [PLAN.md](/Users/gb/Projects/bDS2/PLAN.md) for implementation status and the parity roadmap. - [API.md](/Users/gb/Projects/bDS2/API.md) is generated from the live scripting capability map and should stay in sync with runtime changes.
- When changing persistence or localization behavior, check both the database side and the filesystem/render side before assuming the change is complete.

191
SPECAUDIT.md Normal file
View File

@@ -0,0 +1,191 @@
# Spec Audit Process
This document describes the repeatable process for auditing the Allium specifications against the bDS2 codebase and test suite. Run it whenever specs or code change materially.
## Overview
The audit produces three categories of findings:
1. **Spec-claims-not-in-code** — spec describes behavior the code does not implement
2. **Code-not-in-spec** — code implements behavior the spec does not describe
3. **Spec-claims-not-in-tests** — spec invariants/rules/behaviors lack test coverage
## Step 1: Map the Territory
```bash
# List all spec files
ls specs/*.allium
# List all source modules
ls lib/bds/ lib/bds/**/
# List all test files
ls test/bds/ test/bds/**/
```
Record the mapping between specs and code/test files. Use `specs/bds.allium` as the index — it lists every `use` directive with its domain label.
## Step 2: Extract Spec Claims
For each `.allium` file, extract:
| Claim Type | Pattern | Example |
|---|---|---|
| **invariant** | `invariant Name:` or lines describing always-true properties | `UniqueSlugPerProject: slugs unique within project` |
| **rule** | `rule Name { requires: ... ensures: ... }` | `CreatePost: creates with slug, status=draft` |
| **guarantee** | `guarantee Name:` | `SandboxedExecution: no filesystem/process loading` |
| **config** | `config { key = value }` | `macro_timeout = 10.seconds` |
| **behavior** | Explicit claims in comments or entity descriptions | `"HomeAlwaysPresent: menu always has Home entry"` |
Record the spec file name, claim name, claim type, and line number for each.
## Step 3: Compare Spec Claims Against Code
For each claim, find the corresponding code and verify:
### 3a. Entity/field existence
- Does the Ecto schema have the fields the spec declares?
- Are relationships (has_many, belongs_to) present?
- Are enum/status values complete?
```bash
# Check schema fields
grep -n "field :" lib/bds/posts/post.ex
grep -n "has_many\|belongs_to" lib/bds/posts/post.ex
```
### 3b. Rule implementation
- Does the code enforce the `requires` preconditions?
- Does the code produce the `ensures` postconditions?
- Are side-effects (FTS, embeddings, file writes) triggered?
```bash
# Check function implementation
grep -n "def create_post" lib/bds/posts.ex
grep -n "def publish_post" lib/bds/posts.ex
```
### 3c. Invariant enforcement
- Are constraints enforced at the schema level (unique_index, check_constraint)?
- Are constraints enforced in changeset validations?
- Are constraints enforced in business logic?
```bash
# Check database constraints
grep -n "unique_index\|check_constraint" priv/repo/migrations/*.ex
grep -n "unique_constraint\|validate_" lib/bds/posts/post.ex
```
### 3d. File format compliance
- Does the serialization format match the spec's frontmatter values?
- Are conditional fields omitted when falsy?
- Are required fields always present?
```bash
# Check serialization
grep -n "serialize\|write_file\|Frontmatter" lib/bds/frontmatter.ex lib/bds/posts/file_sync.ex
```
## Step 4: Compare Code Against Spec Claims
Search for code that implements behavior NOT described in any spec:
### 4a. Public API functions not in any spec rule
```bash
# List public functions in a module
grep -n "def " lib/bds/posts.ex | grep -v "defp"
```
### 4b. Schema fields not in any spec entity
```bash
# List all fields
grep -n "field :" lib/bds/posts/post.ex
```
### 4c. Side effects not in engine_side_effects.allium
```bash
# Check what happens after CRUD operations
grep -n "sync_post\|sync_media\|Search\.\|Embeddings\.\|AutoTranslation" lib/bds/posts.ex lib/bds/media.ex
```
### 4d. UI features not in any editor spec
```bash
# Check HEEx templates for UI elements
grep -n "phx-click\|data-phx-" lib/bds/desktop/post_editor_html/post_editor.html.heex
```
## Step 5: Compare Spec Claims Against Tests
For each invariant, rule, and guarantee, search for a test that verifies it:
### 5a. Direct test search
```bash
# Search test names and bodies
grep -rn "test \"" test/bds/posts_test.exs | head -30
grep -rn "test \"" test/bds/media_test.exs | head -30
```
### 5b. Invariant coverage check
For each invariant, determine:
- **YES**: Test explicitly verifies the invariant (creates violation, expects rejection)
- **PARTIAL**: Test verifies the happy path but not violation scenarios
- **NO**: No test exists
### 5c. Rule coverage check
For each rule, determine:
- **YES**: Test exercises `requires` precondition and `ensures` postcondition
- **PARTIAL**: Test exercises the happy path but not preconditions or all postconditions
- **NO**: No test exists
### 5d. Side-effect chain coverage
For each side-effect rule in `engine_side_effects.allium`, check whether a test verifies ALL `ensures` clauses fire together (not just individually).
## Step 6: Classify Findings
Each gap falls into one of these categories with a recommended action:
| Category | Direction | Action |
|---|---|---|
| **Spec correct, code wrong** | Spec → Code | Fix the code |
| **Code correct, spec drifted** | Code → Spec | Update the spec |
| **Code behavior, no spec** | Code → Spec | Distill into spec |
| **Spec claim, no test** | Spec → Test | Write test |
| **Internal spec inconsistency** | Spec → Spec | Align specs |
| **Decision needed** | Both | Resolve with stakeholder |
## Step 7: Produce SPECGAPS.md
Consolidate all findings into `SPECGAPS.md` with:
- Gap ID for tracking
- Clear description of the gap
- Which spec file and line
- Which code file and line
- Recommended path (fix code / update spec / write test / decide)
- Priority (HIGH/MEDIUM/LOW)
## Step 8: Validate
After making changes:
```bash
# Run full test suite
mix test
# Run dialyzer
mix dialyzer
# Validate allium specs (if tool available)
# Use the allium CLI to validate spec files
```
## Re-running the Audit
1. Start from Step 2 — re-extract claims from updated specs
2. Run Steps 3-5 against current code and tests
3. Compare against previous SPECGAPS.md to identify resolved and new gaps
4. Update SPECGAPS.md
The audit should be re-run after:
- Adding new spec files or significant spec changes
- Adding new features or refactoring code
- Adding new test files
- Before any release milestone

195
SPECGAPS.md Normal file
View File

@@ -0,0 +1,195 @@
# Spec Gaps — Allium Specs vs Code vs Tests
Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update spec | **ST** = write test | **SD** = decide | **SI** = fix internal spec inconsistency
---
## A. Spec Claims Not Fulfilled by Code
### A1. Code Must Change (spec is normative)
| ID | Gap | Spec | Code | Path |
|---|---|---|---|---|
| A1-1 | ~~No `archived→draft` or `archived→published` transition~~ | post.allium:121-122 | `unarchive_post/1` implemented, `publish_post` already handled archived→published | **Resolved:** `unarchive_post/1` in posts.ex restores content from disk, UI wired via quick actions, 4 tests added |
| A1-2 | ~~`DeletePost` must delete translations + translation files~~ | post.allium:209-212 | `delete_post/1` now fetches translations before cascade-delete and removes their files from disk | **Resolved:** translation file cleanup added to `delete_post/1` in posts.ex, test added |
| A1-3 | ~~Publish must delete old file when path changes~~ | engine_side_effects.allium:73-74 | `publish_post` now deletes old file when `file_path` changes | **Resolved:** old file deletion added to `publish_post/1` in posts.ex, test added |
| A1-4 | ~~`doNotTranslate: false` written to frontmatter despite "only when true"~~ | frontmatter.allium:398 | `file_sync.ex:78` now converts false→nil so serializer omits the key | **Resolved:** doNotTranslate omitted from frontmatter when false, test added |
| A1-5 | ~~Auto-save after 3000ms idle~~ | editor_post.allium:183-188 | PostEditor schedules auto-save via parent timer on dirty change | **Resolved:** 3000ms idle auto-save timer in Bridges, tab-switch save in ShellLive, cancel on manual save, 3 tests added |
| A1-6 | ~~On-demand rendering in preview server~~ | preview.allium:53-93 | `Preview.Router` matches post/archive/home/language routes and renders on-demand via `Rendering` | **Resolved:** `Preview.Router` implements on-demand template rendering for post, archive, home, date, tag, category, page, and language-prefixed routes; static file fallback retained for non-HTML assets (pagefind, feeds); 6 tests added |
| A1-7 | ~~Template lookup must use all 4 levels (post→tag→category→default)~~ | template_context.allium:267-277 | `resolve_post_template_slug/3` implements tag→category cascade; all callers (preview, generation) updated | **Resolved:** `resolve_post_template_slug/3` in template_selection.ex, callers in preview.ex, router.ex, outputs.ex updated, 8 tests added |
| A1-8 | `ValidateLiquid`/`ValidateScript` before publish | template.allium:110, script.allium:165 | No validation gate before publish | Fix code: add validation step before publish |
| A1-9 | 17 preset colors + custom hex in tag picker | editor_tags.allium | Native `<input type="color">`, no preset palette | Fix code: implement preset color palette popover |
| A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create |
| A1-11 | Graceful shutdown with inflight request tracking | preview.allium:47-48 | Kills acceptor process, no inflight tracking | Fix code: track inflight requests, drain before shutdown |
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |
| A1-13 | Git sidebar shows only "Working tree" placeholder | sidebar_views.allium:651-770 | `sidebar.ex:782-798` returns single entity_list item; `BDS.Git` has full status/diff/commit/history/fetch/pull/push/prune_lfs but sidebar doesn't use it | Fix code: wire sidebar `git_view/0` to `BDS.Git` — render branch, ahead/behind, status file list, commit input, history entries, action buttons per spec |
| A1-14 | Embedding uses TF-IDF hash projection instead of real neural model | embedding.allium:44-53, invariants ModelCaching/VectorCacheInDb | `backends/in_app.ex` hashes terms into sparse vectors via `:erlang.phash2`; no ONNX model, no `"query: "` prefix, no mean pooling, vectors stored as JSON text not Float32Array BLOB, snapshot-based neighbor lookup instead of USearch HNSW index | Fix code: (1) add Bumblebee + ONNX runtime deps to run `Xenova/multilingual-e5-small`, (2) implement lazy model download + cache in app data dir, (3) `"query: "` prefix + mean pooling + L2 norm in backend, (4) store vectors as binary BLOB (1536 bytes), (5) replace JSON snapshot with USearch HNSW index (cosine, M=16, ef=128/64, 5s debounce), (6) cross-language semantic similarity must work |
| A1-15 | ~~Preview vs generation content source strategy undocumented~~ | preview.allium (no invariant), generation.allium (no invariant) | Generation uses only published .md file content (`Generation.Data` snapshots set `content: nil`); preview includes published+draft posts and prefers DB content over file (`Preview.Router` queries `:published`/`:draft`, uses `editor_body`) | **Resolved:** added `PreviewDraftOverlay` invariant to preview.allium and `GenerationPublishedOnly` invariant to generation.allium; both cross-reference each other; code already correct, 3 tests added for draft-in-preview behavior |
### A2. Spec Should Update (code is normative)
| ID | Gap | Spec | Code | Path |
|---|---|---|---|---|
| A2-1 | ~~WYSIWYG/visual editor mode (3 modes)~~ | editor_post.allium:159-164 | Only markdown+preview; visual normalizes to markdown | **Resolved:** spec updated to 2 modes (markdown/preview), visual/WYSIWYG dropped |
| A2-2 | ~~Template/Script are global entities~~ | template.allium, script.allium | Both have `project_id`, per-project uniqueness | **Resolved:** spec updated — added `project_id` to entities, scoped uniqueness invariants and create rules per project |
| A2-3 | ~~TagsFile uses `{tags: [...]}` wrapper~~ | frontmatter.allium:255-273 | Code writes bare array `[...]` | **Resolved:** spec updated — removed wrapper object, TagEntry is now the top-level value, bare array in invariant, camelCase keys |
| A2-4 | ~~Sidecar is "YAML-like, not gray-matter"~~ | frontmatter.allium:174 | Code wraps with `---` delimiters | **Resolved:** spec updated — format comment now says gray-matter style with --- delimiters |
| A2-5 | ~~Translation frontmatter omits status/timestamps~~ | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | **Resolved:** spec updated — TranslationFrontmatter now includes status, created_at, updated_at, published_at; TranslationFilesInheritCanonicalMetadata renamed to TranslationFrontmatterRoundtrip; translation.allium invariant updated to TranslationFilesCarryFullMetadata |
| A2-6 | ~~Search index has single `stemmed_content`~~ | search.allium:40-54 | FTS5 per-field stemmed columns | **Resolved:** spec updated — PostSearchIndex has title/excerpt/content/tags/categories; MediaSearchIndex has title/alt/caption/original_name/tags; SearchMedia now accepts filters; index rules use delete-and-reinsert with per-field stemming |
| A2-7 | ~~Tag archives are single-page~~ | generation.allium:142-147 | Code paginates | **Resolved:** spec updated — GenerateTagPages now paginated like categories, using max_posts_per_page |
| A2-8 | ~~Date archives year+month only~~ | generation.allium:151-159 | Code also generates day-level | **Resolved:** spec updated — GenerateDateArchivePages now includes day-level archives, all three levels paginated |
| A2-9 | ~~Menu is DB entity~~ | menu.allium:20-26 | Purely file-based OPML, no DB table | **Resolved:** spec updated — `entity Menu` changed to `value Menu`, file-only model with OPML persistence, added LoadMenu/SyncMenuFromFilesystem rules |
| A2-10 | ~~Panel tabs: problems, terminal~~ | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | **Resolved:** spec already lists tasks/output/post_links/git_log with availability and fallback rules matching code |
| A2-11 | ~~Git sidebar: commit input, history, push/pull~~ | sidebar_views.allium | Only "Working tree" item | **Moved to A1-13:** backend code exists in BDS.Git, sidebar must wire it up |
| A2-12 | ~~Slug timestamp fallback after 999~~ | post.allium:21 | Unbounded numeric suffix | **Resolved:** spec updated — uniqueness comment now says unbounded numeric suffix, no 999 cap or timestamp fallback |
| A2-13 | ~~Thumbnail generation is async~~ | engine_side_effects.allium:117 | Synchronous | **Resolved:** spec updated — import thumbnail generation now says synchronous (awaited, logged on error), matching code; summary table changed from `async` to `sync` |
| A2-14 | ~~AiModelModality: :video vs :file/:tool~~ | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | **Resolved:** spec updated — modality enum now lists "text" \| "image" \| "audio" \| "file" \| "tool", matching code |
| A2-15 | ~~JSON key convention: snake_case vs camelCase~~ | frontmatter.allium values | Code uses camelCase for all metadata JSON | **Resolved:** all value types in frontmatter.allium updated to camelCase field names; added CamelCaseKeys invariant; surfaces updated; also added linkedPostIds to MediaSidecar (C-2) and projectId to TemplateFrontmatter/ScriptFrontmatter (B1-9) |
| A2-16 | ~~Snowball stemmer language list~~ | search.allium:26-31 | Library determines which have algorithms vs passthrough | **Resolved:** spec updated — StemmerLanguage comment now says "Snowball stemmers via library (Stemex); languages with algorithm get real stemming, others pass through" |
| A2-17 | ~~`provider_package_ref` on AiModel~~ | schema.allium:282 | Not in code; legacy field not needed | **Resolved:** dropped from AiModel entity and AiModelRecordSurface in schema.allium; DB column retained (migration artifact) |
---
## B. Code Behavior Not in Spec
### B1. Must Add to Spec (domain-level, affects behavior)
| ID | Behavior | Code Location | Path |
|---|---|---|---|
| B1-1 | Chat inline surfaces (9 types: card, chart, form, list, metric, mindmap, table, tabs, text/json) | `lib/bds/ui/chat/tool_surfaces.ex:6-15` | Distill into spec |
| B1-2 | Auto-translation system (AutoTranslation.maybe_schedule, media cascade, batch fill) | `lib/bds/posts/auto_translation.ex` | Distill into spec |
| B1-3 | 3 extra settings sections (Technology, MCP, Data Maintenance) | `lib/bds/ui/settings_editor/` | Distill into spec |
| B1-4 | Style/Theme as separate tab (`:style`), not settings section | `lib/bds/ui/style_editor.ex` | Distill into spec |
| B1-5 | `published_*` snapshot fields on Post for diffing | `lib/bds/posts/post.ex:61-65` | Add to post.allium entity |
| B1-6 | Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering) | `lib/bds/rendering/` | Distill into spec |
| B1-7 | 404.html generation | `lib/bds/generation/outputs.ex:344-345` | Add to generation.allium |
| B1-8 | ~~`linkedPostIds` in media sidecar~~ | `lib/bds/media/sidecars.ex:42` | **Resolved:** added to MediaSidecar value in frontmatter.allium (with A2-15) |
| B1-9 | ~~`projectId` in template/script frontmatter~~ | `templates.ex:337`, `scripts.ex:268` | **Resolved:** added projectId to TemplateFrontmatter and ScriptFrontmatter in frontmatter.allium (with A2-15) |
| B1-10 | Media translation editing modal | `media_editor.html.heex:275-303` | Add to editor_media.allium |
| B1-11 | Menu editor drag-drop, indent/unindent/move | `lib/bds/desktop/menu_editor/tree_ops.ex` | Add to editor_misc.allium |
| B1-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium |
| B1-13 | `:confirm_dialog` generic confirmation | `shell_overlay.html.heex:171-187` | Add to modals.allium |
| B1-14 | Publish actions for scripts and templates | `script_editor.html.heex:10-12`, `template_editor.html.heex:10-12` | Add to editor_script.allium, editor_template.allium |
| B1-15 | `:import` as full editor tab | `lib/bds/ui/import_editor.ex` | Add to tabs.allium |
| B1-16 | `:documentation`/`:api_documentation` tab types | `lib/bds/desktop/misc_editor/` | Add to tabs.allium |
| B1-17 | Metadata diff covers embedding, media_translation, post_translation as entity types | `lib/bds/maintenance/repair.ex` | Add to metadata_diff.allium |
| B1-18 | Finished task TTL eviction (1h, keep last 10) | `lib/bds/tasks.ex:365-386` | Add to task.allium |
| B1-19 | `discard_post_changes/1` | `lib/bds/posts.ex:201-227` | Add to post.allium |
| B1-20 | `replace_media_file/2` with checksum/backup | `lib/bds/media.ex:288-337` | Add to media.allium |
### B2. Lower Priority (implementation detail or minor)
| ID | Behavior | Code Location |
|---|---|---|
| B2-1 | `editor_body/1` content resolver | `lib/bds/posts.ex:229-252` |
| B2-2 | `sync_post_from_file/1` single-post reimport | `lib/bds/posts.ex:254-279` |
| B2-3 | `import_orphan_post_file/1` | `lib/bds/posts.ex:289-291` |
| B2-4 | `dashboard_stats/1`, `post_counts_by_year_month/1` | `lib/bds/posts.ex:378-413` |
| B2-5 | `regenerate_missing_thumbnails/2` | `lib/bds/media.ex:47-48` |
| B2-6 | Cache dir computation | `lib/bds/projects.ex:101-106` |
| B2-7 | `remove_stale_published_templates` | `lib/bds/templates.ex:524-552` |
| B2-8 | Rendering Labels module (30+ i18n strings) | `lib/bds/rendering/labels.ex` |
| B2-9 | Progress reporting during reindex | `lib/bds/generation/progress.ex` |
---
## C. Internal Spec Inconsistencies
All reconciled to follow code. Specs must be self-consistent and match code.
| ID | Conflict | Resolution | Path |
|---|---|---|---|
| C-1 | schema.allium ChatMessage has no cache tokens; ai.allium ChatMessage has `cache_read_tokens`/`cache_write_tokens` | Code has cache tokens → align schema.allium with ai.allium | Update schema.allium |
| C-2 | ~~media.allium SidecarFile mentions `linkedPostIds`; frontmatter.allium MediaSidecar does NOT list it~~ | Code writes `linkedPostIds` → add to frontmatter.allium | **Resolved:** linkedPostIds added to MediaSidecar in frontmatter.allium (with A2-15) |
| C-3 | ~~translation.allium says status/timestamps omitted; frontmatter.allium TranslationFrontmatter defines only 5 fields; code writes 8+ fields~~ | Code writes status/timestamps → update both specs to match code | **Resolved:** both specs updated (see A2-5) |
---
## D. Spec Claims Not Covered by Tests
### D1. No Test Coverage (HIGH priority — invariants/guarantees)
| ID | Claim | Spec | Path |
|---|---|---|---|
| D1-1 | UniqueMediaTranslation invariant | media.allium:108 | Write test: create duplicate media translation, expect rejection |
| D1-2 | UniqueTranslationPerLanguage invariant | translation.allium:94 | Write test: create duplicate post translation, expect rejection |
| D1-3 | BundledDefaultTemplatesExistOutsideProjectData | template.allium:65 | Write test: render with no Template rows, bundled template found |
| D1-4 | UserTemplateDirectoryOverridesBundledDefaults | template.allium:75 | Write test: project template overrides bundled same-slug |
| D1-5 | LiquidTagSubset (5 tags only) | template.allium:179 | Write test: unsupported tag raises error |
| D1-6 | LiquidFilterSubset (4 standard + 2 custom) | template.allium:191 | Write test: unsupported filter raises error |
| D1-7 | LiquidOperatorSubset | template.allium:210 | Write test: unsupported operator raises error |
| D1-8 | MacroTimeout guarantee | script.allium:94-95 | Write test: macro times out within budget |
| D1-9 | ExecuteTransform rule (pipeline, ordering, toast budget) | script.allium:229-263 | Write test: transform pipeline executes in order, toast budget enforced |
| D1-10 | TransformPipelineContinuation | script.allium:247-249 | Write test: error in transform doesn't halt pipeline |
| D1-11 | ChatContextTruncation invariant | ai.allium:375-379 | Write test: long chat history trimmed to context window |
| D1-12 | BoundedToolLoop enforcement | ai.allium:381-385 | Write test: tool rounds bounded by chat_max_tool_rounds |
| D1-13 | DiscardPostChangesSideEffects | engine_side_effects.allium:99-104 | Write test: FTS updated after discard |
| D1-14 | ReplaceMediaFileSideEffects | engine_side_effects.allium:128-134 | Write test: file replaced, thumbnails regenerated |
| D1-15 | Drag-and-drop image chain | action_patterns.allium:84-103 | Write integration test |
| D1-16 | DebouncedPersistence (5s) | embedding.allium:204-208 | Write test: index persistence debounced |
| D1-17 | Protected categories cannot be deleted | editor_settings.allium:81-84 | Write test: article/aside/page/picture deletion rejected |
| D1-18 | HomeItemProtection (menu) | editor_misc.allium:206-209 | Write test: cannot move/reorder/delete Home |
### D2. No Test Coverage (MEDIUM priority — rules/behaviors)
| ID | Claim | Spec | Path |
|---|---|---|---|
| D2-1 | RemoveCategory rule | metadata.allium:100 | Write test: remove category, verify list+settings+JSON updated |
| D2-2 | CreateAndPublishTemplate rule | template.allium:105 | Write test: create+publish in one step |
| D2-3 | CreateAndPublishScript rule | script.allium:160 | Write test: create+publish in one step |
| D2-4 | UniqueScriptSlug dedup | script.allium:115 | Write test: two scripts same title → dedup slug |
| D2-5 | FrontmatterRoundtrip invariant | post.allium:223 | Write test: write file, read back, assert all DB fields match |
| D2-6 | SidecarRoundtrip invariant | media.allium:198 | Write test: write sidecar, read back, assert all fields match |
| D2-7 | ConditionalPostFields: nil fields absent from frontmatter | frontmatter.allium:398 | Write test: post with nil excerpt/author/language → fields not in file |
| D2-8 | ConditionalMediaFields: nil fields absent from sidecar | frontmatter.allium:417 | Write test: media with nil title/alt → fields not in sidecar |
| D2-9 | max_posts_per_page 1..500 constraint | metadata.allium:75-77 | Write test: values outside range rejected |
| D2-10 | SandboxedExecution: restricted capabilities blocked | script.allium:84-88 | Write test: filesystem/process/package loading blocked |
| D2-11 | TransformToastBudget enforcement | script.allium:251-258 | Write test: per-script and total toast limits enforced |
| D2-12 | ProgressThrottled: 250ms throttle | task.allium:110-113 | Write test: rapid progress reports throttled |
| D2-13 | archived→draft transition | post.allium:121 | Write test: unarchive post → draft |
| D2-14 | archived→published transition | post.allium:122 | Write test: unarchive post → published |
| D2-15 | AppNoopNotifier: app writes don't produce notification rows | cli_sync.allium:64-68 | Write test: app mutation produces no notification row |
| D2-16 | ValidateMedia rule | media_processing.allium:318-343 | Write test: missing/corrupted/orphan media detected |
| D2-17 | ContentHashSkipsUnchanged during reindex | embedding.allium:199-202 | Write test: unchanged content_hash skips re-embedding |
### D3. Partial Test Coverage (needs expansion)
| ID | Claim | Spec | Gap | Path |
|---|---|---|---|---|
| D3-1 | PublishPost: content=null after publish | post.allium:186 | Not explicitly tested | Add assertion |
| D3-2 | PublishPost: old file deleted on path change | engine_side_effects.allium:73-74 | Not tested | Add test |
| D3-3 | UpsertPostTranslation: do_not_translate guard | translation.allium:113 | Indirectly covered only | Add direct test |
| D3-4 | PublishTemplate: Liquid validation prerequisite | template.allium:139 | Not tested as publish gate | Add test |
| D3-5 | PublishScript: validation prerequisite | script.allium:181 | Not tested as publish gate | Add test |
| D3-6 | ExecuteMacro failure degrades to empty | script.allium:199 | Returns error tuple, not empty | Fix code or update spec |
| D3-7 | TemplateFrontmatter roundtrip | template.allium:53 | Slug verified, no full parse-back | Add roundtrip test |
| D3-8 | DefaultCategories for fresh project | metadata.allium:60 | Defaults present after add, not verified fresh | Add fresh-project test |
| D3-9 | FtsIncludesTranslations | translation.allium:178 | Tested for one language; expand | Test all stemmer languages |
| D3-10 | PostCanonicalUrl format | post.allium:33-40 | Constructed in links test, not asserted as invariant | Add format assertion |
| D3-11 | Slug generation: German transliteration | post.allium:14-22 | "Föö Bär" → "foo-bar-blog" tested; expand ä/ö/ü/ß/ÄÖÜ | Expand test |
### D4. UI Test Coverage Gaps (whole-editor specs)
| ID | Spec | Covered | Not Covered |
|---|---|---|---|
| D4-1 | editor_media.allium | AI analysis, delete | Translate, replace file, link-to-post, translation CRUD, detect language |
| D4-2 | editor_settings.allium | AI endpoints, airplane toggle, rebuild | Protected categories, MCP agents, style/theme, search filter, categories CRUD |
| D4-3 | editor_chat.allium | Chat creation, pinned tab | API key screen, message rendering, input area, model selector, inline surfaces |
| D4-4 | editor_script.allium | Editor layout, create defaults | Save, syntax check, run, delete |
| D4-5 | editor_template.allium | Editor layout, create defaults | Save with validation, validate, delete with references |
| D4-6 | editor_tags.allium | Sync/discover, merge | Cloud sizing, color picker, delete confirmation, create form |
| D4-7 | editor_misc.allium | Menu add/save, metadata diff, validation | Menu protection, import analysis, translation fix, duplicate dismiss, git diff |
---
## Priority Order for Resolution
1. **A1-1 through A1-14** — code must follow spec (includes auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown, real embedding model)
2. **D1-1 through D1-18** — untested invariants/guarantees
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
4. **B1-1 through B1-6** — major code behaviors missing from spec
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
6. **D2-1 through D2-17** — untested rules
7. **D3-1 through D3-11** — partial test coverage
8. **B1-7 through B1-20** — minor code behaviors missing from spec
9. **D4-1 through D4-7** — UI test coverage

87
TESTAUDIT.md Normal file
View File

@@ -0,0 +1,87 @@
# Test Audit Procedure
Periodic review of the unit test suite to ensure every test exercises production
code against real assumptions and behavior.
## Scope
All `*_test.exs` files under `test/`.
## What counts as a valid unit test
A valid unit test **calls at least one production function** from `lib/bds/` and
**asserts on its return value, side effects, or observable behavior**.
Acceptable patterns:
- Calling a production function and asserting its return value.
- Calling a production function with injected test doubles (fake HTTP clients,
fake runtimes) and asserting the production code's orchestration logic.
- Mounting a LiveView or rendering a LiveComponent and asserting HTML output
or database state after interactions.
- Sending events to a GenServer and asserting state transitions.
### Source-property tests (acceptable, not flagged)
Tests that verify structural properties of source code are acceptable and should
not be flagged during this audit. Examples:
- Checking that all public functions have `@spec` annotations (AST parsing).
- Asserting absence of `String.to_atom` or `cond do` in specific files.
- Verifying CSS/JS/template assets contain expected class names or imports.
- Checking that `API.md` matches the output of a documentation generator.
- Verifying database indexes exist via `EXPLAIN QUERY PLAN`.
- Asserting `.allium` spec files have consistent parameter signatures.
- Checking config files for expected values.
- Verifying function decomposition patterns in source.
These are linting/contract/consistency checks. They serve a purpose but are
distinct from behavioral tests.
## What gets flagged
1. **Export-existence-only tests** — tests that call `function_exported?/3` or
`Code.ensure_loaded?/1` without ever invoking the function. These verify
compilation, not behavior. They are redundant when the same module is already
tested via rendering or direct calls in another test file.
2. **Mock-only tests** — tests that define a fake/stub module and only assert
on that fake's behavior without routing through any production code path.
3. **Trivially-passing tests** — tests whose assertions succeed regardless of
whether the production code is correct (e.g., asserting on a hardcoded value
that never touches production logic).
## How to run the audit
Ask Claude Code to:
> Analyse the unit tests of the project and check if all of them actually call
> proper production code or if there are tests that essentially only test
> scaffolds, mocks and helper functions. Every unit test must test proper
> production code against assumptions and behaviour. Source-property tests
> (structure, @spec, asset presence, schema verification, doc staleness) are
> acceptable and should not be flagged.
The audit should:
1. Read every `*_test.exs` file under `test/` in full.
2. For each test block, identify which production function (if any) is called.
3. Flag any test that falls into the categories above.
4. Report flagged tests with file path, line number, and explanation.
## Audit log
### 2026-05-11
Reviewed all 71 test files (69 after cleanup). Found 2 redundant files:
- `test/bds/desktop/shell_live/chat_editor_test.exs` — single test only called
`function_exported?` for `ChatEditor`. The component was already fully tested
via `render_component` in `shell_live_test.exs`. **Deleted.**
- `test/bds/desktop/shell_live/import_editor_test.exs` — single test only called
`Code.ensure_loaded?` + `function_exported?` for `ImportEditor`. The component
was already exercised in `import_shell_live_test.exs`. **Deleted.**
Result after cleanup: 646 tests, 0 failures, 4 skipped.

20
assets/css/app.css Normal file
View File

@@ -0,0 +1,20 @@
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/bds/desktop";
@import "./tokens.css";
@import "./shell.css";
@import "./sidebar.css";
@import "./tabs.css";
@import "./editor.css";
@import "./forms.css";
@import "./panel.css";
@import "./assistant.css";
@import "./overlays.css";
@import "./menu_editor.css";
@import "./media_editor.css";
@import "./import_editor.css";
@import "./misc_editor.css";
@import "./utilities.css";

557
assets/css/assistant.css Normal file
View File

@@ -0,0 +1,557 @@
.settings-view-shell,
.style-view,
.tags-view-shell,
.scripts-view-shell,
.templates-view-shell,
.chat-panel {
height: 100%;
background: var(--vscode-editor-background);
}
.chat-panel {
color: var(--vscode-editor-foreground);
}
.chat-panel-header {
border-bottom: 1px solid var(--vscode-panel-border);
background: var(--vscode-sideBar-background);
}
.chat-panel-title {
flex: 1;
min-width: 0;
gap: 10px;
overflow: visible;
font-size: 14px;
font-weight: 600;
}
.chat-panel-title-main {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-panel-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-model-selector-wrap {
position: relative;
display: inline-flex;
min-width: 0;
}
.chat-model-selector-button,
.chat-model-selector-option {
border: 1px solid var(--vscode-input-border);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
}
.chat-model-selector-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: auto;
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
z-index: 20;
}
.chat-panel .chat-model-selector-button.chat-model-selector-inline {
display: inline-flex;
align-items: center;
gap: 6px;
}
.chat-panel .chat-model-selector-caret {
position: static;
font-size: 10px;
}
.chat-messages,
.chat-surface-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.chat-messages {
}
.chat-message {
display: flex;
max-width: 100%;
margin-bottom: 16px;
}
.chat-message.user {
flex-direction: row-reverse;
}
.chat-message-content {
max-width: min(760px, 100%);
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 12px 14px;
background: var(--vscode-sideBar-background);
color: var(--vscode-editor-foreground);
}
.chat-panel .chat-message.user .chat-message-content {
background: var(--vscode-button-background, var(--accent-color, #007acc));
color: var(--vscode-button-foreground, var(--vscode-list-activeSelectionForeground, #ffffff));
border: 1px solid var(--vscode-button-background, var(--accent-color, #007acc));
border-radius: 6px;
padding: 12px 14px;
line-height: 1.35;
}
.chat-tool-surface-table {
width: 100%;
border-collapse: collapse;
}
.chat-tool-surface-table th,
.chat-tool-surface-table td {
border-bottom: 1px solid var(--vscode-panel-border);
padding: 6px 8px;
text-align: left;
}
.chat-tool-surface-json {
overflow: auto;
padding: 10px 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: var(--vscode-textCodeBlock-background);
}
/* ── Inline surfaces (<details> wrappers) ──────────────────────────── */
.chat-inline-surface {
margin: 10px 0;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-sideBar-background);
overflow: hidden;
}
.chat-inline-surface-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
list-style: none;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.chat-inline-surface-header::-webkit-details-marker {
display: none;
}
.chat-inline-surface-header::marker {
content: "";
}
.chat-inline-surface-icon {
flex: 0 0 auto;
font-size: 14px;
line-height: 1;
opacity: 0.7;
}
.chat-inline-surface-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
color: var(--vscode-editor-foreground);
}
.chat-inline-surface-dismiss {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--vscode-descriptionForeground);
font-size: 16px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.chat-inline-surface:hover .chat-inline-surface-dismiss {
opacity: 1;
}
.chat-inline-surface-dismiss:hover {
background: var(--vscode-toolbar-hoverBackground);
color: var(--vscode-editor-foreground);
}
.chat-inline-surface-body {
padding: 0 12px 12px;
}
.chat-inline-surface-body h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--vscode-editor-foreground);
}
/* ── Chart surface ─────────────────────────────────────────────────── */
.chat-surface-chart-type {
margin: 0 0 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
display: none;
}
.chat-surface-chart-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-surface-chart-row {
display: flex;
flex-direction: column;
gap: 2px;
}
.chat-surface-chart-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 12px;
}
.chat-surface-chart-meta span:first-child {
color: var(--vscode-editor-foreground);
}
.chat-surface-chart-meta span:last-child {
color: var(--vscode-descriptionForeground);
font-variant-numeric: tabular-nums;
}
.chat-surface-chart-bar {
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.chat-surface-chart-bar span {
display: block;
height: 100%;
border-radius: 3px;
background: var(--accent-color);
min-width: 0;
transition: width 0.3s ease;
}
/* ── Card surface ──────────────────────────────────────────────────── */
.chat-surface-card {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-surface-subtitle {
margin: 0;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.chat-surface-body {
margin: 0;
font-size: 13px;
line-height: 1.45;
}
.chat-surface-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.chat-surface-action-button {
padding: 4px 12px;
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-editor-foreground);
font-size: 12px;
cursor: pointer;
}
.chat-surface-action-button:hover {
background: var(--vscode-list-hoverBackground);
}
/* ── Metric surface ────────────────────────────────────────────────── */
.chat-surface-metric {
display: flex;
flex-direction: column;
gap: 2px;
}
.chat-surface-metric-label {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.chat-surface-metric-value {
font-size: 22px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--vscode-editor-foreground);
}
/* ── List surface ──────────────────────────────────────────────────── */
.chat-surface-list {
margin: 0;
padding: 0 0 0 18px;
font-size: 13px;
line-height: 1.5;
}
/* ── Mindmap surface ───────────────────────────────────────────────── */
.chat-surface-mindmap {
margin: 0;
padding: 0;
list-style: none;
font-size: 13px;
}
.chat-surface-mindmap li {
padding: 4px 0;
border-bottom: 1px solid var(--vscode-panel-border);
}
.chat-surface-mindmap li:last-child {
border-bottom: none;
}
.chat-surface-mindmap strong {
display: block;
color: var(--vscode-editor-foreground);
}
.chat-surface-mindmap-children {
display: block;
font-size: 12px;
color: var(--vscode-descriptionForeground);
padding-left: 12px;
}
/* ── Tabs surface ──────────────────────────────────────────────────── */
.chat-surface-tabs {
display: flex;
flex-direction: column;
}
.chat-surface-tab-list {
display: flex;
gap: 0;
border-bottom: 1px solid var(--vscode-panel-border);
}
.chat-surface-tab-button {
padding: 6px 12px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--vscode-descriptionForeground);
font-size: 12px;
cursor: pointer;
}
.chat-surface-tab-button.active {
color: var(--vscode-editor-foreground);
border-bottom-color: var(--accent-color);
}
.chat-surface-tab-button:hover:not(.active) {
color: var(--vscode-editor-foreground);
}
.chat-surface-tab-panel {
padding: 10px 0 0;
}
/* ── Form surface ──────────────────────────────────────────────────── */
.chat-surface-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-surface-form-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.chat-surface-form-field input,
.chat-surface-form-field textarea,
.chat-surface-form-field select {
padding: 5px 8px;
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font: inherit;
}
.chat-surface-form-field textarea {
min-height: 60px;
resize: vertical;
}
.chat-surface-form-checkbox {
display: flex;
align-items: center;
}
/* ── Text surface ──────────────────────────────────────────────────── */
.chat-surface-text {
font-size: 13px;
line-height: 1.45;
white-space: pre-wrap;
}
/* ── Table surface wrapper ─────────────────────────────────────────── */
.chat-tool-surface-table-wrap {
overflow-x: auto;
}
.chat-panel .chat-input-container {
--chat-input-line-height: 22px;
--chat-input-min-height: 24px;
border-top: 1px solid var(--vscode-panel-border);
padding: 12px 16px;
background: var(--vscode-sideBar-background);
}
.chat-panel .chat-input-wrapper {
min-height: 40px;
border: 1px solid var(--vscode-input-border);
border-radius: 8px;
padding: 6px 8px;
background: var(--vscode-input-background);
}
.chat-panel .chat-input-wrapper:focus-within {
border-color: var(--vscode-focusBorder);
}
.chat-panel .chat-input {
flex: 1;
box-sizing: border-box;
height: var(--chat-input-min-height);
min-height: var(--chat-input-min-height);
margin: 0;
padding: 6px 8px;
line-height: var(--chat-input-line-height);
max-height: 160px;
resize: vertical;
border: 0;
outline: none;
background: transparent;
color: var(--vscode-input-foreground);
overflow-y: hidden;
}
.chat-panel .chat-input:focus {
outline: none;
}
.chat-panel .chat-input::placeholder {
color: var(--vscode-input-placeholderForeground);
}
.chat-panel .chat-send-button {
flex: 0 0 auto;
width: 22px;
height: 22px;
max-width: 22px;
max-height: 22px;
padding: 0;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.chat-panel .chat-send-button:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground);
}
.chat-panel .chat-send-button:disabled {
opacity: 0.5;
}
@media (max-width: 720px) {
.chat-panel-header {
align-items: stretch;
flex-direction: column;
padding: 10px 12px;
}
.chat-panel-title {
width: 100%;
flex-wrap: wrap;
}
.chat-model-selector-wrap {
width: 100%;
}
.chat-panel .chat-model-selector-button.chat-model-selector-inline {
justify-content: space-between;
width: 100%;
}
.chat-messages {
padding: 12px;
}
.chat-message-content {
max-width: 100%;
}
.chat-panel .chat-input-container {
padding: 8px 12px;
}
}

956
assets/css/editor.css Normal file
View File

@@ -0,0 +1,956 @@
.editor-shell {
flex: 1;
min-height: 0;
overflow: auto;
background: var(--vscode-editor-background);
}
.editor-frame {
display: grid;
grid-template-columns: minmax(0, 1fr) 240px;
gap: 16px;
padding: 14px 16px;
}
.editor-main,
.editor-meta,
.panel-shell,
.assistant-card {
min-width: 0;
}
.editor-kicker {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
}
.editor-title {
margin: 10px 0 6px;
font-size: 24px;
font-weight: 600;
}
.editor-subtitle {
margin: 0 0 14px;
}
.editor-toolbar {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.editor-toolbar-button {
border: 1px solid var(--vscode-panel-border);
background: transparent;
color: var(--vscode-foreground);
padding: 4px 8px;
border-radius: 3px;
}
.editor-toolbar-button:hover,
.panel-tab:hover {
background: var(--vscode-toolbar-hoverBackground);
}
.editor-section {
padding-top: 4px;
}
.editor-section h2 {
margin: 0 0 8px;
font-size: 16px;
}
.editor-list {
margin: 0;
padding-left: 18px;
line-height: 1.5;
}
.editor-list.compact li {
margin-bottom: 6px;
}
.editor-meta {
border-left: 1px solid var(--vscode-panel-border);
padding-left: 16px;
}
.editor-meta-row {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 0;
border-bottom: 1px solid var(--vscode-panel-border);
}
.post-editor .post-editor-markdown-surface,
.scripts-monaco.monaco-editor-shell,
.templates-monaco.monaco-editor-shell {
min-height: 0;
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
border-color: var(--vscode-panel-border);
}
.post-editor .monaco-editor-instance,
.scripts-monaco .monaco-editor-instance,
.templates-monaco .monaco-editor-instance {
min-height: 0;
background: var(--vscode-editor-background);
}
.monaco-editor-shell .monaco-editor,
.monaco-editor-shell .monaco-editor .margin,
.monaco-editor-shell .monaco-editor-background,
.monaco-editor-shell .monaco-editor .inputarea.ime-input {
background-color: var(--vscode-editor-background) !important;
}
.monaco-editor-shell .monaco-editor,
.monaco-editor-shell .monaco-editor .view-line {
color: var(--vscode-editor-foreground) !important;
}
.monaco-editor-shell .monaco-editor .line-numbers {
color: var(--vscode-editorLineNumber-foreground, #858585) !important;
}
.monaco-editor-shell .monaco-editor .current-line,
.monaco-editor-shell .monaco-editor .view-overlays .current-line {
border-color: var(--vscode-editor-lineHighlightBorder, transparent) !important;
}
.help-doc-view {
--doc-bg: var(--panel-1, #1e1e1e);
--doc-surface: var(--panel-2, #252526);
--doc-border: var(--line, #3c3c3c);
--doc-text: var(--vscode-editor-foreground, #d4d4d4);
--doc-muted: var(--vscode-descriptionForeground, #9da3ad);
--doc-link: var(--vscode-textLink-foreground, #9cdcfe);
--doc-code-bg: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.2));
--doc-hover: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06));
}
.help-doc-view .misc-editor-content {
padding: 0;
overflow: hidden;
}
.documentation-view,
.documentation-scroll {
background: var(--doc-bg, var(--vscode-editor-background));
}
.documentation-view {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.documentation-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 28px 24px 40px;
}
.documentation-content {
max-width: 920px;
margin: 0 auto;
color: var(--doc-text, var(--vscode-editor-foreground));
}
.documentation-article,
.help-doc-markdown {
background: var(--doc-surface);
padding: 18px 20px 24px;
border: 1px solid var(--doc-border);
border-radius: 10px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
}
.documentation-content.markdown-body > .documentation-article > :first-child {
margin-top: 0;
}
.documentation-content.markdown-body > .documentation-article > :last-child {
margin-bottom: 0;
}
.documentation-content.markdown-body h1,
.documentation-content.markdown-body h2,
.documentation-content.markdown-body h3 {
color: var(--doc-text);
border-bottom: 1px solid var(--doc-border);
padding-bottom: 6px;
line-height: 1.25;
}
.documentation-content.markdown-body h1 {
font-size: 1.9rem;
}
.documentation-content.markdown-body h2 {
margin-top: 2rem;
font-size: 1.35rem;
}
.documentation-content.markdown-body h3 {
margin-top: 1.6rem;
font-size: 1.05rem;
}
.documentation-content.markdown-body p,
.documentation-content.markdown-body li,
.documentation-content.markdown-body td,
.documentation-content.markdown-body th {
line-height: 1.6;
}
.documentation-content.markdown-body a {
color: var(--doc-link);
text-decoration-thickness: 1px;
text-underline-offset: 0.14em;
}
.documentation-content.markdown-body a:hover {
color: var(--doc-text);
}
.documentation-content.markdown-body hr {
border: 0;
border-top: 1px solid var(--doc-border);
opacity: 0.8;
}
.documentation-content.markdown-body code {
background: var(--doc-code-bg);
padding: 0.12em 0.4em;
border-radius: 4px;
font: 0.92em/1.45 "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
}
.documentation-content.markdown-body pre {
margin: 0.9rem 0 1.2rem;
background: var(--doc-code-bg);
border: 1px solid var(--doc-border);
border-radius: 8px;
padding: 14px 16px;
overflow: auto;
}
.documentation-content.markdown-body pre code {
padding: 0;
background: transparent;
font-size: 0.9em;
}
.documentation-content.markdown-body blockquote {
margin: 1rem 0;
padding: 0 0 0 12px;
border-left: 3px solid var(--doc-border);
color: var(--doc-muted);
}
.documentation-content.markdown-body table {
width: 100%;
margin: 1rem 0 1.4rem;
border-collapse: collapse;
display: table;
}
.documentation-content.markdown-body th,
.documentation-content.markdown-body td {
border: 1px solid var(--doc-border);
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.documentation-content.markdown-body th {
background: var(--doc-hover);
font-weight: 700;
}
.documentation-content.markdown-body ul,
.documentation-content.markdown-body ol {
margin: 0.85rem 0 1rem;
padding-left: 1.5rem;
display: block;
}
.documentation-content.markdown-body ul {
list-style: disc;
}
.documentation-content.markdown-body ol {
list-style: decimal;
}
.documentation-content.markdown-body li {
margin: 0.3rem 0;
}
.documentation-content.markdown-body li > ul,
.documentation-content.markdown-body li > ol {
margin-top: 0.35rem;
margin-bottom: 0.35rem;
}
.documentation-content.markdown-body strong {
color: var(--doc-text);
}
.documentation-content.markdown-body img {
max-width: 100%;
height: auto;
}
.post-editor,
.scripts-view-shell,
.templates-view-shell {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--vscode-editor-background);
overflow: hidden;
}
.post-editor .editor-tab-dirty {
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
font-size: 10px;
}
.post-editor .editor-tab-meta {
color: var(--vscode-descriptionForeground);
font-size: 11px;
white-space: nowrap;
}
.post-editor .quick-actions-wrapper {
position: relative;
display: inline-block;
}
.post-editor .quick-actions-btn {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.post-editor .quick-actions-btn-icon {
font-size: 12px;
line-height: 1;
}
.post-editor .quick-actions-divider {
height: 1px;
background: var(--vscode-dropdown-border, #454545);
}
.post-editor .quick-action-icon {
font-size: 16px;
flex-shrink: 0;
margin-top: 2px;
}
.post-editor .quick-action-text strong {
font-size: 13px;
font-weight: 500;
}
.post-editor .quick-action-text small {
font-size: 11px;
opacity: 0.7;
}
.post-editor .status-badge,
.scripts-view-shell .status-badge,
.templates-view-shell .status-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.post-editor .status-badge.status-draft,
.scripts-view-shell .status-badge.status-draft,
.templates-view-shell .status-badge.status-draft {
background-color: rgba(204, 167, 0, 0.2);
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
}
.post-editor .status-badge.status-published,
.scripts-view-shell .status-badge.status-published,
.templates-view-shell .status-badge.status-published {
background-color: rgba(115, 201, 145, 0.2);
color: var(--vscode-testing-iconPassed);
}
.post-editor .status-badge.status-archived,
.scripts-view-shell .status-badge.status-archived,
.templates-view-shell .status-badge.status-archived {
background-color: rgba(133, 133, 133, 0.2);
color: var(--vscode-descriptionForeground);
}
.post-editor .auto-save-indicator {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-style: italic;
}
.post-editor .metadata-toggle-header {
}
.post-editor .metadata-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
background: none;
border: none;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: color 0.15s;
flex-shrink: 0;
}
.post-editor .metadata-toggle:hover {
color: var(--vscode-foreground);
}
.post-editor .metadata-toggle-chevron {
font-size: 10px;
}
.post-editor .editor-header-row.is-collapsed {
display: none;
}
.post-editor .editor-media-panel {
width: 200px;
flex-shrink: 0;
}
.post-editor .editor-field label,
.post-editor .editor-body label,
.post-editor .post-editor-links-label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.post-editor .editor-checkbox-label {
display: inline-flex;
align-items: center;
gap: 8px;
text-transform: none;
letter-spacing: 0;
color: var(--vscode-foreground);
}
.post-editor .post-editor-input.is-readonly {
opacity: 0.7;
cursor: not-allowed;
}
.post-editor .post-editor-excerpt {
min-height: 96px;
}
.post-editor .tag-input-container {
position: relative;
width: 100%;
}
.post-editor .tag-input-container.is-disabled {
opacity: 0.72;
}
.post-editor .tag-input-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px 8px;
min-height: 38px;
border: 1px solid var(--vscode-input-border, #3c3c3c);
border-radius: 4px;
background: var(--vscode-input-background, #3c3c3c);
cursor: text;
}
.post-editor .tag-input-wrapper:focus-within {
border-color: var(--vscode-focusBorder, #007fd4);
outline: none;
}
.post-editor .tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 0.85rem;
background: var(--vscode-badge-background, #4d4d4d);
border: 1px solid var(--vscode-widget-border, #454545);
border-radius: 4px;
color: var(--vscode-badge-foreground, #ffffff);
white-space: nowrap;
}
.post-editor .tag-chip.has-color {
border-radius: 12px;
padding: 3px 10px;
}
.post-editor .tag-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
margin-left: 2px;
border: none;
background: transparent;
color: inherit;
font-size: 1rem;
line-height: 1;
cursor: pointer;
opacity: 0.6;
border-radius: 50%;
transition: opacity 0.15s, background 0.15s;
}
.post-editor .tag-chip-remove:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.1);
}
.post-editor .tag-chip.has-color .tag-chip-remove:hover {
background: rgba(0, 0, 0, 0.2);
}
.post-editor .tag-input-field {
flex: 1;
min-width: 120px;
padding: 2px 4px;
border: none;
background: transparent;
color: var(--vscode-input-foreground, #cccccc);
font-family: inherit;
font-size: 0.9rem;
outline: none;
}
.post-editor .tag-input-field::placeholder {
color: var(--vscode-input-placeholderForeground, #a6a6a6);
}
.post-editor .tag-input-field:disabled {
cursor: not-allowed;
}
.post-editor .tag-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
padding: 4px;
background: var(--vscode-dropdown-background, #3c3c3c);
border: 1px solid var(--vscode-widget-border, #454545);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 0, 0, 0.2);
z-index: 1000;
max-height: 240px;
overflow-y: auto;
}
.post-editor .tag-suggestion {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--vscode-dropdown-foreground, #f0f0f0);
font-family: inherit;
font-size: 0.9rem;
text-align: left;
cursor: pointer;
border-radius: 4px;
transition: background 0.1s;
}
.post-editor .tag-suggestion:hover,
.post-editor .tag-suggestion.selected {
background: var(--vscode-list-hoverBackground, #2a2d2e);
}
.post-editor .tag-suggestion-color {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.post-editor .tag-suggestion-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-editor .tag-suggestion.create-new {
border-top: 1px solid var(--vscode-widget-border, #454545);
margin-top: 4px;
padding: 6px 8px;
padding-top: 12px;
color: var(--vscode-notificationsInfoIcon-foreground, #75beff);
}
.post-editor .tag-suggestion.create-new:first-child {
border-top: none;
margin-top: 0;
padding-top: 8px;
}
.post-editor .tag-suggestion-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px dashed currentColor;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 600;
}
.post-editor .editor-language-row select {
flex: 1;
min-width: 0;
}
.post-editor .editor-translation-flag {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
font-size: 14px;
line-height: 1;
cursor: pointer;
flex: 0 0 auto;
}
.post-editor .editor-translation-flag.status-draft {
opacity: 0.82;
}
.post-editor .editor-translation-flag.status-archived {
opacity: 0.45;
filter: grayscale(0.35);
}
.post-editor .editor-translation-flag.active {
border-color: var(--vscode-testing-iconQueued, #cca700);
background: color-mix(in srgb, var(--vscode-testing-iconQueued, #cca700) 14%, transparent);
}
.post-editor .editor-translation-flag:hover {
background: color-mix(in srgb, var(--vscode-list-hoverBackground) 75%, transparent);
}
.post-editor .post-editor-links-panel,
.post-editor .post-editor-side-panel {
padding: 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 82%, white 3%);
}
.post-editor .post-editor-side-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.post-editor .post-editor-links-columns {
display: flex;
gap: 18px;
align-items: flex-start;
margin-top: 10px;
}
.post-editor .post-editor-links-columns > div {
flex: 1;
min-width: 0;
}
.post-editor .post-editor-empty,
.post-editor .post-editor-media-meta {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.post-editor .post-editor-media-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.post-editor .post-editor-media-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
}
.post-editor .editor-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 320px;
}
.post-editor .editor-toolbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.post-editor .editor-toolbar-left {
display: flex;
align-items: center;
justify-content: flex-start;
}
.post-editor .editor-toolbar-center {
display: flex;
align-items: center;
justify-content: center;
}
.post-editor .editor-toolbar-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.post-editor .editor-mode-toggle {
display: flex;
gap: 4px;
}
.post-editor .editor-mode-toggle button,
.post-editor .editor-toolbar-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .editor-mode-toggle button {
background-color: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
}
.post-editor .editor-mode-toggle button:hover,
.post-editor .editor-toolbar-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-toolbar-hoverBackground));
}
.post-editor .editor-mode-toggle button.active {
background-color: var(--vscode-button-background, var(--accent-color));
color: var(--vscode-button-foreground, #ffffff);
}
.post-editor .editor-toolbar-button {
background: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
}
.post-editor .editor-excerpt-panel.is-collapsed {
display: none;
}
.post-editor .gallery-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .gallery-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.post-editor .insert-post-link-button,
.post-editor .insert-media-button {
padding: 4px 8px;
font-size: 14px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .insert-post-link-button:hover,
.post-editor .insert-media-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.post-editor .editor-preview {
flex: 1;
background-color: var(--vscode-input-background);
border-radius: 4px;
overflow: hidden;
position: relative;
min-height: 240px;
padding: 0;
border: none;
}
.post-editor .editor-preview {
flex: 1;
min-height: 240px;
padding: 14px;
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
overflow: auto;
line-height: 1.6;
}
.post-editor .editor-preview-frame {
width: 100%;
min-height: 520px;
border: none;
background: #ffffff;
}
.post-editor .post-editor-markdown-surface {
position: relative;
flex: 1;
min-height: 380px;
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
border-radius: 4px;
background: var(--vscode-input-background);
overflow: hidden;
}
.post-editor .monaco-editor-shell,
.scripts-monaco.monaco-editor-shell,
.templates-monaco.monaco-editor-shell {
position: relative;
}
.monaco-editor-instance {
width: 100%;
height: 100%;
min-height: 100%;
}
.post-editor .monaco-editor-instance {
min-height: 380px;
}
.scripts-monaco .monaco-editor-instance,
.templates-monaco .monaco-editor-instance {
min-height: 420px;
}
.monaco-editor-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: pre;
border: 0;
}
.post-editor .editor-footer {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
border-top: 1px solid var(--vscode-panel-border);
background-color: var(--vscode-sideBar-background);
color: var(--vscode-descriptionForeground);
font-size: 12px;
flex-wrap: wrap;
}
@media (max-width: 980px) {
.post-editor .editor-header,
.scripts-view-shell .ui-editor-header,
.templates-view-shell .ui-editor-header,
.post-editor .metadata-toggle-header,
.post-editor .editor-toolbar {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.post-editor .editor-header-row,
.post-editor .editor-field-row,
.post-editor .post-editor-links-columns {
flex-direction: column;
}
.post-editor .editor-media-panel {
width: 100%;
}
.post-editor .editor-toolbar-right,
.post-editor .ui-editor-actions,
.scripts-view-shell .ui-editor-actions,
.templates-view-shell .ui-editor-actions {
justify-content: flex-start;
}
}

141
assets/css/forms.css Normal file
View File

@@ -0,0 +1,141 @@
.settings-view,
.style-view {
height: 100%;
display: flex;
flex-direction: column;
}
.settings-header,
.style-view-header {
padding: 18px 20px;
border-bottom: 1px solid var(--line, #3c3c3c);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-search input {
width: min(320px, 40vw);
}
.settings-content {
padding: 20px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 18px;
}
.setting-section {
border: 1px solid var(--line, #3c3c3c);
border-radius: 12px;
background: var(--panel-2, #252526);
}
.setting-section-header {
padding: 14px 16px;
border-bottom: 1px solid var(--line, #3c3c3c);
}
.setting-section-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.setting-row {
display: grid;
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.setting-label {
font-weight: 600;
}
.setting-control,
.setting-input-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.setting-actions {
padding: 0 16px 16px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.style-theme-picker {
padding: 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 14px;
}
.style-theme-option {
border: 1px solid var(--line, #3c3c3c);
background: var(--panel-2, #252526);
border-radius: 14px;
padding: 14px;
text-align: left;
cursor: pointer;
}
.style-theme-option.selected {
border-color: var(--accent-color);
box-shadow: 0 0 0 1px var(--accent-color);
}
.style-theme-swatch {
display: flex;
flex-direction: column;
gap: 12px;
}
.style-theme-tones {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 8px;
}
.style-theme-tone {
height: 42px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.style-apply-row {
padding: 0 20px 20px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.style-preview-container {
padding: 0 20px 20px;
flex: 1;
min-height: 0;
}
.style-preview-frame {
width: 100%;
height: 100%;
min-height: 420px;
border: 1px solid var(--line, #3c3c3c);
border-radius: 14px;
background: #ffffff;
}
@media (max-width: 1100px) {
.setting-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,689 @@
.import-analysis {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px 20px 26px;
color: var(--vscode-foreground);
}
.import-analysis-header {
display: flex;
flex-direction: column;
gap: 8px;
}
.import-analysis-header p {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 13px;
line-height: 1.5;
}
.import-definition-name {
width: min(480px, 100%);
border: 1px solid var(--vscode-input-border, transparent);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground, var(--vscode-foreground));
border-radius: 6px;
padding: 10px 12px;
font-size: 18px;
font-weight: 600;
}
.import-file-selectors {
display: grid;
gap: 12px;
}
.import-file-row {
display: grid;
grid-template-columns: 150px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 76%, var(--vscode-input-background));
}
.import-file-row label {
font-size: 12px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.import-file-path {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
font-size: 12px;
}
.import-file-path.placeholder {
color: var(--vscode-descriptionForeground);
}
.import-analysis button,
.import-analysis select {
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 6px;
font-size: 12px;
}
.import-analysis button {
background: var(--vscode-button-secondaryBackground, var(--vscode-button-background));
color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground));
padding: 8px 12px;
cursor: pointer;
}
.import-analysis button:hover:not(:disabled) {
background: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground));
}
.import-analyze-btn,
.import-execute-btn {
background: var(--vscode-button-background) !important;
color: var(--vscode-button-foreground) !important;
}
.import-analysis button:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.import-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.import-spinner {
width: 18px;
height: 18px;
border: 2px solid var(--vscode-descriptionForeground);
border-top-color: var(--vscode-button-background);
border-radius: 50%;
animation: import-spinner-rotate 0.8s linear infinite;
flex-shrink: 0;
}
.import-progress {
display: flex;
flex-direction: column;
gap: 2px;
}
.import-progress-step {
font-size: 13px;
color: var(--vscode-foreground);
}
.import-progress-detail {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
@keyframes import-spinner-rotate {
to {
transform: rotate(360deg);
}
}
.import-site-info {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.import-site-info-item,
.import-stat-card,
.import-date-distribution,
.import-detail-section,
.import-execute-section {
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.import-site-info-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
}
.info-label {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-value {
font-size: 13px;
font-weight: 500;
overflow-wrap: anywhere;
}
.import-stat-cards {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.import-stat-card {
padding: 14px;
}
.import-stat-card h3,
.import-date-distribution h3,
.import-detail-section h3,
.taxonomy-group h4 {
margin: 0;
}
.import-stat-number {
margin-top: 10px;
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.import-stat-breakdown,
.import-execute-summary,
.import-taxonomy-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.import-stat-breakdown {
margin-top: 12px;
}
.import-stat-tag,
.import-count-tag,
.import-taxonomy-pill,
.macro-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.stat-new,
.import-taxonomy-pill.new-tax {
background: rgba(117, 190, 255, 0.16);
color: #75beff;
}
.stat-update,
.stat-mapped,
.import-taxonomy-pill.exists,
.import-taxonomy-pill.mapped,
.macro-status-badge.mapped,
.import-execution-complete {
background: rgba(115, 201, 145, 0.16);
color: #73c991;
}
.stat-conflict {
background: rgba(255, 166, 87, 0.16);
color: #ffb169;
}
.stat-duplicate,
.stat-missing,
.macro-status-badge.unmapped,
.import-execution-error {
background: rgba(204, 167, 0, 0.16);
color: #cca700;
}
.import-date-distribution,
.import-detail-section,
.import-execute-section {
padding: 16px;
}
.import-section-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0;
border: none !important;
background: transparent !important;
color: inherit !important;
font-size: 16px !important;
font-weight: 600;
text-align: left;
}
.import-section-toggle:hover {
background: transparent !important;
opacity: 0.9;
}
.toggle-icon {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.distribution-bars {
display: grid;
gap: 10px;
margin-top: 14px;
}
.distribution-row {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) 72px;
gap: 10px;
align-items: center;
}
.distribution-year,
.distribution-count,
.slug-cell {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
font-size: 11px;
}
.distribution-bar-container {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: var(--vscode-input-background);
}
.distribution-bar {
height: 100%;
min-width: 8px;
border-radius: inherit;
}
.distribution-bar-posts {
background: linear-gradient(90deg, rgba(117, 190, 255, 0.8), rgba(117, 190, 255, 0.35));
}
.import-execute-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.import-execute-summary {
color: var(--vscode-descriptionForeground);
}
.import-execution-complete,
.import-execution-error {
padding: 10px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
}
.import-execution-progress {
display: grid;
gap: 10px;
padding: 16px;
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.import-execution-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.import-execution-header h3 {
margin: 0;
font-size: 14px;
}
.import-progress-bar {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: var(--vscode-input-background);
}
.import-progress-fill {
height: 100%;
background: linear-gradient(90deg, rgba(117, 190, 255, 0.85), rgba(117, 190, 255, 0.45));
}
.import-progress-info {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
}
.import-phase {
font-weight: 600;
}
.import-detail,
.import-counter {
color: var(--vscode-descriptionForeground);
}
.import-detail-table {
width: 100%;
border-collapse: collapse;
margin-top: 14px;
}
.import-detail-table th,
.import-detail-table td {
padding: 10px 8px;
text-align: left;
border-bottom: 1px solid var(--vscode-panel-border);
vertical-align: middle;
font-size: 12px;
}
.import-detail-table th {
font-size: 11px;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.import-detail-table .status-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 9px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.import-detail-table .status-badge.new {
background: rgba(117, 190, 255, 0.16);
color: #75beff;
}
.import-detail-table .status-badge.update {
background: rgba(115, 201, 145, 0.16);
color: #73c991;
}
.import-detail-table .status-badge.conflict {
background: rgba(255, 166, 87, 0.16);
color: #ffb169;
}
.import-detail-table .status-badge.duplicate,
.import-detail-table .status-badge.missing {
background: rgba(204, 167, 0, 0.16);
color: #cca700;
}
.categories-cell,
.existing-match,
.mime-type-cell,
.post-type-cell {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.mime-type-cell,
.post-type-cell,
.existing-match,
.slug-cell {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
}
.resolution-select,
.taxonomy-mapping-input {
min-width: 150px;
background: var(--vscode-dropdown-background, var(--vscode-input-background));
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
padding: 6px 8px;
}
.taxonomy-analyze-row {
display: flex;
align-items: center;
gap: 12px;
padding: 0 0 12px;
margin-top: 12px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.taxonomy-analyze-dropdown {
position: relative;
}
.taxonomy-analyze-btn {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.taxonomy-model-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 220px;
max-height: 280px;
overflow-y: auto;
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
border-radius: 6px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
z-index: 20;
}
.taxonomy-model-option {
width: 100%;
display: block;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
color: var(--vscode-foreground) !important;
text-align: left;
padding: 8px 12px !important;
}
.taxonomy-model-option:hover {
background: var(--vscode-list-hoverBackground) !important;
}
.taxonomy-analyze-hint {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.import-taxonomy-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 14px;
}
.taxonomy-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.import-taxonomy-entry,
.import-taxonomy-edit-form {
display: inline-flex;
align-items: center;
gap: 8px;
}
.import-taxonomy-entry,
.import-taxonomy-edit-form {
flex-wrap: wrap;
}
.import-taxonomy-pill {
border: none;
cursor: default;
}
button.import-taxonomy-pill {
cursor: pointer;
}
.mapped-target {
background: rgba(115, 201, 145, 0.1);
}
.taxonomy-mapping-arrow {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.taxonomy-mapping-input {
min-width: 170px;
border-radius: 6px;
}
.taxonomy-edit-btn,
.taxonomy-clear-btn {
min-width: 28px;
min-height: 28px;
padding: 0 8px !important;
display: inline-flex;
align-items: center;
justify-content: center;
}
.taxonomy-edit-btn.ghost,
.taxonomy-clear-btn {
background: transparent !important;
border: 1px solid var(--vscode-panel-border) !important;
color: var(--vscode-descriptionForeground) !important;
}
.macros-list {
display: grid;
gap: 10px;
margin-top: 14px;
}
.macro-item {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: var(--vscode-input-background);
}
.macro-item.unmapped {
border-left: 3px solid #cca700;
}
.macro-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
}
.macro-name,
.import-taxonomy-pill {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
}
.macro-count {
margin-left: auto;
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.import-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 56px 20px;
color: var(--vscode-descriptionForeground);
border: 1px dashed var(--vscode-panel-border);
border-radius: 12px;
}
.import-empty-state p {
margin: 0;
font-size: 13px;
}
@media (max-width: 1100px) {
.import-site-info,
.import-stat-cards,
.import-taxonomy-groups {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 780px) {
.import-analysis {
padding: 14px;
}
.import-file-row,
.distribution-row,
.import-execute-section,
.import-site-info,
.import-stat-cards,
.import-taxonomy-groups {
grid-template-columns: 1fr;
}
.import-execute-section {
align-items: stretch;
}
.import-file-row {
align-items: stretch;
}
.import-analysis button,
.resolution-select,
.taxonomy-mapping-input {
width: 100%;
}
.taxonomy-analyze-row {
flex-direction: column;
align-items: stretch;
}
.import-taxonomy-entry,
.import-taxonomy-edit-form {
width: 100%;
flex-direction: column;
align-items: stretch;
}
}

325
assets/css/media_editor.css Normal file
View File

@@ -0,0 +1,325 @@
[data-testid="media-editor"] .editor-tab-dirty {
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
font-size: 10px;
}
[data-testid="media-editor"] .ui-editor-actions button {
padding: 4px 10px;
font-size: 12px;
}
[data-testid="media-editor"] .ui-editor-actions button.danger:hover {
background-color: var(--vscode-notificationsErrorIcon-foreground);
}
[data-testid="media-editor"] .auto-save-indicator {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-style: italic;
}
[data-testid="media-editor"] .quick-actions-wrapper {
position: relative;
}
[data-testid="media-editor"] .quick-actions-btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
[data-testid="media-editor"] .quick-actions-btn-icon {
font-size: 12px;
line-height: 1;
}
[data-testid="media-editor"] .quick-actions-divider {
height: 1px;
background: var(--vscode-dropdown-border, #454545);
}
[data-testid="media-editor"] .quick-action-icon {
font-size: 16px;
flex-shrink: 0;
margin-top: 2px;
}
[data-testid="media-editor"] .quick-action-text strong {
font-size: 13px;
font-weight: 500;
}
[data-testid="media-editor"] .quick-action-text small {
font-size: 11px;
opacity: 0.7;
}
[data-testid="media-editor"] > .editor-content.media-editor {
flex-direction: row;
align-items: stretch;
gap: 24px;
}
[data-testid="media-editor"] .editor-field label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
[data-testid="media-editor"] .post-editor-input.disabled,
[data-testid="media-editor"] .post-editor-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
[data-testid="media-editor"] .media-preview {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-input-background);
border-radius: 8px;
min-height: 300px;
overflow: hidden;
}
[data-testid="media-editor"] .media-preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--vscode-descriptionForeground);
}
[data-testid="media-editor"] .media-preview-image {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
width: 100%;
height: 100%;
min-height: 0;
padding: 16px;
box-sizing: border-box;
}
[data-testid="media-editor"] .media-preview-image img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 4px;
}
[data-testid="media-editor"] .media-details {
width: 320px;
gap: 12px;
flex-shrink: 0;
}
[data-testid="media-editor"] .media-details textarea {
resize: vertical;
}
[data-testid="media-editor"] .linked-posts-section label {
display: flex;
justify-content: space-between;
align-items: center;
}
[data-testid="media-editor"] .add-link-btn {
background: var(--vscode-button-secondaryBackground);
border: none;
color: var(--vscode-button-secondaryForeground);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
[data-testid="media-editor"] .add-link-btn:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
[data-testid="media-editor"] .post-picker {
background: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border);
border-radius: 4px;
margin-top: 8px;
max-height: 250px;
overflow-y: auto;
}
[data-testid="media-editor"] .post-picker-search {
padding: 8px;
border-bottom: 1px solid var(--vscode-dropdown-border);
position: sticky;
top: 0;
background: var(--vscode-dropdown-background);
}
[data-testid="media-editor"] .post-picker-search input {
width: 100%;
padding: 6px 10px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 3px;
color: var(--vscode-input-foreground);
font-size: 12px;
}
[data-testid="media-editor"] .post-picker-search input:focus {
outline: none;
border-color: var(--vscode-focusBorder);
}
[data-testid="media-editor"] .post-picker-list {
padding: 4px;
}
[data-testid="media-editor"] .post-picker-item {
width: 100%;
padding: 6px 8px;
cursor: pointer;
border: none;
border-radius: 3px;
background: transparent;
color: inherit;
font-size: 12px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-testid="media-editor"] .post-picker-item:hover {
background: var(--vscode-list-hoverBackground);
}
[data-testid="media-editor"] .post-picker-more {
padding: 6px 8px;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-style: italic;
}
[data-testid="media-editor"] .no-posts,
[data-testid="media-editor"] .no-linked-posts {
padding: 12px 8px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
font-style: italic;
}
[data-testid="media-editor"] .linked-posts-list {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
[data-testid="media-editor"] .linked-post-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: var(--vscode-sideBar-background);
border-radius: 4px;
}
[data-testid="media-editor"] .linked-post-title,
[data-testid="media-editor"] .linked-post-link {
flex: 1;
min-width: 0;
border: none;
background: transparent;
padding: 0;
color: inherit;
text-align: left;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-testid="media-editor"] .linked-post-title:hover,
[data-testid="media-editor"] .linked-post-link:hover {
color: var(--vscode-textLink-foreground);
text-decoration: underline;
}
[data-testid="media-editor"] .linked-post-item .unlink-btn {
background: none;
border: none;
color: var(--vscode-descriptionForeground);
cursor: pointer;
padding: 0 4px;
font-size: 14px;
opacity: 0;
transition: opacity 0.1s;
}
[data-testid="media-editor"] .linked-post-item:hover .unlink-btn {
opacity: 1;
}
[data-testid="media-editor"] .linked-post-item .unlink-btn:hover {
color: var(--vscode-errorForeground);
}
.translation-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.68);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
z-index: 10001;
}
.translation-modal {
width: min(640px, calc(100vw - 32px));
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.translation-modal-header,
.translation-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
}
.translation-modal-header {
border-bottom: 1px solid #3c3c3c;
}
.translation-modal-footer {
border-top: 1px solid #3c3c3c;
justify-content: flex-end;
gap: 10px;
}
.translation-modal-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.translation-modal-close {
border: none;
background: transparent;
color: #c5c5c5;
cursor: pointer;
font-size: 20px;
line-height: 1;
}

259
assets/css/menu_editor.css Normal file
View File

@@ -0,0 +1,259 @@
.menu-editor-header {
}
.menu-editor-header h2 {
margin: 0;
}
.menu-editor-header p {
margin: 0.25rem 0 0;
color: var(--vscode-descriptionForeground);
}
.menu-editor-tree-wrap {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-editor-background);
padding: 0.5rem;
min-height: 0;
}
.menu-editor-toolbar {
margin-bottom: 0.5rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--vscode-panel-border);
}
.menu-editor-tool {
width: 1.8rem;
height: 1.8rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--vscode-foreground);
cursor: pointer;
padding: 0;
}
.menu-editor-tool:hover:not(:disabled) {
background: var(--vscode-toolbar-hoverBackground);
border-color: var(--vscode-panel-border);
}
.menu-editor-tool:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.menu-editor-tree-shell {
flex: 1;
min-height: 0;
overflow: auto;
}
.menu-editor-tree-level {
list-style: none;
margin: 0;
padding: 0;
}
.menu-editor-tree-item {
margin: 0;
padding: 0;
}
.menu-editor-row {
--menu-editor-indent: calc(var(--menu-editor-depth) * 1rem);
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.3rem 0.45rem 0.3rem calc(0.4rem + var(--menu-editor-indent));
border-radius: 4px;
cursor: pointer;
position: relative;
}
.menu-editor-row.is-selected {
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}
.menu-editor-row.is-dragging {
opacity: 0.45;
}
.menu-editor-row.is-drop-before::before,
.menu-editor-row.is-drop-after::after {
content: "";
position: absolute;
left: calc(0.4rem + var(--menu-editor-indent));
right: 0.45rem;
height: 2px;
background: var(--vscode-focusBorder);
}
.menu-editor-row.is-drop-before::before {
top: 0;
}
.menu-editor-row.is-drop-after::after {
bottom: 0;
}
.menu-editor-row.is-drop-inside {
box-shadow: inset 0 0 0 1px var(--vscode-focusBorder);
background: var(--vscode-list-hoverBackground);
}
.menu-editor-row-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
min-width: 1rem;
color: var(--vscode-descriptionForeground);
cursor: grab;
user-select: none;
}
.menu-editor-row-handle:active {
cursor: grabbing;
}
.menu-editor-row-kind {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
min-width: 1rem;
opacity: 0.9;
}
.menu-editor-row-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-editor-row-title.is-editing {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.menu-editor-entry-form {
display: block;
}
.menu-editor-inline-input {
width: 100%;
border: 1px solid var(--vscode-focusBorder);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 0.25rem 0.45rem;
min-height: 1.8rem;
}
.menu-editor-inline-search {
margin-top: 0.5rem;
border-top: 1px solid var(--vscode-panel-border);
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 18rem;
overflow: hidden;
}
.menu-editor-inline-search-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.menu-editor-inline-search-head strong {
display: block;
font-size: 0.8rem;
}
.menu-editor-inline-search-head span {
color: var(--vscode-descriptionForeground);
font-size: 0.75rem;
}
.menu-editor-inline-actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.menu-editor-inline-action {
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 4px;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
padding: 0.2rem 0.5rem;
cursor: pointer;
}
.menu-editor-inline-action:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.menu-editor-picker-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 16rem;
overflow-y: auto;
}
.menu-editor-picker-item {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 0.45rem 0.55rem;
text-align: left;
cursor: pointer;
}
.menu-editor-picker-item:hover {
border-color: var(--vscode-focusBorder);
background: var(--vscode-list-hoverBackground);
}
.menu-editor-picker-item small,
.menu-editor-picker-state {
color: var(--vscode-descriptionForeground);
}
.menu-editor-empty {
color: var(--vscode-descriptionForeground);
padding: 0.5rem 0.25rem;
}
@media (max-width: 720px) {
.menu-editor-inline-search-head {
flex-direction: column;
align-items: flex-start;
}
.menu-editor-inline-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
}

539
assets/css/misc_editor.css Normal file
View File

@@ -0,0 +1,539 @@
/* ── Misc-editor shell (shared by all misc tabs) ──────────────────────── */
.misc-editor-shell {
background: var(--vscode-editor-background);
}
.misc-editor-header {
padding: 12px 16px 8px;
border-bottom: 1px solid var(--vscode-panel-border);
background: var(--vscode-tab-activeBackground);
}
.misc-editor-header h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.misc-editor-header p {
margin: 2px 0 0;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.misc-editor-actions {
flex-shrink: 0;
}
.misc-editor-summary {
padding: 8px 16px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.misc-editor-content {
padding: 16px;
}
/* ── Summary pills ───────────────────────────────────────────────────── */
.misc-summary-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
color: var(--vscode-foreground);
}
.misc-summary-pill span {
color: var(--vscode-descriptionForeground);
}
.misc-summary-pill strong {
font-weight: 600;
}
/* ── Misc card (used by site-validation, empty states) ───────────────── */
.misc-card {
padding: 14px 16px;
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.misc-card h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
.misc-card p {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.misc-card ul {
margin: 6px 0 0;
padding-left: 18px;
font-size: 12px;
line-height: 1.6;
}
/* ── Misc columns (site-validation 3-column layout) ──────────────────── */
.misc-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
/* ── Misc list (find-duplicates) ─────────────────────────────────────── */
.misc-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.misc-list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 4px;
}
.misc-list-item:hover {
background: var(--vscode-list-hoverBackground);
}
.duplicate-pair-row label {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
cursor: pointer;
}
.duplicate-pair-row .linkish {
background: none;
border: none;
color: var(--vscode-textLink-foreground, #3794ff);
cursor: pointer;
padding: 0;
font: inherit;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.14em;
}
.duplicate-pair-row .linkish:hover {
color: var(--vscode-foreground);
}
/* ── Metadata-diff: tab bar ──────────────────────────────────────────── */
.metadata-diff-tool {
display: flex;
flex-direction: column;
gap: 12px;
}
.metadata-diff-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.metadata-diff-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--vscode-tab-inactiveForeground, var(--vscode-descriptionForeground));
font: inherit;
font-size: 12px;
cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.metadata-diff-tab:hover {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
}
.metadata-diff-tab.active {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
border-bottom-color: var(--vscode-focusBorder, #007fd4);
}
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
background: var(--vscode-activityBarBadge-background, #007acc);
color: var(--vscode-activityBarBadge-foreground, #ffffff);
}
/* ── Metadata-diff: field pills ──────────────────────────────────────── */
.metadata-diff-field-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.metadata-diff-field-pill {
display: flex;
align-items: stretch;
border-radius: 6px;
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-input-background);
overflow: hidden;
transition: border-color 0.12s;
}
.metadata-diff-field-pill.active {
border-color: var(--vscode-focusBorder, #007fd4);
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 12%, transparent);
}
.metadata-diff-field-pill-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: none;
background: transparent;
color: var(--vscode-foreground);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.metadata-diff-field-pill-toggle:hover {
background: var(--vscode-list-hoverBackground);
}
.field-pill-label {
font-weight: 500;
}
.field-pill-count {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
}
.metadata-diff-field-pill-actions {
display: flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
border-left: 1px solid var(--vscode-panel-border);
}
.metadata-diff-action-button {
font-size: 11px !important;
padding: 2px 8px !important;
min-height: 22px !important;
}
/* ── Metadata-diff: results area ─────────────────────────────────────── */
.metadata-diff-results {
display: flex;
flex-direction: column;
gap: 12px;
}
.metadata-diff-empty p {
text-align: center;
padding: 20px 0;
}
/* ── Diff item cards (shared by metadata-diff and orphan sections) ──── */
.diff-item-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.diff-item-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
overflow: hidden;
}
.diff-item-card.orphan-file {
border-left: 3px solid var(--vscode-editorWarning-foreground, #cca700);
}
.diff-item-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, transparent);
border-bottom: 1px solid var(--vscode-panel-border);
}
.diff-item-header strong {
font-size: 13px;
font-weight: 600;
color: var(--vscode-foreground);
}
.diff-item-meta {
font-size: 11px;
color: var(--vscode-descriptionForeground);
margin-top: 2px;
}
.diff-item-fields {
padding: 8px 14px;
}
.diff-field-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid color-mix(in srgb, var(--vscode-panel-border) 50%, transparent);
}
.diff-field-row:last-child {
border-bottom: none;
}
.diff-field-name {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
padding-top: 3px;
}
.diff-field-values {
display: flex;
flex-direction: column;
gap: 3px;
}
.diff-field-value {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 1.4;
word-break: break-word;
}
.diff-field-value.db-value {
color: var(--vscode-foreground);
}
.diff-field-value.file-value {
color: var(--vscode-foreground);
opacity: 0.82;
}
.diff-source-label {
flex-shrink: 0;
min-width: 28px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: 3px;
}
.db-value .diff-source-label {
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 22%, transparent);
color: var(--vscode-focusBorder, #007fd4);
}
.file-value .diff-source-label {
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 22%, transparent);
color: var(--vscode-testing-iconPassed, #73c991);
}
/* ── Orphan files section ────────────────────────────────────────────── */
.orphan-files-section {
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 35%, transparent);
border-radius: 8px;
padding: 14px 16px;
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 5%, var(--vscode-editor-background));
}
.orphan-files-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.orphan-files-header h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
}
.orphan-files-actions {
display: flex;
align-items: center;
gap: 8px;
}
.orphan-path span {
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
/* ── Translation validation ──────────────────────────────────────────── */
.translation-validation-view {
display: flex;
flex-direction: column;
gap: 16px;
}
.translation-validation-summary {
padding: 10px 14px;
border-radius: 6px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.translation-validation-summary p {
margin: 0;
}
.translation-validation-section h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
.translation-validation-empty {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.translation-validation-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.translation-validation-card {
padding: 10px 14px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.translation-validation-card-db {
border-left: 3px solid var(--vscode-focusBorder, #007fd4);
}
.translation-validation-card-file {
border-left: 3px solid var(--vscode-testing-iconPassed, #73c991);
}
.translation-validation-card-title {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
}
.translation-validation-card-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 12px;
margin: 0;
font-size: 12px;
}
.translation-validation-card-meta dt {
color: var(--vscode-descriptionForeground);
font-weight: 500;
}
.translation-validation-card-meta dd {
margin: 0;
}
.translation-validation-actions {
display: flex;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--vscode-panel-border);
}
/* ── Git diff ────────────────────────────────────────────────────────── */
.git-diff-view {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
height: 100%;
}
.git-diff-empty {
color: var(--vscode-descriptionForeground);
text-align: center;
padding: 24px 0;
}
.git-diff-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.git-diff-toolbar label {
font-size: 12px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
flex-shrink: 0;
}
.git-diff-toolbar select {
flex: 1;
min-width: 0;
}
.git-diff-editor {
flex: 1;
min-height: 0;
overflow: hidden;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
}

338
assets/css/overlays.css Normal file
View File

@@ -0,0 +1,338 @@
.overlay-root {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10000;
}
.overlay-root:empty {
display: none;
}
.editor-shared-actions {
position: relative;
margin-bottom: 14px;
}
.ai-suggestions-modal-backdrop,
.insert-modal-backdrop,
.language-picker-modal-backdrop,
.confirm-delete-modal-backdrop,
.confirm-dialog-overlay,
.gallery-overlay,
.lightbox-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.68);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
}
.ai-suggestions-modal,
.insert-modal,
.language-picker-modal,
.confirm-delete-modal,
.confirm-dialog,
.gallery-overlay-content {
position: relative;
z-index: 1;
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.ai-suggestions-modal,
.language-picker-modal,
.confirm-delete-modal,
.confirm-dialog {
width: min(680px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
}
.insert-modal {
width: min(680px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.gallery-overlay-content {
width: min(980px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.ai-suggestions-modal-header,
.language-picker-modal-header,
.confirm-delete-modal-header,
.insert-modal-header,
.gallery-overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #3c3c3c;
}
.insert-modal-header {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.insert-modal-header.media-header-only {
flex-direction: row;
align-items: center;
}
.ai-suggestions-modal-header h2,
.language-picker-modal-header h2,
.confirm-delete-modal-header h2,
.gallery-overlay-header h2,
.insert-modal-title,
.confirm-dialog h3 {
margin: 0;
color: #ffffff;
}
.ai-suggestions-modal-close,
.confirm-delete-modal-close,
.gallery-overlay-close,
.shared-popover-close,
.lightbox-close {
border: none;
background: transparent;
color: #c5c5c5;
cursor: pointer;
font-size: 20px;
line-height: 1;
}
.ai-suggestions-modal-body,
.language-picker-modal-body,
.confirm-delete-modal-body {
padding: 20px;
overflow: auto;
}
.ai-suggestions-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-suggestion-item {
display: flex;
gap: 12px;
padding: 16px;
border: 1px solid #3c3c3c;
border-radius: 6px;
background: #252526;
}
.ai-suggestion-checkbox {
position: relative;
display: flex;
align-items: flex-start;
cursor: pointer;
}
.ai-suggestion-checkbox input {
position: absolute;
opacity: 0;
}
.checkmark {
width: 20px;
height: 20px;
border: 2px solid #555555;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #1e1e1e;
}
.ai-suggestion-checkbox input:checked + .checkmark,
.ai-suggestion-checkbox input:checked ~ .checkmark {
background: #0078d4;
border-color: #0078d4;
}
.ai-suggestion-checkbox input:checked + .checkmark::after,
.ai-suggestion-checkbox input:checked ~ .checkmark::after {
content: "✓";
color: #ffffff;
font-size: 12px;
}
.ai-suggestion-content {
flex: 1;
min-width: 0;
}
.ai-suggestion-label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-weight: 600;
}
.ai-suggestion-has-value,
.language-picker-badge,
.insert-modal-similarity-badge {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #c5c5c5;
font-size: 11px;
}
.ai-suggestion-comparison {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.ai-suggestion-column {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
}
.ai-suggestion-column.muted {
color: #9d9d9d;
}
.ai-suggestion-column.highlighted {
border: 1px solid rgba(0, 122, 204, 0.4);
color: #ffffff;
}
.ai-suggestion-column-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ai-suggestion-arrow {
color: #9d9d9d;
}
.ai-suggestion-value {
min-height: 1.4em;
}
.ai-suggestion-value.loading {
color: var(--accent-color);
font-style: italic;
}
.ai-suggestions-error {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 6px;
background: rgba(220, 50, 50, 0.12);
border: 1px solid rgba(220, 50, 50, 0.35);
color: #ff6b6b;
}
.ai-suggestions-modal-footer,
.confirm-delete-modal-footer,
.confirm-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid #3c3c3c;
}
.button-cancel,
.button-delete,
.button-apply,
.confirm-dialog-actions button,
.insert-modal-submit,
.language-picker-row,
.shared-popover-entry,
.colour-swatch {
cursor: pointer;
}
.button-cancel,
.confirm-dialog-actions button,
.insert-modal-submit {
border: 1px solid #4c4c4c;
border-radius: 4px;
padding: 8px 14px;
background: transparent;
color: #f0f0f0;
}
.button-apply,
.confirm-dialog-actions .primary,
.insert-modal-submit {
background: #0e639c;
border-color: #0e639c;
}
.button-delete {
border: none;
border-radius: 4px;
padding: 8px 14px;
background: #c73c3c;
color: #ffffff;
}
.insert-modal-tabs {
display: flex;
margin: 0 -20px;
}
.insert-modal-tab {
flex: 1;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: #9d9d9d;
padding: 10px 16px;
}
.insert-modal-tab.active {
color: #ffffff;
border-bottom-color: #0e639c;
background: #252526;
}
.insert-modal-search {
border-bottom: 1px solid #3c3c3c;
}
.insert-modal-input,
.shared-popover-input {
width: 100%;
border: none;
background: transparent;
color: #f0f0f0;
padding: 14px 20px;
font: inherit;
}

541
assets/css/panel.css Normal file
View File

@@ -0,0 +1,541 @@
.panel-shell {
min-height: 160px;
max-height: 160px;
border-top: 1px solid var(--line);
}
.panel-shell.is-hidden {
display: none;
}
.panel-tabs {
display: flex;
gap: 8px;
}
.panel-tabs {
display: flex;
gap: 2px;
}
.panel-tab {
background: transparent;
border: none;
padding: 6px 12px;
font-size: 12px;
color: var(--vscode-tab-inactiveForeground);
cursor: pointer;
border-bottom: 2px solid transparent;
border-radius: 0;
}
.panel-tab:hover {
color: var(--vscode-tab-activeForeground);
background: transparent;
}
.panel-tab.active {
color: var(--vscode-tab-activeForeground);
border-bottom-color: var(--vscode-focusBorder);
background: transparent;
}
.assistant-content {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
}
.assistant-sidebar-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.assistant-sidebar-heading {
display: flex;
flex-direction: column;
gap: 4px;
}
.assistant-sidebar-description,
.assistant-sidebar-context-text,
.assistant-sidebar-message-content {
color: var(--vscode-descriptionForeground);
}
.assistant-sidebar-status {
border-radius: 999px;
border: 1px solid var(--vscode-panel-border);
padding: 2px 8px;
font-size: 11px;
line-height: 1.4;
}
.assistant-sidebar-status.is-offline {
background: rgba(255, 196, 0, 0.18);
border-color: rgba(255, 196, 0, 0.35);
color: var(--vscode-editor-foreground);
}
.assistant-sidebar-context {
display: flex;
flex-direction: column;
gap: 10px;
padding: 8px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
}
.assistant-sidebar-context-row {
display: flex;
justify-content: space-between;
gap: 12px;
}
.assistant-sidebar-context-label,
.assistant-sidebar-message-role {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
}
.assistant-sidebar-context-value {
text-align: right;
color: var(--vscode-editor-foreground);
}
.assistant-sidebar-context-text,
.assistant-sidebar-message-content {
margin: 0;
white-space: pre-wrap;
}
.assistant-sidebar-prompt-form,
.assistant-sidebar-welcome,
.assistant-sidebar-transcript {
display: flex;
flex-direction: column;
gap: 10px;
}
.assistant-sidebar-prompt {
width: 100%;
min-height: 120px;
resize: vertical;
border: 1px solid var(--vscode-input-border);
border-radius: 6px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 10px;
font: inherit;
}
.assistant-sidebar-prompt:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
.assistant-sidebar-start-button {
align-self: flex-start;
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 999px;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
padding: 7px 14px;
cursor: pointer;
}
.assistant-sidebar-start-button:disabled {
cursor: default;
opacity: 0.55;
}
.assistant-card,
.assistant-sidebar-message {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
border-bottom-width: 1px;
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
}
.assistant-sidebar-message.user {
background: var(--vscode-list-hoverBackground);
}
.assistant-sidebar-message.assistant {
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
}
.status-bar {
height: 22px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--vscode-statusBar-background);
color: var(--vscode-statusBar-foreground);
font-size: 12px;
padding: 0 8px;
user-select: none;
flex-wrap: nowrap;
gap: 0;
border-top: none;
}
.status-bar-left,
.status-bar-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 0;
}
.status-bar-item {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 100%;
max-width: none;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 0;
background: transparent;
font-size: 12px;
}
.status-bar-item .task-message-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
}
.task-spinner {
width: 10px;
height: 10px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.panel-content {
padding: 8px;
}
.task-list {
gap: 4px;
}
.output-list,
.git-log-list {
gap: 6px;
}
.task-entry {
padding: 8px;
border-bottom: none;
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
}
.output-entry {
padding: 8px;
border-bottom: none;
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
font-size: 12px;
color: var(--vscode-editor-foreground);
}
@media (max-width: 1100px) {
.editor-frame {
grid-template-columns: 1fr;
}
.assistant-sidebar-shell {
display: none;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.text-muted {
color: var(--vscode-descriptionForeground);
}
.editor-empty {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
background-color: var(--vscode-editor-background);
overflow-y: auto;
padding: 40px 20px;
}
.dashboard-content {
max-width: 720px;
width: 100%;
}
.dashboard-content h1 {
font-size: 24px;
font-weight: 400;
margin: 0 0 4px;
color: var(--vscode-editor-foreground);
}
.dashboard-content > .text-muted {
margin-bottom: 24px;
display: block;
}
.dashboard-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
padding: 16px;
background-color: var(--vscode-sideBar-background);
border-radius: 6px;
}
.stat-number {
font-size: 32px;
font-weight: 600;
color: var(--vscode-editor-foreground);
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.stat-breakdown {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.stat-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
background-color: var(--vscode-input-background);
color: var(--vscode-descriptionForeground);
}
.stat-published {
color: var(--vscode-testing-iconPassed);
}
.stat-draft {
color: var(--vscode-editorWarning-foreground);
}
.stat-archived {
color: var(--vscode-descriptionForeground);
}
.dashboard-section {
background-color: var(--vscode-sideBar-background);
border-radius: 6px;
padding: 16px;
margin-bottom: 12px;
}
.dashboard-section h4 {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 12px;
}
.timeline-chart {
display: flex;
align-items: flex-end;
gap: 4px;
height: 100px;
}
.timeline-bar-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.timeline-bar {
width: 100%;
max-width: 40px;
background-color: var(--vscode-activityBarBadge-background);
border-radius: 3px 3px 0 0;
margin-top: auto;
min-height: 4px;
position: relative;
transition: opacity 0.15s;
}
.timeline-bar:hover {
opacity: 0.8;
}
.timeline-bar-count {
position: absolute;
top: -16px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: var(--vscode-descriptionForeground);
}
.timeline-bar-label {
display: flex;
flex-direction: column;
align-items: center;
font-size: 9px;
color: var(--vscode-descriptionForeground);
margin-top: 4px;
line-height: 1.15;
}
.timeline-bar-label-month {
white-space: nowrap;
}
.timeline-bar-label-year {
font-size: 8px;
}
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
align-items: baseline;
line-height: 1.6;
}
.dashboard-tag {
padding: 2px 8px;
border-radius: 10px;
background-color: var(--vscode-input-background);
color: var(--vscode-editor-foreground);
cursor: default;
transition: opacity 0.15s;
white-space: nowrap;
}
.dashboard-tag:hover {
opacity: 0.75;
}
.dashboard-tag.has-color {
border-radius: 12px;
}
.dashboard-tag.has-color:hover {
opacity: 0.85;
}
.tag-cloud-more {
font-size: 11px;
}
.tag-count {
font-size: 10px;
opacity: 0.5;
margin-left: 2px;
}
.dashboard-category {
font-size: 12px;
border: 1px solid var(--vscode-input-border);
}
.recent-posts-list {
display: flex;
flex-direction: column;
}
.recent-post-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
width: 100%;
border: none;
background: transparent;
text-align: left;
color: inherit;
}
.recent-post-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.recent-post-title {
flex: 1;
color: var(--vscode-editor-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-post-status {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background-color: var(--vscode-input-background);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.recent-post-status.status-published {
color: var(--vscode-testing-iconPassed);
}
.recent-post-status.status-draft {
color: var(--vscode-editorWarning-foreground);
}
.recent-post-status.status-archived {
color: var(--vscode-descriptionForeground);
}
.recent-post-date {
color: var(--vscode-descriptionForeground);
white-space: nowrap;
}

807
assets/css/shell.css Normal file
View File

@@ -0,0 +1,807 @@
.app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--vscode-editor-background);
}
.app-main {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
.app-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.window-titlebar {
position: relative;
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--vscode-editorGroupHeader-tabsBackground);
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder);
flex-shrink: 0;
app-region: drag;
-webkit-app-region: drag;
padding-right: calc(10px + var(--bds-titlebar-overlay-right, 0px));
}
.window-titlebar-menu-bar {
display: flex;
align-items: center;
height: 100%;
margin-left: 6px;
gap: 2px;
app-region: no-drag;
-webkit-app-region: no-drag;
z-index: 2;
}
.window-titlebar-menu-group {
position: relative;
display: flex;
align-items: center;
height: 100%;
}
.window-titlebar-menu-bar.is-hidden {
display: none;
}
.window-titlebar.is-mac .window-titlebar-menu-bar {
margin-left: max(var(--bds-titlebar-macos-left-inset, 78px), calc(6px + var(--bds-titlebar-overlay-left, 0px)));
}
.window-titlebar-menu-button {
height: 24px;
border: none;
background: transparent;
color: var(--vscode-titleBar-activeForeground);
padding: 0 8px;
border-radius: 4px;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.window-titlebar-menu-button:hover,
.window-titlebar-action-button:hover {
background-color: var(--vscode-toolbar-hoverBackground);
}
.window-titlebar-menu-button.is-active {
background-color: var(--vscode-toolbar-hoverBackground);
}
.window-titlebar-menu-button:focus,
.window-titlebar-menu-button:focus-visible,
.window-titlebar-action-button:focus,
.window-titlebar-action-button:focus-visible {
outline: none;
box-shadow: none;
}
.window-titlebar-menu-dropdown {
position: absolute;
top: 30px;
left: 0;
min-width: 210px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
background-color: var(--vscode-menu-background, var(--vscode-editorWidget-background));
border: 1px solid var(--vscode-menu-border, var(--vscode-panel-border));
border-radius: 6px;
box-shadow: var(--vscode-widget-shadow, 0 8px 24px rgba(0, 0, 0, 0.4));
app-region: no-drag;
-webkit-app-region: no-drag;
z-index: 10;
}
.window-titlebar-menu-item {
border: none;
background: transparent;
color: var(--vscode-menu-foreground, var(--vscode-foreground));
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
text-align: left;
border-radius: 4px;
padding: 6px 8px;
font-size: 12px;
cursor: pointer;
}
.window-titlebar-menu-item:focus,
.window-titlebar-menu-item:focus-visible {
outline: none;
box-shadow: none;
background-color: var(--vscode-toolbar-hoverBackground);
}
.window-titlebar-menu-item:hover,
.window-titlebar-menu-item.is-keyboard-active {
background-color: var(--vscode-menu-selectionBackground, var(--vscode-toolbar-hoverBackground));
}
.window-titlebar-menu-item-accelerator {
opacity: 0.8;
}
.window-titlebar-menu-separator {
height: 1px;
margin: 4px 2px;
background-color: var(--vscode-menu-separatorBackground, rgba(255, 255, 255, 0.08));
}
.window-titlebar-drag-region {
flex: 1;
height: 100%;
}
.window-titlebar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
max-width: 45%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--vscode-titleBar-activeForeground);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
pointer-events: none;
}
.window-titlebar-actions {
height: 100%;
display: flex;
align-items: center;
margin-right: 6px;
app-region: no-drag;
-webkit-app-region: no-drag;
}
.window-titlebar-action-button {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
line-height: 0;
background: transparent;
border: none;
color: var(--vscode-foreground);
cursor: pointer;
border-radius: 4px;
}
.window-titlebar-sidebar-icon,
.window-titlebar-panel-icon,
.window-titlebar-assistant-icon {
width: 14px;
height: 14px;
border: 1.5px solid currentColor;
border-radius: 2px;
display: block;
position: relative;
overflow: hidden;
}
.window-titlebar-sidebar-icon::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 33.3333%;
width: 1.5px;
transform: translateX(-50%);
background-color: currentColor;
}
.window-titlebar-panel-icon::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 66.6667%;
height: 1.5px;
transform: translateY(-50%);
background-color: currentColor;
}
.window-titlebar-assistant-icon::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 66.6667%;
width: 1.5px;
transform: translateX(-50%);
background-color: currentColor;
}
.window-titlebar-sidebar-pane,
.window-titlebar-panel-pane,
.window-titlebar-assistant-pane {
position: absolute;
background-color: currentColor;
transition: opacity 120ms ease;
}
.window-titlebar-sidebar-pane {
left: 0;
top: 0;
width: 33.3333%;
height: 100%;
}
.window-titlebar-panel-pane {
left: 0;
bottom: 0;
width: 100%;
height: 33.3333%;
}
.window-titlebar-assistant-pane {
right: 0;
top: 0;
width: 33.3333%;
height: 100%;
}
.window-titlebar-sidebar-icon.is-inactive .window-titlebar-sidebar-pane,
.window-titlebar-panel-icon.is-inactive .window-titlebar-panel-pane,
.window-titlebar-assistant-icon.is-inactive .window-titlebar-assistant-pane {
opacity: 0;
}
.panel-shell {
height: 200px;
border-top: 1px solid var(--vscode-panel-border);
background: var(--vscode-panel-background);
display: flex;
flex-direction: column;
}
.panel-shell.is-hidden {
display: none;
}
.editor-toolbar-button.is-destructive {
color: #f48771;
}
.shell-overlay-backdrop,
.gallery-overlay-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.68);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
z-index: 10000;
}
.shell-overlay-dismiss {
position: absolute;
inset: 0;
border: none;
background: transparent;
padding: 0;
}
.gallery-overlay {
position: relative;
width: min(980px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 1;
}
.insert-modal-media-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
padding: 16px;
}
.insert-modal-media-item {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid #3c3c3c;
border-radius: 8px;
background: #252526;
color: inherit;
padding: 10px;
text-align: left;
}
.insert-modal-media-thumb {
width: 100%;
min-height: 112px;
border-radius: 6px;
object-fit: cover;
background: rgba(255, 255, 255, 0.04);
}
.insert-modal-media-title {
font-weight: 600;
color: #ffffff;
}
.language-picker-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.language-picker-option {
width: 100%;
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 12px;
align-items: center;
border: none;
border-radius: 4px;
padding: 12px 16px;
background: transparent;
color: inherit;
text-align: left;
}
.language-picker-label,
.language-picker-status,
.lightbox-counter {
color: #9d9d9d;
font-size: 12px;
}
.lightbox-counter {
margin-top: 4px;
}
@media (max-width: 720px) {
.insert-modal-media-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.panel-header {
height: 35px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
background-color: var(--vscode-sideBar-background);
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 {
background: transparent;
border: none;
color: var(--vscode-descriptionForeground);
font-size: 18px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
padding: 0;
}
.panel-close:hover {
background-color: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
}
.panel-content {
flex: 1;
overflow: auto;
padding: 12px 14px;
}
.panel-entry,
.assistant-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.output-list,
.git-log-list {
display: flex;
flex-direction: column;
}
.task-list {
display: flex;
flex-direction: column;
}
.task-entry-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.task-status {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
}
.task-status-running {
color: var(--vscode-terminal-ansiGreen, var(--vscode-statusBar-foreground));
}
.task-status-pending {
color: var(--vscode-terminal-ansiYellow, var(--vscode-statusBar-foreground));
}
.panel-empty-state {
min-height: 100%;
justify-content: center;
}
.status-bar {
height: 22px;
background: var(--vscode-statusBar-background);
color: var(--vscode-statusBar-foreground);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
font-size: 12px;
flex-shrink: 0;
}
.status-bar-left,
.status-bar-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.status-bar-left {
flex-shrink: 1;
min-width: 0;
}
.status-shell-controls {
display: flex;
align-items: stretch;
gap: 2px;
flex-shrink: 0;
}
.status-shell-toggle-button {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 100%;
padding: 0;
line-height: 0;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
border-radius: 3px;
}
.status-shell-toggle-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.status-shell-toggle-button:focus,
.status-shell-toggle-button:focus-visible {
outline: none;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45);
}
.status-shell-toggle-button .window-titlebar-sidebar-icon,
.status-shell-toggle-button .window-titlebar-panel-icon,
.status-shell-toggle-button .window-titlebar-assistant-icon {
width: 12px;
height: 12px;
}
.status-bar-item {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.status-bar-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.status-bar-task-button {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.status-bar-item.theme-badge {
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 3px;
}
.status-bar-item.language-badge {
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 3px;
gap: 4px;
}
.status-bar-item.offline-badge {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.4;
font-size: 13px;
padding: 0 4px;
}
.status-bar-item.offline-badge.active {
background-color: rgba(255, 196, 0, 0.28);
opacity: 1;
}
.project-selector {
position: relative;
flex-shrink: 0;
}
.project-selector-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 22px;
background: transparent;
border: none;
color: var(--vscode-statusBar-foreground);
cursor: pointer;
font-size: 12px;
text-align: left;
}
.project-selector-trigger:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.project-selector-trigger:focus {
outline: none;
}
.project-icon,
.dropdown-arrow,
.project-check-icon {
flex-shrink: 0;
}
.project-name,
.project-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-name {
max-width: 180px;
}
.dropdown-arrow {
opacity: 0.6;
}
.project-dropdown {
position: absolute;
left: 0;
bottom: 100%;
min-width: 220px;
margin-bottom: 4px;
background-color: #252526;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 4px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.project-dropdown-header {
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-descriptionForeground);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.project-list {
max-height: 200px;
overflow-y: auto;
}
.project-item {
display: flex;
align-items: center;
width: 100%;
gap: 8px;
padding: 8px 12px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.project-item:hover,
.project-item.active {
background-color: var(--vscode-list-hoverBackground);
}
.project-item.active .project-check-icon {
color: #89d185;
}
.project-dropdown-footer {
padding: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
display: grid;
gap: 6px;
}
.create-project-btn,
.existing-project-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 6px 12px;
background-color: rgba(255, 255, 255, 0.12);
color: inherit;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.create-project-btn:hover,
.existing-project-btn:hover {
background-color: rgba(255, 255, 255, 0.18);
}
.status-bar-language-select {
background: transparent;
border: none;
color: inherit;
font: inherit;
padding: 0;
}
.status-bar-language-select:focus {
outline: none;
}
.status-bar-count {
font-size: 11px;
opacity: 0.85;
}
.status-bar-item.brand {
font-weight: 600;
}
@media (max-width: 960px) {
.editor-frame {
grid-template-columns: minmax(0, 1fr);
}
.editor-meta {
border-left: none;
border-top: 1px solid var(--vscode-panel-border);
padding-left: 0;
padding-top: 10px;
}
}
.editor-section ul {
margin: 12px 0 0;
padding-left: 18px;
}
.editor-toolbar {
display: flex;
gap: 10px;
}
.editor-toolbar button {
padding: 9px 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-3);
color: var(--ink);
}
.editor-meta {
display: flex;
flex-direction: column;
gap: 12px;
}
.editor-meta-card,
.assistant-card,
.panel-entry {
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);
}

1049
assets/css/sidebar.css Normal file

File diff suppressed because it is too large Load Diff

189
assets/css/tabs.css Normal file
View File

@@ -0,0 +1,189 @@
.tab-bar {
display: flex;
align-items: center;
background-color: var(--vscode-editorGroupHeader-tabsBackground);
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder);
height: 35px;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.tab-bar-tabs {
display: flex;
align-items: center;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
flex: 1;
}
.tab-bar-tabs::-webkit-scrollbar {
height: 0;
display: none;
}
.tab-bar-empty {
display: flex;
align-items: center;
height: 100%;
padding: 0 12px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.tab {
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px 0 10px;
height: 100%;
min-width: 100px;
max-width: 180px;
cursor: pointer;
background-color: var(--vscode-tab-inactiveBackground);
border: none;
border-right: 1px solid var(--vscode-tab-border);
color: var(--vscode-tab-inactiveForeground);
font-size: 13px;
user-select: none;
position: relative;
flex-shrink: 0;
}
.tab-select {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
flex: 1;
height: 100%;
padding: 0;
background: transparent;
border: none;
color: inherit;
font: inherit;
cursor: inherit;
}
.tab:hover {
background-color: var(--vscode-list-hoverBackground);
}
.tab.active {
background-color: var(--vscode-tab-activeBackground);
color: var(--vscode-tab-activeForeground);
}
.tab.active::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--vscode-focusBorder);
}
.tab.transient .tab-title {
font-style: italic;
}
.tab-actions {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}
.tab-dirty-indicator {
color: var(--vscode-editorWarning-foreground, #e2c08d);
font-size: 10px;
line-height: 1;
}
.tab-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.85;
}
.tab-title,
.status-bar-item {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-close {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
line-height: 1;
color: var(--vscode-icon-foreground, #c5c5c5);
border-radius: 3px;
cursor: pointer;
flex-shrink: 0;
border: none;
background: transparent;
padding: 0;
opacity: 0;
}
.tab:hover .tab-close {
opacity: 0.7;
}
.tab.active .tab-close {
opacity: 0.7;
}
.tab-close:hover {
opacity: 1 !important;
background-color: var(--vscode-toolbar-hoverBackground);
color: var(--vscode-tab-activeForeground);
}
.tab-close:active {
background-color: var(--vscode-toolbar-activeBackground, rgba(99, 102, 103, 0.31));
}
.tab.dirty .tab-dirty-indicator {
display: block;
}
.tab.dirty .tab-close {
display: none;
}
.tab.dirty:hover .tab-close {
display: flex;
opacity: 0.7;
}
.tab.dirty:hover .tab-dirty-indicator {
display: none;
}
.tab:focus-visible {
outline: 1px solid var(--vscode-focusBorder, #007fd4);
outline-offset: -1px;
}
.output-item-details {
margin: 4px 0 0;
padding: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.03);
color: inherit;
font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre-wrap;
user-select: text;
}

166
assets/css/tokens.css Normal file
View File

@@ -0,0 +1,166 @@
@theme {
--color-shell-bg: #1e1e1e;
--color-sidebar-bg: #252526;
--color-activity-bg: #333333;
--color-panel-bg: #1e1e1e;
--color-tab-active-bg: #1e1e1e;
--color-tab-inactive-bg: #2d2d2d;
--color-focus-border: #007fd4;
--color-input-bg: rgba(255, 255, 255, 0.06);
--color-input-border: rgba(255, 255, 255, 0.12);
--color-status-bg: #007acc;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
--text-shell: 13px;
--spacing-titlebar: 34px;
--spacing-tabbar: 35px;
--spacing-statusbar: 22px;
}
:root {
--accent-color: #007acc;
--accent-color-transparent: rgba(0, 122, 204, 0.25);
--vscode-editor-background: #1e1e1e;
--vscode-editor-foreground: #cccccc;
--vscode-sideBar-background: #252526;
--vscode-activityBar-background: #333333;
--vscode-activityBar-foreground: #ffffff;
--vscode-panel-background: #1e1e1e;
--vscode-titleBar-activeBackground: #252526;
--vscode-titleBar-activeForeground: #cccccc;
--vscode-statusBar-background: #007acc;
--vscode-statusBar-foreground: #ffffff;
--vscode-tab-activeBackground: #1e1e1e;
--vscode-tab-inactiveBackground: #2d2d2d;
--vscode-tab-activeForeground: #ffffff;
--vscode-tab-inactiveForeground: #969696;
--vscode-editorGroupHeader-tabsBackground: #252526;
--vscode-editorGroupHeader-tabsBorder: #1e1e1e;
--vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31);
--vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31);
--vscode-foreground: #cccccc;
--vscode-descriptionForeground: #858585;
--vscode-panel-border: #80808059;
--vscode-sideBar-border: #80808059;
--vscode-tab-border: #252526;
--vscode-focusBorder: #007fd4;
--vscode-input-background: rgba(255, 255, 255, 0.06);
--vscode-input-border: rgba(255, 255, 255, 0.12);
--vscode-list-hoverBackground: #2a2d2e;
--vscode-list-activeSelectionBackground: #094771;
--vscode-list-activeSelectionForeground: #ffffff;
--vscode-activityBarBadge-background: #007acc;
--vscode-activityBarBadge-foreground: #ffffff;
--vscode-testing-iconPassed: #73c991;
--vscode-editorWarning-foreground: #cca700;
--vscode-input-foreground: #cccccc;
--vscode-input-placeholderForeground: #a6a6a6;
--vscode-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--vscode-font-size: 13px;
--panel-1: var(--vscode-editor-background);
--panel-2: var(--vscode-sideBar-background);
--panel-3: var(--vscode-input-background);
--ink: var(--vscode-foreground);
--line: var(--vscode-panel-border);
--accent: var(--vscode-focusBorder);
--accent-soft: var(--vscode-list-hoverBackground);
--success: var(--vscode-testing-iconPassed);
--sidebar-width: 280px;
--assistant-width: 360px;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
background: var(--vscode-editor-background);
color: var(--vscode-foreground);
}
body {
overflow: hidden;
user-select: none;
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
body > [data-phx-session],
body > [data-phx-main] {
width: 100%;
height: 100%;
min-height: 0;
}
button {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-button-foreground);
background-color: var(--vscode-button-background);
border: none;
padding: 6px 14px;
cursor: pointer;
border-radius: 2px;
}
button:hover {
background-color: var(--vscode-button-hoverBackground);
}
button:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
button.secondary {
background-color: var(--vscode-button-secondaryBackground);
}
button.secondary:hover {
background-color: #4a4d51;
}
button.compact {
padding: 4px 8px;
font-size: 12px;
}
button.primary {
background-color: var(--vscode-button-background);
font-weight: 500;
}
button.primary:hover {
background-color: var(--vscode-button-hoverBackground);
}
button.success {
background-color: #28a745;
}
button.success:hover {
background-color: #218838;
}
button.danger {
background-color: #dc3545;
}
button.danger:hover {
background-color: #c82333;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button svg,
button svg * {
pointer-events: none;
}

301
assets/css/utilities.css Normal file
View File

@@ -0,0 +1,301 @@
@layer components {
.ui-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 28px;
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 4px;
font: inherit;
line-height: 1.2;
cursor: pointer;
user-select: none;
}
.ui-button:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground, #0e639c);
}
.ui-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ui-button-primary {
color: var(--vscode-button-foreground, #ffffff);
background: var(--vscode-button-background, var(--vscode-focusBorder));
}
.ui-button-primary:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground, #0e639c);
}
.ui-button-secondary {
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
background: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
border-color: var(--vscode-button-border, transparent);
}
.ui-button-secondary:hover:not(:disabled) {
background: var(--vscode-button-secondaryHoverBackground, #4a4d51);
}
.ui-button-danger {
color: var(--vscode-errorForeground, #f48771);
background: transparent;
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 45%, transparent);
}
.ui-button-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 14%, transparent);
}
.ui-button-compact {
min-height: 24px;
padding: 3px 8px;
font-size: 12px;
}
.ui-input,
.ui-textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
border-radius: 4px;
background: var(--vscode-input-background, rgba(255, 255, 255, 0.06));
color: var(--vscode-input-foreground, var(--vscode-foreground));
font: inherit;
}
.ui-textarea {
line-height: 1.5;
resize: vertical;
}
.ui-input:focus,
.ui-textarea:focus {
outline: 1px solid var(--vscode-focusBorder, #007fd4);
outline-offset: 1px;
}
.ui-input-readonly,
.ui-input[readonly] {
opacity: 0.7;
cursor: not-allowed;
}
.ui-input-disabled,
.ui-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ui-tab {
border: none;
color: var(--vscode-tab-inactiveForeground, var(--vscode-foreground));
background: transparent;
}
.ui-tab:hover {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
}
.ui-tab-active {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
}
.ui-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ui-panel-entry {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
}
.ui-empty-state {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--vscode-descriptionForeground);
}
.ui-editor-shell {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--vscode-editor-background);
}
.ui-editor-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
min-height: 35px;
padding: 0 12px;
border-bottom: 1px solid var(--vscode-panel-border);
background: var(--vscode-tab-activeBackground);
}
.ui-editor-tab-current {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 6px;
overflow: hidden;
padding: 6px 12px;
border-radius: 4px 4px 0 0;
background: var(--vscode-tab-activeBackground);
color: var(--vscode-tab-activeForeground);
}
.ui-editor-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.ui-toolbar {
display: flex;
align-items: center;
gap: 12px;
min-height: 32px;
}
.ui-toolbar-group {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.ui-field-stack {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.ui-field-stack > label,
.ui-field-label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ui-field-grid-2,
.ui-field-grid-3 {
display: grid;
gap: 16px;
}
.ui-dropdown-menu {
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.ui-dropdown-item {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.ui-dropdown-item:hover:not(:disabled) {
background: var(--vscode-list-hoverBackground, #2a2d2e);
}
.ui-dropdown-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ui-section-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.btn-base {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 28px;
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 4px;
font: inherit;
line-height: 1.2;
cursor: pointer;
user-select: none;
}
.btn-theme-primary {
color: var(--vscode-button-foreground, #ffffff);
background: var(--vscode-button-background, var(--vscode-focusBorder));
}
.btn-theme-primary:hover {
background: var(--vscode-button-hoverBackground, #0e639c);
}
.btn-theme-danger {
color: var(--vscode-errorForeground, #f48771);
background: transparent;
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 45%, transparent);
}
.btn-theme-danger:hover {
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 14%, transparent);
}
.panel-entry {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
}
.monaco-host {
min-width: 0;
min-height: 0;
overflow: hidden;
}
}
@media (min-width: 768px) {
.ui-field-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ui-field-grid-3 {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
}
}

29
assets/js/app.js Normal file
View File

@@ -0,0 +1,29 @@
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";
import "phoenix_html";
import { Hooks } from "./hooks/index.js";
document.addEventListener("DOMContentLoaded", () => {
const csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute("content");
const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks,
metadata: {
keydown: (event) => ({
key: event.key,
meta: event.metaKey,
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
tag: event.target?.tagName || null,
contentEditable: event.target?.isContentEditable || false
})
}
});
liveSocket.connect();
window.liveSocket = liveSocket;
});

View File

@@ -0,0 +1,19 @@
import { clamp } from "../utils/dom.js";
export const applyAppZoom = (nextZoom) => {
const zoom = clamp(Math.round(nextZoom * 100) / 100, 0.5, 2);
window.__bdsAppZoom = zoom;
document.documentElement.style.zoom = String(zoom);
};
export const runDocumentCommand = (command) => {
if (typeof document.execCommand !== "function") {
return false;
}
try {
return document.execCommand(command);
} catch (_error) {
return false;
}
};

View File

@@ -0,0 +1,58 @@
import { activeMonacoEditor, runMonacoEditorAction } from "../monaco/services.js";
import { applyAppZoom, runDocumentCommand } from "./document_commands.js";
export const runMenuRuntimeCommand = (action) => {
const editor = activeMonacoEditor();
switch (action) {
case "undo":
return editor ? runMonacoEditorAction(editor, "undo") : runDocumentCommand("undo");
case "redo":
return editor ? runMonacoEditorAction(editor, "redo") : runDocumentCommand("redo");
case "cut":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardCutAction")
: runDocumentCommand("cut");
case "copy":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardCopyAction")
: runDocumentCommand("copy");
case "paste":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardPasteAction")
: runDocumentCommand("paste");
case "delete":
return editor ? runMonacoEditorAction(editor, "deleteLeft") : runDocumentCommand("delete");
case "select_all":
return editor
? runMonacoEditorAction(editor, "editor.action.selectAll")
: runDocumentCommand("selectAll");
case "find":
return editor ? runMonacoEditorAction(editor, "actions.find") : false;
case "replace":
return editor ? runMonacoEditorAction(editor, "editor.action.startFindReplaceAction") : false;
case "reload":
case "force_reload":
window.location.reload();
return true;
case "reset_zoom":
applyAppZoom(1);
return true;
case "zoom_in":
applyAppZoom((window.__bdsAppZoom || 1) + 0.1);
return true;
case "zoom_out":
applyAppZoom((window.__bdsAppZoom || 1) - 0.1);
return true;
case "toggle_full_screen":
if (document.fullscreenElement) {
document.exitFullscreen?.();
} else {
document.documentElement.requestFullscreen?.();
}
return true;
default:
return false;
}
};

View File

@@ -0,0 +1,39 @@
export const syncTitlebarOverlayInsets = () => {
const rootStyle = document.documentElement.style;
const setInsets = (left, right) => {
rootStyle.setProperty("--bds-titlebar-overlay-left", `${left}px`);
rootStyle.setProperty("--bds-titlebar-overlay-right", `${right}px`);
};
const overlay = navigator.windowControlsOverlay;
if (!overlay) {
setInsets(0, 0);
return () => {};
}
const updateInsets = () => {
if (!overlay.visible) {
setInsets(0, 0);
return;
}
const titlebarRect = overlay.getTitlebarAreaRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || titlebarRect.right;
const leftInset = Math.max(0, Math.round(titlebarRect.left));
const rightInset = Math.max(0, Math.round(viewportWidth - titlebarRect.right));
setInsets(leftInset, rightInset);
};
const onGeometryChange = () => updateInsets();
const onResize = () => updateInsets();
updateInsets();
overlay.addEventListener("geometrychange", onGeometryChange);
window.addEventListener("resize", onResize);
return () => {
overlay.removeEventListener("geometrychange", onGeometryChange);
window.removeEventListener("resize", onResize);
};
};

4
assets/js/constants.js Normal file
View File

@@ -0,0 +1,4 @@
export const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar";
export const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar";
export const UI_LANGUAGE_STORAGE_KEY = "bds-ui-language";
export const WORKBENCH_SESSION_STORAGE_KEY_PREFIX = "bds-workbench-";

View File

@@ -0,0 +1,232 @@
import {
SIDEBAR_STORAGE_KEY,
ASSISTANT_STORAGE_KEY,
UI_LANGUAGE_STORAGE_KEY,
WORKBENCH_SESSION_STORAGE_KEY_PREFIX
} from "../constants.js";
import {
parseJsonObject,
setMediaThumbnailLoaded,
syncMediaThumbnailState,
clamp
} from "../utils/dom.js";
import { shellWidth, setShellWidth, persistWidth, readStoredSize } from "../utils/layout.js";
import {
parseShortcutConfig,
normalizeShortcutKey,
shortcutMatchesEvent,
shortcutTargetIsEditable
} from "../utils/shortcuts.js";
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
export const AppShell = {
mounted() {
this.shortcuts = parseShortcutConfig(this.el.dataset.shortcuts);
this.currentProjectId = this.el.dataset.projectId || "";
this.syncStoredLayout();
this.syncStoredUiLanguage();
this.destroyOverlaySync = syncTitlebarOverlayInsets();
this.workbenchStorageKey = (projectId) =>
projectId ? `${WORKBENCH_SESSION_STORAGE_KEY_PREFIX}${projectId}` : null;
this.restoreStoredWorkbenchSession = () => {
const projectId = this.el.dataset.projectId || "";
const storageKey = this.workbenchStorageKey(projectId);
if (!storageKey) {
return false;
}
const session = parseJsonObject(window.localStorage.getItem(storageKey));
if (!session) {
return false;
}
this.pushEvent("restore_workbench_session", { session });
return true;
};
this.persistWorkbenchSession = () => {
const projectId = this.el.dataset.projectId || "";
const storageKey = this.workbenchStorageKey(projectId);
const session = this.el.dataset.workbenchSession;
if (!storageKey || !session) {
return;
}
window.localStorage.setItem(storageKey, session);
};
this.handleMouseDown = (event) => {
const handle = event.target.closest("[data-role='resize-handle']");
if (!handle || !this.el.contains(handle)) {
return;
}
event.preventDefault();
const target = handle.dataset.resize;
const startX = event.clientX;
const startWidth =
target === "assistant"
? shellWidth("[data-testid='assistant-shell']")
: shellWidth("[data-testid='sidebar-shell']");
const min = target === "assistant" ? 280 : 200;
const max = target === "assistant" ? 640 : 500;
const invert = target === "assistant";
const onMouseMove = (moveEvent) => {
const delta = invert ? startX - moveEvent.clientX : moveEvent.clientX - startX;
const width = clamp(startWidth + delta, min, max);
const selector = target === "assistant" ? "[data-testid='assistant-shell']" : "[data-testid='sidebar-shell']";
setShellWidth(selector, width);
persistWidth(target, width);
};
const onMouseUp = (upEvent) => {
const delta = invert ? startX - upEvent.clientX : upEvent.clientX - startX;
const width = clamp(startWidth + delta, min, max);
persistWidth(target, width);
this.pushEvent("resize_panel", { target, width });
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
};
this.el.addEventListener("mousedown", this.handleMouseDown);
this.handleNativeMenuAction = (event) => {
const action = event.detail?.action;
const ackId = event.detail?.ackId;
if (action) {
this.pushEvent("native_menu_action", { action }, () => {
if (ackId) {
window.dispatchEvent(
new CustomEvent("bds:native-menu-action-ack", { detail: { ackId } })
);
}
});
}
};
this.handleChange = (event) => {
const select = event.target.closest(".status-bar-language-select");
if (select && this.el.contains(select)) {
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, select.value);
}
};
this.handleShortcutKeyDown = (event) => {
if (shortcutTargetIsEditable(event)) {
return;
}
const shortcut = this.shortcuts.find((candidate) => shortcutMatchesEvent(candidate, event));
if (!shortcut) {
return;
}
event.preventDefault();
event.stopPropagation();
this.pushEvent("shortcut", {
key: normalizeShortcutKey(event.key),
meta: event.metaKey,
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
tag: event.target?.tagName || null,
contentEditable: event.target?.isContentEditable || false
});
};
this.handleThumbnailLoad = (event) => {
if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) {
setMediaThumbnailLoaded(event.target, true);
}
};
this.handleThumbnailError = (event) => {
if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) {
setMediaThumbnailLoaded(event.target, false);
}
};
this.handleEvent("menu-runtime-command", ({ action }) => {
if (action) {
runMenuRuntimeCommand(String(action));
}
});
this.handleEvent("url-state", ({ path }) => {
if (path && window.location.pathname + window.location.search !== path) {
window.history.replaceState({}, "", path);
}
});
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
this.el.addEventListener("load", this.handleThumbnailLoad, true);
this.el.addEventListener("error", this.handleThumbnailError, true);
this.el.addEventListener("change", this.handleChange);
syncMediaThumbnailState(this.el);
this.restoreStoredWorkbenchSession();
},
updated() {
const nextProjectId = this.el.dataset.projectId || "";
if (nextProjectId !== this.currentProjectId) {
this.currentProjectId = nextProjectId;
if (this.restoreStoredWorkbenchSession()) {
return;
}
}
syncMediaThumbnailState(this.el);
this.persistWorkbenchSession();
},
destroyed() {
this.el.removeEventListener("mousedown", this.handleMouseDown);
this.el.removeEventListener("load", this.handleThumbnailLoad, true);
this.el.removeEventListener("error", this.handleThumbnailError, true);
this.el.removeEventListener("change", this.handleChange);
window.removeEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.removeEventListener("keydown", this.handleShortcutKeyDown, true);
if (this.destroyOverlaySync) {
this.destroyOverlaySync();
}
},
syncStoredLayout() {
this.pushEvent("sync_layout", {
sidebar_width: readStoredSize(SIDEBAR_STORAGE_KEY, shellWidth("[data-testid='sidebar-shell']"), 200, 500),
assistant_sidebar_width: readStoredSize(ASSISTANT_STORAGE_KEY, 360, 280, 640)
});
},
syncStoredUiLanguage() {
const stored = window.localStorage.getItem(UI_LANGUAGE_STORAGE_KEY);
if (stored) {
this.pushEvent("sync_ui_language", { language: stored });
}
}
};

View File

@@ -0,0 +1,139 @@
export const ChatSurface = {
mounted() {
this.stickToBottom = true;
this.scrollContainer = null;
this.autoResize = () => {
const textarea = this.el.querySelector(".chat-input");
if (!textarea) {
return;
}
const styles = getComputedStyle(textarea);
const minHeight = parseFloat(styles.getPropertyValue("--chat-input-min-height")) || 20;
const maxHeight = parseFloat(styles.getPropertyValue("--chat-input-max-height")) || 160;
textarea.rows = 1;
textarea.style.minHeight = `${minHeight}px`;
if (textarea.value.trim() === "") {
textarea.style.height = `${minHeight}px`;
textarea.style.maxHeight = `${minHeight}px`;
textarea.style.overflowY = "hidden";
return;
}
textarea.style.maxHeight = `${maxHeight}px`;
textarea.style.height = "0px";
const nextHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY = nextHeight >= maxHeight ? "auto" : "hidden";
};
this.syncScrollContainer = () => {
const nextContainer = this.el.querySelector(".chat-messages");
if (nextContainer === this.scrollContainer) {
return;
}
if (this.scrollContainer) {
this.scrollContainer.removeEventListener("scroll", this.handleScroll);
}
this.scrollContainer = nextContainer;
if (this.scrollContainer) {
this.scrollContainer.addEventListener("scroll", this.handleScroll);
}
};
this.scrollToBottom = (force = false) => {
if (!this.scrollContainer) {
return;
}
if (force || this.stickToBottom) {
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
}
};
this.syncExpandedSurfaces = () => {
this.el
.querySelectorAll(".chat-inline-surface[data-expanded='true']")
.forEach((surface) => {
surface.open = true;
});
};
this.surfaceObserver = new MutationObserver(() => {
this.syncExpandedSurfaces();
});
this.handleScroll = () => {
if (!this.scrollContainer) {
this.stickToBottom = true;
return;
}
const distanceFromBottom =
this.scrollContainer.scrollHeight -
this.scrollContainer.scrollTop -
this.scrollContainer.clientHeight;
this.stickToBottom = distanceFromBottom < 48;
};
this.handleInput = (event) => {
if (!event.target.closest(".chat-input")) {
return;
}
this.stickToBottom = true;
this.autoResize();
};
this.handleKeyDown = (event) => {
if (!event.target.closest(".chat-input")) {
return;
}
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
const sendButton = this.el.querySelector("[data-testid='chat-send-button']");
if (sendButton && !sendButton.disabled) {
sendButton.click();
}
}
};
this.el.addEventListener("input", this.handleInput);
this.el.addEventListener("keydown", this.handleKeyDown);
this.syncScrollContainer();
this.syncExpandedSurfaces();
this.surfaceObserver.observe(this.el, { childList: true, subtree: true });
this.autoResize();
window.requestAnimationFrame(() => this.scrollToBottom(true));
},
updated() {
this.syncScrollContainer();
this.syncExpandedSurfaces();
this.autoResize();
window.requestAnimationFrame(() => this.scrollToBottom());
},
destroyed() {
this.surfaceObserver.disconnect();
this.el.removeEventListener("input", this.handleInput);
this.el.removeEventListener("keydown", this.handleKeyDown);
if (this.scrollContainer) {
this.scrollContainer.removeEventListener("scroll", this.handleScroll);
}
}
};

18
assets/js/hooks/index.js Normal file
View File

@@ -0,0 +1,18 @@
import { AppShell } from "./app_shell.js";
import { SidebarInteractions } from "./sidebar_interactions.js";
import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js";
import { ChatSurface } from "./chat_surface.js";
import { MenuEditorTree } from "./menu_editor_tree.js";
import { MonacoEditor } from "./monaco_editor.js";
import { MonacoDiffEditor } from "./monaco_diff_editor.js";
export const Hooks = {
AppShell,
SidebarInteractions,
SettingsSectionScroll,
TagsSectionScroll,
ChatSurface,
MenuEditorTree,
MonacoEditor,
MonacoDiffEditor
};

View File

@@ -0,0 +1,134 @@
export const MenuEditorTree = {
mounted() {
this.dragItemId = null;
this.dragSourceEl = null;
this.dropTargetEl = null;
this.dropPosition = null;
this.clearDropTarget = () => {
if (this.dropTargetEl) {
this.dropTargetEl.classList.remove("is-drop-before", "is-drop-after", "is-drop-inside");
}
this.dropTargetEl = null;
this.dropPosition = null;
};
this.setDropTarget = (row, position) => {
if (this.dropTargetEl === row && this.dropPosition === position) {
return;
}
this.clearDropTarget();
this.dropTargetEl = row;
this.dropPosition = position;
row.classList.add(`is-drop-${position}`);
};
this.handleDragStart = (event) => {
const handle = event.target.closest("[data-menu-drag-handle='true']");
const row = event.target.closest("[data-menu-item-id]");
if (!handle || !row || !this.el.contains(row)) {
return;
}
this.dragItemId = row.dataset.menuItemId || null;
this.dragSourceEl = row;
row.classList.add("is-dragging");
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", this.dragItemId || "");
}
};
this.handleDragOver = (event) => {
const row = event.target.closest("[data-menu-item-id]");
if (!this.dragItemId || !row || !this.el.contains(row)) {
this.clearDropTarget();
return;
}
const targetItemId = row.dataset.menuItemId || "";
if (!targetItemId || targetItemId === this.dragItemId) {
this.clearDropTarget();
return;
}
event.preventDefault();
const rect = row.getBoundingClientRect();
const offsetY = event.clientY - rect.top;
const allowInside = row.dataset.menuCanDropInside === "true";
const insideBandTop = rect.height * 0.3;
const insideBandBottom = rect.height * 0.7;
const position =
allowInside && offsetY >= insideBandTop && offsetY <= insideBandBottom
? "inside"
: offsetY < rect.height / 2
? "before"
: "after";
this.setDropTarget(row, position);
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
};
this.handleDrop = (event) => {
const row = event.target.closest("[data-menu-item-id]");
if (!this.dragItemId || !row || !this.el.contains(row) || !this.dropPosition) {
this.clearDropTarget();
return;
}
event.preventDefault();
this.pushEvent("menu_editor_drop_item", {
drag_item_id: this.dragItemId,
target_item_id: row.dataset.menuItemId,
position: this.dropPosition
});
this.clearDropTarget();
};
this.handleDragLeave = (event) => {
const related = event.relatedTarget;
if (this.dropTargetEl && (!related || !this.dropTargetEl.contains(related))) {
this.clearDropTarget();
}
};
this.handleDragEnd = () => {
if (this.dragSourceEl) {
this.dragSourceEl.classList.remove("is-dragging");
}
this.dragItemId = null;
this.dragSourceEl = null;
this.clearDropTarget();
};
this.el.addEventListener("dragstart", this.handleDragStart);
this.el.addEventListener("dragover", this.handleDragOver);
this.el.addEventListener("drop", this.handleDrop);
this.el.addEventListener("dragleave", this.handleDragLeave);
this.el.addEventListener("dragend", this.handleDragEnd);
},
destroyed() {
this.el.removeEventListener("dragstart", this.handleDragStart);
this.el.removeEventListener("dragover", this.handleDragOver);
this.el.removeEventListener("drop", this.handleDrop);
this.el.removeEventListener("dragleave", this.handleDragLeave);
this.el.removeEventListener("dragend", this.handleDragEnd);
}
};

View File

@@ -0,0 +1,129 @@
import { loadMonaco, ensureMonacoTheme, diffModelPath } from "../monaco/services.js";
export const MonacoDiffEditor = {
mounted() {
this.host = this.el.querySelector(".monaco-diff-editor-instance");
this.originalInput = this.el.querySelector(".monaco-diff-original");
this.modifiedInput = this.el.querySelector(".monaco-diff-modified");
this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree";
this.language = this.el.dataset.monacoDiffLanguage || "plaintext";
this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline";
this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off";
this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true";
this.readValues = () => ({
original: this.originalInput?.value || "",
modified: this.modifiedInput?.value || ""
});
this.applyDataset = () => {
this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree";
this.language = this.el.dataset.monacoDiffLanguage || "plaintext";
this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline";
this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off";
this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true";
};
this.setModels = (monaco) => {
const values = this.readValues();
this.originalModel?.dispose();
this.modifiedModel?.dispose();
this.originalModel = monaco.editor.createModel(
values.original,
this.language,
monaco.Uri.parse(diffModelPath(this.filePath, "original"))
);
this.modifiedModel = monaco.editor.createModel(
values.modified,
this.language,
monaco.Uri.parse(diffModelPath(this.filePath, "modified"))
);
this.editor.setModel({ original: this.originalModel, modified: this.modifiedModel });
this.lastFilePath = this.filePath;
};
loadMonaco()
.then((monaco) => {
if (!this.host) {
return;
}
ensureMonacoTheme(monaco);
this.editor = monaco.editor.createDiffEditor(this.host, {
theme: "bds-theme",
automaticLayout: true,
readOnly: true,
renderSideBySide: this.viewStyle === "side-by-side",
minimap: { enabled: false },
scrollBeyondLastLine: false,
lineNumbers: "on",
diffCodeLens: false,
originalEditable: false,
wordWrap: this.wordWrap,
hideUnchangedRegions: { enabled: this.hideUnchanged },
ignoreTrimWhitespace: false
});
this.setModels(monaco);
})
.catch((error) => {
console.error("Failed to load Monaco diff editor", error);
});
},
updated() {
this.host = this.el.querySelector(".monaco-diff-editor-instance");
this.originalInput = this.el.querySelector(".monaco-diff-original");
this.modifiedInput = this.el.querySelector(".monaco-diff-modified");
this.applyDataset();
if (!this.editor) {
return;
}
loadMonaco().then((monaco) => {
ensureMonacoTheme(monaco);
monaco.editor.setTheme("bds-theme");
this.editor.updateOptions({
renderSideBySide: this.viewStyle === "side-by-side",
wordWrap: this.wordWrap,
hideUnchangedRegions: { enabled: this.hideUnchanged }
});
if (this.lastFilePath !== this.filePath) {
this.setModels(monaco);
return;
}
const values = this.readValues();
if (this.originalModel && this.originalModel.getLanguageId() !== this.language) {
monaco.editor.setModelLanguage(this.originalModel, this.language);
}
if (this.modifiedModel && this.modifiedModel.getLanguageId() !== this.language) {
monaco.editor.setModelLanguage(this.modifiedModel, this.language);
}
if (this.originalModel && this.originalModel.getValue() !== values.original) {
this.originalModel.setValue(values.original);
}
if (this.modifiedModel && this.modifiedModel.getValue() !== values.modified) {
this.modifiedModel.setValue(values.modified);
}
});
},
destroyed() {
this.originalModel?.dispose();
this.modifiedModel?.dispose();
this.editor?.dispose();
}
};

View File

@@ -0,0 +1,238 @@
import {
loadMonaco,
ensureMonacoTheme,
registerMonacoEditor,
unregisterMonacoEditor
} from "../monaco/services.js";
export const MonacoEditor = {
mounted() {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
this.host = this.el.querySelector(".monaco-editor-instance");
this.language = this.el.dataset.monacoLanguage || "plaintext";
this.wordWrap = this.el.dataset.monacoWordWrap || "off";
this.editorId = this.el.dataset.monacoEditorId || "";
this.insertEvent = this.el.dataset.monacoInsertEvent || "";
this.syncTimer = null;
this.isApplyingRemoteUpdate = false;
this.lastKnownValue = this.textarea?.value || "";
this.syncEditorFromTextarea = () => {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
if (!this.textarea || !this.editor) {
return;
}
const value = this.textarea.value || "";
if (this.editor.getValue() !== value) {
this.isApplyingRemoteUpdate = true;
this.editor.setValue(value);
this.isApplyingRemoteUpdate = false;
}
this.lastKnownValue = value;
};
this.layoutEditorSoon = () => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
if (!this.editor) {
return;
}
this.editor.layout();
});
});
};
this.waitForMonacoVisibleSize = () =>
new Promise((resolve) => {
let settled = false;
let attempts = 0;
const hasVisibleSize = () => {
const rect = this.host?.getBoundingClientRect();
return Boolean(rect && rect.width > 0 && rect.height > 0);
};
const finish = () => {
if (settled) {
return;
}
settled = true;
this.visibleSizeObserver?.disconnect();
this.visibleSizeObserver = null;
resolve();
};
const check = () => {
if (hasVisibleSize() || attempts >= 20) {
finish();
return;
}
attempts += 1;
window.requestAnimationFrame(check);
};
if (hasVisibleSize()) {
finish();
return;
}
if (window.ResizeObserver && this.host) {
this.visibleSizeObserver = new ResizeObserver(() => {
if (hasVisibleSize()) {
finish();
}
});
this.visibleSizeObserver.observe(this.host);
}
window.requestAnimationFrame(check);
});
this.queueSync = () => {
if (!this.textarea || !this.editor) {
return;
}
window.clearTimeout(this.syncTimer);
this.syncTimer = window.setTimeout(() => {
if (!this.textarea || !this.editor) {
return;
}
const value = this.editor.getValue();
if (this.textarea.value === value) {
return;
}
this.lastKnownValue = value;
this.textarea.value = value;
this.textarea.dispatchEvent(new Event("input", { bubbles: true }));
}, 120);
};
this.handleInsert = ({ id, content }) => {
if (!this.editor || !content || String(id) !== String(this.editorId)) {
return;
}
const model = this.editor.getModel();
const selection = this.editor.getSelection();
if (!model || !selection) {
return;
}
const value = this.editor.getValue();
const start = model.getOffsetAt(selection.getStartPosition());
const end = model.getOffsetAt(selection.getEndPosition());
const before = value.slice(0, start);
const after = value.slice(end);
const separator = before !== "" && !before.endsWith("\n") ? "\n" : "";
const suffix = after !== "" && !content.endsWith("\n") ? "\n" : "";
const inserted = `${separator}${content}${suffix}`;
this.editor.executeEdits("bds-insert-content", [
{
range: selection,
text: inserted,
forceMoveMarkers: true
}
]);
this.editor.focus();
};
loadMonaco()
.then(async (monaco) => {
if (!this.host || !this.textarea) {
return;
}
await this.waitForMonacoVisibleSize();
ensureMonacoTheme(monaco);
this.editor = monaco.editor.create(this.host, {
value: this.textarea.value || "",
language: this.language,
theme: "bds-theme",
automaticLayout: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: this.wordWrap,
lineNumbers: "on",
lineNumbersMinChars: 3,
fontSize: 14,
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
padding: { top: 12, bottom: 12 },
roundedSelection: false,
renderLineHighlight: "line",
formatOnPaste: true,
cursorStyle: "line",
cursorBlinking: "smooth",
quickSuggestions: this.language === "markdown-with-macros" ? false : true,
tabSize: 2,
insertSpaces: true
});
registerMonacoEditor(this.editorId || this.el.id, this.editor);
monaco.editor.setTheme("bds-theme");
this.syncEditorFromTextarea();
this.layoutEditorSoon();
this.changeSubscription = this.editor.onDidChangeModelContent(() => {
if (this.isApplyingRemoteUpdate) {
return;
}
this.queueSync();
});
if (this.insertEvent) {
this.handleEvent(this.insertEvent, this.handleInsert);
}
})
.catch((error) => {
console.error("Failed to load Monaco editor", error);
});
},
updated() {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
this.host = this.el.querySelector(".monaco-editor-instance");
this.language = this.el.dataset.monacoLanguage || this.language || "plaintext";
this.wordWrap = this.el.dataset.monacoWordWrap || this.wordWrap || "off";
if (!this.editor || !this.textarea) {
return;
}
loadMonaco().then((monaco) => {
ensureMonacoTheme(monaco);
monaco.editor.setTheme("bds-theme");
if (this.editor.getModel()?.getLanguageId() !== this.language) {
monaco.editor.setModelLanguage(this.editor.getModel(), this.language);
}
this.editor.updateOptions({ wordWrap: this.wordWrap });
});
this.syncEditorFromTextarea();
this.layoutEditorSoon();
},
destroyed() {
window.clearTimeout(this.syncTimer);
this.visibleSizeObserver?.disconnect();
this.changeSubscription?.dispose();
unregisterMonacoEditor(this.editorId || this.el.id);
this.editor?.dispose();
}
};

View File

@@ -0,0 +1,31 @@
const makeSectionScrollHook = (datasetKey) => ({
mounted() {
this.lastTargetId = null;
this.scrollToSelectedSection();
},
updated() {
this.scrollToSelectedSection();
},
scrollToSelectedSection() {
const targetId = this.el.dataset[datasetKey];
if (!targetId || targetId === this.lastTargetId) {
return;
}
this.lastTargetId = targetId;
window.requestAnimationFrame(() => {
const target = document.getElementById(targetId);
if (target && this.el.contains(target)) {
target.scrollIntoView({ block: "start", behavior: "smooth" });
}
});
}
});
export const SettingsSectionScroll = makeSectionScrollHook("settingsScrollTarget");
export const TagsSectionScroll = makeSectionScrollHook("tagsScrollTarget");

View File

@@ -0,0 +1,24 @@
export const SidebarInteractions = {
mounted() {
this.handleDblClick = (event) => {
const button = event.target.closest("[data-testid='sidebar-open-item']");
if (!button || !this.el.contains(button)) {
return;
}
this.pushEvent("pin_sidebar_item", {
route: button.dataset.route,
id: button.dataset.itemId,
title: button.dataset.openTitle || "",
subtitle: button.dataset.openSubtitle || ""
});
};
this.el.addEventListener("dblclick", this.handleDblClick);
},
destroyed() {
this.el.removeEventListener("dblclick", this.handleDblClick);
}
};

View File

@@ -0,0 +1,145 @@
let liquidLanguageRegistered = false;
let markdownWithMacrosRegistered = false;
export const registerLiquidLanguage = (monaco) => {
if (liquidLanguageRegistered) {
return;
}
monaco.languages.register({ id: "liquid" });
monaco.languages.setLanguageConfiguration("liquid", {
comments: {
blockComment: ["{% comment %}", "{% endcomment %}"]
},
brackets: [
["{", "}"],
["[", "]"],
["(", ")"]
],
autoClosingPairs: [
{ open: "{", close: "}" },
{ open: "[", close: "]" },
{ open: "(", close: ")" },
{ open: '"', close: '"' },
{ open: "'", close: "'" }
],
surroundingPairs: [
{ open: "{", close: "}" },
{ open: "[", close: "]" },
{ open: "(", close: ")" },
{ open: '"', close: '"' },
{ open: "'", close: "'" }
]
});
monaco.languages.setMonarchTokensProvider("liquid", {
defaultToken: "",
tokenizer: {
root: [
[/\{\{-?/, { token: "delimiter.output", next: "@liquidOutput" }],
[/\{%-?\s*comment\b[^%]*-?%\}/, { token: "comment.block", next: "@liquidComment" }],
[/\{%-?/, { token: "delimiter.tag", next: "@liquidTag" }],
[/<!DOCTYPE/i, "metatag"],
[/<!--/, { token: "comment", next: "@htmlComment" }],
[/(<)(script)/i, ["delimiter.html", "tag.html"], "@scriptTag"],
[/(<)(style)/i, ["delimiter.html", "tag.html"], "@styleTag"],
[/(<\/)([\w:-]+)/, ["delimiter.html", "tag.html"]],
[/(<)([\w:-]+)/, ["delimiter.html", "tag.html"], "@htmlTag"],
[/[^<{]+/, ""],
[/./, ""]
],
liquidOutput: [
[/-?\}\}/, { token: "delimiter.output", next: "@pop" }],
[/\|\s*[a-zA-Z_][\w-]*/, "keyword"],
[/\b(?:true|false|nil|blank|empty)\b/, "keyword"],
[/\b\d+(?:\.\d+)?\b/, "number"],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "string"],
[/[a-zA-Z_][\w.-]*/, "identifier"],
[/[,:()[\]]/, "delimiter"]
],
liquidTag: [
[/-?%\}/, { token: "delimiter.tag", next: "@pop" }],
[/\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)\b/, "keyword"],
[/\|\s*[a-zA-Z_][\w-]*/, "keyword"],
[/\b(?:true|false|nil|blank|empty|contains)\b/, "keyword"],
[/\b\d+(?:\.\d+)?\b/, "number"],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "string"],
[/[><=!]=?|\.|:/, "operator"],
[/[a-zA-Z_][\w.-]*/, "identifier"],
[/[,:()[\]]/, "delimiter"]
],
liquidComment: [
[/\{%-?\s*endcomment\s*-?%\}/, { token: "comment.block", next: "@pop" }],
[/./, "comment.block"]
],
htmlComment: [
[/-->/, { token: "comment", next: "@pop" }],
[/./, "comment"]
],
htmlTag: [
[/\/>/, { token: "delimiter.html", next: "@pop" }],
[/>/, { token: "delimiter.html", next: "@pop" }],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"],
[/[\w:-]+/, "attribute.name"],
[/=/, "delimiter"]
],
scriptTag: [
[/>/, { token: "delimiter.html", next: "@pop" }],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"],
[/[\w:-]+/, "attribute.name"],
[/=/, "delimiter"]
],
styleTag: [
[/>/, { token: "delimiter.html", next: "@pop" }],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"],
[/[\w:-]+/, "attribute.name"],
[/=/, "delimiter"]
]
}
});
liquidLanguageRegistered = true;
};
export const registerMarkdownWithMacrosLanguage = (monaco) => {
if (markdownWithMacrosRegistered) {
return;
}
monaco.languages.register({ id: "markdown-with-macros" });
monaco.languages.setMonarchTokensProvider("markdown-with-macros", {
defaultToken: "",
tokenPostfix: ".md",
tokenizer: {
root: [
[/\[\[[a-zA-Z][\w-]*/, { token: "keyword.macro", next: "@macroParams" }],
[/^#{1,6}\s.*$/, "keyword.header"],
[/^\s*>+/, "string.quote"],
[/^\s*[-+*]\s/, "keyword"],
[/^\s*\d+\.\s/, "keyword"],
[/^\s*```\w*/, { token: "string.code", next: "@codeblock" }],
[/\*\*[^*]+\*\*/, "strong"],
[/\*[^*]+\*/, "emphasis"],
[/__[^_]+__/, "strong"],
[/_[^_]+_/, "emphasis"],
[/`[^`]+`/, "variable"],
[/!?\[[^\]]*\]\([^)]*\)/, "string.link"],
[/!?\[[^\]]*\]\[[^\]]*\]/, "string.link"]
],
macroParams: [
[/\]\]/, { token: "keyword.macro", next: "@root" }],
[/[a-zA-Z][\w-]*(?=\s*=)/, "attribute.name"],
[/=/, "delimiter"],
[/"[^"]*"/, "string"],
[/\s+/, "white"],
[/[^\]"=\s]+/, "attribute.value"]
],
codeblock: [
[/^\s*```\s*$/, { token: "string.code", next: "@root" }],
[/.*$/, "variable.source"]
]
}
});
markdownWithMacrosRegistered = true;
};

View File

@@ -0,0 +1,88 @@
import { loadScript } from "../utils/script_loader.js";
import { ensureMonacoTheme } from "./theme.js";
import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js";
let monacoLoaderPromise;
const monacoEditors = new Map();
export const loadMonaco = () => {
if (window.monaco?.editor) {
ensureMonacoTheme(window.monaco);
registerLiquidLanguage(window.monaco);
registerMarkdownWithMacrosLanguage(window.monaco);
return Promise.resolve(window.monaco);
}
if (monacoLoaderPromise) {
return monacoLoaderPromise;
}
monacoLoaderPromise = loadScript("/monaco/vs/loader.js")
.then(
() =>
new Promise((resolve, reject) => {
window.require.config({ paths: { vs: "/monaco/vs" } });
window.require(["vs/editor/editor.main"], () => {
ensureMonacoTheme(window.monaco);
registerLiquidLanguage(window.monaco);
registerMarkdownWithMacrosLanguage(window.monaco);
resolve(window.monaco);
}, reject);
})
)
.catch((error) => {
monacoLoaderPromise = null;
throw error;
});
return monacoLoaderPromise;
};
export const registerMonacoEditor = (key, editor) => {
if (key) {
monacoEditors.set(key, editor);
}
};
export const unregisterMonacoEditor = (key) => {
if (key) {
monacoEditors.delete(key);
}
};
export const activeMonacoEditor = () => {
for (const editor of monacoEditors.values()) {
if (typeof editor?.hasTextFocus === "function" && editor.hasTextFocus()) {
return editor;
}
}
return null;
};
export const runMonacoEditorAction = (editor, actionId, triggerId = actionId) => {
if (!editor) {
return false;
}
const action = typeof editor.getAction === "function" ? editor.getAction(actionId) : null;
if (action && typeof action.run === "function") {
action.run();
return true;
}
if (typeof editor.trigger === "function") {
editor.trigger("bds-menu", triggerId, null);
return true;
}
return false;
};
export const diffModelPath = (filePath, side) => {
const normalized = String(filePath || "working-tree").replace(/^\/+/, "");
return `inmemory://model/git-diff/${side}/${normalized}`;
};
export { ensureMonacoTheme };

62
assets/js/monaco/theme.js Normal file
View File

@@ -0,0 +1,62 @@
import { cssVar, normalizeMonacoColor } from "../utils/color.js";
let monacoThemeSignature = null;
export const ensureMonacoTheme = (monaco) => {
const background = normalizeMonacoColor(
cssVar("--vscode-editor-background", cssVar("--vscode-input-background", "#1e1e1e")),
"#1e1e1e"
);
const foreground = normalizeMonacoColor(cssVar("--vscode-editor-foreground", "#d4d4d4"), "#d4d4d4");
const lineNumber = normalizeMonacoColor(cssVar("--vscode-editorLineNumber-foreground", "#858585"), "#858585");
const activeLineNumber = normalizeMonacoColor(
cssVar("--vscode-editorLineNumber-activeForeground", foreground),
foreground
);
const selection = normalizeMonacoColor(cssVar("--vscode-editor-selectionBackground", "#264f78"), "#264f78");
const inactiveSelection = normalizeMonacoColor(
cssVar("--vscode-editor-inactiveSelectionBackground", "#3a3d41"),
"#3a3d41"
);
const cursor = normalizeMonacoColor(cssVar("--vscode-editorCursor-foreground", foreground), foreground);
const border = normalizeMonacoColor(cssVar("--vscode-panel-border", "#3c3c3c"), "#3c3c3c");
const lineHighlight = normalizeMonacoColor(
cssVar("--vscode-editor-lineHighlightBackground", background),
background
);
const signature = [background, foreground, lineNumber, activeLineNumber, selection, inactiveSelection, cursor, border].join("|");
if (signature === monacoThemeSignature) {
monaco.editor.setTheme("bds-theme");
return;
}
monaco.editor.defineTheme("bds-theme", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "keyword.macro", foreground: "C586C0", fontStyle: "bold" },
{ token: "attribute.name", foreground: "9CDCFE" },
{ token: "attribute.value", foreground: "CE9178" }
],
colors: {
"editor.background": background,
"editor.foreground": foreground,
"editor.lineHighlightBackground": lineHighlight,
"editorCursor.foreground": cursor,
"editor.selectionBackground": selection,
"editor.inactiveSelectionBackground": inactiveSelection,
"editorLineNumber.foreground": lineNumber,
"editorLineNumber.activeForeground": activeLineNumber,
"editorIndentGuide.background1": border,
"editorIndentGuide.activeBackground1": foreground,
"editorWidget.border": border,
"editorGutter.background": background,
"focusBorder": border,
"input.border": border
}
});
monacoThemeSignature = signature;
monaco.editor.setTheme("bds-theme");
};

46
assets/js/utils/color.js Normal file
View File

@@ -0,0 +1,46 @@
import { clamp } from "./dom.js";
export const cssVar = (name, fallback) => {
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
};
const parseRgbColor = (value) => {
if (!value) {
return null;
}
const hex = value.match(/^#([0-9a-f]{6})$/i);
if (hex) {
return {
r: Number.parseInt(hex[1].slice(0, 2), 16),
g: Number.parseInt(hex[1].slice(2, 4), 16),
b: Number.parseInt(hex[1].slice(4, 6), 16)
};
}
const rgb = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (!rgb) {
return null;
}
return {
r: Number.parseInt(rgb[1], 10),
g: Number.parseInt(rgb[2], 10),
b: Number.parseInt(rgb[3], 10)
};
};
export const normalizeMonacoColor = (value, fallback) => {
const rgb = parseRgbColor(value);
if (!rgb) {
return fallback;
}
return `#${[rgb.r, rgb.g, rgb.b]
.map((channel) => clamp(channel, 0, 255).toString(16).padStart(2, "0"))
.join("")}`;
};

34
assets/js/utils/dom.js Normal file
View File

@@ -0,0 +1,34 @@
export const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
export const parseJsonObject = (value) => {
if (!value) {
return null;
}
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
} catch (_error) {
return null;
}
};
export const setMediaThumbnailLoaded = (image, loaded) => {
const thumbnail = image?.closest(".media-thumbnail");
if (!thumbnail) {
return;
}
if (loaded) {
thumbnail.classList.add("is-loaded");
} else {
thumbnail.classList.remove("is-loaded");
}
};
export const syncMediaThumbnailState = (root) => {
root.querySelectorAll(".media-thumbnail-image").forEach((image) => {
setMediaThumbnailLoaded(image, Boolean(image.complete && image.naturalWidth > 0));
});
};

43
assets/js/utils/layout.js Normal file
View File

@@ -0,0 +1,43 @@
import { clamp } from "./dom.js";
import { SIDEBAR_STORAGE_KEY, ASSISTANT_STORAGE_KEY } from "../constants.js";
export const shellWidth = (selector) => {
const shell = document.querySelector(selector);
if (!shell) {
return 0;
}
const width = Number.parseInt(shell.style.width || "0", 10);
return Number.isNaN(width) ? Math.round(shell.getBoundingClientRect().width) : width;
};
export const setShellWidth = (selector, width) => {
const shell = document.querySelector(selector);
if (shell) {
shell.style.width = `${width}px`;
shell.classList.remove("is-hidden");
}
};
export const persistWidth = (target, width) => {
const key = target === "assistant" ? ASSISTANT_STORAGE_KEY : SIDEBAR_STORAGE_KEY;
window.localStorage.setItem(key, String(width));
};
export const readStoredSize = (key, fallback, min, max) => {
const raw = window.localStorage.getItem(key);
if (!raw) {
return fallback;
}
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed)) {
return fallback;
}
return clamp(parsed, min, max);
};

View File

@@ -0,0 +1,33 @@
export const loadScript = (src) =>
new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
if (existing.dataset.loaded === "true") {
resolve();
return;
}
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
once: true
});
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.addEventListener(
"load",
() => {
script.dataset.loaded = "true";
resolve();
},
{ once: true }
);
script.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
once: true
});
document.head.appendChild(script);
});

View File

@@ -0,0 +1,30 @@
export const normalizeShortcutKey = (key) => String(key || "").toLowerCase();
export const shortcutTargetIsEditable = (event) => {
const tag = event.target?.tagName || null;
return event.target?.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(tag);
};
export const shortcutMatchesEvent = (shortcut, event) => {
const primary = event.metaKey || event.ctrlKey;
return (
normalizeShortcutKey(event.key) === normalizeShortcutKey(shortcut.key) &&
primary === Boolean(shortcut.primary) &&
event.shiftKey === Boolean(shortcut.shift) &&
event.altKey === Boolean(shortcut.alt)
);
};
export const parseShortcutConfig = (value) => {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch (_error) {
return [];
}
};

View File

@@ -6,6 +6,7 @@ config :bds,
config :bds, BDS.Repo, config :bds, BDS.Repo,
database: Path.expand("../priv/data/bds_dev.db", __DIR__), database: Path.expand("../priv/data/bds_dev.db", __DIR__),
pool_size: 5, pool_size: 5,
journal_mode: :wal,
busy_timeout: 15_000, busy_timeout: 15_000,
log: false, log: false,
stacktrace: true, stacktrace: true,
@@ -27,6 +28,31 @@ config :bds, BDS.Desktop.Endpoint,
pubsub_server: BDS.PubSub, pubsub_server: BDS.PubSub,
live_view: [signing_salt: "desktop-live-view"] live_view: [signing_salt: "desktop-live-view"]
config :tailwind,
version: "4.1.14",
default: [
cd: Path.expand("..", __DIR__),
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/app.css
)
]
config :esbuild,
version: "0.25.4",
default: [
cd: Path.expand("../assets", __DIR__),
args: ~w(
js/app.js
--bundle
--target=es2022
--outdir=../priv/static/assets
--external:/fonts/*
--external:/images/*
),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
config :bds, :scripting, config :bds, :scripting,
runtime: BDS.Scripting.Lua, runtime: BDS.Scripting.Lua,
timeout: 300_000, timeout: 300_000,

View File

@@ -1,3 +1,9 @@
import Config import Config
config :bds, BDS.Repo, pool_size: 5 config :bds, BDS.Repo, pool_size: 5
config :bds, BDS.Desktop.Endpoint,
watchers: [
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
esbuild: {Esbuild, :install_and_run, [:default, ~w(--watch)]}
]

View File

@@ -3,6 +3,8 @@ import Config
config :bds, BDS.Repo, config :bds, BDS.Repo,
database: Path.expand("../priv/data/bds_test.db", __DIR__), database: Path.expand("../priv/data/bds_test.db", __DIR__),
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 5 pool_size: 5,
journal_mode: :wal,
busy_timeout: 15_000
config :logger, level: :warning config :logger, level: :warning

View File

@@ -1,5 +1,8 @@
defmodule BDS.AI do defmodule BDS.AI do
@moduledoc false @moduledoc """
Public interface for AI features — endpoint configuration, secret management,
model catalog access, and dispatching chat and one-shot inference requests.
"""
alias BDS.AI.Catalog alias BDS.AI.Catalog
alias BDS.AI.Chat alias BDS.AI.Chat
@@ -59,11 +62,9 @@ defmodule BDS.AI do
model = get_setting("ai.#{kind_key}.model") model = get_setting("ai.#{kind_key}.model")
encrypted_api_key = get_setting(encrypted_key("ai.#{kind_key}.api_key")) encrypted_api_key = get_setting(encrypted_key("ai.#{kind_key}.api_key"))
cond do if is_nil(url) and is_nil(model) and is_nil(encrypted_api_key) do
is_nil(url) and is_nil(model) and is_nil(encrypted_api_key) ->
{:ok, nil} {:ok, nil}
else
true ->
with {:ok, api_key} <- get_secret(encrypted_api_key, backend) do with {:ok, api_key} <- get_secret(encrypted_api_key, backend) do
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}} {:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
end end
@@ -185,4 +186,12 @@ defmodule BDS.AI do
@spec cancel_chat(String.t()) :: :ok @spec cancel_chat(String.t()) :: :ok
defdelegate cancel_chat(conversation_id), to: Chat defdelegate cancel_chat(conversation_id), to: Chat
@spec get_surface_state(String.t()) :: map()
defdelegate get_surface_state(conversation_id), to: Chat
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
{:ok, map()} | {:error, term()}
defdelegate put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces),
to: Chat
end end

View File

@@ -15,6 +15,7 @@ defmodule BDS.AI.Catalog do
alias BDS.AI.Model alias BDS.AI.Model
alias BDS.AI.ModelModality alias BDS.AI.ModelModality
alias BDS.AI.OpenAICompatibleRuntime alias BDS.AI.OpenAICompatibleRuntime
alias BDS.MapUtils
alias BDS.Persistence alias BDS.Persistence
alias BDS.Repo alias BDS.Repo
@@ -111,7 +112,8 @@ defmodule BDS.AI.Catalog do
def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do
capabilities = %{ capabilities = %{
supports_attachment: truthy?(BDS.MapUtils.attr(attrs, :supports_attachment)), supports_attachment: truthy?(BDS.MapUtils.attr(attrs, :supports_attachment)),
supports_tool_calls: truthy?(BDS.MapUtils.attr(attrs, :supports_tool_calls)) supports_tool_calls: truthy?(BDS.MapUtils.attr(attrs, :supports_tool_calls)),
disables_reasoning: truthy?(BDS.MapUtils.attr(attrs, :disables_reasoning))
} }
put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities)) put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities))
@@ -163,7 +165,8 @@ defmodule BDS.AI.Catalog do
@spec model_capabilities(String.t()) :: %{ @spec model_capabilities(String.t()) :: %{
supports_attachment: boolean(), supports_attachment: boolean(),
supports_tool_calls: boolean() supports_tool_calls: boolean(),
disables_reasoning: boolean()
} }
def model_capabilities(model_id) do def model_capabilities(model_id) do
overrides = decode_model_capabilities_override(model_id) overrides = decode_model_capabilities_override(model_id)
@@ -173,7 +176,8 @@ defmodule BDS.AI.Catalog do
{:ok, model} -> {:ok, model} ->
%{ %{
supports_attachment: model.supports_attachment or "image" in model.input_modalities, supports_attachment: model.supports_attachment or "image" in model.input_modalities,
supports_tool_calls: model.supports_tool_calls supports_tool_calls: model.supports_tool_calls,
disables_reasoning: false
} }
_other -> _other ->
@@ -196,7 +200,8 @@ defmodule BDS.AI.Catalog do
String.contains?(normalized, "llava"), String.contains?(normalized, "llava"),
supports_tool_calls: supports_tool_calls:
String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or
String.contains?(normalized, "tool") String.contains?(normalized, "tool"),
disables_reasoning: false
} }
end end
@@ -207,9 +212,7 @@ defmodule BDS.AI.Catalog do
end end
end end
defp atomize_map_keys(map) do defp atomize_map_keys(map), do: MapUtils.safe_atomize_keys(map)
Enum.into(map, %{}, fn {key, value} -> {String.to_atom(key), value} end)
end
defp persist_catalog(payload) do defp persist_catalog(payload) do
now = Persistence.now_ms() now = Persistence.now_ms()
@@ -309,7 +312,7 @@ defmodule BDS.AI.Catalog do
defp parse_modality("audio"), do: :audio defp parse_modality("audio"), do: :audio
defp parse_modality("file"), do: :file defp parse_modality("file"), do: :file
defp parse_modality("tool"), do: :tool defp parse_modality("tool"), do: :tool
defp parse_modality(other) when is_binary(other), do: String.to_atom(other) defp parse_modality(other) when is_binary(other), do: MapUtils.safe_atomize_key(other)
defp encode_nullable(nil), do: nil defp encode_nullable(nil), do: nil
defp encode_nullable(value), do: Jason.encode!(value) defp encode_nullable(value), do: Jason.encode!(value)

View File

@@ -2,6 +2,7 @@ defmodule BDS.AI.Chat do
@moduledoc false @moduledoc false
import Ecto.Query import Ecto.Query
require Logger
alias BDS.AI alias BDS.AI
alias BDS.AI.Catalog alias BDS.AI.Catalog
@@ -23,7 +24,7 @@ defmodule BDS.AI.Chat do
@default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts." @default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts."
@default_max_output_tokens 16_384 @default_max_output_tokens 16_384
@title_max_output_tokens 20 @title_max_output_tokens 256
@chat_title_max_length 30 @chat_title_max_length 30
@chat_max_tool_rounds 10 @chat_max_tool_rounds 10
@default_context_window 128_000 @default_context_window 128_000
@@ -61,6 +62,42 @@ defmodule BDS.AI.Chat do
Repo.get(ChatConversation, conversation_id) Repo.get(ChatConversation, conversation_id)
end end
@spec get_surface_state(String.t()) :: map()
def get_surface_state(conversation_id) when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do
%ChatConversation{surface_state: state} when is_map(state) -> state
_other -> %{}
end
end
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
{:ok, map()} | {:error, term()}
def put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces)
when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do
nil ->
{:error, :not_found}
%ChatConversation{} = conversation ->
state = %{
"surface_data" => surface_data,
"surface_tabs" => surface_tabs,
"dismissed_surfaces" => MapSet.to_list(dismissed_surfaces)
}
conversation
|> ChatConversation.changeset(%{
surface_state: state,
updated_at: Persistence.now_ms()
})
|> Repo.update()
|> case do
{:ok, _updated} -> {:ok, state}
error -> error
end
end
end
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()} @spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do case Repo.get(ChatConversation, conversation_id) do
@@ -68,7 +105,9 @@ defmodule BDS.AI.Chat do
{:error, :not_found} {:error, :not_found}
%ChatConversation{} = conversation -> %ChatConversation{} = conversation ->
Repo.delete_all(from message in ChatMessage, where: message.conversation_id == ^conversation_id) Repo.delete_all(
from message in ChatMessage, where: message.conversation_id == ^conversation_id
)
case Repo.delete(conversation) do case Repo.delete(conversation) do
{:ok, _conversation} -> {:ok, :deleted} {:ok, _conversation} -> {:ok, :deleted}
@@ -374,7 +413,8 @@ defmodule BDS.AI.Chat do
opts, opts,
@chat_max_tool_rounds @chat_max_tool_rounds
), ),
{:ok, reply} <- maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do {:ok, reply} <-
maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
{:ok, reply} {:ok, reply}
end end
end end
@@ -383,13 +423,20 @@ defmodule BDS.AI.Chat do
conversation = Repo.get!(ChatConversation, conversation_id) conversation = Repo.get!(ChatConversation, conversation_id)
cond do cond do
chat_user_message_count(conversation_id) != 1 -> chat_user_message_count(conversation_id) < 1 ->
Logger.debug("Chat title generation skipped reason=:no_user_messages")
{:ok, reply} {:ok, reply}
not generated_chat_title?(conversation.title, conversation.model) -> not generated_chat_title?(conversation.title, conversation.model) ->
Logger.debug(
"Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}"
)
{:ok, reply} {:ok, reply}
true -> true ->
Logger.debug("Chat title generation requested conversation_id=#{conversation_id}")
case generate_chat_title(user_content, opts) do case generate_chat_title(user_content, opts) do
{:ok, title} when is_binary(title) and title != "" -> {:ok, title} when is_binary(title) and title != "" ->
now = Persistence.now_ms() now = Persistence.now_ms()
@@ -417,8 +464,27 @@ defmodule BDS.AI.Chat do
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts), with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
:ok <- Runtime.validate_target(:chat_title, model, mode), :ok <- Runtime.validate_target(:chat_title, model, mode),
request <- build_chat_title_request(user_content, model), request <- build_chat_title_request(user_content, model),
{:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do {:ok, response} <-
{:ok, sanitize_chat_title(Map.get(response, :content))} runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
title = sanitize_chat_title(Map.get(response, :content))
if title == "" do
Logger.warning("Chat title generation returned an empty title",
model: model,
content: inspect(Map.get(response, :content)),
usage: inspect(Map.get(response, :usage))
)
end
{:ok, title}
else
{:error, reason} = error ->
Logger.warning("Chat title generation failed", reason: inspect(reason))
error
other ->
Logger.warning("Chat title generation failed", reason: inspect(other))
other
end end
end end
@@ -431,7 +497,7 @@ defmodule BDS.AI.Chat do
%{ %{
"role" => "system", "role" => "system",
"content" => "content" =>
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Output ONLY the title text." "Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
}, },
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"} %{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
] ]

View File

@@ -11,6 +11,7 @@ defmodule BDS.AI.ChatConversation do
title: String.t() | nil, title: String.t() | nil,
model: String.t() | nil, model: String.t() | nil,
copilot_session_id: String.t() | nil, copilot_session_id: String.t() | nil,
surface_state: map() | nil,
created_at: integer() | nil, created_at: integer() | nil,
updated_at: integer() | nil updated_at: integer() | nil
} }
@@ -19,13 +20,14 @@ defmodule BDS.AI.ChatConversation do
field :title, :string field :title, :string
field :model, :string field :model, :string
field :copilot_session_id, :string field :copilot_session_id, :string
field :surface_state, :map
field :created_at, :integer field :created_at, :integer
field :updated_at, :integer field :updated_at, :integer
end end
def changeset(conversation, attrs) do def changeset(conversation, attrs) do
conversation conversation
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at], |> cast(attrs, [:id, :title, :model, :copilot_session_id, :surface_state, :created_at, :updated_at],
empty_values: [nil] empty_values: [nil]
) )
|> validate_required([:id, :title, :created_at, :updated_at]) |> validate_required([:id, :title, :created_at, :updated_at])

View File

@@ -36,7 +36,9 @@ defmodule BDS.AI.ChatMessage do
:cache_read_tokens, :cache_read_tokens,
:cache_write_tokens, :cache_write_tokens,
:created_at :created_at
], empty_values: [nil]) ],
empty_values: [nil]
)
|> validate_required([:conversation_id, :role, :created_at]) |> validate_required([:conversation_id, :role, :created_at])
|> assoc_constraint(:conversation) |> assoc_constraint(:conversation)
end end

View File

@@ -220,7 +220,7 @@ defmodule BDS.AI.ChatTools do
def execute("count_posts", arguments, project_id) do def execute("count_posts", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1) group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1)
{:ok, result} = Search.search_posts(project_id, "", search_filters(arguments)) result = search_all_counted_posts(project_id, arguments)
groups = groups =
result.posts result.posts
@@ -882,6 +882,16 @@ defmodule BDS.AI.ChatTools do
|> Map.put(:limit, normalize_limit(arguments["limit"])) |> Map.put(:limit, normalize_limit(arguments["limit"]))
end end
defp search_all_counted_posts(project_id, arguments) do
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
filters = Map.put(filters, :limit, max(total, 1))
{:ok, result} = Search.search_posts(project_id, "", filters)
result
end
defp maybe_put(map, _key, nil), do: map defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map defp maybe_put(map, _key, ""), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value) defp maybe_put(map, key, value), do: Map.put(map, key, value)
@@ -899,7 +909,7 @@ defmodule BDS.AI.ChatTools do
defp metadata_attrs(arguments, keys) do defp metadata_attrs(arguments, keys) do
Enum.reduce(keys, %{}, fn key, acc -> Enum.reduce(keys, %{}, fn key, acc ->
maybe_put(acc, String.to_atom(key), arguments[key]) maybe_put(acc, BDS.MapUtils.safe_atomize_key(key), arguments[key])
end) end)
end end

View File

@@ -60,7 +60,9 @@ defmodule BDS.AI.Model do
:interleaved, :interleaved,
:status, :status,
:updated_at :updated_at
], empty_values: [nil]) ],
empty_values: [nil]
)
|> validate_required([ |> validate_required([
:provider, :provider,
:model_id, :model_id,

View File

@@ -1,12 +1,16 @@
defmodule BDS.AI.OneShot do defmodule BDS.AI.OneShot do
@moduledoc false @moduledoc false
require Logger
alias BDS.AI.Chat alias BDS.AI.Chat
alias BDS.AI.OpenAICompatibleRuntime alias BDS.AI.OpenAICompatibleRuntime
alias BDS.AI.Runtime alias BDS.AI.Runtime
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.MapUtils alias BDS.MapUtils
alias BDS.Posts
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Projects
alias BDS.Repo alias BDS.Repo
@default_max_output_tokens 16_384 @default_max_output_tokens 16_384
@@ -162,12 +166,13 @@ defmodule BDS.AI.OneShot do
end end
end end
defp run_one_shot(operation, payload, opts, formatter) do defp run_one_shot(:analyze_image = operation, payload, opts, formatter) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime) runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts), with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
:ok <- Runtime.validate_target(operation, model, mode), :ok <- Runtime.validate_target(operation, model, mode),
request <- build_one_shot_request(operation, payload, model), {:ok, payload} <- resolve_image_data_url(payload),
request <- build_one_shot_request(operation, payload, model, opts),
{:ok, response} <- {:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts), runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, json} <- extract_json_response(response), {:ok, json} <- extract_json_response(response),
@@ -177,55 +182,100 @@ defmodule BDS.AI.OneShot do
end end
end end
defp build_one_shot_request(operation, payload, model) do defp run_one_shot(operation, payload, opts, formatter) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
:ok <- Runtime.validate_target(operation, model, mode),
request <- build_one_shot_request(operation, payload, model, opts),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, json} <- extract_json_response(response),
usage <- Chat.normalize_usage(response.usage),
{:ok, result} <- formatter.(json, usage) do
{:ok, result}
end
end
defp build_one_shot_request(operation, payload, model, opts) do
language = Keyword.get(opts, :language)
source_language =
case Keyword.get(opts, :source_language) || Map.get(payload, :language) do
value when value in [nil, ""] -> nil
value -> value
end
%{ %{
operation: operation, operation: operation,
model: model, model: model,
max_output_tokens: @default_max_output_tokens, max_output_tokens: @default_max_output_tokens,
messages: [ messages: [
%{"role" => "system", "content" => one_shot_system_prompt(operation)}, %{
%{"role" => "user", "content" => one_shot_user_content(operation, payload)} "role" => "system",
"content" => one_shot_system_prompt(operation, language, source_language)
},
%{
"role" => "user",
"content" => one_shot_user_content(operation, payload, language, source_language)
}
] ]
} }
end end
defp one_shot_system_prompt(:detect_language) do defp one_shot_system_prompt(:detect_language, _language, _source_language) do
"Return JSON with exactly one key: language_code." "Return JSON with exactly one key: language_code."
end end
defp one_shot_system_prompt(:analyze_taxonomy) do defp one_shot_system_prompt(:analyze_taxonomy, _language, _source_language) do
"Return JSON with keys tags and categories, each an array of short strings." "Return JSON with keys tags and categories, each an array of short strings."
end end
defp one_shot_system_prompt(:import_taxonomy_mapping) do defp one_shot_system_prompt(:import_taxonomy_mapping, _language, _source_language) do
"You are helping import WordPress taxonomy into an existing blog. Return JSON with exactly two keys: categoryMappings and tagMappings. Each value must be an object mapping imported term names to existing project term names. Only map when the imported term should reuse an existing term to avoid duplicates. Do not invent target terms. Leave unmapped items out of the objects." "You are helping import WordPress taxonomy into an existing blog. Return JSON with exactly two keys: categoryMappings and tagMappings. Each value must be an object mapping imported term names to existing project term names. Only map when the imported term should reuse an existing term to avoid duplicates. Do not invent target terms. Leave unmapped items out of the objects."
end end
defp one_shot_system_prompt(:analyze_post) do defp one_shot_system_prompt(:analyze_post, nil, _source_language) do
"Return JSON with keys title, excerpt, and slug." "Return JSON with keys title, excerpt, and slug. Each value must be a single string (not an array or object)."
end end
defp one_shot_system_prompt(:translate_post) do defp one_shot_system_prompt(:analyze_post, language, _source_language) do
"Return JSON with keys title, excerpt, and slug. Each value must be a single string (not an array or object). Respond in #{language_name(language)}."
end
defp one_shot_system_prompt(:translate_post, _language, nil) do
"Return JSON with keys title, excerpt, and content. Preserve Markdown structure." "Return JSON with keys title, excerpt, and content. Preserve Markdown structure."
end end
defp one_shot_system_prompt(:analyze_image) do defp one_shot_system_prompt(:translate_post, _language, source_language) do
"Return JSON with keys title, excerpt, and content. Preserve Markdown structure. Translate from #{language_name(source_language)} to the requested language."
end
defp one_shot_system_prompt(:analyze_image, nil, _source_language) do
"Return JSON with keys title, alt, and caption for the provided image." "Return JSON with keys title, alt, and caption for the provided image."
end end
defp one_shot_system_prompt(:translate_media) do defp one_shot_system_prompt(:analyze_image, language, _source_language) do
"Return JSON with keys title, alt, and caption for the provided image. Respond in #{language_name(language)}."
end
defp one_shot_system_prompt(:translate_media, _language, nil) do
"Return JSON with keys title, alt, and caption translated to the requested language." "Return JSON with keys title, alt, and caption translated to the requested language."
end end
defp one_shot_user_content(:detect_language, %{text: text}) do defp one_shot_system_prompt(:translate_media, _language, source_language) do
"Return JSON with keys title, alt, and caption. Translate from #{language_name(source_language)} to the requested language."
end
defp one_shot_user_content(:detect_language, %{text: text}, _language, _source_language) do
"Detect the language of this text: #{text}" "Detect the language of this text: #{text}"
end end
defp one_shot_user_content(:analyze_taxonomy, post) do defp one_shot_user_content(:analyze_taxonomy, post, _language, _source_language) do
"Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}" "Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end end
defp one_shot_user_content(:import_taxonomy_mapping, payload) do defp one_shot_user_content(:import_taxonomy_mapping, payload, _language, _source_language) do
[ [
"Analyze these imported taxonomy terms and suggest which ones should map to existing project terms.", "Analyze these imported taxonomy terms and suggest which ones should map to existing project terms.",
"", "",
@@ -246,15 +296,23 @@ defmodule BDS.AI.OneShot do
|> Enum.join("\n") |> Enum.join("\n")
end end
defp one_shot_user_content(:analyze_post, post) do defp one_shot_user_content(:analyze_post, post, nil, _source_language) do
"Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}" "Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end end
defp one_shot_user_content(:translate_post, post) do defp one_shot_user_content(:analyze_post, post, language, _source_language) do
"Translate this post to #{post.target_language}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}" "Suggest an improved title, excerpt, and slug in #{language_name(language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end end
defp one_shot_user_content(:analyze_image, media) do defp one_shot_user_content(:translate_post, post, _language, nil) do
"Translate this post to #{language_name(post.target_language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}"
end
defp one_shot_user_content(:translate_post, post, _language, source_language) do
"Translate this post from #{language_name(source_language)} to #{language_name(post.target_language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}"
end
defp one_shot_user_content(:analyze_image, media, nil, _source_language) do
[ [
%{ %{
"type" => "text", "type" => "text",
@@ -264,23 +322,64 @@ defmodule BDS.AI.OneShot do
] ]
end end
defp one_shot_user_content(:translate_media, media) do defp one_shot_user_content(:analyze_image, media, language, _source_language) do
"Translate this media metadata to #{media.target_language}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}" [
%{
"type" => "text",
"text" =>
"Analyze this image and return title, alt text, and caption in #{language_name(language)}."
},
%{"type" => "image_url", "image_url" => %{"url" => media.image_url}}
]
end end
defp one_shot_user_content(:translate_media, media, _language, nil) do
"Translate this media metadata to #{language_name(media.target_language)}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}"
end
defp one_shot_user_content(:translate_media, media, _language, source_language) do
"Translate this media metadata from #{language_name(source_language)} to #{language_name(media.target_language)}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}"
end
defp language_name("de"), do: "German"
defp language_name("en"), do: "English"
defp language_name("fr"), do: "French"
defp language_name("it"), do: "Italian"
defp language_name("es"), do: "Spanish"
defp language_name(language), do: String.capitalize(to_string(language))
defp extract_json_response(%{json: json}) when is_map(json), do: {:ok, json} defp extract_json_response(%{json: json}) when is_map(json), do: {:ok, json}
defp extract_json_response(%{content: content}) when is_binary(content) do defp extract_json_response(%{content: content}) when is_binary(content) do
case Jason.decode(content) do case Jason.decode(content) do
{:ok, json} when is_map(json) -> {:ok, json} {:ok, json} when is_map(json) ->
_other -> {:error, %{kind: :invalid_json_response}} {:ok, json}
_other ->
Logger.error(
"AI extract_json_response failed to parse content as JSON. Content: #{String.slice(content, 0, 1000)}"
)
{:error, %{kind: :invalid_json_response, content: content}}
end end
end end
defp extract_json_response(_response), do: {:error, %{kind: :invalid_json_response}} defp extract_json_response(response) do
Logger.error(
"AI extract_json_response received response with no JSON and no content: #{inspect(Map.take(response, [:content, :json, :tool_calls]))}"
)
{:error, %{kind: :invalid_json_response}}
end
defp normalize_post_input(%Post{} = post) do defp normalize_post_input(%Post{} = post) do
{:ok, %{title: post.title || "", excerpt: post.excerpt || "", content: post.content || ""}} {:ok,
%{
title: post.title || "",
excerpt: post.excerpt || "",
content: Posts.editor_body(post),
language: post.language || ""
}}
end end
defp normalize_post_input(post_id) when is_binary(post_id) do defp normalize_post_input(post_id) when is_binary(post_id) do
@@ -295,7 +394,8 @@ defmodule BDS.AI.OneShot do
%{ %{
title: MapUtils.attr(attrs, :title) || "", title: MapUtils.attr(attrs, :title) || "",
excerpt: MapUtils.attr(attrs, :excerpt) || "", excerpt: MapUtils.attr(attrs, :excerpt) || "",
content: MapUtils.attr(attrs, :content) || "" content: MapUtils.attr(attrs, :content) || "",
language: MapUtils.attr(attrs, :language) || ""
}} }}
end end
@@ -306,7 +406,10 @@ defmodule BDS.AI.OneShot do
title: media.title || "", title: media.title || "",
alt: media.alt || "", alt: media.alt || "",
caption: media.caption || "", caption: media.caption || "",
image_url: Map.get(media, :image_url) || media_path_to_file_url(media.file_path) image_url: Map.get(media, :image_url),
file_path: media.file_path,
project_id: media.project_id,
language: media.language || ""
}} }}
end end
@@ -324,15 +427,80 @@ defmodule BDS.AI.OneShot do
title: MapUtils.attr(attrs, :title) || "", title: MapUtils.attr(attrs, :title) || "",
alt: MapUtils.attr(attrs, :alt) || "", alt: MapUtils.attr(attrs, :alt) || "",
caption: MapUtils.attr(attrs, :caption) || "", caption: MapUtils.attr(attrs, :caption) || "",
image_url: MapUtils.attr(attrs, :image_url) image_url: MapUtils.attr(attrs, :image_url),
file_path: MapUtils.attr(attrs, :file_path),
project_id: MapUtils.attr(attrs, :project_id),
language: MapUtils.attr(attrs, :language) || ""
}} }}
end end
defp ensure_image_media(%{mime_type: "image/" <> _rest}), do: :ok defp ensure_image_media(%{mime_type: "image/" <> _rest}), do: :ok
defp ensure_image_media(_media), do: {:error, %{kind: :invalid_media_type}} defp ensure_image_media(_media), do: {:error, %{kind: :invalid_media_type}}
defp media_path_to_file_url(nil), do: nil defp resolve_image_data_url(%{image_url: "data:" <> _} = media) do
defp media_path_to_file_url(path), do: "file://" <> path Logger.debug("AI analyze_image using existing data URL")
{:ok, media}
end
defp resolve_image_data_url(%{image_url: "http" <> _} = media) do
Logger.debug("AI analyze_image using HTTP URL: #{media.image_url}")
{:ok, media}
end
defp resolve_image_data_url(%{image_url: "file://" <> path, mime_type: mime_type} = media) do
with {:ok, binary} <- File.read(path) do
data_url = "data:#{mime_type};base64," <> Base.encode64(binary)
Logger.debug(
"AI analyze_image converted file://#{path} to data URL (#{byte_size(data_url)} chars)"
)
{:ok, %{media | image_url: data_url}}
else
{:error, reason} ->
Logger.error("AI analyze_image failed to read file://#{path}: #{inspect(reason)}")
{:error, :file_not_found}
end
end
defp resolve_image_data_url(
%{file_path: file_path, project_id: project_id, mime_type: mime_type} = media
)
when is_binary(file_path) and is_binary(project_id) do
case Projects.get_project(project_id) do
nil ->
Logger.error("AI analyze_image project not found: #{project_id}")
{:error, :file_not_found}
project ->
absolute_path = Path.join(Projects.project_data_dir(project), file_path)
case File.read(absolute_path) do
{:ok, binary} ->
data_url = "data:#{mime_type};base64," <> Base.encode64(binary)
Logger.debug(
"AI analyze_image converted #{absolute_path} to data URL (#{byte_size(data_url)} chars)"
)
{:ok, %{media | image_url: data_url}}
{:error, reason} ->
Logger.error("AI analyze_image failed to read #{absolute_path}: #{inspect(reason)}")
{:error, :file_not_found}
end
end
end
defp resolve_image_data_url(%{image_url: url} = media) when is_binary(url) and url != "" do
Logger.debug("AI analyze_image using URL: #{url}")
{:ok, media}
end
defp resolve_image_data_url(_media) do
Logger.error("AI analyze_image missing image source (no file_path, project_id, or image_url)")
{:error, :missing_image_source}
end
defp normalize_string_list(values) do defp normalize_string_list(values) do
values values

View File

@@ -1,6 +1,8 @@
defmodule BDS.AI.OpenAICompatibleRuntime do defmodule BDS.AI.OpenAICompatibleRuntime do
@moduledoc false @moduledoc false
require Logger
alias BDS.AI.HttpClient alias BDS.AI.HttpClient
def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
@@ -36,14 +38,46 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
"messages" => request.messages, "messages" => request.messages,
"max_tokens" => request.max_output_tokens "max_tokens" => request.max_output_tokens
} }
|> maybe_put_tools(request.tools) |> maybe_disable_thinking(request.model)
|> maybe_put_tools(Map.get(request, :tools, []))
with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)), payload_json = Jason.encode!(payload)
200 <- response.status do
normalize_response(response.body) Logger.debug(
else "AI OpenAI-compatible request operation=#{inspect(Map.get(request, :operation))} model=#{inspect(request.model)} url=#{url} tools=#{payload |> Map.get("tools", []) |> length()} payload_size=#{byte_size(payload_json)}"
status when is_integer(status) -> {:error, %{kind: :http_error, status: status}} )
{:error, reason} -> {:error, %{kind: :http_error, reason: reason}}
case HttpClient.post(url, headers, payload_json) do
{:ok, %{status: 200, body: body}} ->
result = normalize_response(body)
case result do
{:ok, %{json: nil, content: content}} when is_binary(content) ->
Logger.debug(
"AI OpenAI-compatible response parsed but content is not valid JSON. Content: #{String.slice(content, 0, 500)}"
)
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error(
"AI OpenAI-compatible response normalization failed: #{inspect(reason)} body=#{String.slice(body, 0, 1000)}"
)
end
result
{:ok, %{status: status, body: body}} ->
Logger.error(
"AI OpenAI-compatible HTTP error status=#{status} body=#{String.slice(body, 0, 2000)}"
)
{:error, %{kind: :http_error, status: status, body: body}}
{:error, reason} ->
Logger.error("AI OpenAI-compatible HTTP request failed: #{inspect(reason)}")
{:error, %{kind: :http_error, reason: reason}}
end end
end end
@@ -136,6 +170,18 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|> Map.put("tool_choice", "auto") |> Map.put("tool_choice", "auto")
end end
defp maybe_disable_thinking(payload, model) when is_binary(model) do
if BDS.AI.Catalog.model_capabilities(model).disables_reasoning do
Map.update(payload, "chat_template_kwargs", %{"enable_thinking" => false}, fn kwargs ->
Map.put(kwargs || %{}, "enable_thinking", false)
end)
else
payload
end
end
defp maybe_disable_thinking(payload, _model), do: payload
defp normalize_tool_calls(tool_calls) do defp normalize_tool_calls(tool_calls) do
Enum.map(tool_calls, fn tool_call -> Enum.map(tool_calls, fn tool_call ->
%{ %{

View File

@@ -32,10 +32,18 @@ defmodule BDS.AI.Runtime do
@spec validate_target(atom(), String.t(), :airplane | :online) :: :ok | {:error, term()} @spec validate_target(atom(), String.t(), :airplane | :online) :: :ok | {:error, term()}
def validate_target(:analyze_image, model, _mode) do def validate_target(:analyze_image, model, _mode) do
if Catalog.model_capabilities(model).supports_attachment do capabilities = Catalog.model_capabilities(model)
cond do
capabilities.supports_attachment ->
:ok
capabilities.supports_attachment == false ->
{:error,
%{kind: :model_capability_missing, capability: :supports_attachment, model: model}}
true ->
:ok :ok
else
{:error, %{kind: :model_capability_missing, capability: :supports_attachment, model: model}}
end end
end end

View File

@@ -34,6 +34,7 @@ defmodule BDS.Application do
BDS.Preview, BDS.Preview,
BDS.Publishing, BDS.Publishing,
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor}, {Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
{Task.Supervisor, name: BDS.TCP.TaskSupervisor},
BDS.Scripting.JobStore, BDS.Scripting.JobStore,
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor}, {Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
BDS.Scripting.JobSupervisor BDS.Scripting.JobSupervisor

View File

@@ -1,7 +1,11 @@
defmodule BDS.BoundedAtoms do defmodule BDS.BoundedAtoms do
@moduledoc false @moduledoc """
Safe conversion of dynamic values to atoms from pre-defined allow-lists,
preventing atom table exhaustion from untrusted input.
"""
alias BDS.UI.Registry alias BDS.UI.Registry
alias BDS.UI.MenuBar
@panel_tabs [:tasks, :output, :post_links, :git_log] @panel_tabs [:tasks, :output, :post_links, :git_log]
@post_statuses [:draft, :published, :archived] @post_statuses [:draft, :published, :archived]
@@ -37,11 +41,32 @@ defmodule BDS.BoundedAtoms do
:view_posts, :view_posts,
:view_media, :view_media,
:edit_preferences, :edit_preferences,
:open_in_browser,
:open_data_folder,
:preview_post,
:edit_menu, :edit_menu,
:rebuild_database,
:reindex_text,
:rebuild_embedding_index,
:metadata_diff,
:regenerate_calendar,
:validate_translations,
:fill_missing_translations,
:find_duplicates,
:generate_sitemap,
:validate_site,
:upload_site,
:documentation, :documentation,
:api_documentation, :api_documentation,
:close_tab :close_tab
] ]
@menu_actions MenuBar.default_groups(dev_mode?: true)
|> Enum.flat_map(fn group ->
Enum.flat_map(group.items, fn
%{separator: true} -> []
%{id: id} -> [id]
end)
end)
def atom(value, allowed, fallback \\ nil) def atom(value, allowed, fallback \\ nil)
@@ -70,6 +95,7 @@ defmodule BDS.BoundedAtoms do
def ai_endpoint(value, fallback \\ nil), do: atom(value, @ai_endpoints, fallback) def ai_endpoint(value, fallback \\ nil), do: atom(value, @ai_endpoints, fallback)
def mcp_agent(value, fallback \\ nil), do: atom(value, @mcp_agents, fallback) def mcp_agent(value, fallback \\ nil), do: atom(value, @mcp_agents, fallback)
def shell_command(value, fallback \\ nil), do: atom(value, @shell_commands, fallback) def shell_command(value, fallback \\ nil), do: atom(value, @shell_commands, fallback)
def menu_action(value, fallback \\ nil), do: atom(value, @menu_actions, fallback)
defp string_atom(value, allowed, fallback) do defp string_atom(value, allowed, fallback) do
Enum.find(allowed, fallback, &(Atom.to_string(&1) == value)) Enum.find(allowed, fallback, &(Atom.to_string(&1) == value))

View File

@@ -375,13 +375,7 @@ defmodule BDS.Desktop.Automation do
defp normalize_simple_reply("ok"), do: :ok defp normalize_simple_reply("ok"), do: :ok
defp normalize_simple_reply(reply), do: reply defp normalize_simple_reply(reply), do: reply
defp atomize_map(map) when is_map(map) do defp atomize_map(map) when is_map(map), do: BDS.MapUtils.safe_atomize_keys(map)
Enum.into(map, %{}, fn {key, value} ->
normalized_key = if is_binary(key), do: String.to_atom(key), else: key
normalized_value = if is_map(value), do: atomize_map(value), else: value
{normalized_key, normalized_value}
end)
end
defp project_root do defp project_root do
Path.expand("../../..", __DIR__) Path.expand("../../..", __DIR__)

View File

@@ -16,20 +16,14 @@ defmodule BDS.Desktop.Endpoint do
plug(Plug.Static, plug(Plug.Static,
at: "/assets", at: "/assets",
from: {:bds, "priv/ui"}, from: {:bds, "priv/static/assets"},
only: ["app.css", "live.js", "monaco"] only: ["app.css", "app.js"]
) )
plug(Plug.Static, plug(Plug.Static,
at: "/vendor/phoenix", at: "/monaco",
from: {:phoenix, "priv/static"}, from: {:bds, "priv/ui/monaco"},
only: ["phoenix.min.js"] only: ["vs"]
)
plug(Plug.Static,
at: "/vendor/live_view",
from: {:phoenix_live_view, "priv/static"},
only: ["phoenix_live_view.min.js"]
) )
plug(BDS.Desktop.Router) plug(BDS.Desktop.Router)

View File

@@ -0,0 +1,12 @@
defmodule BDS.Desktop.ExternalLinks do
@moduledoc false
@github_url "https://github.com/rfc1437/bDS2"
@github_issues_url "#{@github_url}/issues"
@spec github_url() :: String.t()
def github_url, do: @github_url
@spec github_issues_url() :: String.t()
def github_issues_url, do: @github_issues_url
end

View File

@@ -2,11 +2,26 @@ defmodule BDS.Desktop.FilePicker do
@moduledoc false @moduledoc false
def choose_file(prompt) when is_binary(prompt) do def choose_file(prompt) when is_binary(prompt) do
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
:cancel
else
case :os.type() do case :os.type() do
{:unix, :darwin} -> choose_file_macos(prompt) {:unix, :darwin} -> choose_file_macos(prompt)
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}} _other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
end end
end end
end
def choose_files(prompt, opts \\ []) when is_binary(prompt) do
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
:cancel
else
case :os.type() do
{:unix, :darwin} -> choose_files_macos(prompt, opts)
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
end
end
end
defp choose_file_macos(prompt) do defp choose_file_macos(prompt) do
script = "POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\")" script = "POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\")"
@@ -17,6 +32,50 @@ defmodule BDS.Desktop.FilePicker do
end end
end end
defp choose_files_macos(prompt, opts) do
multiple = Keyword.get(opts, :multiple, false)
image_only = Keyword.get(opts, :image_only, false)
script_parts = ["POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\""]
script_parts =
if image_only do
script_parts ++ [" of type {\"public.image\"}"]
else
script_parts
end
script_parts =
if multiple do
script_parts ++ [" with multiple selections allowed"]
else
script_parts
end
script = Enum.join(script_parts, "") <> ")"
case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
{output, 0} -> parse_choose_files_result(String.trim(output), multiple)
{output, _status} -> normalize_picker_failure(output)
end
end
@doc false
def parse_choose_files_result(output, true = _multiple) do
paths =
output
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
{:ok, paths}
end
@doc false
def parse_choose_files_result(output, false = _multiple) do
{:ok, output}
end
defp normalize_picker_failure(output) do defp normalize_picker_failure(output) do
message = String.trim(output) message = String.trim(output)

View File

@@ -16,9 +16,7 @@ defmodule BDS.Desktop.Layouts do
</head> </head>
<body> <body>
<%= @inner_content %> <%= @inner_content %>
<script defer phx-track-static src="/vendor/phoenix/phoenix.min.js"></script> <script defer phx-track-static src="/assets/app.js"></script>
<script defer phx-track-static src="/vendor/live_view/phoenix_live_view.min.js"></script>
<script defer phx-track-static src="/assets/live.js"></script>
</body> </body>
</html> </html>
""" """

View File

@@ -2,11 +2,12 @@ defmodule BDS.Desktop.MenuBar do
@moduledoc false @moduledoc false
use BDS.Desktop.MenuCompat use BDS.Desktop.MenuCompat
alias BDS.Desktop.Shutdown alias BDS.Desktop.{ExternalLinks, ShellData, Shutdown, UILocale}
alias BDS.UI.Commands alias BDS.UI.Commands
alias BDS.UI.MenuBar, as: ShellMenuBar alias BDS.UI.MenuBar, as: ShellMenuBar
alias Desktop.OS alias Desktop.OS
alias Desktop.Window alias Desktop.Window
use Gettext, backend: BDS.Gettext
def groups(opts \\ []) do def groups(opts \\ []) do
opts opts
@@ -22,6 +23,8 @@ defmodule BDS.Desktop.MenuBar do
@impl true @impl true
def mount(menu) do def mount(menu) do
UILocale.put(ShellData.ui_language())
{:ok, {:ok,
Desktop.Menu.assign( Desktop.Menu.assign(
menu, menu,
@@ -56,7 +59,7 @@ defmodule BDS.Desktop.MenuBar do
end end
def handle_event("view_on_github", menu) do def handle_event("view_on_github", menu) do
OS.launch_default_browser("https://github.com/rfc1437/bDS") OS.launch_default_browser(ExternalLinks.github_url())
{:noreply, menu} {:noreply, menu}
end end
@@ -75,7 +78,7 @@ defmodule BDS.Desktop.MenuBar do
end end
def handle_event("report_issue", menu) do def handle_event("report_issue", menu) do
OS.launch_default_browser("https://github.com/rfc1437/bDS/issues") OS.launch_default_browser(ExternalLinks.github_issues_url())
{:noreply, menu} {:noreply, menu}
end end
@@ -85,6 +88,17 @@ defmodule BDS.Desktop.MenuBar do
end end
@impl true @impl true
def handle_info({:set_ui_locale, locale}, menu) do
UILocale.put(locale)
{:noreply,
Desktop.Menu.assign(
menu,
:groups,
groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
)}
end
def handle_info(_, menu) do def handle_info(_, menu) do
{:noreply, menu} {:noreply, menu}
end end
@@ -127,58 +141,58 @@ defmodule BDS.Desktop.MenuBar do
defp native_label(label, nil), do: label defp native_label(label, nil), do: label
defp native_label(label, shortcut), do: label <> "\t" <> shortcut defp native_label(label, shortcut), do: label <> "\t" <> shortcut
defp group_label(:file), do: "File" defp group_label(:file), do: dgettext("ui", "File")
defp group_label(:edit), do: "Edit" defp group_label(:edit), do: dgettext("ui", "Edit")
defp group_label(:view), do: "View" defp group_label(:view), do: dgettext("ui", "View")
defp group_label(:blog), do: "Blog" defp group_label(:blog), do: dgettext("ui", "Blog")
defp group_label(:help), do: "Help" defp group_label(:help), do: dgettext("ui", "Help")
defp item_label(:new_post), do: "New Post" defp item_label(:new_post), do: dgettext("ui", "New Post")
defp item_label(:import_media), do: "Import Media" defp item_label(:import_media), do: dgettext("ui", "Import Media")
defp item_label(:save), do: "Save" defp item_label(:save), do: dgettext("ui", "Save")
defp item_label(:open_in_browser), do: "Open in Browser" defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser")
defp item_label(:open_data_folder), do: "Open Data Folder" defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder")
defp item_label(:close_tab), do: "Close Tab" defp item_label(:close_tab), do: dgettext("ui", "Close Tab")
defp item_label(:quit), do: "Quit" defp item_label(:quit), do: dgettext("ui", "Quit")
defp item_label(:undo), do: "Undo" defp item_label(:undo), do: dgettext("ui", "Undo")
defp item_label(:redo), do: "Redo" defp item_label(:redo), do: dgettext("ui", "Redo")
defp item_label(:cut), do: "Cut" defp item_label(:cut), do: dgettext("ui", "Cut")
defp item_label(:copy), do: "Copy" defp item_label(:copy), do: dgettext("ui", "Copy")
defp item_label(:paste), do: "Paste" defp item_label(:paste), do: dgettext("ui", "Paste")
defp item_label(:delete), do: "Delete" defp item_label(:delete), do: dgettext("ui", "Delete")
defp item_label(:select_all), do: "Select All" defp item_label(:select_all), do: dgettext("ui", "Select All")
defp item_label(:find), do: "Find" defp item_label(:find), do: dgettext("ui", "Find")
defp item_label(:replace), do: "Replace" defp item_label(:replace), do: dgettext("ui", "Replace")
defp item_label(:edit_preferences), do: "Preferences" defp item_label(:edit_preferences), do: dgettext("ui", "Preferences")
defp item_label(:view_posts), do: "Posts" defp item_label(:view_posts), do: dgettext("ui", "Posts")
defp item_label(:view_media), do: "Media" defp item_label(:view_media), do: dgettext("ui", "Media")
defp item_label(:toggle_sidebar), do: "Toggle Sidebar" defp item_label(:toggle_sidebar), do: dgettext("ui", "Toggle Sidebar")
defp item_label(:toggle_panel), do: "Toggle Panel" defp item_label(:toggle_panel), do: dgettext("ui", "Toggle Panel")
defp item_label(:toggle_assistant_sidebar), do: "Toggle Assistant Sidebar" defp item_label(:toggle_assistant_sidebar), do: dgettext("ui", "Toggle Assistant Sidebar")
defp item_label(:toggle_dev_tools), do: "Toggle Dev Tools" defp item_label(:toggle_dev_tools), do: dgettext("ui", "Toggle Dev Tools")
defp item_label(:reload), do: "Reload" defp item_label(:reload), do: dgettext("ui", "Reload")
defp item_label(:force_reload), do: "Force Reload" defp item_label(:force_reload), do: dgettext("ui", "Force Reload")
defp item_label(:reset_zoom), do: "Reset Zoom" defp item_label(:reset_zoom), do: dgettext("ui", "Reset Zoom")
defp item_label(:zoom_in), do: "Zoom In" defp item_label(:zoom_in), do: dgettext("ui", "Zoom In")
defp item_label(:zoom_out), do: "Zoom Out" defp item_label(:zoom_out), do: dgettext("ui", "Zoom Out")
defp item_label(:toggle_full_screen), do: "Toggle Full Screen" defp item_label(:toggle_full_screen), do: dgettext("ui", "Toggle Full Screen")
defp item_label(:publish_selected), do: "Publish Selected" defp item_label(:publish_selected), do: dgettext("ui", "Publish Selected")
defp item_label(:preview_post), do: "Preview Post" defp item_label(:preview_post), do: dgettext("ui", "Preview Post")
defp item_label(:edit_menu), do: "Edit Menu" defp item_label(:edit_menu), do: dgettext("ui", "Edit Menu")
defp item_label(:rebuild_database), do: "Rebuild Database" defp item_label(:rebuild_database), do: dgettext("ui", "Rebuild Database")
defp item_label(:reindex_text), do: "Reindex Text" defp item_label(:reindex_text), do: dgettext("ui", "Reindex Text")
defp item_label(:rebuild_embedding_index), do: "Rebuild Embedding Index" defp item_label(:rebuild_embedding_index), do: dgettext("ui", "Rebuild Embedding Index")
defp item_label(:metadata_diff), do: "Metadata Diff" defp item_label(:metadata_diff), do: dgettext("ui", "Metadata Diff")
defp item_label(:regenerate_calendar), do: "Regenerate Calendar" defp item_label(:regenerate_calendar), do: dgettext("ui", "Regenerate Calendar")
defp item_label(:validate_translations), do: "Validate Translations" defp item_label(:validate_translations), do: dgettext("ui", "Validate Translations")
defp item_label(:fill_missing_translations), do: "Fill Missing Translations" defp item_label(:fill_missing_translations), do: dgettext("ui", "Fill Missing Translations")
defp item_label(:find_duplicates), do: "Find Duplicate Posts" defp item_label(:find_duplicates), do: dgettext("ui", "Find Duplicate Posts")
defp item_label(:generate_sitemap), do: "Generate Site" defp item_label(:generate_sitemap), do: dgettext("ui", "Generate Site")
defp item_label(:validate_site), do: "Validate Site" defp item_label(:validate_site), do: dgettext("ui", "Validate Site")
defp item_label(:upload_site), do: "Upload Site" defp item_label(:upload_site), do: dgettext("ui", "Upload Site")
defp item_label(:about), do: "About" defp item_label(:about), do: dgettext("ui", "About")
defp item_label(:documentation), do: "Documentation" defp item_label(:documentation), do: dgettext("ui", "Documentation")
defp item_label(:api_documentation), do: "API Documentation" defp item_label(:api_documentation), do: dgettext("ui", "API Documentation")
defp item_label(:view_on_github), do: "View on GitHub" defp item_label(:view_on_github), do: dgettext("ui", "View on GitHub")
defp item_label(:report_issue), do: "Report Issue" defp item_label(:report_issue), do: dgettext("ui", "Report Issue")
end end

View File

@@ -35,7 +35,8 @@ defmodule BDS.Desktop.Overlay do
title: Map.get(context, :insert_media_title, "Insert Media"), title: Map.get(context, :insert_media_title, "Insert Media"),
search_query: "", search_query: "",
results: Enum.map(media, &to_insert_media_result/1), results: Enum.map(media, &to_insert_media_result/1),
all_media: media all_media: media,
post_id: current_id(context)
} }
end end
@@ -48,29 +49,32 @@ defmodule BDS.Desktop.Overlay do
end end
def open(:media, :confirm_delete, context) do def open(:media, :confirm_delete, context) do
delete_details = Map.get(context, :delete_details, %{}) %{
title: title,
entity_name: entity_name,
entity_type: entity_type,
reference_list: reference_list
} = context.delete_details
%{ %{
kind: :confirm_delete, kind: :confirm_delete,
title: Map.get(delete_details, :title, "Delete"), title: title,
entity_name: Map.get(delete_details, :entity_name, ""), entity_name: entity_name,
entity_type: Map.get(delete_details, :entity_type, "media"), entity_type: entity_type,
reference_count: length(Map.get(delete_details, :reference_list, [])), reference_count: length(reference_list),
reference_list: Map.get(delete_details, :reference_list, []) reference_list: reference_list
} }
end end
def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context) def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context)
def open(:tags, :confirm_merge, context) do def open(:tags, :confirm_merge, context) do
merge = Map.get(context, :merge_details, %{}) %{title: title, message: message} = context.merge_details
target = Map.get(merge, :target, "")
count = Map.get(merge, :count, 0)
%{ %{
kind: :confirm_dialog, kind: :confirm_dialog,
title: Map.get(merge, :title, "Merge #{count} tags into #{target}?"), title: title,
message: Map.get(merge, :message, "Cannot be undone.") message: message
} }
end end
@@ -115,8 +119,8 @@ defmodule BDS.Desktop.Overlay do
|> Map.get(:all_media, []) |> Map.get(:all_media, [])
|> Enum.filter(fn media -> |> Enum.filter(fn media ->
normalized == "" or normalized == "" or
search_matches?(Map.get(media, :title, ""), normalized) or search_matches?(media.title, normalized) or
search_matches?(Map.get(media, :original_name, ""), normalized) search_matches?(media.original_name, normalized)
end) end)
|> Enum.map(&to_insert_media_result/1) |> Enum.map(&to_insert_media_result/1)
@@ -203,18 +207,22 @@ defmodule BDS.Desktop.Overlay do
def insert_media_result(_overlay, _media_id), do: nil def insert_media_result(_overlay, _media_id), do: nil
defp language_picker(context, source_language) do defp language_picker(context, source_language) do
existing_translations = Map.get(context, :existing_translations, %{})
language_names = Map.get(context, :language_names, %{})
language_flags = Map.get(context, :language_flags, %{})
targets = targets =
context context
|> Map.get(:blog_languages, []) |> Map.get(:blog_languages, [])
|> Enum.uniq() |> Enum.uniq()
|> Enum.reject(&(&1 == source_language)) |> Enum.reject(&(&1 == source_language))
|> Enum.map(fn code -> |> Enum.map(fn code ->
existing_status = Map.get(Map.get(context, :existing_translations, %{}), code) existing_status = Map.get(existing_translations, code)
%{ %{
code: code, code: code,
name: Map.get(Map.get(context, :language_names, %{}), code, String.upcase(code)), name: Map.get(language_names, code, String.upcase(code)),
flag_emoji: Map.get(Map.get(context, :language_flags, %{}), code, code), flag_emoji: Map.get(language_flags, code, code),
has_existing_translation: not is_nil(existing_status), has_existing_translation: not is_nil(existing_status),
existing_status: existing_status existing_status: existing_status
} }
@@ -228,15 +236,43 @@ defmodule BDS.Desktop.Overlay do
} }
end end
def set_ai_suggestions(%{kind: :ai_suggestions} = overlay, suggestions) do
fields =
Enum.map(overlay.fields, fn field ->
case Map.get(suggestions, field.key) do
nil ->
field
value when is_binary(value) and value != "" ->
%{field | suggested_value: value, loading: false}
_other ->
field
end
end)
%{overlay | fields: fields}
end
def set_ai_suggestions(overlay, _suggestions), do: overlay
def set_ai_suggestions_error(%{kind: :ai_suggestions} = overlay, error_message) do
Map.put(overlay, :error, error_message)
end
def set_ai_suggestions_error(overlay, _error_message), do: overlay
defp normalize_ai_fields(fields) do defp normalize_ai_fields(fields) do
Enum.map(fields, fn field -> Enum.map(fields, fn %{key: key, label: label, current_value: current,
suggested_value: suggested, locked: locked} = field ->
%{ %{
key: to_string(Map.get(field, :key, "")), key: to_string(key),
label: Map.get(field, :label, ""), label: label,
current_value: Map.get(field, :current_value, ""), current_value: current,
suggested_value: Map.get(field, :suggested_value, ""), suggested_value: suggested,
accepted: not Map.get(field, :locked, false), accepted: not locked,
locked: Map.get(field, :locked, false) locked: locked,
loading: Map.get(field, :loading, false)
} }
end) end)
end end
@@ -249,7 +285,7 @@ defmodule BDS.Desktop.Overlay do
end end
defp gallery_images(context) do defp gallery_images(context) do
images = Enum.filter(Map.get(context, :media, []), &Map.get(&1, :is_image, false)) images = Enum.filter(Map.get(context, :media, []), & &1.is_image)
post_media_ids = Map.get(context, :post_media_ids, []) post_media_ids = Map.get(context, :post_media_ids, [])
case Enum.filter(images, &(&1.id in post_media_ids)) do case Enum.filter(images, &(&1.id in post_media_ids)) do
@@ -262,29 +298,29 @@ defmodule BDS.Desktop.Overlay do
%{ %{
post_id: post.id, post_id: post.id,
title: post.title, title: post.title,
status: to_string(Map.get(post, :status, "draft")), status: post.status,
canonical_url: Map.get(post, :canonical_url, "/posts/#{post.id}"), canonical_url: post.canonical_url,
similarity_score: Map.get(post, :similarity_score) similarity_score: nil
} }
end end
defp to_insert_media_result(media) do defp to_insert_media_result(media) do
%{ %{
media_id: media.id, media_id: media.id,
title: Map.get(media, :title, ""), title: media.title,
original_name: Map.get(media, :original_name, media.id), original_name: media.original_name,
is_image: Map.get(media, :is_image, false), is_image: media.is_image,
thumbnail_url: Map.get(media, :thumbnail_url) thumbnail_url: media.thumbnail_url
} }
end end
defp to_gallery_image(media) do defp to_gallery_image(media) do
%{ %{
media_id: media.id, media_id: media.id,
thumbnail_url: Map.get(media, :thumbnail_url), thumbnail_url: media.thumbnail_url,
image_url: Map.get(media, :image_url, Map.get(media, :thumbnail_url)), image_url: media.image_url,
alt_text: Map.get(media, :alt_text), alt_text: media.alt_text,
title: Map.get(media, :title, Map.get(media, :original_name, media.id)) title: media.title
} }
end end

View File

@@ -279,6 +279,19 @@ defmodule BDS.Desktop.ShellCommands do
end) end)
end end
defp dispatch("regenerate_calendar", project, _params) do
queue_task(project, "regenerate_calendar", "Regenerate Calendar", "Generation", fn report ->
{:ok, generation} = Generation.generate_site(project.id, [:core], on_progress: report)
report.(1.0, "Calendar regenerated")
%{
project_id: project.id,
sections: generation.sections,
generated_count: length(generation.generated_files)
}
end)
end
defp dispatch("repair_metadata_diff", project, params) do defp dispatch("repair_metadata_diff", project, params) do
items = normalize_metadata_diff_items(BDS.MapUtils.attr(params, :items, [])) items = normalize_metadata_diff_items(BDS.MapUtils.attr(params, :items, []))
direction = BDS.MapUtils.attr(params, :direction) direction = BDS.MapUtils.attr(params, :direction)
@@ -352,6 +365,33 @@ defmodule BDS.Desktop.ShellCommands do
) )
end end
defp dispatch("fill_missing_translations", project, _params) do
with {:ok, metadata} <- Metadata.get_project_metadata(project.id) do
if translation_fill_enabled?(metadata) do
queue_task(
project,
"fill_missing_translations",
"Fill Missing Translations",
"AI",
fn report ->
{:ok, result} = Posts.fill_missing_translations(project.id, on_progress: report)
Map.put(result, :project_id, project.id)
end
)
else
{:ok,
%{
kind: "output",
action: "fill_missing_translations",
title: "Fill Missing Translations",
message: "All translations are up to date",
project_id: project.id,
level: "info"
}}
end
end
end
defp dispatch("find_duplicates", project, _params) do defp dispatch("find_duplicates", project, _params) do
queue_task(project, "find_duplicates", "Find Duplicate Posts", "Embeddings", fn report -> queue_task(project, "find_duplicates", "Find Duplicate Posts", "Embeddings", fn report ->
{:ok, pairs} = Embeddings.find_duplicates(project.id, on_progress: report) {:ok, pairs} = Embeddings.find_duplicates(project.id, on_progress: report)
@@ -408,6 +448,19 @@ defmodule BDS.Desktop.ShellCommands do
end end
end end
defp translation_fill_enabled?(metadata) do
([metadata.main_language] ++ metadata.blog_languages)
|> Enum.map(fn language ->
language
|> to_string()
|> String.trim()
|> String.downcase()
end)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
|> length() > 1
end
defp rebuild_database_steps(project) do defp rebuild_database_steps(project) do
[ [
%{ %{

View File

@@ -1,9 +1,12 @@
defmodule BDS.Desktop.ShellData do defmodule BDS.Desktop.ShellData do
@moduledoc false @moduledoc false
use Gettext, backend: BDS.Gettext
alias BDS.Git alias BDS.Git
alias BDS.I18n alias BDS.I18n
alias BDS.Projects alias BDS.Projects
alias BDS.Repo
alias BDS.UI.Dashboard alias BDS.UI.Dashboard
alias BDS.UI.Sidebar alias BDS.UI.Sidebar
alias BDS.UI.Workbench alias BDS.UI.Workbench
@@ -12,145 +15,6 @@ defmodule BDS.Desktop.ShellData do
Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server" Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server"
end end
def ui_language do
I18n.current_ui_locale()
end
def translations(locale \\ nil) do
I18n.get_ui_translations(effective_ui_language(locale))
end
def supported_ui_languages do
Enum.map(I18n.supported_languages(), fn language ->
%{code: language.code, flag: I18n.flag(language.code)}
end)
end
def translate(key, bindings \\ %{}, locale \\ nil) do
text = Map.get(translations(locale), to_string(key), to_string(key))
Enum.reduce(bindings, text, fn {binding, value}, acc ->
String.replace(acc, "%{#{binding}}", to_string(value))
end)
end
def project_snapshot do
Projects.shell_snapshot()
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table: projects") do
reraise error, __STACKTRACE__
end
default_project_snapshot()
end
def current_project(projects_snapshot) do
Enum.find(projects_snapshot.projects, &(&1.id == projects_snapshot.active_project_id)) ||
List.first(projects_snapshot.projects)
end
def dashboard(project_id) do
Dashboard.snapshot(project_id)
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
Dashboard.empty_snapshot()
end
def sidebar_view(project_id, view_id, params \\ %{}) do
Sidebar.view(project_id, view_id, params)
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
Sidebar.view(nil, view_id, params)
end
def assistant_cards do
[
%{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."},
%{
label: "Filesystem Sync",
text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."
},
%{label: "Desktop Runtime", text: "The app window is now served from LiveView state."}
]
end
def editor_meta(task_status) do
[
%{label: "Status", value: task_status.running_task_message || "Idle"},
%{label: "Mode", value: "Offline"},
%{label: "Main Language", value: ui_language()}
]
end
def status_bar(workbench, task_status, dashboard, opts \\ []) do
Workbench.status_bar(workbench,
post_count: dashboard.post_stats.total_posts,
media_count: dashboard.media_stats.media_count,
theme_badge: "desktop-shell",
ui_language: Keyword.get(opts, :ui_language, ui_language()),
offline_mode: Keyword.get(opts, :offline_mode, true),
running_task_message: task_status.running_task_message,
running_task_overflow: task_status.running_task_overflow,
active_post_status: nil
)
end
def git_badge_count(project_id, opts \\ [])
def git_badge_count(nil, _opts), do: 0
def git_badge_count("default", _opts), do: 0
def git_badge_count(project_id, opts) when is_binary(project_id) do
provider = Keyword.get(opts, :provider, git_remote_state_provider())
try do
case provider.(project_id, []) do
{:ok, %{behind: behind}} when is_integer(behind) and behind > 0 -> behind
{:ok, %{behind: behind}} when is_binary(behind) -> parse_positive_count(behind)
_other -> 0
end
rescue
error in [DBConnection.OwnershipError, Exqlite.Error] ->
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
0
end
end
def panel_tabs(workbench) do
[:tasks, :output]
|> maybe_add_panel_tab(workbench.editor_route, :post_links)
|> maybe_add_panel_tab(workbench.editor_route, :git_log)
|> Kernel.++([workbench.panel.active_tab])
|> Enum.uniq()
end
defp git_remote_state_provider do
Application.get_env(:bds, :git_remote_state_provider, &Git.remote_state/2)
end
defp parse_positive_count(value) do
case Integer.parse(value) do
{count, _rest} when count > 0 -> count
_other -> 0
end
end
def activity_icon(id) do def activity_icon(id) do
case to_string(id) do case to_string(id) do
"posts" -> "posts" ->
@@ -188,22 +52,141 @@ defmodule BDS.Desktop.ShellData do
end end
end end
def ui_language do
I18n.current_ui_locale()
end
def supported_ui_languages do
Enum.map(I18n.supported_languages(), fn language ->
%{code: language.code, flag: I18n.flag(language.code)}
end)
end
def project_snapshot do
if Repo.ready?() do
{:ok, Projects.shell_snapshot()}
else
{:error, :not_ready}
end
end
def current_project(projects_snapshot) do
Enum.find(projects_snapshot.projects, &(&1.id == projects_snapshot.active_project_id)) ||
List.first(projects_snapshot.projects)
end
def dashboard(project_id) do
if Repo.ready?() do
{:ok, Dashboard.snapshot(project_id)}
else
{:error, :not_ready}
end
end
def sidebar_view(project_id, view_id, params \\ %{}) do
if Repo.ready?() do
{:ok, Sidebar.view(project_id, view_id, params)}
else
{:error, :not_ready}
end
end
def assistant_cards do
[
%{
label: dgettext("ui", "Offline Gate"),
text: dgettext("ui", "Automatic AI actions stay gated by airplane mode.")
},
%{
label: dgettext("ui", "Filesystem Sync"),
text:
dgettext("ui", "Metadata flush, diffing, and rebuild hooks still need editor wiring.")
},
%{
label: dgettext("ui", "Desktop Runtime"),
text: dgettext("ui", "The app window is now served from LiveView state.")
}
]
end
def editor_meta(task_status) do
[
%{
label: dgettext("ui", "Status"),
value: task_status.running_task_message || dgettext("ui", "Idle")
},
%{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")},
%{label: dgettext("ui", "Main Language"), value: ui_language()}
]
end
def status_bar(workbench, task_status, dashboard, opts \\ []) do
Workbench.status_bar(workbench,
post_count: dashboard.post_stats.total_posts,
media_count: dashboard.media_stats.media_count,
theme_badge: "desktop-shell",
ui_language: Keyword.get(opts, :ui_language, ui_language()),
offline_mode: Keyword.get(opts, :offline_mode, true),
running_task_message: task_status.running_task_message,
running_task_overflow: task_status.running_task_overflow,
active_post_status: nil
)
end
def git_badge_count(project_id, opts \\ [])
def git_badge_count(nil, _opts), do: {:ok, 0}
def git_badge_count("default", _opts), do: {:ok, 0}
def git_badge_count(project_id, opts) when is_binary(project_id) do
if not Repo.ready?() do
{:error, :not_ready}
else
provider = Keyword.get(opts, :provider, git_remote_state_provider())
custom_provider? = provider != (&BDS.Git.remote_state/2)
has_git =
custom_provider? ||
case BDS.Projects.get_project(project_id) do
nil -> false
project -> File.dir?(Path.join(BDS.Projects.project_data_dir(project), ".git"))
end
count =
if has_git do
case provider.(project_id, []) do
{:ok, %{behind: behind}} when is_integer(behind) and behind > 0 -> behind
{:ok, %{behind: behind}} when is_binary(behind) -> parse_positive_count(behind)
_other -> 0
end
else
0
end
{:ok, count}
end
end
def panel_tabs(workbench) do
[:tasks, :output]
|> maybe_add_panel_tab(workbench.editor_route, :post_links)
|> maybe_add_panel_tab(workbench.editor_route, :git_log)
|> Kernel.++([workbench.panel.active_tab])
|> Enum.uniq()
end
def dashboard_status_label(status) do def dashboard_status_label(status) do
case to_string(status) do case to_string(status) do
"draft" -> translate("dashboard.status.draft") "draft" -> dgettext("ui", "Draft")
"published" -> translate("dashboard.status.published") "published" -> dgettext("ui", "Published")
"archived" -> translate("dashboard.status.archived") "archived" -> dgettext("ui", "Archived")
other -> other |> String.replace("_", " ") |> String.capitalize() other -> other |> String.replace("_", " ") |> String.capitalize()
end end
end end
def dashboard_post_count_label(count) do def dashboard_post_count_label(count) do
normalized_count = count || 0 normalized_count = count || 0
dngettext("ui", "%{count} post", "%{count} posts", normalized_count, count: normalized_count)
key =
if normalized_count == 1, do: "dashboard.postCount.one", else: "dashboard.postCount.other"
translate(key, %{count: normalized_count})
end end
def dashboard_tag_cloud_items(items) when is_list(items) do def dashboard_tag_cloud_items(items) when is_list(items) do
@@ -258,10 +241,10 @@ defmodule BDS.Desktop.ShellData do
def route_label(route) do def route_label(route) do
case to_string(route) do case to_string(route) do
"git_log" -> "git_log" ->
"Git Log" dgettext("ui", "Git Log")
"post_links" -> "post_links" ->
"Post Links" dgettext("ui", "Post Links")
other -> other ->
other other
@@ -288,11 +271,16 @@ defmodule BDS.Desktop.ShellData do
end end
end end
defp effective_ui_language(nil) do defp git_remote_state_provider do
BDS.Desktop.UILocale.current() || ui_language() Application.get_env(:bds, :git_remote_state_provider, &Git.remote_state/2)
end end
defp effective_ui_language(locale), do: locale defp parse_positive_count(value) do
case Integer.parse(value) do
{count, _rest} when count > 0 -> count
_other -> 0
end
end
defp maybe_add_panel_tab(tabs, :post, :post_links), do: tabs ++ [:post_links] defp maybe_add_panel_tab(tabs, :post, :post_links), do: tabs ++ [:post_links]
@@ -301,7 +289,7 @@ defmodule BDS.Desktop.ShellData do
defp maybe_add_panel_tab(tabs, _route, _tab), do: tabs defp maybe_add_panel_tab(tabs, _route, _tab), do: tabs
defp default_project_snapshot do def default_project_snapshot do
%{ %{
active_project_id: "default", active_project_id: "default",
projects: [ projects: [

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,258 @@
defmodule BDS.Desktop.ShellLive.Bridges do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [connected?: 1, send_update: 2]
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{ChatEditor, PostEditor}
alias BDS.Desktop.ShellLive.{CliSync, SessionUtil}
alias BDS.UI.Workbench
@refreshable_tab_meta_types [:import, :chat]
@spec handle_info(tuple() | atom(), Phoenix.LiveView.Socket.t(), map()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
# ── Generic editor notifications (sent via Notify module) ────────────────
def handle_info({:editor_output, title, message, detail, level}, socket, callbacks) do
{:noreply, callbacks.append_output.(socket, title, message, detail, level)}
end
def handle_info({:editor_tab_meta, type, id, updates}, socket, callbacks)
when is_atom(type) and is_map(updates) do
key = {type, id}
current_meta = Map.get(socket.assigns.tab_meta, key, %{})
next_meta = Map.merge(current_meta, updates)
tab_meta = Map.put(socket.assigns.tab_meta, key, next_meta)
socket = assign(socket, :tab_meta, tab_meta)
if type in @refreshable_tab_meta_types do
{:noreply, callbacks.refresh_sidebar.(socket, socket.assigns.workbench)}
else
{:noreply, socket}
end
end
def handle_info({:editor_dirty, type, id, dirty?}, socket, _callbacks) do
workbench =
if dirty? do
Workbench.mark_dirty(socket.assigns.workbench, type, id)
else
Workbench.clear_dirty(socket.assigns.workbench, type, id)
end
{:noreply, assign(socket, :workbench, workbench)}
end
@default_auto_save_delay 3000
def handle_info({:schedule_auto_save, type, id}, socket, _callbacks) do
timers = socket.assigns[:auto_save_timers] || %{}
key = {type, id}
case Map.get(timers, key) do
nil -> :ok
old_ref -> Process.cancel_timer(old_ref)
end
delay = Application.get_env(:bds, :auto_save_delay, @default_auto_save_delay)
ref = Process.send_after(self(), {:auto_save_fire, type, id}, delay)
{:noreply, assign(socket, :auto_save_timers, Map.put(timers, key, ref))}
end
def handle_info({:cancel_auto_save, type, id}, socket, _callbacks) do
timers = socket.assigns[:auto_save_timers] || %{}
key = {type, id}
case Map.get(timers, key) do
nil -> :ok
old_ref -> Process.cancel_timer(old_ref)
end
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, key))}
end
def handle_info({:auto_save_fire, :post, post_id}, socket, _callbacks) do
timers = socket.assigns[:auto_save_timers] || %{}
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, {:post, post_id}))}
end
def handle_info({:editor_command, action, params}, socket, callbacks) do
{:noreply, callbacks.apply_shell_command.(socket, action, params)}
end
# ── Shared actions (already generic) ─────────────────────────────────────
def handle_info({:open_sidebar_item, params, intent}, socket, callbacks) do
{:noreply, callbacks.open_sidebar.(socket, params, intent)}
end
def handle_info(:reload_shell, socket, callbacks) do
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
end
def handle_info({:close_tab, type, id}, socket, callbacks) do
{:noreply,
callbacks.refresh_layout.(socket, Workbench.close_tab(socket.assigns.workbench, type, id))}
end
def handle_info(:tags_changed, socket, callbacks) do
{:noreply, callbacks.refresh_content.(socket, socket.assigns.workbench)}
end
def handle_info(:settings_changed, socket, callbacks) do
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
end
# ── Chat editor messages (sent from AI streaming, not from Notify) ──────
def handle_info({:chat_tool_call, conversation_id, tool_call}, socket, _callbacks) do
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :note_tool_call,
tool_call: tool_call
)
{:noreply, socket}
end
def handle_info({:chat_tool_result, conversation_id, name}, socket, _callbacks) do
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :note_tool_result,
name: name
)
{:noreply, socket}
end
def handle_info({:chat_streaming_content, conversation_id, content}, socket, _callbacks) do
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :note_streaming_content,
content: content
)
{:noreply, socket}
end
def handle_info({:chat_editor_task_started, conversation_id, ref}, socket, _callbacks) do
refs = Map.put(socket.assigns.chat_editor_request_refs, ref, conversation_id)
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
end
def handle_info({:chat_editor_task_cancelled, _conversation_id, ref}, socket, _callbacks) do
refs = Map.delete(socket.assigns.chat_editor_request_refs, ref)
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
end
def handle_info({:persist_surface_state, conversation_id}, socket, _callbacks) do
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :persist_surface_state
)
{:noreply, socket}
end
def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do
{:noreply,
callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
end
def handle_info({:chat_editor_toggle_panel}, socket, callbacks) do
{:noreply,
callbacks.refresh_layout.(socket, Workbench.toggle_panel(socket.assigns.workbench))}
end
def handle_info({:chat_editor_toggle_assistant_sidebar}, socket, callbacks) do
{:noreply,
callbacks.refresh_layout.(
socket,
Workbench.toggle_assistant_sidebar(socket.assigns.workbench)
)}
end
def handle_info({:chat_editor_switch_view, view}, socket, callbacks) do
{:noreply,
callbacks.refresh_sidebar.(socket, Workbench.click_activity(socket.assigns.workbench, view))}
end
# ── Post editor cross-component messages (sent from OverlayManager) ─────
def handle_info({:post_editor_insert_content, post_id, content}, socket, _callbacks) do
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :insert_content,
content: content
)
{:noreply, socket}
end
def handle_info({:post_editor_translate, post_id, language}, socket, _callbacks) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :translate, language: language)
{:noreply, socket}
end
def handle_info({:post_editor_apply_ai_suggestions, post_id, fields}, socket, _callbacks) do
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :apply_ai_suggestions,
fields: fields
)
{:noreply, socket}
end
# ── External system messages ─────────────────────────────────────────────
def handle_info({:entity_changed, payload}, socket, callbacks) when is_map(payload) do
{:noreply, CliSync.apply_entity_change(socket, payload, callbacks.refresh_content)}
end
def handle_info(:refresh_task_status, socket, callbacks) do
raw_task_status = BDS.Tasks.status_snapshot()
socket =
case SessionUtil.next_completed_task_result(socket, raw_task_status) do
nil ->
task_status =
BDS.Desktop.ShellLive.TaskLocalization.localize_task_status(
raw_task_status,
socket.assigns.page_language
)
socket
|> assign(:task_status, task_status)
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|> assign(
:status,
ShellData.status_bar(
socket.assigns.workbench,
task_status,
socket.assigns.dashboard,
ui_language: socket.assigns.page_language,
offline_mode: socket.assigns.offline_mode
)
)
task ->
socket
|> SessionUtil.mark_task_result_handled(task.id)
|> callbacks.apply_shell_command_result.(task.result)
end
if connected?(socket) do
Process.send_after(self(), :refresh_task_status, BDS.Desktop.ShellLive.refresh_interval())
end
{:noreply, socket}
end
def handle_info(_message, socket, _callbacks), do: {:noreply, socket}
end

View File

@@ -1,283 +1,542 @@
defmodule BDS.Desktop.ShellLive.ChatEditor do defmodule BDS.Desktop.ShellLive.ChatEditor do
@moduledoc false @moduledoc false
use Phoenix.Component require Logger
use Phoenix.LiveComponent
import Phoenix.HTML, only: [raw: 1] import Phoenix.HTML, only: [raw: 1]
alias BDS.AI alias BDS.{AI, BoundedAtoms, MapUtils, Persistence}
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking} alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
alias BDS.Desktop.ShellLive.Notify
alias BDS.Desktop.ShellLive.TabHelpers
use Gettext, backend: BDS.Gettext
embed_templates("chat_editor_html/*") embed_templates("chat_editor_html/*")
# ── Public API: state assignment ─────────────────────────────────────────── # ── LiveComponent lifecycle ────────────────────────────────────────────────
@spec assign_socket(term()) :: term() @spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
def assign_socket(socket) do @impl true
assign(socket, :chat_editor, MessageBuild.build(socket.assigns)) def update(%{action: :finish_request}, %{assigns: %{request: nil}} = socket) do
{:ok, socket}
end end
defdelegate build(assigns), to: MessageBuild def update(%{action: :finish_request, result: result}, socket) do
{:ok, do_finish_request(socket, result)}
end
# ── Public API: model selection ──────────────────────────────────────────── def update(%{action: :note_tool_call, tool_call: tool_call}, socket) do
{:ok, do_note_tool_call(socket, tool_call)}
end
defdelegate toggle_model_selector(socket, reload), to: ModelSelection def update(%{action: :note_tool_result, name: name}, socket) do
defdelegate set_model(socket, model_id, reload, append_output), to: ModelSelection {:ok, do_note_tool_result(socket, name)}
end
# ── Public API: input + surface state ────────────────────────────────────── def update(%{action: :note_streaming_content, content: content}, socket) do
{:ok, do_note_streaming_content(socket, content)}
end
@spec update_input(term(), term(), term()) :: term() def update(%{action: :persist_surface_state}, socket) do
def update_input(socket, value, reload) do {:ok, persist_surface_state(socket)}
%{id: conversation_id} = socket.assigns.current_tab end
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> ensure_state()
|> build_data()
{:ok, socket}
end
@spec render(map()) :: Phoenix.LiveView.Rendered.t()
@impl true
def render(%{chat_editor: nil} = assigns), do: ~H"<div></div>"
def render(assigns) do
chat_editor(assigns)
end
# ── Event handlers ─────────────────────────────────────────────────────────
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
@impl true
def handle_event("change_chat_editor_input", %{"message" => message}, socket) do
{:noreply, assign(socket, :input, to_string(message || "")) |> build_data()}
end
def handle_event("toggle_chat_model_selector", _params, socket) do
{:noreply,
assign(socket, :model_selector_open?, not socket.assigns.model_selector_open?)
|> build_data()}
end
def handle_event("select_chat_model", %{"model" => model_id}, socket) do
conversation_id = socket.assigns.conversation_id
case AI.set_conversation_model(conversation_id, model_id) do
{:ok, _conversation} ->
{:noreply, assign(socket, :model_selector_open?, false) |> build_data()}
{:error, reason} ->
Notify.output(dgettext("ui", "Chat"), inspect(reason), "error")
{:noreply, assign(socket, :model_selector_open?, false) |> build_data()}
end
end
def handle_event("send_chat_editor_message", _params, socket) do
{:noreply, do_send_message(socket)}
end
def handle_event("abort_chat_editor_message", _params, socket) do
{:noreply, do_abort_message(socket)}
end
def handle_event(
"change_chat_surface_form",
%{"surface" => %{"id" => surface_id, "fields" => fields}},
socket
) do
next_data = Map.put(socket.assigns.surface_data, surface_id, fields)
{:noreply, assign(socket, :surface_data, next_data) |> schedule_surface_state_persist() |> build_data()}
end
def handle_event(
"select_chat_surface_tab",
%{"surface-id" => surface_id, "index" => index},
socket
) do
socket =
socket socket
|> assign( |> assign(
:chat_editor_inputs, :surface_tabs,
Map.put(socket.assigns.chat_editor_inputs, conversation_id, to_string(value || "")) Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
) )
|> reload.(socket.assigns.workbench) |> persist_surface_state()
|> build_data()
{:noreply, socket}
end end
@spec update_surface_form(term(), term(), term(), term()) :: term() def handle_event("dismiss_chat_surface", %{"surface-id" => surface_id}, socket) do
def update_surface_form(socket, surface_id, fields, reload) socket =
when is_binary(surface_id) and is_map(fields) do
next_data = Map.put(socket.assigns.chat_editor_surface_data, surface_id, fields)
socket socket
|> assign(:chat_editor_surface_data, next_data) |> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|> reload.(socket.assigns.workbench) |> persist_surface_state()
|> build_data()
{:noreply, socket}
end end
@spec select_surface_tab(term(), term(), term(), term()) :: term() def handle_event("chat_surface_action", params, socket) do
def select_surface_tab(socket, surface_id, index, reload) {:noreply, do_handle_surface_action(socket, params)}
when is_binary(surface_id) and is_integer(index) and index >= 0 do end
socket
|> assign( def handle_event("open_chat_settings", _params, socket) do
:chat_editor_surface_tabs, Notify.open_sidebar_item(
Map.put(socket.assigns.chat_editor_surface_tabs, surface_id, index) %{
"route" => "settings",
"id" => "settings-ai",
"title" => "Settings",
"subtitle" => "AI"
},
:pin
) )
|> reload.(socket.assigns.workbench)
{:noreply, socket}
end end
@spec dismiss_surface(term(), term(), term()) :: term() # ── State initialisation ──────────────────────────────────────────────────
def dismiss_surface(socket, surface_id, reload) when is_binary(surface_id) do
socket defp ensure_state(socket) do
|> assign( conversation_id = socket.assigns.current_tab.id
:chat_editor_dismissed_surfaces,
MapSet.put(socket.assigns.chat_editor_dismissed_surfaces, surface_id) persisted = AI.get_surface_state(conversation_id)
)
|> reload.(socket.assigns.workbench) {surface_data, surface_tabs, dismissed_surfaces} =
case persisted do
state when is_map(state) and map_size(state) > 0 ->
{
state["surface_data"] || %{},
state["surface_tabs"] || %{},
MapSet.new(state["dismissed_surfaces"] || [])
}
_other ->
{%{}, %{}, MapSet.new()}
end end
@spec current_surface_data(term(), term()) :: term() defaults = %{
def current_surface_data(socket, surface_id) when is_binary(surface_id) do conversation_id: conversation_id,
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{}) input: "",
model_selector_open?: false,
request: nil,
surface_data: surface_data,
surface_tabs: surface_tabs,
dismissed_surfaces: dismissed_surfaces,
action_error: nil
}
Enum.reduce(defaults, socket, fn {key, default}, acc ->
if is_nil(Map.get(acc.assigns, key)) do
assign(acc, key, default)
else
acc
end
end)
end end
@spec set_action_error(term(), term(), term(), term()) :: term() # ── Data builder ──────────────────────────────────────────────────────────
def set_action_error(socket, conversation_id, message, reload)
when is_binary(conversation_id) and is_binary(message) do defp build_data(socket) do
socket conversation_id = socket.assigns.conversation_id
|> assign( request = socket.assigns.request
:chat_editor_action_errors,
Map.put(socket.assigns.chat_editor_action_errors, conversation_id, message) fake_assigns = %{
) current_tab: socket.assigns.current_tab,
|> reload.(socket.assigns.workbench) chat_editor_requests: if(request, do: %{conversation_id => request}, else: %{}),
chat_model_selectors_open: %{conversation_id => socket.assigns.model_selector_open?},
chat_editor_inputs: %{conversation_id => socket.assigns.input},
chat_editor_surface_data: socket.assigns.surface_data,
chat_editor_surface_tabs: socket.assigns.surface_tabs,
chat_editor_dismissed_surfaces: socket.assigns.dismissed_surfaces,
chat_editor_action_errors: %{conversation_id => socket.assigns.action_error},
offline_mode: socket.assigns.offline_mode
}
chat_editor = MessageBuild.build(fake_assigns)
assign(socket, :chat_editor, chat_editor)
end end
@spec clear_action_error(term(), term(), term()) :: term() # ── Messaging ──────────────────────────────────────────────────────────────
def clear_action_error(socket, conversation_id, reload) when is_binary(conversation_id) do
socket
|> assign(
:chat_editor_action_errors,
Map.delete(socket.assigns.chat_editor_action_errors, conversation_id)
)
|> reload.(socket.assigns.workbench)
end
# ── Public API: messaging ────────────────────────────────────────────────── defp do_send_message(socket) do
conversation_id = socket.assigns.conversation_id
@spec send_message(term(), term(), term()) :: term() message = String.trim(socket.assigns.input || "")
def send_message(socket, reload, append_output) do
%{id: conversation_id} = socket.assigns.current_tab
message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim()
cond do cond do
message == "" -> message == "" ->
reload.(socket, socket.assigns.workbench) build_data(socket)
Map.has_key?(socket.assigns.chat_editor_requests, conversation_id) -> not is_nil(socket.assigns.request) ->
reload.(socket, socket.assigns.workbench) build_data(socket)
socket.assigns.offline_mode -> socket.assigns.offline_mode ->
socket Notify.output(dgettext("ui", "Chat"),
|> append_output.( dgettext("ui", "Automatic AI actions stay gated by airplane mode."), "info")
translated("Chat"),
translated("Automatic AI actions stay gated by airplane mode."), build_data(socket)
nil,
"info"
)
|> reload.(socket.assigns.workbench)
ModelSelection.needs_api_key?(false) -> ModelSelection.needs_api_key?(false) ->
reload.(socket, socket.assigns.workbench) build_data(socket)
true -> true ->
live_view_pid = self() parent = self()
started_at = Persistence.now_ms()
task = task =
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
AI.send_chat_message(conversation_id, message, AI.send_chat_message(conversation_id, message,
project_id: socket.assigns.projects.active_project_id, project_id: active_project_id(socket),
event_target: live_view_pid event_target: parent
) )
end) end)
:ok = allow_repo_sandbox(task.pid) :ok = allow_repo_sandbox(task.pid)
Notify.parent({:chat_editor_task_started, conversation_id, task.ref})
socket socket
|> assign( |> assign(:input, "")
:chat_editor_inputs, |> assign(:request, %{
Map.put(socket.assigns.chat_editor_inputs, conversation_id, "")
)
|> assign(
:chat_editor_requests,
Map.put(socket.assigns.chat_editor_requests, conversation_id, %{
ref: task.ref, ref: task.ref,
pid: task.pid, pid: task.pid,
started_at: started_at,
message: message, message: message,
content: "", content: "",
tool_events: [] tool_events: []
}) })
) |> assign(:action_error, nil)
|> assign( |> build_data()
:chat_editor_request_refs,
Map.put(socket.assigns.chat_editor_request_refs, task.ref, conversation_id)
)
|> assign(
:chat_editor_action_errors,
Map.delete(socket.assigns.chat_editor_action_errors, conversation_id)
)
|> reload.(socket.assigns.workbench)
end end
end end
@spec abort_message(term(), term()) :: term() defp do_abort_message(socket) do
def abort_message(socket, reload) do conversation_id = socket.assigns.conversation_id
%{id: conversation_id} = socket.assigns.current_tab
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do case socket.assigns.request do
nil -> nil ->
reload.(socket, socket.assigns.workbench) build_data(socket)
%{ref: ref} = _request -> %{ref: ref} = _request ->
:ok = AI.cancel_chat(conversation_id) :ok = AI.cancel_chat(conversation_id)
Notify.parent({:chat_editor_task_cancelled, conversation_id, ref})
socket socket
|> assign( |> assign(:request, nil)
:chat_editor_requests, |> clear_streaming_state()
Map.delete(socket.assigns.chat_editor_requests, conversation_id)
)
|> assign(
:chat_editor_request_refs,
Map.delete(socket.assigns.chat_editor_request_refs, ref)
)
|> reload.(socket.assigns.workbench)
end end
end end
@spec note_tool_call(term(), term(), term(), term()) :: term() defp clear_streaming_state(socket) do
def note_tool_call(socket, conversation_id, tool_call, reload) input = socket.assigns.input || ""
when is_binary(conversation_id) and is_map(tool_call) do chat_editor = socket.assigns.chat_editor || %{}
update_request(
socket, chat_editor =
conversation_id, chat_editor
fn request -> |> Map.put(:is_streaming, false)
|> Map.put(:pending_user_message, nil)
|> Map.put(:streaming_content, "")
|> Map.put(:streaming_tool_markers, [])
|> Map.put(:streaming_inline_surfaces, [])
|> Map.put(:send_disabled?, String.trim(input) == "")
assign(socket, :chat_editor, chat_editor)
end
defp do_finish_request(socket, result) do
case result do
{:ok, reply} ->
socket
|> update_tab_meta_from_reply(reply)
|> assign(:request, nil)
|> build_data()
{:error, :cancelled} ->
assign(socket, :request, nil) |> build_data()
{:error, %{kind: :endpoint_not_configured}} ->
assign(socket, :request, nil) |> build_data()
{:error, reason} ->
Notify.output(dgettext("ui", "Chat"), format_error(reason), "error")
assign(socket, :request, nil) |> build_data()
end
end
defp do_note_tool_call(socket, tool_call) when is_map(tool_call) do
update_request(socket, fn request ->
update_in( update_in(
request.tool_events, request.tool_events,
&(&1 ++ &(&1 ++
[ [
%{ %{
type: :call, type: :call,
id: ToolTracking.tool_call_id(tool_call),
name: ToolTracking.tool_call_name(tool_call), name: ToolTracking.tool_call_name(tool_call),
arguments: ToolTracking.tool_call_arguments(tool_call) arguments: ToolTracking.tool_call_arguments(tool_call)
} }
]) ])
) )
end, end)
reload
)
end end
@spec note_tool_result(term(), term(), term(), term()) :: term() defp do_note_tool_result(socket, name) when is_binary(name) do
def note_tool_result(socket, conversation_id, name, reload) update_request(socket, fn request ->
when is_binary(conversation_id) and is_binary(name) do
update_request(
socket,
conversation_id,
fn request ->
update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}])) update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}]))
end, end)
reload
)
end end
@spec note_streaming_content(term(), term(), term(), term()) :: term() defp do_note_streaming_content(socket, content) when is_binary(content) do
def note_streaming_content(socket, conversation_id, content, reload) update_request(socket, fn request -> %{request | content: content} end)
when is_binary(conversation_id) and is_binary(content) do
update_request(
socket,
conversation_id,
fn request -> %{request | content: content} end,
reload
)
end end
@spec finish_request(term(), term(), term(), term(), term()) :: term() defp update_request(socket, updater) do
def finish_request(socket, ref, result, reload, append_output) when is_reference(ref) do case socket.assigns.request do
case Map.pop(socket.assigns.chat_editor_request_refs, ref) do nil ->
{nil, _remaining_refs} -> build_data(socket)
socket
{conversation_id, remaining_refs} -> request ->
socket =
socket socket
|> assign(:chat_editor_request_refs, remaining_refs) |> assign(:request, updater.(request))
|> assign( |> build_data()
:chat_editor_requests, end
Map.delete(socket.assigns.chat_editor_requests, conversation_id) end
defp update_tab_meta_from_reply(socket, reply) do
title =
reply
|> MapUtils.attr(:conversation, %{})
|> MapUtils.attr(:title)
if is_binary(title) and String.trim(title) != "" do
Notify.tab_meta(:chat, socket.assigns.conversation_id, title, "")
end
socket
end
# ── Surface actions ────────────────────────────────────────────────────────
defp do_handle_surface_action(socket, params) do
surface_id = Map.get(params, "surface-id", "")
payload =
params
|> Map.get("payload")
|> decode_payload()
|> maybe_put_form_data(socket, surface_id)
case normalize_action(Map.get(params, "action", "")) do
:open_post ->
case Map.get(payload, "postId") || Map.get(payload, "post_id") do
post_id when is_binary(post_id) and post_id != "" ->
Notify.open_sidebar_item(
%{
"route" => "post",
"id" => post_id,
"title" => TabHelpers.post_title(post_id),
"subtitle" => TabHelpers.post_subtitle(post_id)
},
:pin
) )
case result do assign(socket, :action_error, nil) |> build_data()
{:ok, _reply} ->
reload.(socket, socket.assigns.workbench)
{:error, :cancelled} -> _other ->
reload.(socket, socket.assigns.workbench) set_action_error(socket, "Invalid payload for openPost action")
{:error, %{kind: :endpoint_not_configured}} ->
reload.(socket, socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Chat"), format_error(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end end
:open_media ->
case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do
media_id when is_binary(media_id) and media_id != "" ->
Notify.open_sidebar_item(
%{
"route" => "media",
"id" => media_id,
"title" => TabHelpers.media_title(media_id),
"subtitle" => TabHelpers.media_subtitle(media_id)
},
:pin
)
assign(socket, :action_error, nil) |> build_data()
_other ->
set_action_error(socket, "Invalid payload for openMedia action")
end
:open_settings ->
Notify.open_sidebar_item(
%{
"route" => "settings",
"id" => "settings-ai",
"title" => "Settings",
"subtitle" => "AI"
},
:pin
)
assign(socket, :action_error, nil) |> build_data()
:open_chat ->
chat_id =
Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") ||
socket.assigns.conversation_id
Notify.open_sidebar_item(
%{
"route" => "chat",
"id" => chat_id,
"title" => Map.get(payload, "title", "Chat"),
"subtitle" => Map.get(payload, "subtitle", "")
},
:pin
)
assign(socket, :action_error, nil) |> build_data()
:switch_view ->
case BoundedAtoms.sidebar_view(Map.get(payload, "view")) do
nil ->
set_action_error(socket, "Invalid payload for switchView action")
view ->
Notify.parent({:chat_editor_switch_view, view})
assign(socket, :action_error, nil) |> build_data()
end
:toggle_sidebar ->
Notify.parent({:chat_editor_toggle_sidebar})
assign(socket, :action_error, nil) |> build_data()
:toggle_panel ->
Notify.parent({:chat_editor_toggle_panel})
assign(socket, :action_error, nil) |> build_data()
:toggle_assistant_sidebar ->
Notify.parent({:chat_editor_toggle_assistant_sidebar})
assign(socket, :action_error, nil) |> build_data()
:unknown ->
set_action_error(socket, "Unsupported assistant action")
end
end
defp set_action_error(socket, message) do
assign(socket, :action_error, message) |> build_data()
end
defp decode_payload(nil), do: %{}
defp decode_payload(""), do: %{}
defp decode_payload(payload) when is_binary(payload) do
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) -> decoded
_other -> %{}
end
end
defp decode_payload(_payload), do: %{}
defp maybe_put_form_data(payload, socket, surface_id)
when is_binary(surface_id) and surface_id != "" do
form_data = Map.get(socket.assigns.surface_data, surface_id, %{})
if form_data == %{}, do: payload, else: Map.put(payload, "formData", form_data)
end
defp maybe_put_form_data(payload, _socket, _surface_id), do: payload
defp normalize_action(action) do
action
|> to_string()
|> String.replace("_", "")
|> String.downcase()
|> case do
"openpost" -> :open_post
"openmedia" -> :open_media
"opensettings" -> :open_settings
"openchat" -> :open_chat
"switchview" -> :switch_view
"setactiveview" -> :switch_view
"togglesidebar" -> :toggle_sidebar
"togglepanel" -> :toggle_panel
"openpanel" -> :toggle_panel
"toggleassistantsidebar" -> :toggle_assistant_sidebar
_other -> :unknown
end end
end end
# ── HEEx-callable helpers ───────────────────────────────────────────────── # ── HEEx-callable helpers ─────────────────────────────────────────────────
@spec message_role_label(term()) :: term() @spec message_role_label(atom()) :: String.t()
def message_role_label(:user), do: translated("chat.role.you") def message_role_label(:user), do: dgettext("ui", "You")
def message_role_label(_role), do: translated("chat.role.assistant") def message_role_label(_role), do: dgettext("ui", "Assistant")
defdelegate tool_call_name(tool_call), to: ToolTracking defdelegate tool_call_name(tool_call), to: ToolTracking
defdelegate tool_call_arguments(tool_call), to: ToolTracking defdelegate tool_call_arguments(tool_call), to: ToolTracking
@spec tool_surface_type(term()) :: term() @spec tool_surface_type(map()) :: String.t()
def tool_surface_type(surface), do: Map.get(surface, :type, "json") def tool_surface_type(surface), do: Map.get(surface, :type, "json")
@spec markdown_html(binary()) :: Phoenix.HTML.Safe.t()
def markdown_html(content) when is_binary(content) do def markdown_html(content) when is_binary(content) do
html = html =
case Earmark.as_html(content, escape: true) do case Earmark.as_html(content, escape: true) do
@@ -289,13 +548,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
raw(html) raw(html)
end end
@spec markdown_html(term()) :: term()
def markdown_html(_content), do: "" def markdown_html(_content), do: ""
@spec payload_json(term()) :: term() @spec payload_json(map() | nil) :: String.t()
def payload_json(nil), do: "{}" def payload_json(nil), do: "{}"
def payload_json(payload) when is_map(payload), do: Jason.encode!(payload) def payload_json(payload) when is_map(payload), do: Jason.encode!(payload)
@spec chart_width(number(), term()) :: number()
def chart_width(_max_value, value) when not is_number(value), do: 0 def chart_width(_max_value, value) when not is_number(value), do: 0
def chart_width(max_value, value) when is_number(max_value) and max_value > 0 do def chart_width(max_value, value) when is_number(max_value) and max_value > 0 do
@@ -307,18 +566,17 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> Float.round(2) |> Float.round(2)
end end
@spec chart_width(term(), term()) :: term()
def chart_width(_max_value, _value), do: 0 def chart_width(_max_value, _value), do: 0
@spec truthy?(term()) :: boolean()
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
@spec truthy?(term()) :: term()
def truthy?(_value), do: false def truthy?(_value), do: false
# ── HEEx components ─────────────────────────────────────────────────────── # ── HEEx components ───────────────────────────────────────────────────────
attr(:markers, :list, required: true) attr(:markers, :list, required: true)
@spec chat_tool_markers(term()) :: term() @spec chat_tool_markers(map()) :: Phoenix.LiveView.Rendered.t()
def chat_tool_markers(assigns) do def chat_tool_markers(assigns) do
~H""" ~H"""
<%= if @markers != [] do %> <%= if @markers != [] do %>
@@ -333,10 +591,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<% end %> <% end %>
</summary> </summary>
<div class="chat-tool-marker-details" data-testid="chat-tool-marker-details"> <div class="chat-tool-marker-details" data-testid="chat-tool-marker-details">
<div class="chat-tool-marker-detail-label"><%= translated("chat.toolArguments") %></div> <div class="chat-tool-marker-detail-label"><%= dgettext("ui", "Arguments") %></div>
<pre><%= Jason.encode!(marker.arguments || %{}, pretty: true) %></pre> <pre><%= Jason.encode!(marker.arguments || %{}, pretty: true) %></pre>
<%= if marker.result not in [nil, ""] do %> <%= if marker.result not in [nil, ""] do %>
<div class="chat-tool-marker-detail-label"><%= translated("chat.toolResult") %></div> <div class="chat-tool-marker-detail-label"><%= dgettext("ui", "Result") %></div>
<pre><%= marker.result %></pre> <pre><%= marker.result %></pre>
<% end %> <% end %>
</div> </div>
@@ -348,15 +606,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end end
attr(:surface, :map, required: true) attr(:surface, :map, required: true)
attr(:myself, :any, required: false)
@spec chat_surface(term()) :: term() @spec chat_surface(map()) :: Phoenix.LiveView.Rendered.t()
def chat_surface(assigns) do def chat_surface(assigns) do
~H""" ~H"""
<details id={@surface.id} class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface" data-expanded={Map.get(@surface, :expanded?, false)} open={Map.get(@surface, :expanded?, false)}> <details id={@surface.id} class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface" data-expanded={surface_expanded_attr(@surface)} open={Map.get(@surface, :expanded?, false)}>
<summary class="chat-inline-surface-header"> <summary class="chat-inline-surface-header">
<span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span> <span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span>
<span class="chat-inline-surface-title"><%= surface_title(@surface) %></span> <span class="chat-inline-surface-title"><%= surface_title(@surface) %></span>
<button class="chat-inline-surface-dismiss" type="button" phx-click="dismiss_chat_surface" phx-value-surface-id={@surface.id} aria-label={translated("chat.dismissSurface")} data-testid="chat-inline-surface-dismiss">×</button> <button class="chat-inline-surface-dismiss" type="button" phx-click="dismiss_chat_surface" phx-target={@myself} phx-value-surface-id={@surface.id} aria-label={dgettext("ui", "Dismiss surface")} data-testid="chat-inline-surface-dismiss">×</button>
</summary> </summary>
<div class="chat-inline-surface-body"> <div class="chat-inline-surface-body">
<%= case @surface.type do %> <%= case @surface.type do %>
@@ -376,6 +635,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
class="chat-surface-action-button" class="chat-surface-action-button"
type="button" type="button"
phx-click="chat_surface_action" phx-click="chat_surface_action"
phx-target={@myself}
phx-value-surface-id={@surface.id} phx-value-surface-id={@surface.id}
phx-value-action={action.action} phx-value-action={action.action}
phx-value-payload={payload_json(action.payload)} phx-value-payload={payload_json(action.payload)}
@@ -475,6 +735,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
class={["chat-surface-tab-button", if(index == @surface.selected_index, do: "active")]} class={["chat-surface-tab-button", if(index == @surface.selected_index, do: "active")]}
type="button" type="button"
phx-click="select_chat_surface_tab" phx-click="select_chat_surface_tab"
phx-target={@myself}
phx-value-surface-id={@surface.id} phx-value-surface-id={@surface.id}
phx-value-index={index} phx-value-index={index}
> >
@@ -498,7 +759,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<%= if present?(@surface.title) do %> <%= if present?(@surface.title) do %>
<h3><%= @surface.title %></h3> <h3><%= @surface.title %></h3>
<% end %> <% end %>
<form class="chat-surface-form" phx-change="change_chat_surface_form"> <form class="chat-surface-form" phx-change="change_chat_surface_form" phx-target={@myself}>
<input type="hidden" name="surface[id]" value={@surface.id} /> <input type="hidden" name="surface[id]" value={@surface.id} />
<%= for field <- @surface.fields do %> <%= for field <- @surface.fields do %>
@@ -535,6 +796,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
class="chat-surface-action-button" class="chat-surface-action-button"
type="button" type="button"
phx-click="chat_surface_action" phx-click="chat_surface_action"
phx-target={@myself}
phx-value-surface-id={@surface.id} phx-value-surface-id={@surface.id}
phx-value-action={@surface.submit_action} phx-value-action={@surface.submit_action}
phx-value-payload="{}" phx-value-payload="{}"
@@ -566,6 +828,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp surface_icon("tabs"), do: "" defp surface_icon("tabs"), do: ""
defp surface_icon(_type), do: "" defp surface_icon(_type), do: ""
defp surface_expanded_attr(surface) do
if Map.get(surface, :expanded?, false), do: "true", else: "false"
end
defp surface_title(surface) do defp surface_title(surface) do
cond do cond do
present?(Map.get(surface, :title)) -> Map.get(surface, :title) present?(Map.get(surface, :title)) -> Map.get(surface, :title)
@@ -576,19 +842,44 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── Private helpers ─────────────────────────────────────────────────────── # ── Private helpers ───────────────────────────────────────────────────────
defp update_request(socket, conversation_id, updater, reload) do @surface_state_debounce_ms 500
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
nil ->
socket
request -> defp persist_surface_state(socket) do
socket conversation_id = socket.assigns.conversation_id
|> assign( surface_data = socket.assigns.surface_data
:chat_editor_requests, surface_tabs = socket.assigns.surface_tabs
Map.put(socket.assigns.chat_editor_requests, conversation_id, updater.(request)) dismissed_surfaces = socket.assigns.dismissed_surfaces
case AI.put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces) do
{:ok, _state} ->
:ok
{:error, reason} ->
Logger.warning("Failed to persist surface state for conversation #{conversation_id}",
reason: inspect(reason)
) )
|> reload.(socket.assigns.workbench)
end end
socket
end
defp schedule_surface_state_persist(socket) do
if socket.assigns[:surface_state_timer] do
Process.cancel_timer(socket.assigns[:surface_state_timer])
end
timer =
Process.send_after(
self(),
{:persist_surface_state, socket.assigns.conversation_id},
@surface_state_debounce_ms
)
assign(socket, :surface_state_timer, timer)
end
defp active_project_id(socket) do
socket.assigns[:project_id]
end end
defp allow_repo_sandbox(pid) when is_pid(pid) do defp allow_repo_sandbox(pid) when is_pid(pid) do
@@ -633,11 +924,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp present?(value), do: not is_nil(value) defp present?(value), do: not is_nil(value)
defp format_error(%{kind: :endpoint_not_configured}), defp format_error(%{kind: :endpoint_not_configured}),
do: translated("chat.apiKeyRequiredDescription") do: dgettext("ui", "Configure an API key in Settings to enable AI chat.")
defp format_error(reason), do: inspect(reason) defp format_error(reason), do: inspect(reason)
@spec translated(term(), term()) :: term() defp parse_integer(value) when is_integer(value), do: value
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) defp parse_integer(value) do
case Integer.parse(to_string(value)) do
{int, _} -> int
:error -> 0
end
end
end end

View File

@@ -3,8 +3,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
alias BDS.AI alias BDS.AI
alias BDS.AI.ChatConversation alias BDS.AI.ChatConversation
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ChatEditor.{ModelSelection, ToolSurfaces, ToolTracking} alias BDS.Desktop.ShellLive.ChatEditor.{ModelSelection, ToolSurfaces, ToolTracking}
use Gettext, backend: BDS.Gettext
@spec build(term()) :: term() @spec build(term()) :: term()
def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do
@@ -17,10 +17,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
request = Map.get(assigns.chat_editor_requests, conversation.id) request = Map.get(assigns.chat_editor_requests, conversation.id)
effective_model = AI.effective_chat_model(conversation) effective_model = AI.effective_chat_model(conversation)
available_models = AI.available_chat_models(effective_model) available_models = AI.available_chat_models(effective_model)
streaming_tool_markers = streaming_tool_markers(messages, request)
streaming_content = streaming_content(messages, request)
%{ %{
id: conversation.id, id: conversation.id,
title: conversation.title || translated("chat.newChat"), title: conversation.title || dgettext("ui", "New Chat"),
model: conversation.model, model: conversation.model,
effective_model: effective_model, effective_model: effective_model,
available_models: available_models, available_models: available_models,
@@ -31,9 +33,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
messages: build_entries(messages, assigns), messages: build_entries(messages, assigns),
pending_user_message: pending_user_message(messages, request), pending_user_message: pending_user_message(messages, request),
is_streaming: not is_nil(request), is_streaming: not is_nil(request),
streaming_content: streaming_content(request), streaming_content: streaming_content,
streaming_tool_markers: ToolTracking.tool_markers_from_events(request), streaming_tool_markers: streaming_tool_markers,
streaming_inline_surfaces: streaming_inline_surfaces(conversation.id, request, assigns), streaming_inline_surfaces:
streaming_inline_surfaces(conversation.id, streaming_tool_markers, assigns),
offline?: Map.get(assigns, :offline_mode, true), offline?: Map.get(assigns, :offline_mode, true),
needs_api_key?: ModelSelection.needs_api_key?(Map.get(assigns, :offline_mode, true)), needs_api_key?: ModelSelection.needs_api_key?(Map.get(assigns, :offline_mode, true)),
action_error: Map.get(assigns.chat_editor_action_errors, conversation.id), action_error: Map.get(assigns.chat_editor_action_errors, conversation.id),
@@ -137,28 +140,133 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
defp pending_user_message(_messages, nil), do: nil defp pending_user_message(_messages, nil), do: nil
defp pending_user_message(messages, %{message: message}) when is_binary(message) do defp pending_user_message(messages, %{message: message} = request) when is_binary(message) do
cond do
persisted_user_message_for_request?(messages, request) ->
nil
true ->
legacy_pending_user_message(messages, message)
end
end
defp pending_user_message(_messages, _request), do: nil
defp legacy_pending_user_message(messages, message) do
case messages |> Enum.reverse() |> Enum.find(&(&1.role not in [:system, :tool])) do case messages |> Enum.reverse() |> Enum.find(&(&1.role not in [:system, :tool])) do
%{role: :user, content: ^message} -> nil %{role: :user, content: ^message} -> nil
_other -> message _other -> message
end end
end end
defp pending_user_message(_messages, _request), do: nil
defp streaming_content(nil), do: "" defp streaming_content(nil), do: ""
defp streaming_content(%{content: content}) when is_binary(content), do: content defp streaming_content(%{content: content}) when is_binary(content), do: content
defp streaming_content(_request), do: "" defp streaming_content(_request), do: ""
defp streaming_inline_surfaces(_conversation_id, nil, _assigns), do: [] defp streaming_content(messages, request) do
content = streaming_content(request)
defp streaming_inline_surfaces(conversation_id, request, assigns) do if content != "" and persisted_assistant_content_for_request?(messages, request, content) do
""
else
content
end
end
defp streaming_tool_markers(_messages, nil), do: []
defp streaming_tool_markers(messages, request) do
request request
|> ToolTracking.tool_markers_from_events() |> ToolTracking.tool_markers_from_events()
|> drop_persisted_tool_markers(messages, request)
end
defp streaming_inline_surfaces(_conversation_id, [], _assigns), do: []
defp streaming_inline_surfaces(conversation_id, tool_markers, assigns) do
tool_markers
|> ToolSurfaces.build_render_surfaces("streaming-#{conversation_id}", assigns) |> ToolSurfaces.build_render_surfaces("streaming-#{conversation_id}", assigns)
|> mark_surfaces_expanded(assigns) |> mark_surfaces_expanded(assigns)
end end
defp translated(text, bindings \\ %{}), defp persisted_user_message_for_request?(messages, %{message: message} = request)
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) when is_binary(message) do
messages
|> persisted_messages_for_request(request)
|> Enum.any?(fn persisted_message ->
persisted_message.role == :user and persisted_message.content == message
end)
end
defp persisted_user_message_for_request?(_messages, _request), do: false
defp persisted_assistant_content_for_request?(messages, request, content)
when is_binary(content) and content != "" do
messages
|> persisted_messages_for_request(request)
|> Enum.any?(fn persisted_message ->
persisted_message.role == :assistant and (persisted_message.content || "") == content
end)
end
defp persisted_assistant_content_for_request?(_messages, _request, _content), do: false
defp drop_persisted_tool_markers(tool_markers, messages, request) do
persisted_markers = persisted_tool_markers_for_request(messages, request)
{remaining, _persisted_markers} =
Enum.reduce(tool_markers, {[], persisted_markers}, fn marker,
{remaining, persisted_markers} ->
case pop_matching_tool_marker(persisted_markers, marker) do
{nil, persisted_markers} -> {remaining ++ [marker], persisted_markers}
{_matched, persisted_markers} -> {remaining, persisted_markers}
end
end)
remaining
end
defp persisted_tool_markers_for_request(messages, request) do
messages
|> persisted_messages_for_request(request)
|> Enum.flat_map(fn message ->
if message.role == :assistant do
ToolTracking.normalize_tool_calls(message.tool_calls)
else
[]
end
end)
end
defp pop_matching_tool_marker(tool_markers, marker) do
case Enum.find_index(tool_markers, &same_tool_marker?(&1, marker)) do
nil -> {nil, tool_markers}
index -> {Enum.at(tool_markers, index), List.delete_at(tool_markers, index)}
end
end
defp same_tool_marker?(left, right) do
cond do
is_binary(left.id) and is_binary(right.id) ->
left.id == right.id
true ->
left.name == right.name and (left.arguments || %{}) == (right.arguments || %{})
end
end
defp persisted_messages_for_request(messages, request) do
case request_started_at(request) do
started_at when is_integer(started_at) ->
Enum.filter(messages, fn message ->
is_integer(message.created_at) and message.created_at >= started_at
end)
_other ->
[]
end
end
defp request_started_at(%{started_at: started_at}) when is_integer(started_at), do: started_at
defp request_started_at(_request), do: nil
end end

View File

@@ -2,9 +2,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
@moduledoc false @moduledoc false
alias BDS.AI alias BDS.AI
alias BDS.Desktop.ShellData
import Phoenix.Component, only: [assign: 3] import Phoenix.Component, only: [assign: 3]
use Gettext, backend: BDS.Gettext
@spec toggle_model_selector(term(), term()) :: term() @spec toggle_model_selector(term(), term()) :: term()
def toggle_model_selector(socket, reload) do def toggle_model_selector(socket, reload) do
@@ -34,7 +34,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
{:error, reason} -> {:error, reason} ->
socket socket
|> append_output.(translated("Chat"), inspect(reason), nil, "error") |> append_output.(dgettext("ui", "Chat"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
end end
@@ -78,7 +78,4 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
defp blank?(value) when is_binary(value), do: String.trim(value) == "" defp blank?(value) when is_binary(value), do: String.trim(value) == ""
defp blank?(nil), do: true defp blank?(nil), do: true
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -1,7 +1,7 @@
defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
@moduledoc false @moduledoc false
alias BDS.Desktop.ShellData use Gettext, backend: BDS.Gettext
@render_tool_names MapSet.new([ @render_tool_names MapSet.new([
"render_card", "render_card",
@@ -85,7 +85,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|> List.wrap() |> List.wrap()
|> Enum.map(fn entry -> |> Enum.map(fn entry ->
%{ %{
label: map_value(entry, "label", translated("chat.role.assistant")), label: map_value(entry, "label", dgettext("ui", "Assistant")),
value: numeric_value(map_value(entry, "value", 0)), value: numeric_value(map_value(entry, "value", 0)),
segments: List.wrap(map_value(entry, "segments", [])) segments: List.wrap(map_value(entry, "segments", []))
} }
@@ -173,7 +173,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
fields: fields, fields: fields,
submit_label: submit_label:
map_value(arguments, "submitLabel") || map_value(arguments, "submitLabel") ||
map_value(arguments, "submit_label", translated("chat.stop")), map_value(arguments, "submit_label", dgettext("ui", "Stop")),
submit_action: submit_action:
map_value(arguments, "submitAction") || map_value(arguments, "submitAction") ||
map_value(arguments, "submit_action", "submitForm") map_value(arguments, "submit_action", "submitForm")
@@ -249,7 +249,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
defp decode_surface_actions(actions) when is_list(actions) do defp decode_surface_actions(actions) when is_list(actions) do
Enum.map(actions, fn action -> Enum.map(actions, fn action ->
%{ %{
label: map_value(action, "label", translated("chat.openSettings")), label: map_value(action, "label", dgettext("ui", "Open Settings")),
action: map_value(action, "action", "openSettings"), action: map_value(action, "action", "openSettings"),
payload: map_value(action, "payload", %{}) payload: map_value(action, "payload", %{})
} }
@@ -287,7 +287,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
defp map_value(map, key, default \\ nil) defp map_value(map, key, default \\ nil)
defp map_value(map, key, default) when is_map(map) and is_binary(key) do defp map_value(map, key, default) when is_map(map) and is_binary(key) do
Map.get(map, key, Map.get(map, String.to_atom(key), default)) Map.get(map, key, Map.get(map, String.to_existing_atom(key), default))
rescue rescue
ArgumentError -> Map.get(map, key, default) ArgumentError -> Map.get(map, key, default)
end end
@@ -296,7 +296,4 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
defp truthy?(value) when value in [true, "true", 1, "1", "on"], do: true defp truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
defp truthy?(_value), do: false defp truthy?(_value), do: false
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -8,6 +8,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
BDS.MapUtils.attr(tool_call, :name) || "tool" BDS.MapUtils.attr(tool_call, :name) || "tool"
end end
@spec tool_call_id(term()) :: term()
def tool_call_id(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :id)
end
@spec tool_call_id(term()) :: term()
def tool_call_id(_tool_call), do: nil
@spec tool_call_arguments(term()) :: term() @spec tool_call_arguments(term()) :: term()
def tool_call_arguments(tool_call) when is_map(tool_call) do def tool_call_arguments(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{} BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
@@ -72,7 +80,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
markers ++ markers ++
[ [
%{ %{
id: nil, id: Map.get(event, :id),
name: event.name, name: event.name,
arguments: event.arguments, arguments: event.arguments,
args_preview: tool_arguments_preview(event.arguments || %{}), args_preview: tool_arguments_preview(event.arguments || %{}),

View File

@@ -1,28 +1,29 @@
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel" data-testid="chat-editor" phx-hook="ChatSurface"> <div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel ui-editor-shell flex h-full min-h-0 flex-col" data-testid="chat-editor" phx-hook="ChatSurface">
<div class="chat-panel-header"> <div class="chat-panel-header flex shrink-0 items-center justify-between gap-3 px-4 py-3">
<div class="chat-panel-title"> <div class="chat-panel-title flex min-w-0 flex-1 items-center justify-between gap-3">
<span class="chat-panel-title-main"> <span class="chat-panel-title-main">
<%= if @chat_editor.needs_api_key? do %> <%= if @chat_editor.needs_api_key? do %>
<%= translated("chat.setupTitle") %> <%= dgettext("ui", "AI Chat Setup") %>
<% else %> <% else %>
<%= @chat_editor.title %> <%= @chat_editor.title %>
<% end %> <% end %>
</span> </span>
<%= unless @chat_editor.needs_api_key? do %> <%= unless @chat_editor.needs_api_key? do %>
<span class="chat-model-selector-wrap"> <span class="chat-model-selector-wrap relative shrink-0">
<button <button
class="chat-model-selector-button chat-model-selector-inline" class="chat-model-selector-button chat-model-selector-inline ui-button ui-button-secondary inline-flex items-center gap-2"
type="button" type="button"
phx-click="toggle_chat_model_selector" phx-click="toggle_chat_model_selector"
phx-target={@myself}
data-testid="chat-model-selector-button" data-testid="chat-model-selector-button"
> >
<span><%= @chat_editor.effective_model || translated("chat.modelUnavailable") %></span> <span><%= @chat_editor.effective_model || dgettext("ui", "No model") %></span>
<span class="chat-model-selector-caret">▾</span> <span class="chat-model-selector-caret">▾</span>
</button> </button>
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %> <%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
<div class="chat-model-selector-menu"> <div class="chat-model-selector-menu ui-dropdown-menu absolute right-0 top-full z-10 mt-2 flex min-w-56 flex-col">
<%= for group <- @chat_editor.available_model_groups do %> <%= for group <- @chat_editor.available_model_groups do %>
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}> <section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}>
<%= if length(@chat_editor.available_model_groups) > 1 do %> <%= if length(@chat_editor.available_model_groups) > 1 do %>
@@ -32,11 +33,12 @@
<%= for model <- group.models do %> <%= for model <- group.models do %>
<button <button
class={[ class={[
"chat-model-selector-option", "chat-model-selector-option ui-dropdown-item flex items-center justify-between gap-2 text-left",
if(model.id == @chat_editor.effective_model, do: "active") if(model.id == @chat_editor.effective_model, do: "active")
]} ]}
type="button" type="button"
phx-click="select_chat_model" phx-click="select_chat_model"
phx-target={@myself}
phx-value-model={model.id} phx-value-model={model.id}
data-testid="chat-model-selector-option" data-testid="chat-model-selector-option"
data-provider={group.provider} data-provider={group.provider}
@@ -53,35 +55,55 @@
</div> </div>
</div> </div>
<div class="chat-messages chat-surface-scroll"> <div class="chat-messages chat-surface-scroll min-h-0 flex-1 overflow-auto">
<%= if @chat_editor.needs_api_key? do %> <%= if @chat_editor.needs_api_key? do %>
<div class="chat-welcome chat-api-key-state" data-testid="chat-api-key-required"> <div class="chat-welcome chat-api-key-state ui-section-card flex flex-col items-start gap-3 p-4" data-testid="chat-api-key-required">
<div class="chat-welcome-icon">🔑</div> <div class="chat-welcome-icon">🔑</div>
<h2><%= translated("chat.apiKeyRequiredTitle") %></h2> <h2><%= dgettext("ui", "API Key Required") %></h2>
<p><%= translated("chat.apiKeyRequiredDescription") %></p> <p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p>
<div class="api-key-form"> <div class="api-key-form">
<button class="api-key-submit" type="button" phx-click="open_chat_settings"><%= translated("chat.openSettings") %></button> <button class="api-key-submit ui-button ui-button-primary" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button>
</div> </div>
</div> </div>
<% else %> <% else %>
<%= if Enum.empty?(@chat_editor.messages) and not @chat_editor.is_streaming do %> <%= if Enum.empty?(@chat_editor.messages) and not @chat_editor.is_streaming do %>
<div class="chat-welcome"> <div class="chat-welcome ui-section-card flex flex-col items-start gap-3 p-4">
<div class="chat-welcome-icon">🤖</div> <div class="chat-welcome-icon">🤖</div>
<h2><%= translated("chat.welcomeTitle") %></h2> <h2><%= dgettext("ui", "Welcome to the AI Assistant") %></h2>
<p><%= translated("chat.welcomeDescription") %></p> <p><%= dgettext("ui", "I can help you manage your blog with rich visualizations. Try asking me to:") %></p>
<ul> <ul>
<li><%= translated("chat.welcomeTipSearch") %></li> <li><%= dgettext("ui", "Search for posts about a specific topic") %></li>
<li><%= translated("chat.welcomeTipChart") %></li> <li><%= dgettext("ui", "Show a chart of posts published per month") %></li>
<li><%= translated("chat.welcomeTipTable") %></li> <li><%= dgettext("ui", "Compare my recent posts in a table") %></li>
<li><%= translated("chat.welcomeTipMetadata") %></li> <li><%= dgettext("ui", "Update metadata for posts or media") %></li>
<li><%= translated("chat.welcomeTipTabs") %></li> <li><%= dgettext("ui", "Show post statistics by year in tabs with charts") %></li>
</ul> </ul>
</div> </div>
<% else %> <% else %>
<%= for message <- @chat_editor.messages do %>
<div class={["chat-message flex items-start gap-3", to_string(message.role || "assistant")]}>
<div class="chat-message-avatar"><%= if message.role == :user, do: "👤", else: "🤖" %></div>
<div class="chat-message-content ui-section-card">
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
<.chat_tool_markers markers={message.tool_markers} />
<%= if message.role == :assistant do %>
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
<%= for surface <- message.inline_surfaces do %>
<.chat_surface surface={surface} myself={@myself} />
<% end %>
<% else %>
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
<% end %>
</div>
</div>
<% end %>
<%= if @chat_editor.pending_user_message do %> <%= if @chat_editor.pending_user_message do %>
<div class="chat-message user pending" data-testid="chat-pending-user-message"> <div class="chat-message user pending flex items-start gap-3" data-testid="chat-pending-user-message">
<div class="chat-message-avatar">👤</div> <div class="chat-message-avatar">👤</div>
<div class="chat-message-content"> <div class="chat-message-content ui-section-card">
<div class="chat-message-header"> <div class="chat-message-header">
<span class="chat-message-role"><%= message_role_label(:user) %></span> <span class="chat-message-role"><%= message_role_label(:user) %></span>
</div> </div>
@@ -90,31 +112,10 @@
</div> </div>
<% end %> <% end %>
<%= for message <- @chat_editor.messages do %>
<div class={["chat-message", to_string(message.role || "assistant")]}>
<div class="chat-message-avatar"><%= if message.role == :user, do: "👤", else: "🤖" %></div>
<div class="chat-message-content">
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
<.chat_tool_markers markers={message.tool_markers} />
<%= if message.role == :assistant do %>
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
<% else %>
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
<% end %>
</div>
</div>
<%= for surface <- message.inline_surfaces do %>
<.chat_surface surface={surface} />
<% end %>
<% end %>
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %> <%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
<div class="chat-message assistant streaming" data-testid="chat-streaming-message"> <div class="chat-message assistant streaming flex items-start gap-3" data-testid="chat-streaming-message">
<div class="chat-message-avatar">🤖</div> <div class="chat-message-avatar">🤖</div>
<div class="chat-message-content"> <div class="chat-message-content ui-section-card">
<div class="chat-message-header"> <div class="chat-message-header">
<span class="chat-message-role"><%= message_role_label(:assistant) %></span> <span class="chat-message-role"><%= message_role_label(:assistant) %></span>
<span class="streaming-indicator">●</span> <span class="streaming-indicator">●</span>
@@ -124,16 +125,15 @@
<%= if @chat_editor.streaming_content != "" do %> <%= if @chat_editor.streaming_content != "" do %>
<div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div> <div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div>
<% end %> <% end %>
</div>
</div>
<%= for surface <- @chat_editor.streaming_inline_surfaces do %> <%= for surface <- @chat_editor.streaming_inline_surfaces do %>
<.chat_surface surface={surface} /> <.chat_surface surface={surface} myself={@myself} />
<% end %> <% end %>
</div>
</div>
<% end %> <% end %>
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %> <%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>
<div class="chat-message assistant thinking" data-testid="chat-streaming-thinking"> <div class="chat-message assistant thinking flex items-start gap-3" data-testid="chat-streaming-thinking">
<div class="chat-message-avatar">🤖</div> <div class="chat-message-avatar">🤖</div>
<div class="chat-message-content"> <div class="chat-message-content">
<div class="chat-thinking-indicator"> <div class="chat-thinking-indicator">
@@ -147,14 +147,14 @@
</div> </div>
<%= unless @chat_editor.needs_api_key? do %> <%= unless @chat_editor.needs_api_key? do %>
<div class="chat-input-container" data-testid="chat-input-container"> <div class="chat-input-container ui-field-stack flex shrink-0 flex-col gap-3" data-testid="chat-input-container">
<%= if @chat_editor.is_streaming do %> <%= if @chat_editor.is_streaming do %>
<button class="chat-abort-button" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message">◼ <%= translated("chat.stop") %></button> <button class="chat-abort-button ui-button ui-button-secondary" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message" phx-target={@myself}>◼ <%= dgettext("ui", "Stop") %></button>
<% end %> <% end %>
<form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message"> <form class="chat-input-wrapper flex items-end gap-2" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
<textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={translated("chat.inputPlaceholder")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea> <textarea class="chat-input chat-surface-input ui-textarea" name="message" rows="1" placeholder={dgettext("ui", "Type a message...")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
<button class="chat-send-button" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" disabled={@chat_editor.send_disabled?}>↑</button> <button class="chat-send-button ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself} disabled={@chat_editor.send_disabled?}>↑</button>
</form> </form>
<%= if @chat_editor.action_error do %> <%= if @chat_editor.action_error do %>

View File

@@ -3,144 +3,7 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
import Phoenix.Component, only: [assign: 3] import Phoenix.Component, only: [assign: 3]
alias BDS.BoundedAtoms use Gettext, backend: BDS.Gettext
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{ChatEditor, TabHelpers}
alias BDS.UI.Workbench
@doc """
Handle a chat-surface action from a chat message. Receives callbacks for
`reload_shell/2` and `open_sidebar_item/3` to remain decoupled from
`BDS.Desktop.ShellLive` private state.
"""
def handle_action(socket, params, callbacks) do
surface_id = Map.get(params, "surface-id", "")
payload =
params
|> Map.get("payload")
|> decode_payload()
|> maybe_put_form_data(socket, surface_id)
case normalize_action(Map.get(params, "action", "")) do
:open_post ->
case Map.get(payload, "postId") || Map.get(payload, "post_id") do
post_id when is_binary(post_id) and post_id != "" ->
socket
|> clear_action_error()
|> callbacks.open_sidebar.(
%{
"route" => "post",
"id" => post_id,
"title" => TabHelpers.post_title(post_id),
"subtitle" => TabHelpers.post_subtitle(post_id)
},
:pin
)
_other ->
ChatEditor.set_action_error(
socket,
socket.assigns.current_tab.id,
"Invalid payload for openPost action",
callbacks.reload
)
end
:open_media ->
case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do
media_id when is_binary(media_id) and media_id != "" ->
socket
|> clear_action_error()
|> callbacks.open_sidebar.(
%{
"route" => "media",
"id" => media_id,
"title" => TabHelpers.media_title(media_id),
"subtitle" => TabHelpers.media_subtitle(media_id)
},
:pin
)
_other ->
ChatEditor.set_action_error(
socket,
socket.assigns.current_tab.id,
"Invalid payload for openMedia action",
callbacks.reload
)
end
:open_settings ->
socket
|> clear_action_error()
|> callbacks.open_sidebar.(
%{
"route" => "settings",
"id" => "settings-ai",
"title" => "Settings",
"subtitle" => "AI"
},
:pin
)
:open_chat ->
chat_id =
Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") ||
socket.assigns.current_tab.id
socket
|> clear_action_error()
|> callbacks.open_sidebar.(
%{
"route" => "chat",
"id" => chat_id,
"title" => Map.get(payload, "title", "Chat"),
"subtitle" => Map.get(payload, "subtitle", "")
},
:pin
)
:switch_view ->
case BoundedAtoms.sidebar_view(Map.get(payload, "view")) do
nil ->
ChatEditor.set_action_error(
socket,
socket.assigns.current_tab.id,
"Invalid payload for switchView action",
callbacks.reload
)
view ->
socket
|> clear_action_error()
|> callbacks.reload.(Workbench.click_activity(socket.assigns.workbench, view))
end
:toggle_sidebar ->
socket
|> clear_action_error()
|> callbacks.reload.(Workbench.toggle_sidebar(socket.assigns.workbench))
:toggle_panel ->
socket
|> clear_action_error()
|> callbacks.reload.(Workbench.toggle_panel(socket.assigns.workbench))
:toggle_assistant_sidebar ->
socket
|> clear_action_error()
|> callbacks.reload.(Workbench.toggle_assistant_sidebar(socket.assigns.workbench))
:unknown ->
ChatEditor.set_action_error(
socket,
socket.assigns.current_tab.id,
"Unsupported assistant action",
callbacks.reload
)
end
end
def assistant_turn(prompt, socket) do def assistant_turn(prompt, socket) do
[ [
@@ -149,12 +12,12 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
] ]
end end
def assistant_project_name(nil), do: translated("Projects") def assistant_project_name(nil), do: dgettext("ui", "Projects")
def assistant_project_name(project), do: project.name def assistant_project_name(project), do: project.name
def assistant_message_label("assistant"), do: translated("Assistant") def assistant_message_label("assistant"), do: dgettext("ui", "Assistant")
def assistant_message_label("user"), do: translated("You") def assistant_message_label("user"), do: dgettext("ui", "You")
def assistant_message_label(_role), do: translated("Assistant") def assistant_message_label(_role), do: dgettext("ui", "Assistant")
def assistant_message_testid(role), do: "assistant-message-#{role}" def assistant_message_testid(role), do: "assistant-message-#{role}"
@@ -165,76 +28,19 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
end end
end end
def clear_action_error(%{assigns: %{current_tab: %{type: :chat, id: conversation_id}}} = socket) do
assign(
socket,
:chat_editor_action_errors,
Map.delete(socket.assigns.chat_editor_action_errors, conversation_id)
)
end
def clear_action_error(socket), do: socket
defp decode_payload(nil), do: %{}
defp decode_payload(""), do: %{}
defp decode_payload(payload) when is_binary(payload) do
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) -> decoded
_other -> %{}
end
end
defp decode_payload(_payload), do: %{}
defp maybe_put_form_data(payload, socket, surface_id)
when is_binary(surface_id) and surface_id != "" do
form_data = ChatEditor.current_surface_data(socket, surface_id)
if form_data == %{} do
payload
else
Map.put(payload, "formData", form_data)
end
end
defp maybe_put_form_data(payload, _socket, _surface_id), do: payload
defp normalize_action(action) do
action
|> to_string()
|> String.replace("_", "")
|> String.downcase()
|> case do
"openpost" -> :open_post
"openmedia" -> :open_media
"opensettings" -> :open_settings
"openchat" -> :open_chat
"switchview" -> :switch_view
"setactiveview" -> :switch_view
"togglesidebar" -> :toggle_sidebar
"togglepanel" -> :toggle_panel
"openpanel" -> :toggle_panel
"toggleassistantsidebar" -> :toggle_assistant_sidebar
_other -> :unknown
end
end
defp assistant_reply(socket) do defp assistant_reply(socket) do
if socket.assigns.offline_mode do if socket.assigns.offline_mode do
ShellData.translate( BDS.Gettext.lgettext(
"Automatic AI actions stay gated by airplane mode.", socket.assigns.page_language,
%{}, "ui",
socket.assigns.page_language "Automatic AI actions stay gated by airplane mode."
) )
else else
ShellData.translate( BDS.Gettext.lgettext(
"The assistant sidebar chat surface is ready, but model execution is not connected yet.", socket.assigns.page_language,
%{}, "ui",
socket.assigns.page_language "The assistant sidebar chat surface is ready, but model execution is not connected yet."
) )
end end
end end
defp translated(text), do: ShellData.translate(text, %{}, BDS.Desktop.UILocale.current())
end end

View File

@@ -46,29 +46,6 @@ defmodule BDS.Desktop.ShellLive.CliSync do
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:shell_overlay, nil) |> assign(:shell_overlay, nil)
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id})) |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id}))
|> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id))
|> assign(
:post_editor_active_languages,
Map.delete(socket.assigns.post_editor_active_languages, post_id)
)
|> assign(
:post_editor_tag_queries,
Map.delete(socket.assigns.post_editor_tag_queries, post_id)
)
|> assign(
:post_editor_category_queries,
Map.delete(socket.assigns.post_editor_category_queries, post_id)
)
|> assign(
:post_editor_quick_actions_open,
Map.delete(socket.assigns.post_editor_quick_actions_open, post_id)
)
|> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id))
|> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id))
|> assign(
:post_editor_save_states,
Map.delete(socket.assigns.post_editor_save_states, post_id)
)
{socket, workbench} {socket, workbench}
end end
@@ -81,27 +58,6 @@ defmodule BDS.Desktop.ShellLive.CliSync do
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:shell_overlay, nil) |> assign(:shell_overlay, nil)
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id})) |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id}))
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|> assign(
:media_editor_quick_actions_open,
Map.delete(socket.assigns.media_editor_quick_actions_open, media_id)
)
|> assign(
:media_editor_post_pickers_open,
Map.delete(socket.assigns.media_editor_post_pickers_open, media_id)
)
|> assign(
:media_editor_post_picker_queries,
Map.delete(socket.assigns.media_editor_post_picker_queries, media_id)
)
|> assign(
:media_editor_save_states,
Map.delete(socket.assigns.media_editor_save_states, media_id)
)
|> assign(
:media_editor_translation_forms,
Map.delete(socket.assigns.media_editor_translation_forms, media_id)
)
{socket, workbench} {socket, workbench}
end end

View File

@@ -1,361 +0,0 @@
defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
@moduledoc false
use Phoenix.Component
alias BDS.Desktop.ShellData
alias BDS.{MCP, Scripts, Scripting, Templates}
alias BDS.Scripts.Script
alias BDS.Templates.Template
embed_templates("code_entity_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
socket
|> assign(:script_editor, build_script(socket.assigns))
|> assign(:template_editor, build_template(socket.assigns))
end
@spec update_script(term(), term(), term()) :: term()
def update_script(socket, params, reload) do
%{id: script_id} = socket.assigns.current_tab
socket
|> assign(
:script_editor_drafts,
Map.put(socket.assigns.script_editor_drafts, script_id, normalize_script_params(params))
)
|> reload.(socket.assigns.workbench)
end
@spec save_script(term(), term(), term()) :: term()
def save_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab
case Scripts.get_script(script_id) do
nil ->
reload.(socket, socket.assigns.workbench)
%Script{} = script ->
draft = current_script_draft(socket.assigns, script)
case Scripting.validate(draft["content"] || "") do
:ok ->
case Scripts.update_script(script.id, script_attrs(draft)) do
{:ok, _updated} ->
socket
|> assign(
:script_editor_drafts,
Map.delete(socket.assigns.script_editor_drafts, script.id)
)
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Scripts"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
{:error, reason} ->
socket
|> append_output.(translated("Scripts"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
@spec check_script(term(), term(), term()) :: term()
def check_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab
case Scripts.get_script(script_id) do
nil ->
reload.(socket, socket.assigns.workbench)
%Script{} = script ->
case Scripting.validate(current_script_draft(socket.assigns, script)["content"] || "") do
:ok ->
append_output.(socket, translated("Scripts"), translated("Syntax is valid"))
|> reload.(socket.assigns.workbench)
{:error, reason} ->
append_output.(socket, translated("Scripts"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
@spec run_script(term(), term(), term()) :: term()
def run_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab
case Scripts.get_script(script_id) do
nil ->
reload.(socket, socket.assigns.workbench)
%Script{} = script ->
draft = current_script_draft(socket.assigns, script)
case Scripting.execute_project_script(
script.project_id,
draft["content"] || "",
draft["entrypoint"] || "main",
[]
) do
{:ok, result} ->
socket
|> append_output.(translated("Scripts"), inspect(result))
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Scripts"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
@spec delete_script(term(), term(), term()) :: term()
def delete_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab
case Scripts.delete_script(script_id) do
{:ok, _deleted} ->
reload.(socket, BDS.UI.Workbench.close_tab(socket.assigns.workbench, :scripts, script_id))
{:error, reason} ->
append_output.(socket, translated("Scripts"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@spec update_template(term(), term(), term()) :: term()
def update_template(socket, params, reload) do
%{id: template_id} = socket.assigns.current_tab
socket
|> assign(
:template_editor_drafts,
Map.put(
socket.assigns.template_editor_drafts,
template_id,
normalize_template_params(params)
)
)
|> reload.(socket.assigns.workbench)
end
@spec save_template(term(), term(), term()) :: term()
def save_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab
case Templates.get_template(template_id) do
nil ->
reload.(socket, socket.assigns.workbench)
%Template{} = template ->
draft = current_template_draft(socket.assigns, template)
with {:ok, %{valid: true}} <- MCP.validate_template(draft["content"] || ""),
{:ok, _updated} <- Templates.update_template(template.id, template_attrs(draft)) do
socket
|> assign(
:template_editor_drafts,
Map.delete(socket.assigns.template_editor_drafts, template.id)
)
|> reload.(socket.assigns.workbench)
else
{:ok, %{valid: false, errors: errors}} ->
append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error")
|> reload.(socket.assigns.workbench)
{:error, reason} ->
append_output.(socket, translated("Templates"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
@spec validate_template(term(), term(), term()) :: term()
def validate_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab
case Templates.get_template(template_id) do
nil ->
reload.(socket, socket.assigns.workbench)
%Template{} = template ->
case MCP.validate_template(
current_template_draft(socket.assigns, template)["content"] || ""
) do
{:ok, %{valid: true}} ->
append_output.(
socket,
translated("Templates"),
translated("Template syntax is valid")
)
|> reload.(socket.assigns.workbench)
{:ok, %{valid: false, errors: errors}} ->
append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
@spec delete_template(term(), term(), term()) :: term()
def delete_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab
case Templates.delete_template(template_id, force: true) do
{:ok, _deleted} ->
reload.(
socket,
BDS.UI.Workbench.close_tab(socket.assigns.workbench, :templates, template_id)
)
{:error, reason} ->
append_output.(socket, translated("Templates"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@spec build_script(term()) :: term()
def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do
case Scripts.get_script(script_id) do
nil ->
nil
%Script{} = script ->
draft = current_script_draft(assigns, script)
%{
id: script.id,
title: draft["title"],
slug: draft["slug"],
kind: draft["kind"],
entrypoint: draft["entrypoint"],
enabled: draft["enabled"],
content: draft["content"],
entrypoints: discover_entrypoints(draft["content"]),
created_at: script.created_at,
updated_at: script.updated_at
}
end
end
def build_script(_assigns), do: nil
@spec build_template(term()) :: term()
def build_template(%{current_tab: %{type: :templates, id: template_id}} = assigns) do
case Templates.get_template(template_id) do
nil ->
nil
%Template{} = template ->
draft = current_template_draft(assigns, template)
%{
id: template.id,
title: draft["title"],
slug: draft["slug"],
kind: draft["kind"],
enabled: draft["enabled"],
content: draft["content"],
created_at: template.created_at,
updated_at: template.updated_at
}
end
end
def build_template(_assigns), do: nil
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec format_timestamp(term()) :: term()
def format_timestamp(nil), do: ""
def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp)
defp normalize_script_params(params) do
%{
"title" => Map.get(params, "title", ""),
"slug" => Map.get(params, "slug", ""),
"kind" => Map.get(params, "kind", "utility"),
"entrypoint" => Map.get(params, "entrypoint", "main"),
"enabled" => Map.get(params, "enabled") in [true, "true", "on", "1", 1],
"content" => Map.get(params, "content", "")
}
end
defp normalize_template_params(params) do
%{
"title" => Map.get(params, "title", ""),
"slug" => Map.get(params, "slug", ""),
"kind" => Map.get(params, "kind", "post"),
"enabled" => Map.get(params, "enabled") in [true, "true", "on", "1", 1],
"content" => Map.get(params, "content", "")
}
end
defp current_script_draft(assigns, %Script{} = script) do
Map.get(assigns.script_editor_drafts, script.id, %{
"title" => script.title || "",
"slug" => script.slug || "",
"kind" => to_string(script.kind || :utility),
"entrypoint" => script.entrypoint || "main",
"enabled" => script.enabled != false,
"content" => script.content || ""
})
end
defp current_template_draft(assigns, %Template{} = template) do
Map.get(assigns.template_editor_drafts, template.id, %{
"title" => template.title || "",
"slug" => template.slug || "",
"kind" => to_string(template.kind || :post),
"enabled" => template.enabled != false,
"content" => template.content || ""
})
end
defp script_attrs(draft) do
%{
title: draft["title"],
slug: draft["slug"],
kind: BDS.BoundedAtoms.script_kind(draft["kind"], :utility),
entrypoint: draft["entrypoint"],
enabled: draft["enabled"],
content: draft["content"]
}
end
defp template_attrs(draft) do
%{
title: draft["title"],
slug: draft["slug"],
kind: normalize_template_kind(draft["kind"]),
enabled: draft["enabled"],
content: draft["content"]
}
end
defp normalize_template_kind("post"), do: :post
defp normalize_template_kind("list"), do: :list
defp normalize_template_kind("not-found"), do: :"not-found"
defp normalize_template_kind("partial"), do: :partial
defp normalize_template_kind(_kind), do: :post
defp discover_entrypoints(content) do
[
"main"
| Regex.scan(~r/function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/, content || "",
capture: :all_but_first
)
|> List.flatten()
|> Enum.reject(&(&1 == "main"))
]
end
end

View File

@@ -1,42 +0,0 @@
<div class="scripts-view-shell" data-testid="script-editor">
<div class="editor-header scripts-header">
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @script_editor.title %></span></div></div>
<div class="editor-actions">
<button class="scripts-save-button" type="button" phx-click="save_script_editor"><%= translated("Save") %></button>
<button class="scripts-run-button" type="button" phx-click="run_script_editor"><%= translated("Run") %></button>
<button class="scripts-check-button" type="button" phx-click="check_script_editor"><%= translated("Check Syntax") %></button>
<button class="secondary danger" type="button" phx-click="delete_script_editor"><%= translated("Delete") %></button>
</div>
</div>
<form class="editor-content scripts-view" phx-change="change_script_editor">
<div class="editor-header-row scripts-meta-row">
<div class="editor-meta">
<div class="editor-field-row">
<div class="editor-field"><label><%= translated("Title") %></label><input type="text" name="script_editor[title]" value={@script_editor.title} /></div>
<div class="editor-field"><label><%= translated("Slug") %></label><input type="text" name="script_editor[slug]" value={@script_editor.slug} /></div>
</div>
<div class="editor-field-row">
<div class="editor-field"><label><%= translated("Kind") %></label><select name="script_editor[kind]"><option value="utility" selected={@script_editor.kind == "utility"}>utility</option><option value="macro" selected={@script_editor.kind == "macro"}>macro</option><option value="transform" selected={@script_editor.kind == "transform"}>transform</option></select></div>
<div class="editor-field"><label><%= translated("Entrypoint") %></label><select name="script_editor[entrypoint]"><%= for entrypoint <- @script_editor.entrypoints do %><option value={entrypoint} selected={entrypoint == @script_editor.entrypoint}><%= entrypoint %></option><% end %></select></div>
<div class="editor-field scripts-enabled-field"><label><input type="checkbox" name="script_editor[enabled]" checked={@script_editor.enabled} /> <%= translated("Enabled") %></label></div>
</div>
</div>
</div>
<div class="editor-body scripts-editor">
<div class="editor-toolbar scripts-toolbar"><div class="editor-toolbar-left"><label><%= translated("Content") %></label></div></div>
<div
id={"script-editor-monaco-shell-#{@script_editor.id}"}
class="scripts-monaco monaco-editor-shell"
phx-hook="MonacoEditor"
data-monaco-editor-id={@script_editor.id}
data-monaco-input-id={"script-editor-content-#{@script_editor.id}"}
data-monaco-language="lua"
data-monaco-word-wrap="on"
>
<div id={"script-editor-monaco-#{@script_editor.id}"} class="monaco-editor-instance" phx-update="ignore"></div>
<textarea id={"script-editor-content-#{@script_editor.id}"} class="monaco-editor-input code-editor-textarea" name="script_editor[content]" spellcheck="false"><%= @script_editor.content %></textarea>
</div>
</div>
<div class="editor-footer"><span class="text-muted text-small"><%= translated("Created") %>: <%= format_timestamp(@script_editor.created_at) %></span><span class="text-muted text-small"><%= translated("Updated") %>: <%= format_timestamp(@script_editor.updated_at) %></span></div>
</form>
</div>

View File

@@ -1,40 +0,0 @@
<div class="templates-view-shell" data-testid="template-editor">
<div class="editor-header templates-header">
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @template_editor.title %></span></div></div>
<div class="editor-actions">
<button class="templates-save-button" type="button" phx-click="save_template_editor"><%= translated("Save") %></button>
<button class="templates-validate-button" type="button" phx-click="validate_template_editor"><%= translated("Validate") %></button>
<button class="secondary danger" type="button" phx-click="delete_template_editor"><%= translated("Delete") %></button>
</div>
</div>
<form class="editor-content templates-view" phx-change="change_template_editor">
<div class="editor-header-row templates-meta-row">
<div class="editor-meta">
<div class="editor-field-row">
<div class="editor-field"><label><%= translated("Title") %></label><input type="text" name="template_editor[title]" value={@template_editor.title} /></div>
<div class="editor-field"><label><%= translated("Slug") %></label><input type="text" name="template_editor[slug]" value={@template_editor.slug} /></div>
</div>
<div class="editor-field-row">
<div class="editor-field"><label><%= translated("Kind") %></label><select name="template_editor[kind]"><option value="post" selected={@template_editor.kind == :post or @template_editor.kind == "post"}>post</option><option value="list" selected={@template_editor.kind == :list or @template_editor.kind == "list"}>list</option><option value="not-found" selected={@template_editor.kind == :"not-found" or @template_editor.kind == "not-found"}>not-found</option><option value="partial" selected={@template_editor.kind == :partial or @template_editor.kind == "partial"}>partial</option></select></div>
<div class="editor-field templates-enabled-field"><label><input type="checkbox" name="template_editor[enabled]" checked={@template_editor.enabled} /> <%= translated("Enabled") %></label></div>
</div>
</div>
</div>
<div class="editor-body templates-editor">
<div class="editor-toolbar templates-toolbar"><div class="editor-toolbar-left"><label><%= translated("Content") %></label></div></div>
<div
id={"template-editor-monaco-shell-#{@template_editor.id}"}
class="templates-monaco monaco-editor-shell"
phx-hook="MonacoEditor"
data-monaco-editor-id={@template_editor.id}
data-monaco-input-id={"template-editor-content-#{@template_editor.id}"}
data-monaco-language="liquid"
data-monaco-word-wrap="on"
>
<div id={"template-editor-monaco-#{@template_editor.id}"} class="monaco-editor-instance" phx-update="ignore"></div>
<textarea id={"template-editor-content-#{@template_editor.id}"} class="monaco-editor-input code-editor-textarea" name="template_editor[content]" spellcheck="false"><%= @template_editor.content %></textarea>
</div>
</div>
<div class="editor-footer"><span class="text-muted text-small"><%= translated("Created") %>: <%= format_timestamp(@template_editor.created_at) %></span><span class="text-muted text-small"><%= translated("Updated") %>: <%= format_timestamp(@template_editor.updated_at) %></span></div>
</form>
</div>

View File

@@ -0,0 +1,172 @@
defmodule BDS.Desktop.ShellLive.GalleryImport do
@moduledoc false
require Logger
alias BDS.{AI, Media, Metadata}
@doc """
Starts the image import pipeline: for each selected path, imports the file,
runs AI analysis, updates metadata, links to the post, and translates to
all configured blog languages.
Processes images with a concurrency cap via a sliding window.
"""
@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
{:ok, metadata} = Metadata.get_project_metadata(project_id)
main_language = metadata.main_language || 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)
tasks =
Enum.map(in_flight, fn path ->
Task.async(fn ->
process_single_image(path, project_id, post_id, language, translate_targets, parent)
end)
end)
known_refs = MapSet.new(tasks, & &1.ref)
drain_tasks(
remaining, tasks, known_refs, project_id, post_id, language, translate_targets, parent
)
send(parent, {:add_images_complete, length(paths)})
end
defp drain_tasks(
[], tasks, _known_refs, _project_id, _post_id, _language, _translate_targets, _parent
) do
Enum.each(tasks, fn task -> Task.await(task, :infinity) end)
end
defp drain_tasks(
[next_path | rest],
tasks,
known_refs,
project_id,
post_id,
language,
translate_targets,
parent
) do
receive do
{ref, _result} when is_reference(ref) ->
if MapSet.member?(known_refs, ref) do
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
new_task =
Task.async(fn ->
process_single_image(
next_path, project_id, post_id, language, translate_targets, parent
)
end)
drain_tasks(
rest,
[new_task | remaining_tasks],
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
project_id,
post_id,
language,
translate_targets,
parent
)
else
drain_tasks(
[next_path | rest], tasks, known_refs,
project_id, post_id, language, translate_targets, parent
)
end
{:DOWN, ref, :process, _pid, _reason} when is_reference(ref) ->
if MapSet.member?(known_refs, ref) do
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
new_task =
Task.async(fn ->
process_single_image(
next_path, project_id, post_id, language, translate_targets, parent
)
end)
drain_tasks(
rest,
[new_task | remaining_tasks],
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
project_id,
post_id,
language,
translate_targets,
parent
)
else
drain_tasks(
[next_path | rest], tasks, known_refs,
project_id, post_id, language, translate_targets, parent
)
end
end
end
defp pop_task_by_ref(tasks, ref) do
Enum.reduce(tasks, {nil, []}, fn
%{ref: ^ref} = task, {nil, rest} -> {task, rest}
task, {found, rest} -> {found, [task | rest]}
end)
end
defp process_single_image(
path, project_id, post_id, language, translate_targets, parent
) do
with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
true <- String.starts_with?(media.mime_type || "", "image/"),
{:ok, result} <- AI.analyze_image(media.id, language: language),
{:ok, _updated} <- Media.update_media(media.id, %{
title: result.title,
alt: result.alt,
caption: result.caption
}),
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
translate_media_translations(media.id, translate_targets)
title = result.title || media.original_name
send(parent, {:add_image_processed, title})
else
false ->
:ok
{:error, reason} ->
Logger.warning("Image pipeline error for #{path}: #{inspect(reason)}")
send(parent, {:add_image_error, path, reason})
end
end
defp translate_media_translations(_media_id, []), do: :ok
defp translate_media_translations(media_id, [target | rest]) do
case AI.translate_media(media_id, target) do
{:ok, translation} ->
Media.upsert_media_translation(media_id, target, %{
title: translation.title,
alt: translation.alt,
caption: translation.caption
})
translate_media_translations(media_id, rest)
{:error, reason} ->
Logger.warning(
"Media translation failed for #{media_id} -> #{target}: #{inspect(reason)}"
)
translate_media_translations(media_id, rest)
end
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
@moduledoc false @moduledoc false
alias BDS.{ImportAnalysis, ImportDefinitions, Metadata} alias BDS.{ImportAnalysis, ImportDefinitions, Metadata}
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData} alias BDS.Desktop.{FilePicker, FolderPicker}
use Gettext, backend: BDS.Gettext
@spec change_definition(term(), term(), term()) :: term() @spec change_definition(term(), term(), term()) :: term()
def change_definition(socket, params, reload) do def change_definition(socket, params, reload) do
@@ -18,7 +19,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
@spec select_uploads_folder(term(), term(), term()) :: term() @spec select_uploads_folder(term(), term(), term()) :: term()
def select_uploads_folder(socket, reload, append_output) do def select_uploads_folder(socket, reload, append_output) do
with %{id: definition_id} <- socket.assigns.current_tab do with %{id: definition_id} <- socket.assigns.current_tab do
case FolderPicker.choose_directory(translated("importAnalysis.uploadsFolder")) do case FolderPicker.choose_directory(dgettext("ui", "Uploads Folder")) do
{:ok, uploads_folder_path} -> {:ok, uploads_folder_path} ->
{:ok, _definition} = {:ok, _definition} =
ImportDefinitions.update_definition(definition_id, %{ ImportDefinitions.update_definition(definition_id, %{
@@ -32,7 +33,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
{:error, %{message: message}} -> {:error, %{message: message}} ->
socket socket
|> append_output.(translated("activity.import"), message, nil, "error") |> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
else else
@@ -44,7 +45,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
def select_and_analyze(socket, reload, append_output) do def select_and_analyze(socket, reload, append_output) do
with %{id: definition_id} <- socket.assigns.current_tab, with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id) do %{} = definition <- ImportDefinitions.get_definition(definition_id) do
case FilePicker.choose_file(translated("importAnalysis.wxrFile")) do case FilePicker.choose_file(dgettext("ui", "WXR File")) do
{:ok, wxr_file_path} -> {:ok, wxr_file_path} ->
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
@@ -78,7 +79,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
:import_editor_analysis_states, :import_editor_analysis_states,
Map.put(socket.assigns.import_editor_analysis_states, definition_id, %{ Map.put(socket.assigns.import_editor_analysis_states, definition_id, %{
loading: true, loading: true,
step: translated("importAnalysis.analyzingWxr"), step: dgettext("ui", "Analyzing WXR file..."),
detail: Path.basename(wxr_file_path), detail: Path.basename(wxr_file_path),
file_path: wxr_file_path, file_path: wxr_file_path,
ref: task.ref ref: task.ref
@@ -99,7 +100,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
{:error, %{message: message}} -> {:error, %{message: message}} ->
socket socket
|> append_output.(translated("activity.import"), message, nil, "error") |> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
else else
@@ -167,18 +168,18 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
{:error, reason} -> {:error, reason} ->
socket socket
|> append_output.(translated("activity.import"), inspect(reason), nil, "error") |> append_output.(dgettext("ui", "Import"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
{:error, %{message: message}} -> {:error, %{message: message}} ->
socket socket
|> append_output.(translated("activity.import"), message, nil, "error") |> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
{:error, reason} -> {:error, reason} ->
socket socket
|> append_output.(translated("activity.import"), inspect(reason), nil, "error") |> append_output.(dgettext("ui", "Import"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
end end
@@ -200,7 +201,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
:import_editor_analysis_states, :import_editor_analysis_states,
Map.delete(socket.assigns.import_editor_analysis_states, definition_id) Map.delete(socket.assigns.import_editor_analysis_states, definition_id)
) )
|> append_output.(translated("activity.import"), message, nil, "error") |> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
end end
@@ -265,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
@spec default_author(term()) :: term() @spec default_author(term()) :: term()
def default_author(project_id) do def default_author(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id)
Map.get(metadata, :default_author) metadata.default_author
end end
@spec suggested_definition_name(term()) :: term() @spec suggested_definition_name(term()) :: term()
@@ -294,12 +295,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
def translate_phase(step) when is_binary(step) do def translate_phase(step) when is_binary(step) do
case step do case step do
"parsing" -> translated("importAnalysis.analysisPhase.parsing") "parsing" -> dgettext("ui", "Parsing WXR file...")
"scanning" -> translated("importAnalysis.analysisPhase.scanning") "scanning" -> dgettext("ui", "Scanning entries...")
"taxonomies" -> translated("importAnalysis.analysisPhase.taxonomies") "taxonomies" -> dgettext("ui", "Analyzing taxonomies...")
"posts" -> translated("importAnalysis.analysisPhase.posts") "posts" -> dgettext("ui", "Analyzing posts...")
"media" -> translated("importAnalysis.analysisPhase.media") "media" -> dgettext("ui", "Analyzing media...")
"complete" -> translated("importAnalysis.analysisPhase.complete") "complete" -> dgettext("ui", "Analysis complete")
other -> other other -> other
end end
end end
@@ -307,8 +308,5 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
@spec translate_phase(term()) :: term() @spec translate_phase(term()) :: term()
def translate_phase(other), do: other def translate_phase(other), do: other
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp present?(value), do: value not in [nil, ""] defp present?(value), do: value not in [nil, ""]
end end

View File

@@ -2,8 +2,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
@moduledoc false @moduledoc false
alias BDS.{ImportDefinitions, ImportExecution} alias BDS.{ImportDefinitions, ImportExecution}
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ImportEditor.AnalysisState alias BDS.Desktop.ShellLive.ImportEditor.AnalysisState
use Gettext, backend: BDS.Gettext
@spec execute_import(term(), term(), term()) :: term() @spec execute_import(term(), term(), term()) :: term()
def execute_import(socket, reload, _append_output) do def execute_import(socket, reload, _append_output) do
@@ -129,8 +129,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
}) })
) )
|> append_output.( |> append_output.(
translated("activity.import"), dgettext("ui", "Import"),
translated("importAnalysis.importComplete", %{count: previous_state.count}), dgettext("ui", "Import completed successfully!", count: previous_state.count),
nil, nil,
"info" "info"
) )
@@ -148,7 +148,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: nil ref: nil
}) })
) )
|> append_output.(translated("activity.import"), message, nil, "error") |> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
{:error, reason} -> {:error, reason} ->
@@ -165,7 +165,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: nil ref: nil
}) })
) )
|> append_output.(translated("activity.import"), message, nil, "error") |> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
end end
@@ -208,7 +208,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: nil ref: nil
}) })
) )
|> append_output.(translated("activity.import"), message, nil, "error") |> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
end end
@@ -265,16 +265,14 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
seconds = div(ms, 1000) seconds = div(ms, 1000)
if seconds < 60 do if seconds < 60 do
translated("importAnalysis.eta", %{ dgettext("ui", "ETA: %{value}", value: dgettext("ui", "%{count}s", count: seconds))
value: translated("importAnalysis.etaSeconds", %{count: seconds})
})
else else
m = div(seconds, 60) m = div(seconds, 60)
s = rem(seconds, 60) s = rem(seconds, 60)
translated("importAnalysis.eta", %{ dgettext("ui", "ETA: %{value}",
value: translated("importAnalysis.etaMinutes", %{minutes: m, seconds: s}) value: dgettext("ui", "%{minutes}m %{seconds}s", minutes: m, seconds: s)
}) )
end end
end end
@@ -282,18 +280,15 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
def translate_execution_phase(phase) when is_binary(phase) do def translate_execution_phase(phase) when is_binary(phase) do
case phase do case phase do
"tags" -> translated("importAnalysis.phase.tags") "tags" -> dgettext("ui", "Importing tags & categories...")
"posts" -> translated("importAnalysis.phase.posts") "posts" -> dgettext("ui", "Importing posts...")
"media" -> translated("importAnalysis.phase.media") "media" -> dgettext("ui", "Importing media...")
"pages" -> translated("importAnalysis.phase.pages") "pages" -> dgettext("ui", "Importing pages...")
"complete" -> translated("importAnalysis.phase.complete") "complete" -> dgettext("ui", "Import complete")
other -> other other -> other
end end
end end
@spec translate_execution_phase(term()) :: term() @spec translate_execution_phase(term()) :: term()
def translate_execution_phase(other), do: other def translate_execution_phase(other), do: other
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -2,7 +2,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
@moduledoc false @moduledoc false
alias BDS.{AI, ImportDefinitions, Metadata, Tags} alias BDS.{AI, ImportDefinitions, Metadata, Tags}
alias BDS.Desktop.ShellData use Gettext, backend: BDS.Gettext
@spec start_taxonomy_edit(term(), term(), term()) :: term() @spec start_taxonomy_edit(term(), term(), term()) :: term()
def start_taxonomy_edit( def start_taxonomy_edit(
@@ -85,11 +85,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
socket.assigns.offline_mode -> socket.assigns.offline_mode ->
socket socket
|> append_output.( |> append_output.(
translated("activity.import"), dgettext("ui", "Import"),
ShellData.translate( BDS.Gettext.lgettext(
"Automatic AI actions stay gated by airplane mode.", socket.assigns.page_language,
%{}, "ui",
socket.assigns.page_language "Automatic AI actions stay gated by airplane mode."
), ),
nil, nil,
"info" "info"
@@ -124,8 +124,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
socket socket
|> append_output.( |> append_output.(
translated("activity.import"), dgettext("ui", "Import"),
translated("importAnalysis.mappedCount", %{count: mapped_count}), dgettext("ui", "%{count} mapped", count: mapped_count),
Map.get(socket.assigns.import_editor_selected_models, definition_id), Map.get(socket.assigns.import_editor_selected_models, definition_id),
"info" "info"
) )
@@ -134,7 +134,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
{:error, reason} -> {:error, reason} ->
socket socket
|> append_output.( |> append_output.(
translated("activity.import"), dgettext("ui", "Import"),
inspect(reason), inspect(reason),
Map.get(socket.assigns.import_editor_selected_models, definition_id), Map.get(socket.assigns.import_editor_selected_models, definition_id),
"error" "error"
@@ -222,7 +222,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
{:ok, metadata} = Metadata.get_project_metadata(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id)
%{ %{
categories: Enum.uniq(Map.get(metadata, :categories, []) || []), categories: Enum.uniq(metadata.categories || []),
tags: project_id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.uniq() tags: project_id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.uniq()
} }
end end
@@ -274,19 +274,16 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
def taxonomy_mapping_tooltip(item) do def taxonomy_mapping_tooltip(item) do
action = action =
if present?(item.mapped_to), if present?(item.mapped_to),
do: translated("importAnalysis.mappingActionEdit"), do: dgettext("ui", "edit"),
else: translated("importAnalysis.mappingActionAdd") else: dgettext("ui", "add")
translated("importAnalysis.mappingTooltip", %{action: action}) dgettext("ui", "Click to %{action} mapping", action: action)
end end
@spec maybe_put_option(term(), term(), term()) :: term() @spec maybe_put_option(term(), term(), term()) :: term()
def maybe_put_option(opts, _key, nil), do: opts def maybe_put_option(opts, _key, nil), do: opts
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value) def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp present?(value), do: value not in [nil, ""] defp present?(value), do: value not in [nil, ""]
defp blank_to_nil(""), do: nil defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value defp blank_to_nil(value), do: value

View File

@@ -1,5 +1,5 @@
<div <div
class="app" class="app flex h-full w-full flex-col"
id="bds-shell-app" id="bds-shell-app"
phx-hook="AppShell" phx-hook="AppShell"
data-shortcuts={encoded_shortcuts(@client_shortcuts)} data-shortcuts={encoded_shortcuts(@client_shortcuts)}
@@ -77,8 +77,8 @@
data-testid="toggle-sidebar" data-testid="toggle-sidebar"
type="button" type="button"
phx-click="toggle_sidebar" phx-click="toggle_sidebar"
aria-label={translated("Toggle sidebar")} aria-label={dgettext("ui", "Toggle sidebar")}
title={translated("Toggle sidebar")} title={dgettext("ui", "Toggle sidebar")}
> >
<span class={["window-titlebar-sidebar-icon", if(@workbench.sidebar_visible, do: "is-active", else: "is-inactive")]}> <span class={["window-titlebar-sidebar-icon", if(@workbench.sidebar_visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-sidebar-pane"></span> <span class="window-titlebar-sidebar-pane"></span>
@@ -89,8 +89,8 @@
data-testid="toggle-panel" data-testid="toggle-panel"
type="button" type="button"
phx-click="toggle_panel" phx-click="toggle_panel"
aria-label={translated("Toggle panel")} aria-label={dgettext("ui", "Toggle panel")}
title={translated("Toggle panel")} title={dgettext("ui", "Toggle panel")}
> >
<span class={["window-titlebar-panel-icon", if(@workbench.panel.visible, do: "is-active", else: "is-inactive")]}> <span class={["window-titlebar-panel-icon", if(@workbench.panel.visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-panel-pane"></span> <span class="window-titlebar-panel-pane"></span>
@@ -101,8 +101,8 @@
data-testid="toggle-assistant" data-testid="toggle-assistant"
type="button" type="button"
phx-click="toggle_assistant_sidebar" phx-click="toggle_assistant_sidebar"
aria-label={translated("Toggle assistant")} aria-label={dgettext("ui", "Toggle assistant")}
title={translated("Toggle assistant")} title={dgettext("ui", "Toggle assistant")}
> >
<span class={["window-titlebar-assistant-icon", if(@workbench.assistant_sidebar_visible, do: "is-active", else: "is-inactive")]}> <span class={["window-titlebar-assistant-icon", if(@workbench.assistant_sidebar_visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-assistant-pane"></span> <span class="window-titlebar-assistant-pane"></span>
@@ -112,12 +112,12 @@
</div> </div>
<% end %> <% end %>
<div class="app-main"> <div class="app-main flex min-h-0 flex-1 overflow-hidden">
<aside class="activity-bar" data-region="activity-bar"> <aside class="activity-bar flex h-full shrink-0 flex-col items-center justify-between" data-region="activity-bar">
<div class="activity-bar-top"> <div class="activity-bar-top flex flex-col items-center gap-1">
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :top)) do %> <%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :top)) do %>
<button <button
class={["activity-bar-item", if(button.active, do: "active")]} class={["activity-bar-item inline-flex h-12 w-12 items-center justify-center", if(button.active, do: "active")]}
data-testid="activity-button" data-testid="activity-button"
data-view={button.id} data-view={button.id}
data-active={to_string(button.active)} data-active={to_string(button.active)}
@@ -134,10 +134,10 @@
</button> </button>
<% end %> <% end %>
</div> </div>
<div class="activity-bar-bottom"> <div class="activity-bar-bottom flex flex-col items-center gap-1">
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :bottom)) do %> <%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :bottom)) do %>
<button <button
class={["activity-bar-item", if(button.active, do: "active")]} class={["activity-bar-item inline-flex h-12 w-12 items-center justify-center", if(button.active, do: "active")]}
data-testid="activity-button" data-testid="activity-button"
data-view={button.id} data-view={button.id}
data-active={to_string(button.active)} data-active={to_string(button.active)}
@@ -157,29 +157,29 @@
</aside> </aside>
<section <section
class={["sidebar-shell", if(not @workbench.sidebar_visible, do: "is-hidden")]} class={["sidebar-shell flex min-w-0 shrink-0 overflow-hidden", if(not @workbench.sidebar_visible, do: "is-hidden")]}
data-testid="sidebar-shell" data-testid="sidebar-shell"
style={"width: #{if(@workbench.sidebar_visible, do: @workbench.sidebar_width, else: 0)}px;"} style={"width: #{if(@workbench.sidebar_visible, do: @workbench.sidebar_width, else: 0)}px;"}
> >
<div class="sidebar" data-region="sidebar"> <div class="sidebar flex min-w-0 flex-1 overflow-hidden" data-region="sidebar">
<div id="sidebar-content" class="sidebar-content sidebar-body" phx-hook="SidebarInteractions"> <div id="sidebar-content" class="sidebar-content sidebar-body flex min-h-0 flex-1 flex-col overflow-y-auto" phx-hook="SidebarInteractions">
<div class="sidebar-section"> <div class="sidebar-section">
<% create_action = sidebar_create_action(@workbench.active_view) %> <% create_action = sidebar_create_action(@workbench.active_view) %>
<div class="sidebar-section-header"> <div class="sidebar-section-header flex items-center justify-between gap-2">
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span> <span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
<%= if create_action || ShellSidebarComponents.filters_enabled?(@sidebar_data) do %> <%= if create_action || ShellSidebarComponents.filters_enabled?(@sidebar_data) do %>
<div class="sidebar-actions"> <div class="sidebar-actions flex items-center gap-1">
<%= if ShellSidebarComponents.filters_enabled?(@sidebar_data) do %> <%= if ShellSidebarComponents.filters_enabled?(@sidebar_data) do %>
<button <button
class={[ class={[
"sidebar-action", "sidebar-action inline-flex h-8 w-8 items-center justify-center",
if(ShellSidebarComponents.filters_visible?(@sidebar_data), do: "active") if(ShellSidebarComponents.filters_visible?(@sidebar_data), do: "active")
]} ]}
data-testid="sidebar-filter-toggle" data-testid="sidebar-filter-toggle"
type="button" type="button"
phx-click="toggle_sidebar_filters" phx-click="toggle_sidebar_filters"
aria-label={translated(Map.get(@sidebar_data.filters, :toggle_filters_label))} aria-label={Map.get(@sidebar_data.filters, :toggle_filters_label)}
title={translated(Map.get(@sidebar_data.filters, :toggle_filters_label))} title={Map.get(@sidebar_data.filters, :toggle_filters_label)}
> >
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 12v-1h4v1H6zM4 8v-1h8v1H4zm-2-4v-1h12v1H2z"/> <path d="M6 12v-1h4v1H6zM4 8v-1h8v1H4zm-2-4v-1h12v1H2z"/>
@@ -188,14 +188,14 @@
<% end %> <% end %>
<%= if create_action do %> <%= if create_action do %>
<button <button
class="sidebar-action" class="sidebar-action inline-flex h-8 w-8 items-center justify-center"
data-testid="sidebar-create-action" data-testid="sidebar-create-action"
data-sidebar-action={create_action.kind} data-sidebar-action={create_action.kind}
type="button" type="button"
phx-click="create_sidebar_item" phx-click="create_sidebar_item"
phx-value-kind={create_action.kind} phx-value-kind={create_action.kind}
aria-label={translated(create_action.label)} aria-label={create_action.label}
title={translated(create_action.label)} title={create_action.label}
> >
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/> <path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
@@ -212,16 +212,16 @@
<div class="resizable-panel-divider sidebar-divider" data-resize="sidebar" data-role="resize-handle"></div> <div class="resizable-panel-divider sidebar-divider" data-resize="sidebar" data-role="resize-handle"></div>
</section> </section>
<main class="app-content" data-region="content"> <main class="app-content flex min-w-0 flex-1 flex-col overflow-hidden" data-region="content">
<div class="tab-bar" data-region="tab-bar"> <div class="tab-bar flex h-[35px] shrink-0 items-center overflow-hidden" data-region="tab-bar">
<%= if Enum.empty?(@workbench.tabs) do %> <%= if Enum.empty?(@workbench.tabs) do %>
<div class="tab-bar-empty"><%= translated("Dashboard") %></div> <div class="tab-bar-empty flex h-full items-center px-3 text-sm"><%= dgettext("ui", "Dashboard") %></div>
<% else %> <% else %>
<div class="tab-bar-tabs"> <div class="tab-bar-tabs flex min-w-0 flex-1 items-stretch overflow-x-auto">
<%= for tab <- @workbench.tabs do %> <%= for tab <- @workbench.tabs do %>
<div <div
class={[ class={[
"tab", "tab flex min-w-0 max-w-[240px] shrink-0 items-stretch",
if(@workbench.active_tab == {tab.type, tab.id}, do: "active"), if(@workbench.active_tab == {tab.type, tab.id}, do: "active"),
if(tab.is_transient, do: "transient"), if(tab.is_transient, do: "transient"),
if(Workbench.dirty?(@workbench, tab.type, tab.id), do: "dirty") if(Workbench.dirty?(@workbench, tab.type, tab.id), do: "dirty")
@@ -231,21 +231,21 @@
tabindex="0" tabindex="0"
> >
<button <button
class="tab-select" class="tab-select flex min-w-0 flex-1 items-center gap-2 overflow-hidden px-3 text-sm"
type="button" type="button"
phx-click="select_tab" phx-click="select_tab"
phx-value-type={tab.type} phx-value-type={tab.type}
phx-value-id={tab.id} phx-value-id={tab.id}
> >
<span class="tab-icon"><%= raw(ShellData.activity_icon(BDS.Desktop.ShellLive.TabHelpers.tab_icon_id(tab))) %></span> <span class="tab-icon shrink-0"><%= raw(ShellData.activity_icon(BDS.Desktop.ShellLive.TabHelpers.tab_icon_id(tab))) %></span>
<span class="tab-title"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(tab, @tab_meta) %></span> <span class="tab-title truncate"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(tab, @tab_meta) %></span>
</button> </button>
<div class="tab-actions"> <div class="tab-actions flex items-center gap-1 pr-2">
<%= if Workbench.dirty?(@workbench, tab.type, tab.id) do %> <%= if Workbench.dirty?(@workbench, tab.type, tab.id) do %>
<span class="tab-dirty-indicator">●</span> <span class="tab-dirty-indicator">●</span>
<% end %> <% end %>
<button <button
class="tab-close" class="tab-close inline-flex h-6 w-6 items-center justify-center"
data-testid="tab-close" data-testid="tab-close"
data-tab-type={tab.type} data-tab-type={tab.type}
data-tab-id={tab.id} data-tab-id={tab.id}
@@ -253,8 +253,8 @@
phx-click="close_tab" phx-click="close_tab"
phx-value-type={tab.type} phx-value-type={tab.type}
phx-value-id={tab.id} phx-value-id={tab.id}
aria-label={translated("Close tab")} aria-label={dgettext("ui", "Close tab")}
title={translated("Close tab")} title={dgettext("ui", "Close tab")}
> >
× ×
</button> </button>
@@ -265,45 +265,45 @@
<% end %> <% end %>
</div> </div>
<section class="editor-shell" data-region="editor"> <section class="editor-shell flex min-h-0 flex-1 flex-col overflow-hidden" data-region="editor">
<%= if is_nil(@current_tab) do %> <%= if is_nil(@current_tab) do %>
<div class="editor-empty"> <div class="editor-empty">
<div class="dashboard-content"> <div class="dashboard-content">
<h1 data-testid="editor-title"><%= translated("dashboard.title") %></h1> <h1 data-testid="editor-title"><%= dgettext("ui", "Dashboard") %></h1>
<p class="text-muted"><%= translated("dashboard.subtitle") %></p> <p class="text-muted"><%= dgettext("ui", "Overview of your blog database") %></p>
<div class="dashboard-stats"> <div class="dashboard-stats">
<div class="stat-card"> <div class="stat-card">
<div class="stat-number"><%= @dashboard.post_stats.total_posts || 0 %></div> <div class="stat-number"><%= @dashboard.post_stats.total_posts || 0 %></div>
<div class="stat-label"><%= translated("dashboard.stats.totalPosts") %></div> <div class="stat-label"><%= dgettext("ui", "Total Posts") %></div>
<div class="stat-breakdown"> <div class="stat-breakdown">
<span class="stat-tag stat-published"><%= translated("dashboard.stats.published", %{count: @dashboard.post_stats.published_count || 0}) %></span> <span class="stat-tag stat-published"><%= dgettext("ui", "dashboard.stats.published", count: @dashboard.post_stats.published_count || 0) %></span>
<span class="stat-tag stat-draft"><%= translated("dashboard.stats.drafts", %{count: @dashboard.post_stats.draft_count || 0}) %></span> <span class="stat-tag stat-draft"><%= dgettext("ui", "dashboard.stats.drafts", count: @dashboard.post_stats.draft_count || 0) %></span>
<%= if (@dashboard.post_stats.archived_count || 0) > 0 do %> <%= if (@dashboard.post_stats.archived_count || 0) > 0 do %>
<span class="stat-tag stat-archived"><%= translated("dashboard.stats.archived", %{count: @dashboard.post_stats.archived_count || 0}) %></span> <span class="stat-tag stat-archived"><%= dgettext("ui", "dashboard.stats.archived", count: @dashboard.post_stats.archived_count || 0) %></span>
<% end %> <% end %>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-number"><%= @dashboard.media_stats.media_count || 0 %></div> <div class="stat-number"><%= @dashboard.media_stats.media_count || 0 %></div>
<div class="stat-label"><%= translated("dashboard.stats.mediaFiles") %></div> <div class="stat-label"><%= dgettext("ui", "Media Files") %></div>
<div class="stat-breakdown"> <div class="stat-breakdown">
<span class="stat-tag"><%= translated("dashboard.stats.images", %{count: @dashboard.media_stats.image_count || 0}) %></span> <span class="stat-tag"><%= dgettext("ui", "dashboard.stats.images", count: @dashboard.media_stats.image_count || 0) %></span>
<span class="stat-tag"><%= ShellData.format_bytes(@dashboard.media_stats.total_bytes || 0) %></span> <span class="stat-tag"><%= ShellData.format_bytes(@dashboard.media_stats.total_bytes || 0) %></span>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-number"><%= length(@dashboard_tag_cloud_items) %></div> <div class="stat-number"><%= length(@dashboard_tag_cloud_items) %></div>
<div class="stat-label"><%= translated("dashboard.stats.tags") %></div> <div class="stat-label"><%= dgettext("ui", "Tags") %></div>
<div class="stat-breakdown"> <div class="stat-breakdown">
<span class="stat-tag"><%= translated("dashboard.stats.categories", %{count: length(@dashboard_category_counts)}) %></span> <span class="stat-tag"><%= dgettext("ui", "dashboard.stats.categories", count: length(@dashboard_category_counts)) %></span>
</div> </div>
</div> </div>
</div> </div>
<%= if Enum.any?(@dashboard_timeline_entries) do %> <%= if Enum.any?(@dashboard_timeline_entries) do %>
<div class="dashboard-section"> <div class="dashboard-section">
<h4><%= translated("dashboard.section.postsOverTime") %></h4> <h4><%= dgettext("ui", "Posts Over Time") %></h4>
<div class="timeline-chart"> <div class="timeline-chart">
<%= for entry <- @dashboard_timeline_entries do %> <%= for entry <- @dashboard_timeline_entries do %>
<div class="timeline-bar-container"> <div class="timeline-bar-container">
@@ -322,7 +322,7 @@
<%= if Enum.any?(@dashboard_tag_cloud_items) do %> <%= if Enum.any?(@dashboard_tag_cloud_items) do %>
<div class="dashboard-section"> <div class="dashboard-section">
<h4><%= translated("dashboard.section.tags") %></h4> <h4><%= dgettext("ui", "Tags") %></h4>
<div class="tag-cloud"> <div class="tag-cloud">
<%= for item <- @dashboard_tag_cloud_items do %> <%= for item <- @dashboard_tag_cloud_items do %>
<span class={["dashboard-tag", if(item.color, do: "has-color")]} style={ShellData.render_dashboard_tag_style(item)} title={ShellData.dashboard_post_count_label(item.count)}><%= item.tag %></span> <span class={["dashboard-tag", if(item.color, do: "has-color")]} style={ShellData.render_dashboard_tag_style(item)} title={ShellData.dashboard_post_count_label(item.count)}><%= item.tag %></span>
@@ -333,7 +333,7 @@
<%= if Enum.any?(@dashboard_category_counts) do %> <%= if Enum.any?(@dashboard_category_counts) do %>
<div class="dashboard-section"> <div class="dashboard-section">
<h4><%= translated("dashboard.section.categories") %></h4> <h4><%= dgettext("ui", "Categories") %></h4>
<div class="tag-cloud"> <div class="tag-cloud">
<%= for category <- @dashboard_category_counts do %> <%= for category <- @dashboard_category_counts do %>
<span class="dashboard-tag dashboard-category" title={ShellData.dashboard_post_count_label(category.count || 0)}> <span class="dashboard-tag dashboard-category" title={ShellData.dashboard_post_count_label(category.count || 0)}>
@@ -347,7 +347,7 @@
<%= if Enum.any?(@dashboard_recent_posts) do %> <%= if Enum.any?(@dashboard_recent_posts) do %>
<div class="dashboard-section"> <div class="dashboard-section">
<h4><%= translated("dashboard.section.recentlyUpdated") %></h4> <h4><%= dgettext("ui", "Recently Updated") %></h4>
<div class="recent-posts-list"> <div class="recent-posts-list">
<%= for post <- @dashboard_recent_posts do %> <%= for post <- @dashboard_recent_posts do %>
<button <button
@@ -373,8 +373,8 @@
<div class="dashboard-inspector-meta" hidden> <div class="dashboard-inspector-meta" hidden>
<%= for item <- @editor_meta do %> <%= for item <- @editor_meta do %>
<section class="editor-meta-row"> <section class="editor-meta-row">
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong> <strong data-testid="editor-meta-label"><%= item.label %></strong>
<span><%= translated(item.value) %></span> <span><%= item.value %></span>
</section> </section>
<% end %> <% end %>
</div> </div>
@@ -382,38 +382,47 @@
</div> </div>
<% else %> <% else %>
<%= cond do %> <%= cond do %>
<% @current_tab.type == :post and @post_editor -> %> <% @current_tab.type == :post -> %>
<PostEditor.post_editor post_editor={@post_editor} toolbar_buttons={BDS.Desktop.ShellLive.PanelRenderer.editor_toolbar_buttons(@current_tab)} /> <.live_component module={PostEditor} id={"post-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} />
<% @current_tab.type == :media and @media_editor -> %> <% @current_tab.type == :media -> %>
<MediaEditor.media_editor media_editor={@media_editor} /> <.live_component module={MediaEditor} id={"media-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} />
<% @current_tab.type == :settings and @settings_editor -> %> <% @current_tab.type in [:settings, :style] and @current_project -> %>
<SettingsEditor.settings_editor settings_editor={@settings_editor} /> <.live_component module={SettingsEditor} id="settings-editor"
project_id={@current_project.id}
current_project={@current_project}
projects={@projects}
workbench={@workbench}
current_tab={@current_tab}
tab_meta={@tab_meta}
offline_mode={@offline_mode}
/>
<% @current_tab.type == :style and @style_editor -> %> <% @current_tab.type == :menu_editor and @current_project -> %>
<SettingsEditor.style_editor style_editor={@style_editor} /> <.live_component module={MenuEditor} id="menu-editor"
project_id={@current_project.id}
current_tab={@current_tab}
tab_meta={@tab_meta}
/>
<% @current_tab.type == :menu_editor and @menu_editor -> %> <% @current_tab.type == :tags and @current_project -> %>
<MenuEditor.menu_editor menu_editor={@menu_editor} /> <.live_component module={TagsEditor} id="tags-editor" project_id={@current_project.id} current_tab={@current_tab} tab_meta={@tab_meta} />
<% @current_tab.type == :tags and @tags_editor -> %> <% @current_tab.type == :scripts -> %>
<TagsEditor.tags_editor tags_editor={@tags_editor} /> <.live_component module={ScriptEditor} id={"script-editor-#{@current_tab.id}"} current_tab={@current_tab} />
<% @current_tab.type == :scripts and @script_editor -> %> <% @current_tab.type == :templates -> %>
<CodeEntityEditor.script_editor script_editor={@script_editor} /> <.live_component module={TemplateEditor} id={"template-editor-#{@current_tab.id}"} current_tab={@current_tab} />
<% @current_tab.type == :templates and @template_editor -> %> <% @current_tab.type == :chat -> %>
<CodeEntityEditor.template_editor template_editor={@template_editor} /> <.live_component module={ChatEditor} id={"chat-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} project_id={@projects.active_project_id} />
<% @current_tab.type == :chat and @chat_editor -> %> <% @current_tab.type == :import -> %>
<ChatEditor.chat_editor chat_editor={@chat_editor} /> <.live_component module={ImportEditor} id={"import-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} project_id={@projects.active_project_id} />
<% @current_tab.type == :import and @import_editor -> %> <% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff, :documentation, :api_documentation] -> %>
<ImportEditor.import_editor import_editor={@import_editor} /> <.live_component module={MiscEditor} id={"misc-editor-#{@current_tab.type}-#{@current_tab.id}"} current_tab={@current_tab} tab_meta={@tab_meta} project_id={@projects.active_project_id} />
<% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff] and @misc_editor -> %>
<MiscEditor.misc_editor misc_editor={@misc_editor} />
<% true -> %> <% true -> %>
<div class="editor-frame"> <div class="editor-frame">
@@ -433,8 +442,8 @@
<aside class="editor-meta"> <aside class="editor-meta">
<%= for item <- @editor_meta do %> <%= for item <- @editor_meta do %>
<section class="editor-meta-row"> <section class="editor-meta-row">
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong> <strong data-testid="editor-meta-label"><%= item.label %></strong>
<span><%= translated(item.value) %></span> <span><%= item.value %></span>
</section> </section>
<% end %> <% end %>
</aside> </aside>
@@ -443,12 +452,12 @@
<% end %> <% end %>
</section> </section>
<section class={["panel-shell", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel"> <section class={["panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
<div class="panel-header"> <div class="panel-header flex items-center justify-between gap-2">
<div class="panel-tabs"> <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", 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 h-9 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}
@@ -458,93 +467,93 @@
<% end %> <% end %>
</div> </div>
<button <button
class="panel-close" class="panel-close ui-button ui-button-secondary inline-flex h-8 w-8 items-center justify-center"
data-testid="panel-close" data-testid="panel-close"
type="button" type="button"
phx-click="toggle_panel" phx-click="toggle_panel"
aria-label={translated("Close panel")} aria-label={dgettext("ui", "Close panel")}
title={translated("Close panel")} title={dgettext("ui", "Close panel")}
> >
× ×
</button> </button>
</div> </div>
<div class="panel-content"> <div class="panel-content min-h-0 flex-1 overflow-auto">
<%= BDS.Desktop.ShellLive.PanelRenderer.render_panel_body(assigns) %> <%= BDS.Desktop.ShellLive.PanelRenderer.render_panel_body(assigns) %>
</div> </div>
</section> </section>
</main> </main>
<section <section
class={["assistant-sidebar-shell", if(not @workbench.assistant_sidebar_visible, do: "is-hidden")]} class={["assistant-sidebar-shell flex min-w-0 shrink-0 overflow-hidden", if(not @workbench.assistant_sidebar_visible, do: "is-hidden")]}
data-testid="assistant-shell" data-testid="assistant-shell"
style={"width: #{if(@workbench.assistant_sidebar_visible, do: @workbench.assistant_sidebar_width, else: 0)}px;"} style={"width: #{if(@workbench.assistant_sidebar_visible, do: @workbench.assistant_sidebar_width, else: 0)}px;"}
> >
<div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div> <div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div>
<aside class="assistant-sidebar" data-region="assistant-sidebar"> <aside class="assistant-sidebar flex min-w-0 flex-1 overflow-hidden" data-region="assistant-sidebar">
<div class="assistant-content"> <div class="assistant-content flex min-h-0 flex-1 flex-col">
<header class="assistant-sidebar-header"> <header class="assistant-sidebar-header flex items-start justify-between gap-3">
<div class="assistant-sidebar-heading"> <div class="assistant-sidebar-heading">
<strong><%= translated("AI Assistant") %></strong> <strong><%= dgettext("ui", "AI Assistant") %></strong>
<span class="assistant-sidebar-description"><%= translated("AI conversations") %></span> <span class="assistant-sidebar-description"><%= dgettext("ui", "AI conversations") %></span>
</div> </div>
<span class={[ <span class={[
"assistant-sidebar-status", "assistant-sidebar-status",
if(@offline_mode, do: "is-offline", else: "is-online") if(@offline_mode, do: "is-offline", else: "is-online")
]}> ]}>
<%= if @offline_mode, do: translated("Offline"), else: translated("Chat") %> <%= if @offline_mode, do: dgettext("ui", "Offline"), else: dgettext("ui", "Chat") %>
</span> </span>
</header> </header>
<section class="assistant-sidebar-context" data-testid="assistant-context"> <section class="assistant-sidebar-context flex shrink-0 flex-col gap-2" data-testid="assistant-context">
<div class="assistant-sidebar-context-row"> <div class="assistant-sidebar-context-row flex items-center justify-between gap-2">
<span class="assistant-sidebar-context-label"><%= translated("Project") %></span> <span class="assistant-sidebar-context-label"><%= dgettext("ui", "Project") %></span>
<span class="assistant-sidebar-context-value"><%= BDS.Desktop.ShellLive.ChatSurface.assistant_project_name(@current_project) %></span> <span class="assistant-sidebar-context-value"><%= BDS.Desktop.ShellLive.ChatSurface.assistant_project_name(@current_project) %></span>
</div> </div>
<div class="assistant-sidebar-context-row"> <div class="assistant-sidebar-context-row flex items-center justify-between gap-2">
<span class="assistant-sidebar-context-label"><%= translated("Editor") %></span> <span class="assistant-sidebar-context-label"><%= dgettext("ui", "Editor") %></span>
<span class="assistant-sidebar-context-value"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(@current_tab, @tab_meta) %></span> <span class="assistant-sidebar-context-value"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(@current_tab, @tab_meta) %></span>
</div> </div>
<p class="assistant-sidebar-context-text"><%= BDS.Desktop.ShellLive.TabHelpers.tab_subtitle(@current_tab, @tab_meta) %></p> <p class="assistant-sidebar-context-text"><%= BDS.Desktop.ShellLive.TabHelpers.tab_subtitle(@current_tab, @tab_meta) %></p>
</section> </section>
<form <form
class="assistant-sidebar-prompt-form" class="assistant-sidebar-prompt-form flex shrink-0 flex-col gap-3"
data-testid="assistant-prompt-form" data-testid="assistant-prompt-form"
phx-change="update_assistant_prompt" phx-change="update_assistant_prompt"
phx-submit="submit_assistant_prompt" phx-submit="submit_assistant_prompt"
> >
<textarea <textarea
class="assistant-sidebar-prompt" class="assistant-sidebar-prompt min-h-[8rem] w-full resize-y"
data-testid="assistant-prompt-input" data-testid="assistant-prompt-input"
name="assistant[prompt]" name="assistant[prompt]"
rows="6" rows="6"
placeholder={translated("Ask the assistant about the active project or editor.")} placeholder={dgettext("ui", "Ask the assistant about the active project or editor.")}
><%= @assistant_prompt %></textarea> ><%= @assistant_prompt %></textarea>
<button <button
class="assistant-sidebar-start-button" class="assistant-sidebar-start-button ui-button ui-button-primary"
data-testid="assistant-start-button" data-testid="assistant-start-button"
type="submit" type="submit"
disabled={String.trim(@assistant_prompt || "") == ""} disabled={String.trim(@assistant_prompt || "") == ""}
> >
<%= translated("Start chat") %> <%= dgettext("ui", "Start chat") %>
</button> </button>
</form> </form>
<%= if Enum.empty?(@assistant_messages) do %> <%= if Enum.empty?(@assistant_messages) do %>
<div class="assistant-sidebar-welcome"> <div class="assistant-sidebar-welcome min-h-0 flex-1 overflow-auto">
<%= for card <- @assistant_cards do %> <%= for card <- @assistant_cards do %>
<section class="assistant-card"> <section class="assistant-card flex flex-col gap-1">
<strong><%= translated(card.label) %></strong> <strong><%= card.label %></strong>
<span><%= translated(card.text) %></span> <span><%= card.text %></span>
</section> </section>
<% end %> <% end %>
</div> </div>
<% else %> <% else %>
<div class="assistant-sidebar-transcript"> <div class="assistant-sidebar-transcript min-h-0 flex-1 overflow-auto">
<%= for message <- @assistant_messages do %> <%= for message <- @assistant_messages do %>
<article <article
class={["assistant-sidebar-message", message.role]} class={["assistant-sidebar-message flex flex-col gap-1", message.role]}
data-testid={BDS.Desktop.ShellLive.ChatSurface.assistant_message_testid(message.role)} data-testid={BDS.Desktop.ShellLive.ChatSurface.assistant_message_testid(message.role)}
> >
<span class="assistant-sidebar-message-role"><%= BDS.Desktop.ShellLive.ChatSurface.assistant_message_label(message.role) %></span> <span class="assistant-sidebar-message-role"><%= BDS.Desktop.ShellLive.ChatSurface.assistant_message_label(message.role) %></span>
@@ -558,41 +567,41 @@
</section> </section>
</div> </div>
<footer class="status-bar" data-region="status-bar" data-testid="status-bar"> <footer class="status-bar flex h-[22px] shrink-0 items-center justify-between gap-2" data-region="status-bar" data-testid="status-bar">
<div class="status-bar-left"> <div class="status-bar-left flex min-w-0 items-center gap-2 overflow-hidden">
<%= if @is_mac_ui do %> <%= if @is_mac_ui do %>
<div class="status-shell-controls" data-testid="status-shell-controls"> <div class="status-shell-controls flex items-center gap-1" data-testid="status-shell-controls">
<button <button
class="status-shell-toggle-button" class="status-shell-toggle-button inline-flex h-6 w-6 items-center justify-center"
data-testid="toggle-sidebar" data-testid="toggle-sidebar"
type="button" type="button"
phx-click="toggle_sidebar" phx-click="toggle_sidebar"
aria-label={translated("Toggle sidebar")} aria-label={dgettext("ui", "Toggle sidebar")}
title={translated("Toggle sidebar")} title={dgettext("ui", "Toggle sidebar")}
> >
<span class={["window-titlebar-sidebar-icon", if(@workbench.sidebar_visible, do: "is-active", else: "is-inactive")]}> <span class={["window-titlebar-sidebar-icon", if(@workbench.sidebar_visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-sidebar-pane"></span> <span class="window-titlebar-sidebar-pane"></span>
</span> </span>
</button> </button>
<button <button
class="status-shell-toggle-button" class="status-shell-toggle-button inline-flex h-6 w-6 items-center justify-center"
data-testid="toggle-panel" data-testid="toggle-panel"
type="button" type="button"
phx-click="toggle_panel" phx-click="toggle_panel"
aria-label={translated("Toggle panel")} aria-label={dgettext("ui", "Toggle panel")}
title={translated("Toggle panel")} title={dgettext("ui", "Toggle panel")}
> >
<span class={["window-titlebar-panel-icon", if(@workbench.panel.visible, do: "is-active", else: "is-inactive")]}> <span class={["window-titlebar-panel-icon", if(@workbench.panel.visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-panel-pane"></span> <span class="window-titlebar-panel-pane"></span>
</span> </span>
</button> </button>
<button <button
class="status-shell-toggle-button" class="status-shell-toggle-button inline-flex h-6 w-6 items-center justify-center"
data-testid="toggle-assistant" data-testid="toggle-assistant"
type="button" type="button"
phx-click="toggle_assistant_sidebar" phx-click="toggle_assistant_sidebar"
aria-label={translated("Toggle assistant")} aria-label={dgettext("ui", "Toggle assistant")}
title={translated("Toggle assistant")} title={dgettext("ui", "Toggle assistant")}
> >
<span class={["window-titlebar-assistant-icon", if(@workbench.assistant_sidebar_visible, do: "is-active", else: "is-inactive")]}> <span class={["window-titlebar-assistant-icon", if(@workbench.assistant_sidebar_visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-assistant-pane"></span> <span class="window-titlebar-assistant-pane"></span>
@@ -600,12 +609,12 @@
</button> </button>
</div> </div>
<% end %> <% end %>
<div class="project-selector"> <div class="project-selector relative shrink-0">
<button <button
class="project-selector-trigger" class="project-selector-trigger inline-flex items-center gap-2"
data-testid="project-selector-trigger" data-testid="project-selector-trigger"
type="button" type="button"
title={translated("Switch project")} title={dgettext("ui", "Switch project")}
phx-click="toggle_project_menu" phx-click="toggle_project_menu"
> >
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon"> <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon">
@@ -620,7 +629,7 @@
<%= if @project_menu_open do %> <%= if @project_menu_open do %>
<div class="project-dropdown" data-testid="project-dropdown" phx-click-away="close_project_menu"> <div class="project-dropdown" data-testid="project-dropdown" phx-click-away="close_project_menu">
<div class="project-dropdown-header"> <div class="project-dropdown-header">
<span><%= translated("Projects") %></span> <span><%= dgettext("ui", "Projects") %></span>
</div> </div>
<div class="project-list"> <div class="project-list">
<%= for project <- @projects.projects do %> <%= for project <- @projects.projects do %>
@@ -641,32 +650,32 @@
</div> </div>
<div class="project-dropdown-footer"> <div class="project-dropdown-footer">
<button class="existing-project-btn" type="button" phx-click="import_project"> <button class="existing-project-btn" type="button" phx-click="import_project">
<span><%= translated("Open Existing Blog") %></span> <span><%= dgettext("ui", "Open Existing Blog") %></span>
</button> </button>
<button class="create-project-btn" type="button" phx-click="create_project"> <button class="create-project-btn" type="button" phx-click="create_project">
<span><%= translated("New Project") %></span> <span><%= dgettext("ui", "New Project") %></span>
</button> </button>
</div> </div>
</div> </div>
<% end %> <% end %>
</div> </div>
<button class="status-bar-item status-bar-task-button" data-testid="status-task-button" type="button" phx-click="open_tasks_panel"> <button class="status-bar-item status-bar-task-button inline-flex items-center gap-2" data-testid="status-task-button" type="button" phx-click="open_tasks_panel">
<%= if @status.left.running_task_message do %> <%= if @status.left.running_task_message do %>
<span class="task-spinner"></span> <span class="task-spinner"></span>
<% end %> <% end %>
<span class="task-message-text"><%= @status.left.running_task_message || translated("Idle") %></span> <span class="task-message-text"><%= @status.left.running_task_message || dgettext("ui", "Idle") %></span>
<%= if (@status.left.running_task_overflow || 0) > 0 do %> <%= if (@status.left.running_task_overflow || 0) > 0 do %>
<span class="status-bar-count">+<%= @status.left.running_task_overflow %></span> <span class="status-bar-count">+<%= @status.left.running_task_overflow %></span>
<% end %> <% end %>
</button> </button>
</div> </div>
<div class="status-bar-right"> <div class="status-bar-right flex items-center gap-2 overflow-hidden">
<span class="status-bar-item"><%= @status.right.post_count %></span> <span class="status-bar-item"><%= @status.right.post_count %></span>
<span class="status-bar-item"><%= @status.right.media_count %></span> <span class="status-bar-item"><%= @status.right.media_count %></span>
<span class="status-bar-item theme-badge"><%= @status.right.theme_badge %></span> <span class="status-bar-item theme-badge"><%= @status.right.theme_badge %></span>
<button class={["status-bar-item", "offline-badge", if(@status.right.offline_mode, do: "active")]} data-testid="status-offline-button" type="button" phx-click="toggle_offline_mode" title={translated("Toggle offline mode")}>✈</button> <button class={["status-bar-item", "offline-badge", if(@status.right.offline_mode, do: "active")]} data-testid="status-offline-button" type="button" phx-click="toggle_offline_mode" title={dgettext("ui", "Toggle offline mode")}>✈</button>
<form class="status-bar-item language-badge" data-testid="status-language-form" phx-change="change_ui_language"> <form class="status-bar-item language-badge flex items-center gap-1" data-testid="status-language-form" phx-change="change_ui_language">
<span><%= translated("UI") %></span> <span><%= dgettext("ui", "UI") %></span>
<select class="status-bar-language-select" name="ui_language" data-testid="status-language-select"> <select class="status-bar-language-select" name="ui_language" data-testid="status-language-select">
<%= for language <- @supported_ui_languages do %> <%= for language <- @supported_ui_languages do %>
<option selected={language.code == @page_language} value={language.code}><%= language.flag %></option> <option selected={language.code == @page_language} value={language.code}><%= language.flag %></option>

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +1,46 @@
<div class="media-editor editor" data-testid="media-editor"> <div class="media-editor ui-editor-shell flex h-full min-h-0 flex-col" data-testid="media-editor">
<div class="editor-header"> <div class="editor-header ui-editor-header flex shrink-0 items-start justify-between gap-3">
<div class="editor-tabs"> <div class="flex min-w-0 flex-1 overflow-hidden">
<div class={[ <div class={[
"editor-tab", "ui-tab ui-tab-active ui-editor-tab-current inline-flex max-w-full items-center gap-2 overflow-hidden px-3 py-2",
"active",
if(@media_editor.dirty?, do: "dirty") if(@media_editor.dirty?, do: "dirty")
]}> ]}>
<span class="editor-tab-title" data-testid="editor-title"><%= @media_editor.display_title %></span> <span class="truncate" data-testid="editor-title"><%= @media_editor.display_title %></span>
<%= if @media_editor.dirty? do %> <%= if @media_editor.dirty? do %>
<span class="editor-tab-dirty" title={translated("Unsaved")}>●</span> <span class="editor-tab-dirty" title={dgettext("ui", "Unsaved")}>●</span>
<% end %> <% end %>
</div> </div>
</div> </div>
<div class="editor-actions"> <div class="ui-editor-actions flex flex-wrap items-center justify-end gap-2">
<%= if @media_editor.save_state in [:dirty, :saved] do %> <%= if @media_editor.save_state in [:dirty, :saved] do %>
<span class="auto-save-indicator"><%= media_editor_save_state_label(@media_editor.save_state) %></span> <span class="auto-save-indicator"><%= media_editor_save_state_label(@media_editor.save_state) %></span>
<% end %> <% end %>
<div class="quick-actions-wrapper"> <div class="quick-actions-wrapper relative">
<button <button
class="secondary quick-actions-btn" class="secondary quick-actions-btn ui-button ui-button-secondary inline-flex items-center gap-2"
type="button" type="button"
phx-click="toggle_media_editor_quick_actions" phx-click="toggle_media_editor_quick_actions"
phx-value-id={@media_editor.id} phx-target={@myself}
> >
<span class="quick-actions-btn-icon">⚡</span> <span class="quick-actions-btn-icon">⚡</span>
<span class="quick-actions-btn-label"><%= translated("Quick Actions") %></span> <span class="quick-actions-btn-label"><%= dgettext("ui", "Quick Actions") %></span>
</button> </button>
<%= if @media_editor.quick_actions_open? do %> <%= if @media_editor.quick_actions_open? do %>
<div class="quick-actions-menu"> <div class="quick-actions-menu ui-dropdown-menu absolute right-0 top-full z-10 mt-2 flex min-w-72 flex-col">
<%= if @media_editor.is_image do %> <%= if @media_editor.is_image do %>
<button <button
class="quick-action-item" class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
data-testid="editor-toolbar-overlay-button" data-testid="editor-toolbar-overlay-button"
type="button" type="button"
phx-click="open_overlay" phx-click="open_overlay"
phx-value-kind="ai_suggestions" phx-value-kind="ai_suggestions"
> >
<span class="quick-action-text"> <span class="quick-action-text flex min-w-0 flex-1 flex-col">
<strong><%= translated("AI Suggestions") %></strong> <strong><%= dgettext("ui", "AI Suggestions") %></strong>
<small><%= translated("Review title, alt text, and caption suggestions") %></small> <small><%= dgettext("ui", "Review title, alt text, and caption suggestions") %></small>
</span> </span>
<span class="quick-action-icon">🤖</span> <span class="quick-action-icon">🤖</span>
</button> </button>
@@ -50,15 +49,15 @@
<% end %> <% end %>
<button <button
class="quick-action-item" class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
type="button" type="button"
phx-click="detect_media_editor_language" phx-click="detect_media_editor_language"
phx-value-id={@media_editor.id} phx-target={@myself}
disabled={not @media_editor.can_detect_language?} disabled={not @media_editor.can_detect_language?}
> >
<span class="quick-action-text"> <span class="quick-action-text flex min-w-0 flex-1 flex-col">
<strong><%= translated("Detect Language") %></strong> <strong><%= dgettext("ui", "Detect Language") %></strong>
<small><%= translated("Persist the detected language for this media item") %></small> <small><%= dgettext("ui", "Persist the detected language for this media item") %></small>
</span> </span>
<span class="quick-action-icon">🔍</span> <span class="quick-action-icon">🔍</span>
</button> </button>
@@ -66,16 +65,16 @@
<div class="quick-actions-divider"></div> <div class="quick-actions-divider"></div>
<button <button
class="quick-action-item" class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
data-testid="editor-toolbar-overlay-button" data-testid="editor-toolbar-overlay-button"
type="button" type="button"
phx-click="open_overlay" phx-click="open_overlay"
phx-value-kind="language_picker" phx-value-kind="language_picker"
disabled={not @media_editor.can_translate?} disabled={not @media_editor.can_translate?}
> >
<span class="quick-action-text"> <span class="quick-action-text flex min-w-0 flex-1 flex-col">
<strong><%= translated("Translate") %></strong> <strong><%= dgettext("ui", "Translate") %></strong>
<small><%= translated("Select a target language for this media item") %></small> <small><%= dgettext("ui", "Select a target language for this media item") %></small>
</span> </span>
<span class="quick-action-icon">🌍</span> <span class="quick-action-icon">🌍</span>
</button> </button>
@@ -83,32 +82,32 @@
<% end %> <% end %>
</div> </div>
<button class="secondary" type="button" phx-click="replace_media_editor_file" phx-value-id={@media_editor.id}> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="replace_media_editor_file" phx-target={@myself}>
<%= translated("Replace File") %> <%= dgettext("ui", "Replace File") %>
</button> </button>
<button data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-value-id={@media_editor.id}> <button class="ui-button ui-button-primary" data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-target={@myself}>
<%= translated("Save") %> <%= dgettext("ui", "Save") %>
</button> </button>
<button <button
class="secondary danger" class="secondary danger ui-button ui-button-secondary ui-button-danger"
data-testid="media-delete-button" data-testid="media-delete-button"
type="button" type="button"
phx-click="open_overlay" phx-click="open_overlay"
phx-value-kind="confirm_delete" phx-value-kind="confirm_delete"
> >
<%= translated("Delete") %> <%= dgettext("ui", "Delete") %>
</button> </button>
</div> </div>
</div> </div>
<div class="editor-content media-editor"> <div class="editor-content media-editor grid min-h-0 flex-1 gap-4 overflow-auto p-4 xl:grid-cols-[minmax(320px,1fr)_minmax(0,1.2fr)]">
<div class="media-preview"> <div class="media-preview flex min-h-[16rem] items-center justify-center">
<%= if @media_editor.is_image and @media_editor.preview_url do %> <%= if @media_editor.is_image and @media_editor.preview_url do %>
<div class="media-preview-image"> <div class="media-preview-image">
<img src={@media_editor.preview_url} alt={@media_editor.form["alt"] || @media_editor.original_name} /> <img src={@media_editor.preview_url} alt={@media_editor.form["alt"] || @media_editor.original_name} />
</div> </div>
<% else %> <% else %>
<div class="media-preview-placeholder"> <div class="media-preview-placeholder flex h-full w-full flex-col items-center justify-center gap-3">
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3"> <svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"></path> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"></path>
</svg> </svg>
@@ -117,61 +116,61 @@
<% end %> <% end %>
</div> </div>
<div class="media-details"> <div class="media-details min-w-0">
<form class="media-editor-details-form" data-testid="media-editor-form" phx-change="change_media_editor"> <form class="media-editor-details-form flex flex-col gap-4" data-testid="media-editor-form" phx-change="change_media_editor" phx-target={@myself}>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= translated("File Name") %></label> <label><%= dgettext("ui", "File Name") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.original_name} disabled /> <input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.original_name} disabled />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= translated("MIME Type") %></label> <label><%= dgettext("ui", "MIME Type") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.mime_type} disabled /> <input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.mime_type} disabled />
</div> </div>
<div class="editor-field-row"> <div class="editor-field-row ui-field-grid-2 grid gap-4 md:grid-cols-2">
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= translated("Size") %></label> <label><%= dgettext("ui", "Size") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.file_size} disabled /> <input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.file_size} disabled />
</div> </div>
<%= if @media_editor.dimensions do %> <%= if @media_editor.dimensions do %>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= translated("Dimensions") %></label> <label><%= dgettext("ui", "Dimensions") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.dimensions} disabled /> <input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.dimensions} disabled />
</div> </div>
<% end %> <% end %>
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= translated("Title") %></label> <label><%= dgettext("ui", "Title") %></label>
<input class="post-editor-input" type="text" name="media_editor[title]" value={@media_editor.form["title"]} /> <input class="post-editor-input ui-input" type="text" name="media_editor[title]" value={@media_editor.form["title"]} />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= translated("Alt Text") %></label> <label><%= dgettext("ui", "Alt Text") %></label>
<input class="post-editor-input" type="text" name="media_editor[alt]" value={@media_editor.form["alt"]} /> <input class="post-editor-input ui-input" type="text" name="media_editor[alt]" value={@media_editor.form["alt"]} />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= translated("Caption") %></label> <label><%= dgettext("ui", "Caption") %></label>
<textarea class="post-editor-textarea" name="media_editor[caption]" rows="3"><%= @media_editor.form["caption"] %></textarea> <textarea class="post-editor-textarea ui-textarea" name="media_editor[caption]" rows="3"><%= @media_editor.form["caption"] %></textarea>
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= translated("Tags") %></label> <label><%= dgettext("ui", "Tags") %></label>
<input class="post-editor-input" type="text" name="media_editor[tags]" value={@media_editor.form["tags"]} /> <input class="post-editor-input ui-input" type="text" name="media_editor[tags]" value={@media_editor.form["tags"]} />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= translated("Author") %></label> <label><%= dgettext("ui", "Author") %></label>
<input class="post-editor-input" type="text" name="media_editor[author]" value={@media_editor.form["author"]} /> <input class="post-editor-input ui-input" type="text" name="media_editor[author]" value={@media_editor.form["author"]} />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= translated("Language") %></label> <label><%= dgettext("ui", "Language") %></label>
<select class="post-editor-input" name="media_editor[language]"> <select class="post-editor-input ui-input" name="media_editor[language]">
<option value=""><%= translated("None") %></option> <option value=""><%= dgettext("ui", "None") %></option>
<%= for language <- @media_editor.languages do %> <%= for language <- @media_editor.languages do %>
<option value={language} selected={language == @media_editor.form["language"]}><%= language_label(language) %></option> <option value={language} selected={language == @media_editor.form["language"]}><%= language_label(language) %></option>
<% end %> <% end %>
@@ -180,28 +179,28 @@
</form> </form>
<%= if @media_editor.form["language"] not in [nil, ""] do %> <%= if @media_editor.form["language"] not in [nil, ""] do %>
<div class="editor-field media-translations-section"> <div class="editor-field media-translations-section flex flex-col gap-2">
<label><%= translated("Translations") %></label> <label><%= dgettext("ui", "Translations") %></label>
<%= if Enum.empty?(@media_editor.translations) do %> <%= if Enum.empty?(@media_editor.translations) do %>
<div class="no-linked-posts"><%= translated("No translations") %></div> <div class="no-linked-posts"><%= dgettext("ui", "No translations") %></div>
<% else %> <% else %>
<div class="linked-posts-list"> <div class="linked-posts-list flex flex-col gap-2">
<%= for translation <- @media_editor.translations do %> <%= for translation <- @media_editor.translations do %>
<div class="linked-post-item"> <div class="linked-post-item flex items-center justify-between gap-2">
<button <button
class="linked-post-title linked-post-link" class="linked-post-title linked-post-link"
type="button" type="button"
phx-click="edit_media_translation" phx-click="edit_media_translation"
phx-value-id={@media_editor.id} phx-target={@myself}
phx-value-language={translation.language} phx-value-language={translation.language}
> >
<%= translation.flag %> <%= language_label(translation.language) %><%= if translation.title, do: " — #{translation.title}" %> <%= translation.flag %> <%= language_label(translation.language) %><%= if translation.title, do: " — #{translation.title}" %>
</button> </button>
<button class="secondary compact" type="button" phx-click="refresh_media_translation" phx-value-id={@media_editor.id} phx-value-language={translation.language}> <button class="secondary compact ui-button ui-button-secondary ui-button-compact" type="button" phx-click="refresh_media_translation" phx-target={@myself} phx-value-language={translation.language}>
<%= translated("Refresh") %> <%= dgettext("ui", "Refresh") %>
</button> </button>
<button class="unlink-btn" type="button" phx-click="delete_media_translation" phx-value-id={@media_editor.id} phx-value-language={translation.language}>×</button> <button class="unlink-btn" type="button" phx-click="delete_media_translation" phx-target={@myself} phx-value-language={translation.language}>×</button>
</div> </div>
<% end %> <% end %>
</div> </div>
@@ -209,38 +208,38 @@
</div> </div>
<% end %> <% end %>
<div class="editor-field linked-posts-section"> <div class="editor-field linked-posts-section flex flex-col gap-2">
<label> <label>
<%= translated("Linked Posts") %> <%= dgettext("ui", "Linked Posts") %>
<button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-value-id={@media_editor.id}> <button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-target={@myself}>
<%= translated("Link to Post") %> <%= dgettext("ui", "Link to Post") %>
</button> </button>
</label> </label>
<%= if @media_editor.post_picker_open? do %> <%= if @media_editor.post_picker_open? do %>
<div class="post-picker"> <div class="post-picker flex flex-col gap-3">
<div class="post-picker-search"> <div class="post-picker-search">
<input <input
type="text" type="text"
name="media_post_picker[query]" name="media_post_picker[query]"
value={@media_editor.post_picker_query} value={@media_editor.post_picker_query}
placeholder={translated("Search posts")} placeholder={dgettext("ui", "Search posts")}
phx-change="change_media_post_picker" phx-change="change_media_post_picker"
phx-value-id={@media_editor.id} phx-target={@myself}
/> />
</div> </div>
<%= if Enum.empty?(@media_editor.post_picker_results) do %> <%= if Enum.empty?(@media_editor.post_picker_results) do %>
<div class="no-posts"><%= translated("No posts to link") %></div> <div class="no-posts"><%= dgettext("ui", "No posts to link") %></div>
<% else %> <% else %>
<div class="post-picker-list"> <div class="post-picker-list flex flex-col gap-2">
<%= for result <- @media_editor.post_picker_results do %> <%= for result <- @media_editor.post_picker_results do %>
<button class="post-picker-item" type="button" phx-click="link_media_to_post" phx-value-id={@media_editor.id} phx-value-post-id={result.post_id}> <button class="post-picker-item" type="button" phx-click="link_media_to_post" phx-target={@myself} phx-value-post-id={result.post_id}>
<%= result.title %> <%= result.title %>
</button> </button>
<% end %> <% end %>
<%= if @media_editor.post_picker_overflow_count > 0 do %> <%= if @media_editor.post_picker_overflow_count > 0 do %>
<div class="post-picker-more"><%= translated("and %{count} more", %{count: @media_editor.post_picker_overflow_count}) %></div> <div class="post-picker-more"><%= dgettext("ui", "and %{count} more", count: @media_editor.post_picker_overflow_count) %></div>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
@@ -248,11 +247,11 @@
<% end %> <% end %>
<%= if Enum.empty?(@media_editor.linked_posts) do %> <%= if Enum.empty?(@media_editor.linked_posts) do %>
<div class="no-linked-posts"><%= translated("Not linked to any posts") %></div> <div class="no-linked-posts"><%= dgettext("ui", "Not linked to any posts") %></div>
<% else %> <% else %>
<div class="linked-posts-list"> <div class="linked-posts-list flex flex-col gap-2">
<%= for linked_post <- @media_editor.linked_posts do %> <%= for linked_post <- @media_editor.linked_posts do %>
<div class="linked-post-item"> <div class="linked-post-item flex items-center justify-between gap-2">
<button <button
class="linked-post-title linked-post-link" class="linked-post-title linked-post-link"
type="button" type="button"
@@ -264,7 +263,7 @@
> >
📄 <%= linked_post.title %> 📄 <%= linked_post.title %>
</button> </button>
<button class="unlink-btn" type="button" phx-click="unlink_media_from_post" phx-value-id={@media_editor.id} phx-value-post-id={linked_post.post_id}>×</button> <button class="unlink-btn" type="button" phx-click="unlink_media_from_post" phx-target={@myself} phx-value-post-id={linked_post.post_id}>×</button>
</div> </div>
<% end %> <% end %>
</div> </div>
@@ -275,29 +274,29 @@
<%= if @media_editor.editing_translation do %> <%= if @media_editor.editing_translation do %>
<div class="translation-modal-backdrop"> <div class="translation-modal-backdrop">
<div class="translation-modal"> <div class="translation-modal flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden">
<div class="translation-modal-header"> <div class="translation-modal-header flex items-center justify-between gap-3">
<h2><%= translated("Edit Translation") %></h2> <h2><%= dgettext("ui", "Edit Translation") %></h2>
<button class="translation-modal-close" type="button" phx-click="close_media_translation_editor">×</button> <button class="translation-modal-close" type="button" phx-click="close_media_translation_editor" phx-target={@myself}>×</button>
</div> </div>
<form class="translation-modal-body" phx-change="change_media_translation"> <form class="translation-modal-body flex flex-col gap-4 overflow-auto" phx-change="change_media_translation" phx-target={@myself}>
<input type="hidden" name="media_translation[language]" value={@media_editor.editing_translation["language"]} /> <input type="hidden" name="media_translation[language]" value={@media_editor.editing_translation["language"]} />
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= translated("Title") %></label> <label><%= dgettext("ui", "Title") %></label>
<input class="post-editor-input" type="text" name="media_translation[title]" value={@media_editor.editing_translation["title"]} /> <input class="post-editor-input ui-input" type="text" name="media_translation[title]" value={@media_editor.editing_translation["title"]} />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= translated("Alt Text") %></label> <label><%= dgettext("ui", "Alt Text") %></label>
<input class="post-editor-input" type="text" name="media_translation[alt]" value={@media_editor.editing_translation["alt"]} /> <input class="post-editor-input ui-input" type="text" name="media_translation[alt]" value={@media_editor.editing_translation["alt"]} />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= translated("Caption") %></label> <label><%= dgettext("ui", "Caption") %></label>
<textarea class="post-editor-textarea" name="media_translation[caption]" rows="3"><%= @media_editor.editing_translation["caption"] %></textarea> <textarea class="post-editor-textarea ui-textarea" name="media_translation[caption]" rows="3"><%= @media_editor.editing_translation["caption"] %></textarea>
</div> </div>
</form> </form>
<div class="translation-modal-footer"> <div class="translation-modal-footer flex items-center justify-end gap-2">
<button class="secondary" type="button" phx-click="close_media_translation_editor"><%= translated("Cancel") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="close_media_translation_editor" phx-target={@myself}><%= dgettext("ui", "Cancel") %></button>
<button type="button" phx-click="save_media_translation" phx-value-id={@media_editor.id}><%= translated("Save") %></button> <button class="ui-button ui-button-primary" type="button" phx-click="save_media_translation" phx-target={@myself}><%= dgettext("ui", "Save") %></button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,11 @@
defmodule BDS.Desktop.ShellLive.MenuEditor do defmodule BDS.Desktop.ShellLive.MenuEditor do
@moduledoc false @moduledoc false
use Phoenix.Component use Phoenix.LiveComponent
alias BDS.Desktop.ShellData use Gettext, backend: BDS.Gettext
alias BDS.Desktop.ShellLive.Notify
alias BDS.Desktop.ShellLive.MenuEditor.{ alias BDS.Desktop.ShellLive.MenuEditor.{
DraftManagement, DraftManagement,
@@ -15,158 +17,243 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
embed_templates("menu_editor_html/*") embed_templates("menu_editor_html/*")
@spec assign_socket(term()) :: term() @spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
def assign_socket(socket) do @impl true
case socket.assigns[:current_tab] do def update(%{action: :save} = assigns, socket) do
%{type: :menu_editor, id: tab_id} -> socket = assign(socket, Map.drop(assigns, [:action]))
state = State.ensure_state(socket.assigns) socket = do_save(socket)
menu_editor = State.build(socket.assigns, state) {:ok, socket}
end
def update(assigns, socket) do
socket =
socket socket
|> assign(:menu_editor_state, state) |> assign(assigns)
|> assign(:menu_editor, menu_editor) |> ensure_project_assigns()
|> assign( |> build_data()
:tab_meta,
Map.put(socket.assigns.tab_meta, {:menu_editor, tab_id}, %{
title: translated("menuEditor.tabTitle"),
subtitle: translated("menuEditor.description")
})
)
_other -> {:ok, socket}
assign(socket, :menu_editor, nil)
end
end end
@spec select_item(term(), term(), term()) :: term() @spec render(map()) :: Phoenix.LiveView.Rendered.t()
def select_item(socket, item_id, reload) do @impl true
def render(assigns) do
menu_editor(assigns)
end
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
@impl true
def handle_event("menu_editor_select_item", %{"item_id" => item_id}, socket) do
socket =
socket socket
|> State.update_state(fn state -> %{state | selected_id: item_id} end) |> State.update_state(fn state -> %{state | selected_id: item_id} end)
|> reload.(socket.assigns.workbench) |> build_data()
{:noreply, socket}
end end
@spec change_entry(term(), term(), term()) :: term() def handle_event("change_menu_editor_entry", %{"menu_editor_entry" => params}, socket) do
def change_entry(socket, params, reload) do
query = Map.get(params, "query", "") query = Map.get(params, "query", "")
socket =
socket socket
|> State.update_state(fn state -> put_in(state, [:draft, :query], query) end) |> State.update_state(fn state -> put_in(state, [:draft, :query], query) end)
|> reload.(socket.assigns.workbench) |> build_data()
{:noreply, socket}
end end
@spec submit_entry(term(), term()) :: term() def handle_event("submit_menu_editor_entry", _params, socket) do
def submit_entry(socket, reload) do socket =
case DraftManagement.current_draft(socket.assigns) do case DraftManagement.current_draft(socket.assigns) do
%{type: :page} -> %{type: :page} ->
socket socket
|> State.update_state(&DraftManagement.finalize_submenu_draft/1) |> State.update_state(&DraftManagement.finalize_submenu_draft/1)
|> reload.(socket.assigns.workbench) |> build_data()
%{type: :category} -> %{type: :category} ->
socket socket
|> DraftManagement.confirm_category_draft(&State.update_state/2) |> DraftManagement.confirm_category_draft(&State.update_state/2)
|> reload.(socket.assigns.workbench) |> build_data()
_other -> _other ->
reload.(socket, socket.assigns.workbench) socket
end
end end
@spec cancel_entry(term(), term()) :: term() {:noreply, socket}
def cancel_entry(socket, reload) do end
def handle_event("cancel_menu_editor_entry", _params, socket) do
socket =
socket socket
|> State.update_state(&DraftManagement.cancel_draft/1) |> State.update_state(&DraftManagement.cancel_draft/1)
|> reload.(socket.assigns.workbench) |> build_data()
{:noreply, socket}
end end
@spec select_page(term(), term(), term()) :: term() def handle_event("select_menu_editor_page", %{"post_id" => post_id}, socket) do
def select_page(socket, post_id, reload) do socket =
case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do
nil -> nil ->
reload.(socket, socket.assigns.workbench) socket
post -> post ->
socket socket
|> State.update_state(&DraftManagement.assign_page_to_draft(&1, post)) |> State.update_state(&DraftManagement.assign_page_to_draft(&1, post))
|> reload.(socket.assigns.workbench) |> build_data()
end
end end
@spec select_category(term(), term(), term()) :: term() {:noreply, socket}
def select_category(socket, name, reload) do end
def handle_event("select_menu_editor_category", %{"name" => name}, socket) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
socket =
case Enum.find(PageCategory.category_options(project_id), &(&1.name == name)) do case Enum.find(PageCategory.category_options(project_id), &(&1.name == name)) do
nil -> nil ->
reload.(socket, socket.assigns.workbench) socket
category -> category ->
socket socket
|> State.update_state(&DraftManagement.assign_category_to_draft(&1, category)) |> State.update_state(&DraftManagement.assign_category_to_draft(&1, category))
|> reload.(socket.assigns.workbench) |> build_data()
end
end end
@spec toolbar_action(term(), term(), term(), term()) :: term() {:noreply, socket}
def toolbar_action(socket, action, reload, append_output) do
case action do
"add-entry" ->
socket
|> State.update_state(&DraftManagement.start_page_draft/1)
|> reload.(socket.assigns.workbench)
"add-category-archive" ->
socket
|> State.update_state(&DraftManagement.start_category_draft/1)
|> reload.(socket.assigns.workbench)
"save" ->
State.save(socket, reload, append_output)
"move-up" ->
socket
|> State.update_state(&TreeOps.move_selected(&1, :up))
|> reload.(socket.assigns.workbench)
"move-down" ->
socket
|> State.update_state(&TreeOps.move_selected(&1, :down))
|> reload.(socket.assigns.workbench)
"indent" ->
socket
|> State.update_state(&TreeOps.indent_selected/1)
|> reload.(socket.assigns.workbench)
"unindent" ->
socket
|> State.update_state(&TreeOps.unindent_selected/1)
|> reload.(socket.assigns.workbench)
"delete" ->
socket
|> State.update_state(&TreeOps.delete_selected/1)
|> reload.(socket.assigns.workbench)
_other ->
reload.(socket, socket.assigns.workbench)
end
end end
@spec drop_item(term(), term(), term(), term(), term()) :: term() def handle_event("menu_editor_toolbar_action", %{"action" => action}, socket) do
def drop_item(socket, drag_item_id, target_item_id, position, reload) do socket = handle_toolbar_action(socket, action)
{:noreply, socket}
end
def handle_event(
"menu_editor_drop_item",
%{
"drag_item_id" => drag_item_id,
"target_item_id" => target_item_id,
"position" => position
},
socket
) do
socket =
socket socket
|> State.update_state(&TreeOps.drop_selected(&1, drag_item_id, target_item_id, position)) |> State.update_state(&TreeOps.drop_selected(&1, drag_item_id, target_item_id, position))
|> reload.(socket.assigns.workbench) |> build_data()
{:noreply, socket}
end end
@spec handle_keydown(term(), term(), term()) :: term() def handle_event("menu_editor_keydown", %{"key" => "Escape"}, socket) do
def handle_keydown(socket, "Escape", reload) do socket =
cancel_entry(socket, reload) socket
|> State.update_state(&DraftManagement.cancel_draft/1)
|> build_data()
{:noreply, socket}
end end
def handle_keydown(socket, _key, reload) do def handle_event("menu_editor_keydown", _params, socket) do
reload.(socket, socket.assigns.workbench) {:noreply, socket}
end
defp handle_toolbar_action(socket, "add-entry") do
socket
|> State.update_state(&DraftManagement.start_page_draft/1)
|> build_data()
end
defp handle_toolbar_action(socket, "add-category-archive") do
socket
|> State.update_state(&DraftManagement.start_category_draft/1)
|> build_data()
end
defp handle_toolbar_action(socket, "save") do
do_save(socket)
end
defp handle_toolbar_action(socket, "move-up") do
socket
|> State.update_state(&TreeOps.move_selected(&1, :up))
|> build_data()
end
defp handle_toolbar_action(socket, "move-down") do
socket
|> State.update_state(&TreeOps.move_selected(&1, :down))
|> build_data()
end
defp handle_toolbar_action(socket, "indent") do
socket
|> State.update_state(&TreeOps.indent_selected/1)
|> build_data()
end
defp handle_toolbar_action(socket, "unindent") do
socket
|> State.update_state(&TreeOps.unindent_selected/1)
|> build_data()
end
defp handle_toolbar_action(socket, "delete") do
socket
|> State.update_state(&TreeOps.delete_selected/1)
|> build_data()
end
defp handle_toolbar_action(socket, _other), do: socket
defp do_save(socket) do
state = socket.assigns.menu_editor_state
{:ok, _menu} =
BDS.Menu.update_menu(
state.project_id,
Enum.map(state.items, &TreeOps.persisted_item/1)
)
notify_output(dgettext("ui", "Blog Menu"), dgettext("ui", "Blog menu saved"), "info")
socket |> build_data()
end
defp ensure_project_assigns(socket) do
project_id = socket.assigns.project_id
if Map.has_key?(socket.assigns, :projects) do
socket
else
assign(socket, :projects, %{active_project_id: project_id})
end
end
defp build_data(socket) do
tab_id = socket.assigns.current_tab.id
state = State.ensure_state(socket.assigns)
menu_editor = State.build(socket.assigns, state)
tab_meta =
Map.put(socket.assigns.tab_meta, {:menu_editor, tab_id}, %{
title: dgettext("ui", "Blog Menu"),
subtitle:
dgettext(
"ui",
"Manage the central blog navigation outline and save it to meta/menu.opml."
)
})
socket
|> assign(:menu_editor_state, state)
|> assign(:menu_editor, menu_editor)
|> assign(:tab_meta, tab_meta)
end
defp notify_output(title, message, level) do
Notify.output(title, message, level)
end end
attr(:menu_editor, :map, required: true) attr(:menu_editor, :map, required: true)
@@ -177,6 +264,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
attr(:items, :list, required: true) attr(:items, :list, required: true)
attr(:menu_editor, :map, required: true) attr(:menu_editor, :map, required: true)
attr(:depth, :integer, required: true) attr(:depth, :integer, required: true)
attr(:myself, :any, required: true)
@spec menu_tree_level(term()) :: term() @spec menu_tree_level(term()) :: term()
def menu_tree_level(assigns) do def menu_tree_level(assigns) do
@@ -199,9 +287,10 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
data-selected={to_string(selected?)} data-selected={to_string(selected?)}
phx-click={unless(editing?, do: "menu_editor_select_item")} phx-click={unless(editing?, do: "menu_editor_select_item")}
phx-value-item_id={unless(editing?, do: item.item_id)} phx-value-item_id={unless(editing?, do: item.item_id)}
phx-target={@myself}
style={"--menu-editor-depth: #{@depth};"} style={"--menu-editor-depth: #{@depth};"}
> >
<span class="menu-editor-row-handle" data-menu-drag-handle="true" title={translated("menuEditor.dragHandle")}>⋮⋮</span> <span class="menu-editor-row-handle" data-menu-drag-handle="true" title={dgettext("ui", "Drag menu item")}>⋮⋮</span>
<span class="menu-editor-row-kind" title={kind_label(item.kind)} aria-label={kind_label(item.kind)}> <span class="menu-editor-row-kind" title={kind_label(item.kind)} aria-label={kind_label(item.kind)}>
<.kind_icon kind={item.kind} /> <.kind_icon kind={item.kind} />
</span> </span>
@@ -213,6 +302,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
data-testid="menu-editor-entry-form" data-testid="menu-editor-entry-form"
phx-change="change_menu_editor_entry" phx-change="change_menu_editor_entry"
phx-submit="submit_menu_editor_entry" phx-submit="submit_menu_editor_entry"
phx-target={@myself}
> >
<input <input
class="menu-editor-inline-input" class="menu-editor-inline-input"
@@ -233,16 +323,16 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
<div class="menu-editor-inline-actions"> <div class="menu-editor-inline-actions">
<%= if @menu_editor.draft.type == :page do %> <%= if @menu_editor.draft.type == :page do %>
<button class="menu-editor-inline-action" data-testid="menu-editor-create-submenu" type="submit"> <button class="menu-editor-inline-action" data-testid="menu-editor-create-submenu" type="submit">
<%= translated("menuEditor.addSubmenu") %> <%= dgettext("ui", "Add Submenu") %>
</button> </button>
<% else %> <% else %>
<button class="menu-editor-inline-action" type="submit"> <button class="menu-editor-inline-action" type="submit">
<%= translated("menuEditor.addCategoryArchive") %> <%= dgettext("ui", "Add Category Archive") %>
</button> </button>
<% end %> <% end %>
<button class="menu-editor-inline-action" type="button" phx-click="cancel_menu_editor_entry"> <button class="menu-editor-inline-action" type="button" phx-click="cancel_menu_editor_entry" phx-target={@myself}>
<%= translated("Cancel") %> <%= dgettext("ui", "Cancel") %>
</button> </button>
</div> </div>
</div> </div>
@@ -250,7 +340,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
<%= if @menu_editor.draft.type == :page do %> <%= if @menu_editor.draft.type == :page do %>
<div class="menu-editor-picker-list"> <div class="menu-editor-picker-list">
<%= if @menu_editor.filtered_pages == [] do %> <%= if @menu_editor.filtered_pages == [] do %>
<div class="menu-editor-picker-state"><%= translated("menuEditor.pagePicker.empty") %></div> <div class="menu-editor-picker-state"><%= dgettext("ui", "No matching pages found.") %></div>
<% else %> <% else %>
<%= for post <- @menu_editor.filtered_pages do %> <%= for post <- @menu_editor.filtered_pages do %>
<button <button
@@ -258,6 +348,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
type="button" type="button"
phx-click="select_menu_editor_page" phx-click="select_menu_editor_page"
phx-value-post_id={post.id} phx-value-post_id={post.id}
phx-target={@myself}
> >
<span><%= post.title %></span> <span><%= post.title %></span>
<small><%= post.slug %></small> <small><%= post.slug %></small>
@@ -268,7 +359,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
<% else %> <% else %>
<div class="menu-editor-picker-list"> <div class="menu-editor-picker-list">
<%= if @menu_editor.filtered_categories == [] do %> <%= if @menu_editor.filtered_categories == [] do %>
<div class="menu-editor-picker-state"><%= translated("menuEditor.categoryPicker.empty") %></div> <div class="menu-editor-picker-state"><%= dgettext("ui", "No matching categories found.") %></div>
<% else %> <% else %>
<%= for category <- @menu_editor.filtered_categories do %> <%= for category <- @menu_editor.filtered_categories do %>
<button <button
@@ -276,6 +367,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
type="button" type="button"
phx-click="select_menu_editor_category" phx-click="select_menu_editor_category"
phx-value-name={category.name} phx-value-name={category.name}
phx-target={@myself}
> >
<span><%= category.title %></span> <span><%= category.title %></span>
<small><%= category.name %></small> <small><%= category.name %></small>
@@ -294,7 +386,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
<%= if item.children != [] do %> <%= if item.children != [] do %>
<ul class="menu-editor-tree-level"> <ul class="menu-editor-tree-level">
<.menu_tree_level items={item.children} menu_editor={@menu_editor} depth={@depth + 1} /> <.menu_tree_level items={item.children} menu_editor={@menu_editor} depth={@depth + 1} myself={@myself} />
</ul> </ul>
<% end %> <% end %>
</li> </li>
@@ -320,10 +412,6 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
""" """
end end
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec row_label(term(), term()) :: term() @spec row_label(term(), term()) :: term()
def row_label(item, category_titles) do def row_label(item, category_titles) do
if item.kind == :category_archive do if item.kind == :category_archive do
@@ -334,24 +422,27 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
@spec kind_label(term()) :: term() @spec kind_label(term()) :: term()
def kind_label(:home), do: translated("menuEditor.type.home") def kind_label(:home), do: dgettext("ui", "Home")
def kind_label(:page), do: translated("menuEditor.type.page") def kind_label(:page), do: dgettext("ui", "Page")
def kind_label(:category_archive), do: translated("menuEditor.type.categoryArchive") def kind_label(:category_archive), do: dgettext("ui", "Category Archive")
def kind_label(:submenu), do: translated("menuEditor.type.submenu") def kind_label(:submenu), do: dgettext("ui", "Submenu")
defdelegate draft_item?(menu_editor, item_id), to: TreePredicates defdelegate draft_item?(menu_editor, item_id), to: TreePredicates
@spec editing_title(term()) :: term() @spec editing_title(term()) :: term()
def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive") def editing_title(%{draft: %{type: :category}}), do: dgettext("ui", "Add Category Archive")
def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title") def editing_title(_menu_editor), do: dgettext("ui", "Select Page")
@spec editing_hint(term()) :: term() @spec editing_hint(term()) :: term()
def editing_hint(%{draft: %{type: :category}}), do: translated("menuEditor.categoryPicker.hint") def editing_hint(%{draft: %{type: :category}}),
def editing_hint(_menu_editor), do: translated("menuEditor.createHint") do: dgettext("ui", "Select an existing category or press Enter to create a new archive entry")
def editing_hint(_menu_editor),
do: dgettext("ui", "Select a page below or press Enter to create a submenu")
@spec editing_placeholder(term()) :: term() @spec editing_placeholder(term()) :: term()
def editing_placeholder(%{draft: %{type: :category}}), def editing_placeholder(%{draft: %{type: :category}}),
do: translated("menuEditor.newCategoryPlaceholder") do: dgettext("ui", "Type a category name")
def editing_placeholder(_menu_editor), do: translated("menuEditor.newEntryPlaceholder") def editing_placeholder(_menu_editor), do: dgettext("ui", "Type a page title or submenu label")
end end

View File

@@ -1,10 +1,10 @@
defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
@moduledoc false @moduledoc false
alias BDS.Desktop.ShellData
alias BDS.Metadata alias BDS.Metadata
alias BDS.Desktop.ShellLive.MenuEditor.PageCategory alias BDS.Desktop.ShellLive.MenuEditor.PageCategory
alias BDS.Desktop.ShellLive.MenuEditor.TreeOps alias BDS.Desktop.ShellLive.MenuEditor.TreeOps
use Gettext, backend: BDS.Gettext
@spec current_draft(term()) :: term() @spec current_draft(term()) :: term()
def current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft) def current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft)
@@ -14,7 +14,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
item = %{ item = %{
item_id: Ecto.UUID.generate(), item_id: Ecto.UUID.generate(),
kind: :page, kind: :page,
label: translated("menuEditor.newPage"), label: dgettext("ui", "New Page"),
slug: nil, slug: nil,
children: [], children: [],
is_home: false is_home: false
@@ -57,7 +57,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
def finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do def finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do
label = label =
if(String.trim(query) == "", if(String.trim(query) == "",
do: translated("menuEditor.newSubmenu"), do: dgettext("ui", "New Submenu"),
else: String.trim(query) else: String.trim(query)
) )
@@ -144,7 +144,4 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
update_state_fun.(socket, &assign_category_to_draft(&1, category)) update_state_fun.(socket, &assign_category_to_draft(&1, category))
end end
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -3,9 +3,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
use Phoenix.Component use Phoenix.Component
alias BDS.Desktop.ShellData
alias BDS.Menu alias BDS.Menu
alias BDS.Desktop.ShellLive.MenuEditor.{PageCategory, TreeOps, TreePredicates} alias BDS.Desktop.ShellLive.MenuEditor.{PageCategory, TreeOps, TreePredicates}
use Gettext, backend: BDS.Gettext
@spec ensure_state(term()) :: term() @spec ensure_state(term()) :: term()
def ensure_state(assigns) do def ensure_state(assigns) do
@@ -30,8 +30,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
draft_query = Map.get(draft || %{}, :query, "") draft_query = Map.get(draft || %{}, :query, "")
%{ %{
title: translated("menuEditor.title"), title: dgettext("ui", "Blog Menu Editor"),
description: translated("menuEditor.description"), description:
dgettext(
"ui",
"Manage the central blog navigation outline and save it to meta/menu.opml."
),
items: state.items, items: state.items,
selected_id: state.selected_id, selected_id: state.selected_id,
draft: draft, draft: draft,
@@ -66,8 +70,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
socket socket
|> append_output.( |> append_output.(
translated("menuEditor.tabTitle"), dgettext("ui", "Blog Menu"),
translated("menuEditor.saved"), dgettext("ui", "Blog menu saved"),
nil, nil,
"info" "info"
) )
@@ -94,7 +98,4 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
draft: nil draft: nil
} }
end end
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -1,53 +1,53 @@
<div class="menu-editor-view" data-testid="menu-editor" phx-window-keydown={if(@menu_editor.draft, do: "menu_editor_keydown")}> <div class="menu-editor-view ui-editor-shell flex h-full min-h-0 flex-col p-4" data-testid="menu-editor" phx-window-keydown={if(@menu_editor.draft, do: "menu_editor_keydown")} phx-target={@myself}>
<div class="menu-editor-header"> <div class="menu-editor-header flex shrink-0 items-start justify-between gap-3">
<div> <div class="ui-field-stack">
<h2><%= @menu_editor.title %></h2> <h2><%= @menu_editor.title %></h2>
<p><%= @menu_editor.description %></p> <p><%= @menu_editor.description %></p>
</div> </div>
</div> </div>
<div class="menu-editor-main"> <div class="menu-editor-main min-h-0 flex-1 overflow-hidden">
<div class="menu-editor-tree-wrap"> <div class="menu-editor-tree-wrap ui-section-card flex h-full min-h-0 flex-col">
<div class="menu-editor-toolbar" data-testid="menu-editor-toolbar" role="toolbar" aria-label={@menu_editor.title}> <div class="menu-editor-toolbar ui-toolbar flex flex-wrap items-center gap-2" data-testid="menu-editor-toolbar" role="toolbar" aria-label={@menu_editor.title}>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="add-entry" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-entry" title={translated("menuEditor.addEntry")}> <button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="add-entry" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-entry" phx-target={@myself} title={dgettext("ui", "menuEditor.addEntry")}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 2h2v5h5v2H9v5H7V9H2V7h5V2z" /></svg> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 2h2v5h5v2H9v5H7V9H2V7h5V2z" /></svg>
</button> </button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="save" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="save" title={translated("menuEditor.save")}> <button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="save" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="save" phx-target={@myself} title={dgettext("ui", "menuEditor.save")}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg>
</button> </button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="add-category-archive" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-category-archive" title={translated("menuEditor.addCategoryArchive")}> <button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="add-category-archive" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-category-archive" phx-target={@myself} title={dgettext("ui", "menuEditor.addCategoryArchive")}>
<span aria-hidden="true"><%= translated("menuEditor.addCategoryArchiveShort") %></span> <span aria-hidden="true"><%= dgettext("ui", "menuEditor.addCategoryArchiveShort") %></span>
</button> </button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="move-up" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-up" title={translated("menuEditor.moveUp")} disabled={not @menu_editor.can_move_up?}> <button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="move-up" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-up" phx-target={@myself} title={dgettext("ui", "menuEditor.moveUp")} disabled={not @menu_editor.can_move_up?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg>
</button> </button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="move-down" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-down" title={translated("menuEditor.moveDown")} disabled={not @menu_editor.can_move_down?}> <button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="move-down" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-down" phx-target={@myself} title={dgettext("ui", "menuEditor.moveDown")} disabled={not @menu_editor.can_move_down?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 3h2v6h3l-4 4-4-4h3V3z" /></svg> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 3h2v6h3l-4 4-4-4h3V3z" /></svg>
</button> </button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="indent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="indent" title={translated("menuEditor.indent")} disabled={not @menu_editor.can_indent?}> <button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="indent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="indent" phx-target={@myself} title={dgettext("ui", "menuEditor.indent")} disabled={not @menu_editor.can_indent?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm6-1 3 2-3 2V9z" /></svg> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm6-1 3 2-3 2V9z" /></svg>
</button> </button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="unindent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="unindent" title={translated("menuEditor.unindent")} disabled={not @menu_editor.can_unindent?}> <button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="unindent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="unindent" phx-target={@myself} title={dgettext("ui", "menuEditor.unindent")} disabled={not @menu_editor.can_unindent?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm3-1-3 2 3 2V9z" /></svg> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm3-1-3 2 3 2V9z" /></svg>
</button> </button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="delete" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="delete" title={translated("menuEditor.delete")} disabled={not @menu_editor.can_delete?}> <button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="delete" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="delete" phx-target={@myself} title={dgettext("ui", "menuEditor.delete")} disabled={not @menu_editor.can_delete?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6 2h4l1 1h3v2H2V3h3l1-1zm-1 4h2v6H5V6zm4 0h2v6H9V6z" /></svg> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6 2h4l1 1h3v2H2V3h3l1-1zm-1 4h2v6H5V6zm4 0h2v6H9V6z" /></svg>
</button> </button>
</div> </div>
<%= if @menu_editor.items == [] do %> <%= if @menu_editor.items == [] do %>
<div class="menu-editor-empty"><%= translated("menuEditor.empty") %></div> <div class="menu-editor-empty flex min-h-0 flex-1 items-center justify-center"><%= dgettext("ui", "menuEditor.empty") %></div>
<% else %> <% else %>
<div id="menu-editor-tree-shell" class="menu-editor-tree-shell" phx-hook="MenuEditorTree"> <div id="menu-editor-tree-shell" class="menu-editor-tree-shell min-h-0 flex-1 overflow-auto" phx-hook="MenuEditorTree">
<ul class="menu-editor-tree-level"> <ul class="menu-editor-tree-level">
<.menu_tree_level items={@menu_editor.items} menu_editor={@menu_editor} depth={0} /> <.menu_tree_level items={@menu_editor.items} menu_editor={@menu_editor} depth={0} myself={@myself} />
</ul> </ul>
</div> </div>
<% end %> <% end %>

View File

@@ -1,18 +1,22 @@
defmodule BDS.Desktop.ShellLive.MiscEditor do defmodule BDS.Desktop.ShellLive.MiscEditor do
@moduledoc false @moduledoc false
use Phoenix.Component use Phoenix.LiveComponent
import Phoenix.HTML, only: [raw: 1]
import Ecto.Query import Ecto.Query
alias BDS.{Embeddings, Generation, Git, Posts, Repo} alias BDS.{Embeddings, Generation, Git, HelpDocs, Posts, Repo}
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellLive.Notify
alias BDS.MapUtils alias BDS.MapUtils
alias BDS.Settings.Setting alias BDS.Settings.Setting
use Gettext, backend: BDS.Gettext
embed_templates("misc_editor_html/*") embed_templates("misc_editor_html/*")
@misc_routes [ @misc_routes [
:documentation,
:api_documentation,
:site_validation, :site_validation,
:metadata_diff, :metadata_diff,
:translation_validation, :translation_validation,
@@ -20,30 +24,60 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
:git_diff :git_diff
] ]
@spec assign_socket(term()) :: term() # ── LiveComponent lifecycle ────────────────────────────────────────────────
def assign_socket(socket) do
assign(socket, :misc_editor, build(socket.assigns)) @spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
@impl true
def update(%{current_tab: current_tab, tab_meta: tab_meta, project_id: project_id}, socket) do
socket =
socket
|> assign(:current_tab, current_tab)
|> assign(:project_id, project_id)
|> assign(:tab_meta, tab_meta)
|> ensure_state()
|> build_data()
{:ok, socket}
end end
@spec rerun(term()) :: term() defp ensure_state(socket) do
def rerun(socket) do socket
case meta(socket.assigns) do |> assign_new(:selected_pairs, fn -> MapSet.new() end)
%{action: action} when is_binary(action) -> |> assign_new(:git_selected_file, fn -> nil end)
{:command, action} |> assign_new(:active_tab, fn -> nil end)
|> assign_new(:active_field, fn -> nil end)
_other ->
case misc_route_action(socket.assigns.current_tab.type) do
nil -> {:noop, socket}
action -> {:command, action}
end
end
end end
@spec apply_site_validation(term(), term()) :: term() defp build_data(socket) do
def apply_site_validation(socket, append_output) do misc_editor = do_build(socket.assigns)
assign(socket, :misc_editor, misc_editor)
end
@spec render(map()) :: Phoenix.LiveView.Rendered.t()
@impl true
def render(assigns) do
misc_editor(assigns)
end
# ── Event handlers ─────────────────────────────────────────────────────────
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
@impl true
def handle_event("rerun_misc_editor", _params, socket) do
action = rerun_action(socket.assigns)
if action do
notify_command(action)
end
{:noreply, socket}
end
def handle_event("apply_site_validation", _params, socket) do
meta = meta(socket.assigns) meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{}) payload = Map.get(meta, :payload, %{})
project_id = Map.get(meta, :project_id, socket.assigns.projects.active_project_id) project_id = Map.get(meta, :project_id, socket.assigns.project_id)
report = %{ report = %{
sitemap_path: Map.get(payload, :sitemap_path), sitemap_path: Map.get(payload, :sitemap_path),
@@ -57,98 +91,22 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
case Generation.apply_validation(project_id, report) do case Generation.apply_validation(project_id, report) do
{:ok, result} -> {:ok, result} ->
{:rerun, notify_output(
socket dgettext("ui", "Site Validation"),
|> append_output.( dgettext("ui", "Validation changes applied"),
translated("Site Validation"),
translated("Validation changes applied"),
inspect(result) inspect(result)
)} )
notify_command("validate_site")
{:noreply, socket}
end end
rescue rescue
error -> error ->
{:socket, notify_output(dgettext("ui", "Site Validation"), inspect(error), nil, "error")
append_output.(socket, translated("Site Validation"), inspect(error), nil, "error")} {:noreply, socket}
end end
@spec toggle_duplicate(term(), term(), term()) :: term() def handle_event("fix_translation_validation", _params, socket) do
def toggle_duplicate(socket, pair_id, reload) do
selected_by_tab = Map.get(socket.assigns, :misc_editor_selected_pairs, %{})
current = Map.get(selected_by_tab, socket.assigns.current_tab.id, MapSet.new())
next =
if MapSet.member?(current, pair_id) do
MapSet.delete(current, pair_id)
else
MapSet.put(current, pair_id)
end
socket
|> assign(
:misc_editor_selected_pairs,
Map.put(selected_by_tab, socket.assigns.current_tab.id, next)
)
|> reload.(socket.assigns.workbench)
end
@spec dismiss_duplicate(term(), term(), term(), term(), term()) :: term()
def dismiss_duplicate(socket, post_id_a, post_id_b, reload, append_output) do
case Embeddings.dismiss_duplicate_pair(post_id_a, post_id_b) do
{:ok, _saved_pair} ->
socket
|> update_payload(fn payload ->
update_in(payload[:pairs], fn pairs ->
Enum.reject(pairs || [], fn pair ->
pair_identity(pair) == pair_id(post_id_a, post_id_b)
end)
end)
end)
|> clear_selected_pair(pair_id(post_id_a, post_id_b))
|> append_output.(translated("Find Duplicates"), translated("Pair dismissed"))
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Find Duplicates"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@spec dismiss_selected(term(), term(), term()) :: term()
def dismiss_selected(socket, reload, append_output) do
tab_id = socket.assigns.current_tab.id
selected =
socket.assigns.misc_editor_selected_pairs
|> Map.get(tab_id, MapSet.new())
|> MapSet.to_list()
|> Enum.map(&decode_pair_id/1)
|> Enum.reject(&is_nil/1)
case Embeddings.dismiss_duplicate_pairs(selected) do
{:ok, _saved_pairs} ->
socket
|> update_payload(fn payload ->
update_in(payload[:pairs], fn pairs ->
Enum.reject(pairs || [], fn pair -> pair_identity(pair) in selected end)
end)
end)
|> assign(
:misc_editor_selected_pairs,
Map.put(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.new())
)
|> append_output.(translated("Find Duplicates"), translated("Selected pairs dismissed"))
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Find Duplicates"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@spec fix_translation_validation(term(), term()) :: term()
def fix_translation_validation(socket, append_output) do
report = report =
socket.assigns socket.assigns
|> meta() |> meta()
@@ -157,97 +115,282 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
{:ok, result} = Posts.fix_invalid_translations(report) {:ok, result} = Posts.fix_invalid_translations(report)
{:rerun, notify_output(
socket dgettext("ui", "Translation Validation"),
|> append_output.( dgettext(
translated("Translation Validation"), "ui",
translated("translationValidation.toast.fixSuccess", %{ "Deleted %{dbRows} DB rows and %{files} files, flushed %{flushed} translations to disk",
dbRows: result.deleted_database_rows, dbRows: result.deleted_database_rows,
files: result.deleted_files, files: result.deleted_files,
flushed: result.flushed_translations flushed: result.flushed_translations
}) )
)} )
notify_command("validate_translations")
{:noreply, socket}
rescue rescue
error -> error ->
{:socket, notify_output(dgettext("ui", "Translation Validation"), inspect(error), nil, "error")
append_output.(socket, translated("Translation Validation"), inspect(error), nil, "error")} {:noreply, socket}
end end
@spec select_git_diff_file(term(), term()) :: term() def handle_event("select_git_diff_file", %{"path" => path}, socket) do
def select_git_diff_file(socket, file_path) do {:noreply, assign(socket, :git_selected_file, path) |> build_data()}
assign(
socket,
:misc_editor_git_selected_files,
Map.put(
socket.assigns.misc_editor_git_selected_files,
socket.assigns.current_tab.id,
file_path
)
)
end end
@spec metadata_diff_repair_request(term(), term(), term()) :: term() def handle_event("toggle_duplicate_pair", %{"pair-id" => pair_id}, socket) do
def metadata_diff_repair_request(socket, field, direction) do current = socket.assigns.selected_pairs
meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{})
items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1)
tabs = metadata_diff_tabs(items, [])
active_tab = metadata_diff_active_tab(socket.assigns, tabs)
repair_items = next =
items if MapSet.member?(current, pair_id) do
|> Enum.filter(&(&1.tab_id == active_tab)) MapSet.delete(current, pair_id)
|> Enum.filter(fn item -> Enum.any?(item.differences, &(diff_name(&1) == field)) end)
|> Enum.map(&%{"entity_type" => &1.entity_type, "entity_id" => &1.entity_id})
cond do
not metadata_diff_repairable_tab?(active_tab) ->
{:error, translated("No repair action available")}
repair_items == [] ->
{:error, translated("No metadata diff items selected")}
true ->
{:ok,
%{
"direction" => direction,
"field" => field,
"tab" => active_tab,
"items" => repair_items
}}
end
end
@spec metadata_diff_orphan_import_request(term()) :: term()
def metadata_diff_orphan_import_request(socket) do
meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{})
items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1)
orphan_files =
Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1)
tabs = metadata_diff_tabs(items, orphan_files)
active_tab = metadata_diff_active_tab(socket.assigns, tabs)
selected_orphans =
orphan_files
|> Enum.filter(&(&1.tab_id == active_tab))
|> Enum.map(&%{"file_path" => &1.file_path})
if selected_orphans == [] do
{:error, translated("No orphan files selected")}
else else
{:ok, %{"tab" => active_tab, "orphans" => selected_orphans}} MapSet.put(current, pair_id)
end
{:noreply, assign(socket, :selected_pairs, next) |> build_data()}
end
def handle_event(
"dismiss_duplicate_pair",
%{"post-id-a" => post_id_a, "post-id-b" => post_id_b},
socket
) do
case Embeddings.dismiss_duplicate_pair(post_id_a, post_id_b) do
{:ok, _saved_pair} ->
tab_type = socket.assigns.current_tab.type
tab_id = socket.assigns.current_tab.id
pair_id = pair_id(post_id_a, post_id_b)
payload = Map.get(meta(socket.assigns), :payload, %{})
next_pairs =
update_in(payload[:pairs], fn pairs ->
Enum.reject(pairs || [], fn pair ->
pair_identity(pair) == pair_id
end)
end)
next_payload = Map.put(payload, :pairs, next_pairs)
notify_tab_meta(tab_type, tab_id, %{payload: next_payload})
notify_output(dgettext("ui", "Find Duplicates"), dgettext("ui", "Pair dismissed"))
selected = MapSet.delete(socket.assigns.selected_pairs, pair_id)
{:noreply, assign(socket, :selected_pairs, selected) |> build_data()}
{:error, reason} ->
notify_output(dgettext("ui", "Find Duplicates"), inspect(reason), nil, "error")
{:noreply, socket}
end end
end end
@spec build(term()) :: term() def handle_event("dismiss_selected_duplicates", _params, socket) do
def build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do selected =
socket.assigns.selected_pairs
|> MapSet.to_list()
|> Enum.map(&decode_pair_id/1)
|> Enum.reject(&is_nil/1)
case Embeddings.dismiss_duplicate_pairs(selected) do
{:ok, _saved_pairs} ->
tab_type = socket.assigns.current_tab.type
tab_id = socket.assigns.current_tab.id
payload = Map.get(meta(socket.assigns), :payload, %{})
next_pairs =
update_in(payload[:pairs], fn pairs ->
Enum.reject(pairs || [], fn pair -> pair_identity(pair) in selected end)
end)
next_payload = Map.put(payload, :pairs, next_pairs)
notify_tab_meta(tab_type, tab_id, %{payload: next_payload})
notify_output(
dgettext("ui", "Find Duplicates"),
dgettext("ui", "Selected pairs dismissed")
)
{:noreply, assign(socket, :selected_pairs, MapSet.new()) |> build_data()}
{:error, reason} ->
notify_output(dgettext("ui", "Find Duplicates"), inspect(reason), nil, "error")
{:noreply, socket}
end
end
def handle_event("repair_metadata_diff", %{"field" => field, "direction" => direction}, socket) do
case metadata_diff_repair_request(socket.assigns, field, direction) do
{:ok, params} ->
notify_command("repair_metadata_diff", params)
{:noreply, socket}
{:error, message} ->
notify_output(dgettext("ui", "Metadata Diff"), message, nil, "error")
{:noreply, socket}
end
end
def handle_event("import_metadata_diff_orphans", _params, socket) do
case metadata_diff_orphan_import_request(socket.assigns) do
{:ok, params} ->
notify_command("import_metadata_diff_orphans", params)
{:noreply, socket}
{:error, message} ->
notify_output(dgettext("ui", "Metadata Diff"), message, nil, "error")
{:noreply, socket}
end
end
def handle_event("select_metadata_diff_tab", %{"tab" => tab}, socket) do
{:noreply,
socket
|> assign(:active_tab, tab)
|> assign(:active_field, nil)
|> build_data()}
end
def handle_event("toggle_metadata_diff_field", %{"field" => field}, socket) do
current = socket.assigns.active_field
next = if current == field, do: nil, else: field
{:noreply, assign(socket, :active_field, next) |> build_data()}
end
def handle_event("open_duplicate_post", %{"id" => id, "title" => title}, socket) do
notify_open_sidebar_item(
%{"route" => "post", "id" => id, "title" => title, "subtitle" => "draft"},
:preview
)
{:noreply, socket}
end
# ── Public helper functions (used by template) ─────────────────────────────
@spec misc_class(atom()) :: String.t()
def misc_class(:documentation), do: "help-doc-view"
def misc_class(:api_documentation), do: "help-doc-view"
def misc_class(:site_validation), do: "site-validation-view"
def misc_class(:metadata_diff), do: "metadata-diff-view"
def misc_class(:translation_validation), do: "translation-validation-view"
def misc_class(:find_duplicates), do: "duplicates-view"
def misc_class(:git_diff), do: "git-diff-view"
@spec markdown_html(String.t()) :: Phoenix.HTML.safe()
def markdown_html(content) do
html =
case Earmark.as_html(content || "", escape: true) do
{:ok, rendered, _messages} -> rendered
{:error, rendered, _messages} -> rendered
end
raw(html)
end
@spec refreshable?(atom()) :: boolean()
def refreshable?(kind), do: kind not in [:documentation, :api_documentation]
@spec summary_items(map()) :: [{String.t(), any()}]
def summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary)
def summary_items(_misc), do: []
@spec duplicate_checked?(map(), String.t()) :: boolean()
def duplicate_checked?(misc, pair_id), do: MapSet.member?(misc.selected_pairs, pair_id)
@spec pair_id_from_pair(map()) :: String.t()
def pair_id_from_pair(pair), do: pair_identity(pair)
@spec translation_issue_label(map()) :: String.t()
def translation_issue_label(issue) do
case issue_value(issue, :issue) do
"same-language-as-canonical" ->
dgettext("ui", "Translation language matches canonical post language")
"do-not-translate-has-translations" ->
dgettext("ui", "Post is marked as do-not-translate but has translations")
"content-in-database" ->
dgettext("ui", "Published translation has content stuck in DB instead of filesystem")
_other ->
dgettext("ui", "Translation points to a missing source post")
end
end
@spec translation_issue_languages(map()) :: String.t()
def translation_issue_languages(issue) do
canonical_language = issue_value(issue, :canonical_language)
translation_language = issue_value(issue, :translation_language)
if canonical_language in [nil, ""] do
translation_language
else
dgettext("ui", "%{canonical} = %{translation}",
canonical: canonical_language,
translation: translation_language
)
end
end
@spec translation_issue_value(map(), atom() | String.t()) :: any()
def translation_issue_value(issue, key), do: issue_value(issue, key)
@spec git_diff_language(String.t() | nil) :: String.t()
def git_diff_language(nil), do: "plaintext"
def git_diff_language(file_path) do
case file_path |> Path.extname() |> String.downcase() do
".md" -> "markdown"
".markdown" -> "markdown"
".mdx" -> "markdown"
".ts" -> "typescript"
".tsx" -> "typescript"
".js" -> "javascript"
".jsx" -> "javascript"
".json" -> "json"
".css" -> "css"
".html" -> "html"
".yml" -> "yaml"
".yaml" -> "yaml"
_other -> "plaintext"
end
end
# ── Private helpers ────────────────────────────────────────────────────────
defp notify_command(action, params \\ %{}) do
Notify.command(action, params)
end
defp notify_output(title, message, detail \\ nil, level \\ "info") do
Notify.output(title, message, detail, level)
end
defp notify_tab_meta(tab_type, tab_id, updates) do
Notify.tab_meta_merge(tab_type, tab_id, updates)
end
defp notify_open_sidebar_item(params, intent) do
Notify.open_sidebar_item(params, intent)
end
defp rerun_action(assigns) do
case meta(assigns) do
%{action: action} when is_binary(action) ->
action
_other ->
misc_route_action(assigns.current_tab.type)
end
end
defp do_build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do
meta = meta(assigns) meta = meta(assigns)
payload = Map.get(meta, :payload, %{}) payload = Map.get(meta, :payload, %{})
case type do case type do
:documentation -> build_help_doc(type, meta)
:api_documentation -> build_help_doc(type, meta)
:site_validation -> build_site_validation(meta, payload) :site_validation -> build_site_validation(meta, payload)
:metadata_diff -> build_metadata_diff(assigns, meta, payload) :metadata_diff -> build_metadata_diff(assigns, meta, payload)
:translation_validation -> build_translation_validation(meta, payload) :translation_validation -> build_translation_validation(meta, payload)
@@ -256,36 +399,26 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec build(term()) :: term() defp do_build(_assigns), do: nil
def build(_assigns), do: nil
@spec translated(term(), term()) :: term() defp build_help_doc(type, meta) do
def translated(text, bindings \\ %{}), help_doc = HelpDocs.fetch(type)
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec misc_class(term()) :: term() %{
def misc_class(:site_validation), do: "site-validation-view" kind: type,
def misc_class(:metadata_diff), do: "metadata-diff-view" title: Map.get(meta, :title, help_doc.title),
def misc_class(:translation_validation), do: "translation-validation-view" subtitle: Map.get(meta, :subtitle, help_doc.subtitle),
def misc_class(:find_duplicates), do: "duplicates-view" summary: %{},
def misc_class(:git_diff), do: "git-diff-view" markdown: help_doc.markdown
}
def summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary) end
@spec summary_items(term()) :: term()
def summary_items(_misc), do: []
@spec duplicate_checked?(term(), term()) :: term()
def duplicate_checked?(misc, pair_id), do: MapSet.member?(misc.selected_pairs, pair_id)
@spec pair_id_from_pair(term()) :: term()
def pair_id_from_pair(pair), do: pair_identity(pair)
defp build_site_validation(meta, payload) do defp build_site_validation(meta, payload) do
summary = Map.get(payload, :summary, %{}) summary = Map.get(payload, :summary, %{})
%{ %{
kind: :site_validation, kind: :site_validation,
title: Map.get(meta, :title, translated("Site Validation")), title: Map.get(meta, :title, dgettext("ui", "Site Validation")),
subtitle: Map.get(meta, :subtitle, ""), subtitle: Map.get(meta, :subtitle, ""),
summary: %{ summary: %{
expected: Map.get(summary, :expected_count, 0), expected: Map.get(summary, :expected_count, 0),
@@ -311,8 +444,8 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1) Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1)
tabs = metadata_diff_tabs(items, orphan_files) tabs = metadata_diff_tabs(items, orphan_files)
active_tab = metadata_diff_active_tab(assigns, tabs) active_tab = assigns.active_tab || metadata_diff_active_tab_fallback(tabs)
active_field = metadata_diff_active_field(assigns) active_field = assigns.active_field
current_tab = current_tab =
Enum.find(tabs, &(&1.id == active_tab)) || List.first(tabs) || empty_metadata_diff_tab() Enum.find(tabs, &(&1.id == active_tab)) || List.first(tabs) || empty_metadata_diff_tab()
@@ -321,7 +454,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
%{ %{
kind: :metadata_diff, kind: :metadata_diff,
title: Map.get(meta, :title, translated("Metadata Diff")), title: Map.get(meta, :title, dgettext("ui", "Metadata Diff")),
subtitle: Map.get(meta, :subtitle, ""), subtitle: Map.get(meta, :subtitle, ""),
summary: Map.get(payload, :summary, %{}), summary: Map.get(payload, :summary, %{}),
tabs: tabs:
@@ -332,7 +465,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
field_summaries: field_summaries(current_tab.items), field_summaries: field_summaries(current_tab.items),
items: filtered_items, items: filtered_items,
orphan_files: if(is_nil(active_field), do: current_tab.orphan_files, else: []), orphan_files: if(is_nil(active_field), do: current_tab.orphan_files, else: []),
empty_message: translated("No items") empty_message: dgettext("ui", "No items")
} }
end end
@@ -341,16 +474,18 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
%{ %{
kind: :translation_validation, kind: :translation_validation,
title: Map.get(meta, :title, translated("Translation Validation")), title: Map.get(meta, :title, dgettext("ui", "Translation Validation")),
subtitle: Map.get(meta, :subtitle, ""), subtitle: Map.get(meta, :subtitle, ""),
summary: %{}, summary: %{},
summary_text: summary_text:
translated("translationValidation.summary", %{ dgettext(
"ui",
"Checked DB rows: %{dbRows} · Checked files: %{files} · Invalid DB rows: %{invalidDb} · Invalid files: %{invalidFiles}",
dbRows: report.checked_database_row_count, dbRows: report.checked_database_row_count,
files: report.checked_filesystem_file_count, files: report.checked_filesystem_file_count,
invalidDb: length(report.invalid_database_rows), invalidDb: length(report.invalid_database_rows),
invalidFiles: length(report.invalid_filesystem_files) invalidFiles: length(report.invalid_filesystem_files)
}), ),
invalid_database_rows: report.invalid_database_rows, invalid_database_rows: report.invalid_database_rows,
invalid_filesystem_files: report.invalid_filesystem_files, invalid_filesystem_files: report.invalid_filesystem_files,
can_fix?: report.invalid_database_rows != [] or report.invalid_filesystem_files != [] can_fix?: report.invalid_database_rows != [] or report.invalid_filesystem_files != []
@@ -358,22 +493,19 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
defp build_duplicates(assigns, meta, payload) do defp build_duplicates(assigns, meta, payload) do
selected_pairs =
Map.get(assigns.misc_editor_selected_pairs, assigns.current_tab.id, MapSet.new())
%{ %{
kind: :find_duplicates, kind: :find_duplicates,
title: Map.get(meta, :title, translated("Find Duplicates")), title: Map.get(meta, :title, dgettext("ui", "Find Duplicates")),
subtitle: Map.get(meta, :subtitle, ""), subtitle: Map.get(meta, :subtitle, ""),
summary: Map.get(payload, :summary, %{}), summary: Map.get(payload, :summary, %{}),
pairs: Map.get(payload, :pairs, []), pairs: Map.get(payload, :pairs, []),
selected_pairs: selected_pairs selected_pairs: assigns.selected_pairs
} }
end end
defp build_git_diff(assigns, meta) do defp build_git_diff(assigns, meta) do
project_id = assigns.projects.active_project_id project_id = assigns.project_id
selected_files = Map.get(assigns, :misc_editor_git_selected_files, %{}) selected_file = assigns.git_selected_file
{files, diff, error_message} = {files, diff, error_message} =
case Git.status(project_id) do case Git.status(project_id) do
@@ -386,7 +518,11 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|> Enum.sort() |> Enum.sort()
selected_file_path = selected_file_path =
select_git_diff_path(assigns.current_tab.id, file_paths, selected_files) if selected_file in file_paths do
selected_file
else
List.first(file_paths)
end
diff = diff =
case selected_file_path do case selected_file_path do
@@ -416,92 +552,71 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
%{ %{
kind: :git_diff, kind: :git_diff,
title: Map.get(meta, :title, translated("Git Diff")), title: Map.get(meta, :title, dgettext("ui", "Git Diff")),
subtitle: Map.get(meta, :subtitle, ""), subtitle: Map.get(meta, :subtitle, ""),
summary: %{}, summary: %{},
files: files, files: files,
selected_file_path: diff.file_path, selected_file_path: diff.file_path,
active_diff: Map.put(diff, :language, git_diff_language(diff.file_path)), active_diff: Map.put(diff, :language, git_diff_language(diff.file_path)),
preferences: preferences, preferences: preferences,
empty_message: error_message || translated("No unstaged changes") empty_message: error_message || dgettext("ui", "No unstaged changes")
} }
end end
@spec translation_issue_label(term()) :: term()
def translation_issue_label(issue) do
case issue_value(issue, :issue) do
"same-language-as-canonical" ->
translated("translationValidation.issue.sameLanguage")
"do-not-translate-has-translations" ->
translated("translationValidation.issue.doNotTranslate")
"content-in-database" ->
translated("translationValidation.issue.contentInDatabase")
_other ->
translated("translationValidation.issue.missingSource")
end
end
@spec translation_issue_languages(term()) :: term()
def translation_issue_languages(issue) do
canonical_language = issue_value(issue, :canonical_language)
translation_language = issue_value(issue, :translation_language)
if canonical_language in [nil, ""] do
translation_language
else
translated("translationValidation.languagesWithCanonical", %{
canonical: canonical_language,
translation: translation_language
})
end
end
@spec translation_issue_value(term(), term()) :: term()
def translation_issue_value(issue, key), do: issue_value(issue, key)
@spec git_diff_language(term()) :: term()
def git_diff_language(nil), do: "plaintext"
def git_diff_language(file_path) do
case file_path |> Path.extname() |> String.downcase() do
".md" -> "markdown"
".markdown" -> "markdown"
".mdx" -> "markdown"
".ts" -> "typescript"
".tsx" -> "typescript"
".js" -> "javascript"
".jsx" -> "javascript"
".json" -> "json"
".css" -> "css"
".html" -> "html"
".yml" -> "yaml"
".yaml" -> "yaml"
_other -> "plaintext"
end
end
defp meta(assigns) do defp meta(assigns) do
Map.get(assigns.tab_meta, {assigns.current_tab.type, assigns.current_tab.id}, %{}) Map.get(assigns.tab_meta, {assigns.current_tab.type, assigns.current_tab.id}, %{})
end end
defp update_payload(socket, updater) do defp metadata_diff_repair_request(assigns, field, direction) do
key = {socket.assigns.current_tab.type, socket.assigns.current_tab.id} payload = Map.get(meta(assigns), :payload, %{})
meta = Map.get(socket.assigns.tab_meta, key, %{}) items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1)
next_meta = Map.update(meta, :payload, %{}, updater) tabs = metadata_diff_tabs(items, [])
assign(socket, :tab_meta, Map.put(socket.assigns.tab_meta, key, next_meta)) active_tab = assigns.active_tab || metadata_diff_active_tab_fallback(tabs)
repair_items =
items
|> Enum.filter(&(&1.tab_id == active_tab))
|> Enum.filter(fn item -> Enum.any?(item.differences, &(diff_name(&1) == field)) end)
|> Enum.map(&%{"entity_type" => &1.entity_type, "entity_id" => &1.entity_id})
cond do
not metadata_diff_repairable_tab?(active_tab) ->
{:error, dgettext("ui", "No repair action available")}
repair_items == [] ->
{:error, dgettext("ui", "No metadata diff items selected")}
true ->
{:ok,
%{
"direction" => direction,
"field" => field,
"tab" => active_tab,
"items" => repair_items
}}
end
end end
defp clear_selected_pair(socket, pair_id) do defp metadata_diff_orphan_import_request(assigns) do
tab_id = socket.assigns.current_tab.id payload = Map.get(meta(assigns), :payload, %{})
current = Map.get(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.new()) items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1)
next_pairs = orphan_files =
Map.put(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.delete(current, pair_id)) Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1)
assign(socket, :misc_editor_selected_pairs, next_pairs) tabs = metadata_diff_tabs(items, orphan_files)
active_tab = assigns.active_tab || metadata_diff_active_tab_fallback(tabs)
selected_orphans =
orphan_files
|> Enum.filter(&(&1.tab_id == active_tab))
|> Enum.map(&%{"file_path" => &1.file_path})
if selected_orphans == [] do
{:error, dgettext("ui", "No orphan files selected")}
else
{:ok, %{"tab" => active_tab, "orphans" => selected_orphans}}
end
end end
defp pair_id(post_id_a, post_id_b), do: Enum.sort([post_id_a, post_id_b]) |> Enum.join("::") defp pair_id(post_id_a, post_id_b), do: Enum.sort([post_id_a, post_id_b]) |> Enum.join("::")
@@ -555,7 +670,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
defp empty_metadata_diff_tab do defp empty_metadata_diff_tab do
%{ %{
id: "posts", id: "posts",
label: translated("Posts"), label: dgettext("ui", "Posts"),
items: [], items: [],
orphan_files: [], orphan_files: [],
diff_count: 0, diff_count: 0,
@@ -564,19 +679,9 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
} }
end end
defp metadata_diff_active_tab(assigns, tabs) do defp metadata_diff_active_tab_fallback(tabs) do
tab_id = Map.get(assigns.metadata_diff_active_tabs || %{}, assigns.current_tab.id)
if Enum.any?(tabs, &(&1.id == tab_id)) do
tab_id
else
tabs |> List.first() |> Map.get(:id) tabs |> List.first() |> Map.get(:id)
end end
end
defp metadata_diff_active_field(assigns) do
Map.get(assigns.metadata_diff_field_filters || %{}, assigns.current_tab.id)
end
defp metadata_diff_filtered_items(items, nil), do: items defp metadata_diff_filtered_items(items, nil), do: items
@@ -634,17 +739,17 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
MapUtils.attr(item, :meta_label) || entity_id MapUtils.attr(item, :meta_label) || entity_id
end end
defp metadata_diff_item_type_label("post"), do: translated("Post") defp metadata_diff_item_type_label("post"), do: dgettext("ui", "Post")
defp metadata_diff_item_type_label("post_translation"), do: translated("Translations") defp metadata_diff_item_type_label("post_translation"), do: dgettext("ui", "Translations")
defp metadata_diff_item_type_label("media"), do: translated("Media") defp metadata_diff_item_type_label("media"), do: dgettext("ui", "Media")
defp metadata_diff_item_type_label("media_translation"), do: translated("Translations") defp metadata_diff_item_type_label("media_translation"), do: dgettext("ui", "Translations")
defp metadata_diff_item_type_label("script"), do: translated("Script") defp metadata_diff_item_type_label("script"), do: dgettext("ui", "Script")
defp metadata_diff_item_type_label("template"), do: translated("Template") defp metadata_diff_item_type_label("template"), do: dgettext("ui", "Template")
defp metadata_diff_item_type_label("project"), do: translated("Project") defp metadata_diff_item_type_label("project"), do: dgettext("ui", "Project")
defp metadata_diff_item_type_label("publishing"), do: translated("Publishing") defp metadata_diff_item_type_label("publishing"), do: dgettext("ui", "Publishing")
defp metadata_diff_item_type_label("categories"), do: translated("Categories") defp metadata_diff_item_type_label("categories"), do: dgettext("ui", "Categories")
defp metadata_diff_item_type_label("category_meta"), do: translated("Categories") defp metadata_diff_item_type_label("category_meta"), do: dgettext("ui", "Categories")
defp metadata_diff_item_type_label("embedding"), do: translated("Embeddings") defp metadata_diff_item_type_label("embedding"), do: dgettext("ui", "Embeddings")
defp metadata_diff_item_type_label(entity_type), defp metadata_diff_item_type_label(entity_type),
do: entity_type |> String.replace("_", " ") |> String.capitalize() do: entity_type |> String.replace("_", " ") |> String.capitalize()
@@ -662,12 +767,12 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
defp metadata_diff_tab_id("embedding"), do: "embeddings" defp metadata_diff_tab_id("embedding"), do: "embeddings"
defp metadata_diff_tab_id(_entity_type), do: "project" defp metadata_diff_tab_id(_entity_type), do: "project"
defp metadata_diff_tab_label("posts"), do: translated("Posts") defp metadata_diff_tab_label("posts"), do: dgettext("ui", "Posts")
defp metadata_diff_tab_label("media"), do: translated("Media") defp metadata_diff_tab_label("media"), do: dgettext("ui", "Media")
defp metadata_diff_tab_label("scripts"), do: translated("Scripts") defp metadata_diff_tab_label("scripts"), do: dgettext("ui", "Scripts")
defp metadata_diff_tab_label("templates"), do: translated("Templates") defp metadata_diff_tab_label("templates"), do: dgettext("ui", "Templates")
defp metadata_diff_tab_label("project"), do: translated("Project") defp metadata_diff_tab_label("project"), do: dgettext("ui", "Project")
defp metadata_diff_tab_label("embeddings"), do: translated("Embeddings") defp metadata_diff_tab_label("embeddings"), do: dgettext("ui", "Embeddings")
defp metadata_diff_tab_label(tab_id), defp metadata_diff_tab_label(tab_id),
do: tab_id |> String.replace("_", " ") |> String.capitalize() do: tab_id |> String.replace("_", " ") |> String.capitalize()
@@ -737,16 +842,6 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
defp issue_value(_issue, _key), do: nil defp issue_value(_issue, _key), do: nil
defp select_git_diff_path(tab_id, files, selected_files) do
selected = Map.get(selected_files, tab_id)
if selected in files do
selected
else
List.first(files)
end
end
defp empty_git_diff(file_path) do defp empty_git_diff(file_path) do
%{file_path: file_path, original: "", modified: "", error: nil} %{file_path: file_path, original: "", modified: "", error: nil}
end end

Some files were not shown because too many files have changed in this diff Show More