mahbot 0.7.1

An autonomous agentic engineering system that manages software development through role separation, subagents, and deterministic diagnostics.
Documentation
//! Settings Users section state — user list, delete confirmation, and Telegram
//! channel binding.

use crate::users::{UserRecordEntry, UserStore};

use iced::Task;

use super::common::SingleLineEditorState;
use super::editor_widget::EditorAction;

/// De-duplicated access to the global user store.
pub(crate) fn user_store() -> Result<&'static UserStore, String> {
    crate::users::USER_STORE
        .get()
        .ok_or_else(|| "User store not initialized".to_string())
}

/// Run a single-field `set_selected_workspace`, mapping an empty value (or a
/// `personal:{user}` workspace name) to `None` — the personal workspace is
/// stored as NULL and computed on the fly. Updates the workspace column.
pub(crate) async fn set_selected_workspace_field(
    sender: String,
    workspace: String,
) -> Result<(), String> {
    let store = user_store()?;
    // Empty and personal-workspace values both mean "no shared workspace
    // selected" → NULL.
    let val = if workspace.is_empty() || crate::users::is_personal_workspace(&workspace) {
        None
    } else {
        Some(workspace.as_str())
    };
    store
        .set_selected_workspace(&sender, val)
        .await
        .map_err(|e| e.to_string())
}

#[derive(Debug, Clone)]
pub enum UsersMessage {
    Refreshed(Vec<UserRecordEntry>),
    RefreshError(String),
    DeleteUser(String),
    ConfirmDelete(String),
    CancelDelete,
    DeleteResult(Result<(), String>),

    /// Open the inline Telegram binding input for a user.
    OpenBindInput(String),
    /// Close the inline binding input.
    CloseBindInput,
    /// Inline binding text input changed.
    BindInputChanged(EditorAction),
    /// Confirm binding the entered Telegram @username or numeric id to the
    /// target user.
    SubmitBind(String),
    /// Unbind a Telegram channel from a user.
    UnbindChannel(String, String),
    /// Result of a bind/unbind operation.
    BindResult(Result<(), String>, String),

    /// Dismiss modals/panels (Escape key).
    Escape,

    /// Request toast notification.
    Toast(super::ToastMessage),
}

pub struct UsersState {
    pub(crate) users: Vec<UserRecordEntry>,
    pub(crate) load_state: super::common::AsyncLoadState,

    // Delete confirmation
    pub(crate) delete_target: Option<String>,

    // Telegram binding inline input (single-target, like delete_target)
    pub(crate) bind_target: Option<String>,
    pub(crate) bind_input: SingleLineEditorState,
    pub(crate) binding: bool,
}

impl UsersState {
    #[must_use]
    pub fn new() -> Self {
        Self {
            users: Vec::new(),
            load_state: super::common::AsyncLoadState::new(),
            delete_target: None,
            bind_target: None,
            bind_input: SingleLineEditorState::new(""),
            binding: false,
        }
    }

    #[allow(clippy::unused_self)]
    pub fn refresh(&self) -> Task<UsersMessage> {
        Task::perform(
            async {
                let store = user_store()?;
                let users = store.list_users().await.map_err(|e| e.to_string())?;
                Ok::<_, String>(users)
            },
            |res| match res {
                Ok(users) => UsersMessage::Refreshed(users),
                Err(e) => UsersMessage::RefreshError(e),
            },
        )
    }

    #[expect(clippy::too_many_lines)]
    pub fn update(&mut self, msg: UsersMessage) -> Task<UsersMessage> {
        match msg {
            UsersMessage::Refreshed(users) => {
                self.users = users;
                // A successful read replaces the previous failure.
                self.load_state.clear_error();

                Task::none()
            }
            UsersMessage::RefreshError(e) => {
                self.load_state.fail(e);
                Task::none()
            }
            UsersMessage::DeleteUser(sender) => {
                self.delete_target = Some(sender);
                Task::none()
            }
            UsersMessage::ConfirmDelete(sender) => {
                self.delete_target = None;
                Task::perform(
                    async move {
                        let store = user_store()?;
                        store.delete_user(&sender).await.map_err(|e| e.to_string())
                    },
                    UsersMessage::DeleteResult,
                )
            }
            UsersMessage::CancelDelete | UsersMessage::Escape => {
                self.delete_target = None;
                self.bind_target = None;
                self.bind_input.clear();
                Task::none()
            }
            UsersMessage::DeleteResult(Ok(())) => {
                self.load_state.clear_error();
                self.refresh()
            }
            UsersMessage::DeleteResult(Err(e)) => {
                self.load_state.fail(e.clone());
                Task::done(UsersMessage::Toast(super::ToastMessage::Error(e)))
            }
            UsersMessage::Toast(_) => Task::none(),
            UsersMessage::OpenBindInput(user_name) => {
                self.bind_target = Some(user_name);
                self.bind_input.clear();
                // Also cancel any pending delete confirmation (mutual exclusion).
                self.delete_target = None;
                Task::none()
            }
            UsersMessage::CloseBindInput => {
                self.bind_target = None;
                self.bind_input.clear();
                Task::none()
            }
            UsersMessage::BindInputChanged(action) => {
                if let Some(task) = super::common::focus_navigation_task(&action) {
                    return task;
                }
                self.bind_input.apply_action(action);
                Task::none()
            }
            UsersMessage::SubmitBind(user_name) => {
                self.binding = true;
                let user_clone = user_name.clone();
                let input = self.bind_input.text().clone();
                Task::perform(
                    async move {
                        let store = user_store()?;
                        store
                            .bind_telegram(&user_clone, &input)
                            .await
                            .map(|_| ())
                            .map_err(|e| e.to_string())
                    },
                    move |res| UsersMessage::BindResult(res, user_name),
                )
            }
            UsersMessage::UnbindChannel(user_name, identifier) => {
                self.binding = true;
                let user_clone = user_name.clone();
                Task::perform(
                    async move {
                        let store = user_store()?;
                        store
                            .unbind_channel(&user_clone, "telegram", &identifier)
                            .await
                            .map_err(|e| e.to_string())
                    },
                    move |res| UsersMessage::BindResult(res, user_name),
                )
            }
            UsersMessage::BindResult(Ok(()), _user_name) => {
                self.binding = false;
                self.bind_target = None;
                self.bind_input.clear();
                self.refresh()
            }
            UsersMessage::BindResult(Err(e), user_name) => {
                self.binding = false;
                // A failed bind leaves the editor open with the toast as its
                // only signal; a failed unbind has no editor to keep.
                if self.bind_target.as_deref() != Some(&user_name) {
                    self.load_state.fail(format!("Failed to unbind: {e}"));
                }
                Task::done(UsersMessage::Toast(super::ToastMessage::Error(e)))
            }
        }
    }
}