feat: step 11 done

This commit is contained in:
2026-04-29 19:24:45 +02:00
parent 4ae6c55e83
commit 155fda8b81
6 changed files with 284 additions and 9 deletions

View File

@@ -17,7 +17,7 @@ defmodule BDS.Application do
def desktop_children(_env) do
if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do
[{BDS.Desktop.Server, []} | desktop_window_children()]
[{BDS.Desktop.Server, []}, BDS.CliSync.Watcher | desktop_window_children()]
else
[]
end

View File

@@ -0,0 +1,73 @@
defmodule BDS.CliSync.Watcher do
@moduledoc false
use GenServer
alias BDS.CliSync
@topic "entity:changed"
@default_poll_interval_ms 100
def start_link(opts \\ []) do
case Keyword.pop(opts, :name, __MODULE__) do
{nil, init_opts} -> GenServer.start_link(__MODULE__, init_opts)
{name, init_opts} -> GenServer.start_link(__MODULE__, init_opts, name: name)
end
end
def topic, do: @topic
def poll_now(server \\ __MODULE__) do
GenServer.call(server, :poll_now)
end
@impl true
def init(opts) do
state = %{
poll_interval_ms: normalize_positive_integer(Keyword.get(opts, :poll_interval_ms), @default_poll_interval_ms),
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub)
}
{:ok, schedule_poll(state)}
end
@impl true
def handle_call(:poll_now, _from, state) do
{:reply, :ok, process_notifications(state)}
end
@impl true
def handle_info(:poll, state) do
{:noreply,
state
|> process_notifications()
|> schedule_poll()}
end
defp process_notifications(state) do
{:ok, notifications} = CliSync.db_file_change_detected()
{:ok, _pruned} = CliSync.prune_notifications()
Enum.each(notifications, fn notification ->
Phoenix.PubSub.broadcast(state.pubsub, topic(), {:entity_changed, notification_payload(notification)})
end)
state
end
defp notification_payload(notification) do
%{
entity: notification.entity_type,
entity_id: notification.entity_id,
action: notification.action
}
end
defp schedule_poll(state) do
Process.send_after(self(), :poll, state.poll_interval_ms)
state
end
defp normalize_positive_integer(value, _default) when is_integer(value) and value > 0, do: value
defp normalize_positive_integer(_value, default), do: default
end

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML
alias BDS.AI
alias BDS.CliSync.Watcher
alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData}
alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MenuEditor, MiscEditor, SettingsEditor, TagsEditor}
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
@@ -47,6 +48,7 @@ defmodule BDS.Desktop.ShellLive do
connected = connected?(socket)
if connected do
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
:timer.send_interval(@refresh_interval, :refresh_task_status)
end
@@ -1163,6 +1165,10 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)}
end
def handle_info({:entity_changed, payload}, socket) when is_map(payload) do
{:noreply, apply_cli_entity_change(socket, payload)}
end
def handle_info(:refresh_task_status, socket) do
raw_task_status = BDS.Tasks.status_snapshot()
@@ -1252,6 +1258,117 @@ defmodule BDS.Desktop.ShellLive do
|> assign_misc_editor()
end
defp apply_cli_entity_change(socket, payload) do
entity = Map.get(payload, :entity) || Map.get(payload, "entity") || Map.get(payload, :entity_type) || Map.get(payload, "entity_type")
entity_id = Map.get(payload, :entity_id) || Map.get(payload, "entity_id") || Map.get(payload, :entityId) || Map.get(payload, "entityId")
action = normalize_cli_entity_action(Map.get(payload, :action) || Map.get(payload, "action"))
if is_binary(entity) and entity != "" and is_binary(entity_id) and entity_id != "" and action in [:created, :updated, :deleted] do
{socket, workbench} = maybe_close_deleted_cli_tab(socket, entity, entity_id, action)
socket
|> maybe_refresh_cli_tab_meta(entity, entity_id, action)
|> reload_shell(workbench)
else
socket
end
end
defp maybe_close_deleted_cli_tab(socket, "post", post_id, :deleted) do
workbench = Workbench.close_tab(socket.assigns.workbench, :post, post_id)
socket =
socket
|> assign(:workbench, workbench)
|> assign(:shell_overlay, nil)
|> 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}
end
defp maybe_close_deleted_cli_tab(socket, "media", media_id, :deleted) do
workbench = Workbench.close_tab(socket.assigns.workbench, :media, media_id)
socket =
socket
|> assign(:workbench, workbench)
|> assign(:shell_overlay, nil)
|> 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}
end
defp maybe_close_deleted_cli_tab(socket, _entity, _entity_id, _action), do: {socket, socket.assigns.workbench}
defp maybe_refresh_cli_tab_meta(socket, "post", post_id, action) when action in [:created, :updated] do
maybe_put_cli_tab_meta(socket, :post, post_id, fn ->
case Repo.get(Post, post_id) do
%Post{} = post -> %{title: post.title || post.slug || post.id, subtitle: Atom.to_string(post.status || :draft)}
_other -> nil
end
end)
end
defp maybe_refresh_cli_tab_meta(socket, "media", media_id, action) when action in [:created, :updated] do
maybe_put_cli_tab_meta(socket, :media, media_id, fn ->
case Repo.get(Media, media_id) do
%Media{} = media -> %{title: media.title || media.filename || media.id, subtitle: media.filename || media.mime_type || "media"}
_other -> nil
end
end)
end
defp maybe_refresh_cli_tab_meta(socket, _entity, _entity_id, _action), do: socket
defp maybe_put_cli_tab_meta(socket, route, entity_id, meta_fun) do
key = {route, entity_id}
if cli_tab_present?(socket.assigns.workbench, key) or Map.has_key?(socket.assigns.tab_meta, key) do
case meta_fun.() do
%{} = fresh_meta ->
updated_meta = Map.update(socket.assigns.tab_meta, key, fresh_meta, &Map.merge(&1, fresh_meta))
assign(socket, :tab_meta, updated_meta)
_other ->
socket
end
else
socket
end
end
defp cli_tab_present?(%{tabs: tabs}, {route, entity_id}) do
Enum.any?(tabs, &(&1.type == route and &1.id == entity_id))
end
defp normalize_cli_entity_action(action) when action in [:created, :updated, :deleted], do: action
defp normalize_cli_entity_action(action) do
action
|> to_string()
|> String.downcase()
|> case do
"created" -> :created
"updated" -> :updated
"deleted" -> :deleted
_other -> :unknown
end
end
defp render_panel_body(assigns) do
case assigns.workbench.panel.active_tab do
:tasks -> render_task_entries(assigns)