feat: first take at UI app

This commit is contained in:
2026-04-24 14:54:04 +02:00
parent 78609377be
commit 1b5a5008eb
24 changed files with 2630 additions and 3 deletions

268
lib/bds/ui/workbench.ex Normal file
View File

@@ -0,0 +1,268 @@
defmodule BDS.UI.Workbench do
@moduledoc false
alias BDS.UI.Registry
@singleton_tabs MapSet.new([
:settings,
:tags,
:style,
:scripts,
:menu_editor,
:documentation,
:api_documentation,
:metadata_diff,
:site_validation,
:translation_validation,
:find_duplicates
])
defstruct sidebar_visible: true,
sidebar_width: 280,
active_view: :posts,
assistant_sidebar_visible: false,
assistant_sidebar_width: 360,
panel: %{visible: false, active_tab: :tasks},
tabs: [],
active_tab: nil,
editor_route: :dashboard,
dirty_tabs: MapSet.new()
def new(opts \\ []) do
%__MODULE__{
sidebar_visible: Keyword.get(opts, :sidebar_visible, true),
sidebar_width: clamp_sidebar_width(Keyword.get(opts, :sidebar_width, 280)),
active_view: normalize_type(Keyword.get(opts, :active_view, :posts)),
assistant_sidebar_visible: Keyword.get(opts, :assistant_sidebar_visible, false),
assistant_sidebar_width:
clamp_assistant_sidebar_width(Keyword.get(opts, :assistant_sidebar_width, 360)),
panel: %{
visible: Keyword.get(opts, :panel_visible, false),
active_tab: Keyword.get(opts, :panel_tab, :tasks)
},
dirty_tabs: MapSet.new(Keyword.get(opts, :dirty_tabs, []))
}
|> sync_editor_route()
|> normalize_panel()
end
def set_sidebar_width(state, width) when is_integer(width) do
%{state | sidebar_width: clamp_sidebar_width(width)}
end
def set_assistant_sidebar_width(state, width) when is_integer(width) do
%{state | assistant_sidebar_width: clamp_assistant_sidebar_width(width)}
end
def open_tab(state, type, id, intent) do
{tabs, opened_tab} = upsert_tab(state.tabs, normalize_type(type), id, intent)
state
|> Map.put(:tabs, tabs)
|> Map.put(:active_tab, tab_ref(opened_tab))
|> sync_editor_route()
|> normalize_panel()
end
def open_tab_in_background(state, type, id, intent) do
current_active = state.active_tab
{tabs, _opened_tab} = upsert_tab(state.tabs, normalize_type(type), id, intent)
state
|> Map.put(:tabs, tabs)
|> Map.put(:active_tab, current_active)
|> sync_editor_route()
|> normalize_panel()
end
def close_tab(state, type, id) do
type = normalize_type(type)
target = {type, id}
index = Enum.find_index(state.tabs, &(tab_ref(&1) == target))
if is_nil(index) do
state
else
tabs = List.delete_at(state.tabs, index)
next_active =
cond do
state.active_tab != target -> state.active_tab
tabs == [] -> nil
index < length(tabs) -> tab_ref(Enum.at(tabs, index))
true -> tab_ref(List.last(tabs))
end
state
|> Map.put(:tabs, tabs)
|> Map.put(:active_tab, next_active)
|> sync_editor_route()
|> normalize_panel()
end
end
def pin_tab(state, type, id) do
type = normalize_type(type)
tabs =
Enum.map(state.tabs, fn tab ->
if tab_ref(tab) == {type, id}, do: %{tab | is_transient: false}, else: tab
end)
%{state | tabs: tabs}
end
def clear_tabs(state) do
%{state | tabs: [], active_tab: nil, editor_route: :dashboard, dirty_tabs: MapSet.new()}
|> normalize_panel()
end
def mark_dirty(state, type, id) do
if normalize_type(type) == :post do
%{state | dirty_tabs: MapSet.put(state.dirty_tabs, {normalize_type(type), id})}
else
state
end
end
def clear_dirty(state, type, id) do
%{state | dirty_tabs: MapSet.delete(state.dirty_tabs, {normalize_type(type), id})}
end
def dirty?(state, type, id) do
MapSet.member?(state.dirty_tabs, {normalize_type(type), id})
end
def toggle_sidebar(state), do: %{state | sidebar_visible: not state.sidebar_visible}
def set_panel_visible(state, visible) when is_boolean(visible) do
%{state | panel: %{state.panel | visible: visible}}
end
def toggle_panel(state) do
set_panel_visible(state, not state.panel.visible)
end
def toggle_assistant_sidebar(state) do
%{state | assistant_sidebar_visible: not state.assistant_sidebar_visible}
end
def set_panel_tab(state, tab) when tab in [:tasks, :output, :post_links, :git_log] do
%{state | panel: %{state.panel | active_tab: tab}}
end
def click_activity(state, activity_id) do
activity_id = normalize_type(activity_id)
cond do
state.active_view == activity_id -> toggle_sidebar(state)
state.sidebar_visible -> %{state | active_view: activity_id}
true -> %{state | active_view: activity_id, sidebar_visible: true}
end
end
def activity_buttons(state, git_badge_count \\ 0) do
Registry.sidebar_views()
|> Enum.map(fn view ->
%{
id: view.id,
label: view.label,
activity_group: view.activity_group,
active: state.sidebar_visible and state.active_view == view.id,
badge: activity_badge(view.id, git_badge_count)
}
end)
end
def status_bar(state, opts) do
%{
left: %{
running_task_message: Keyword.get(opts, :running_task_message),
running_task_overflow: Keyword.get(opts, :running_task_overflow)
},
right: %{
post_status: post_status(state, Keyword.get(opts, :active_post_status)),
post_count: "#{Keyword.get(opts, :post_count, 0)} posts",
media_count: "#{Keyword.get(opts, :media_count, 0)} media",
token_usage: token_usage(state, Keyword.get(opts, :token_usage)),
theme_badge: Keyword.get(opts, :theme_badge, "default"),
offline_mode: Keyword.get(opts, :offline_mode, false),
ui_language: Keyword.get(opts, :ui_language, "en"),
brand: "bDS"
}
}
end
defp upsert_tab(tabs, type, id, intent) do
transient? = transient_tab?(type, intent)
case Enum.find_index(tabs, &(tab_ref(&1) == {type, id})) do
nil ->
new_tab = %{type: type, id: id, is_transient: transient?}
tabs =
cond do
transient? -> replace_transient_tab(tabs, type, new_tab)
true -> tabs ++ [new_tab]
end
{tabs, new_tab}
index ->
existing = Enum.at(tabs, index)
updated = if intent == :pin, do: %{existing | is_transient: false}, else: existing
{List.replace_at(tabs, index, updated), updated}
end
end
defp replace_transient_tab(tabs, type, new_tab) do
case Enum.find_index(tabs, &(&1.type == type and &1.is_transient)) do
nil -> tabs ++ [new_tab]
index -> List.replace_at(tabs, index, new_tab)
end
end
defp transient_tab?(type, _intent) when type in [:chat, :import], do: false
defp transient_tab?(type, intent) do
if MapSet.member?(@singleton_tabs, type), do: false, else: transient_from_intent(intent)
end
defp transient_from_intent(:preview), do: true
defp transient_from_intent(_intent), do: false
defp sync_editor_route(%{active_tab: nil} = state), do: %{state | editor_route: :dashboard}
defp sync_editor_route(%{active_tab: {type, _id}} = state), do: %{state | editor_route: type}
defp normalize_panel(state) do
if panel_tab_available?(state.editor_route, state.panel.active_tab) do
state
else
%{state | panel: %{state.panel | active_tab: :tasks}}
end
end
defp panel_tab_available?(_route, tab) when tab in [:tasks, :output], do: true
defp panel_tab_available?(:post, :post_links), do: true
defp panel_tab_available?(route, :git_log) when route in [:post, :media], do: true
defp panel_tab_available?(_route, _tab), do: false
defp activity_badge(:git, count) when is_integer(count) and count > 0 do
%{count: count, display: if(count > 99, do: "99+", else: Integer.to_string(count))}
end
defp activity_badge(_id, _count), do: nil
defp post_status(%{editor_route: :post}, status) when not is_nil(status), do: to_string(status)
defp post_status(_state, _status), do: nil
defp token_usage(%{editor_route: :chat}, usage), do: usage
defp token_usage(_state, _usage), do: nil
defp normalize_type(type) when is_atom(type), do: type
defp normalize_type(type) when is_binary(type), do: String.to_atom(type)
defp tab_ref(tab), do: {tab.type, tab.id}
defp clamp_sidebar_width(width), do: max(200, min(width, 500))
defp clamp_assistant_sidebar_width(width), do: max(280, min(width, 640))
end