#![expect(
clippy::new_without_default,
clippy::struct_excessive_bools,
clippy::if_not_else,
clippy::collapsible_if
)]
pub mod board;
pub mod common;
pub mod context_menu;
pub mod diff;
pub mod diff_widget;
pub mod editor;
pub mod editor_widget;
pub mod git;
pub mod highlight;
pub mod home;
pub mod logs;
pub mod sessions;
pub mod settings;
pub mod shell;
pub mod text_rendering;
pub mod theme;
pub mod tool_failures;
pub mod users;
pub mod widget_helpers;
pub mod widgets;
pub mod workspaces;
use crate::board::{Ticket, TicketPhase};
use crate::logs::LogStore;
use iced::keyboard;
use iced::widget::Space;
use iced::widget::rule;
use iced::widget::{Column, Row, button, column, container, row, scrollable, text, tooltip};
use iced::window;
use iced::{Alignment, Color, Element, Length, Task};
use self::context_menu::ContextMenu;
use iced_fonts::lucide;
pub const JETBRAINS_MONO: iced::Font = iced::Font {
family: iced::font::Family::Name("JetBrains Mono"),
weight: iced::font::Weight::Normal,
stretch: iced::font::Stretch::Normal,
style: iced::font::Style::Normal,
};
use std::collections::HashMap;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use tokio::sync::broadcast;
pub static LOG_BROADCAST: OnceLock<broadcast::Sender<String>> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Page {
Home,
Sessions,
Logs,
Shell,
Editor,
Settings,
}
impl Page {
const fn sidebar_pages() -> &'static [Page] {
&[Page::Home, Page::Editor, Page::Shell]
}
const fn footer_pages() -> &'static [Page] {
&[Page::Sessions, Page::Logs, Page::Settings]
}
const fn label(self) -> &'static str {
match self {
Page::Home => "Home",
Page::Sessions => "Sessions",
Page::Logs => "Logs",
Page::Shell => "Shell",
Page::Editor => "Editor",
Page::Settings => "Settings",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastKind {
Success,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UpdateStatus {
Unavailable,
Available,
InProgress,
}
#[derive(Debug, Clone)]
pub struct Toast {
pub message: String,
pub kind: ToastKind,
pub created_at: Instant,
}
#[derive(Debug, Clone)]
pub enum ToastMessage {
Saved,
Created,
Deleted,
Error(String),
Warning(String),
SuccessMsg(String),
}
impl Toast {
fn new(message: String, kind: ToastKind) -> Self {
Self {
message,
kind,
created_at: Instant::now(),
}
}
fn from_toast_msg(msg: &ToastMessage) -> Self {
match msg {
ToastMessage::Saved => Toast::new("Saved".to_string(), ToastKind::Success),
ToastMessage::Created => Toast::new("Created".to_string(), ToastKind::Success),
ToastMessage::Deleted => Toast::new("Deleted".to_string(), ToastKind::Success),
ToastMessage::Error(s) => Toast::new(format!("Failed: {s}"), ToastKind::Error),
ToastMessage::Warning(s) => Toast::new(s.clone(), ToastKind::Warning),
ToastMessage::SuccessMsg(s) => Toast::new(s.clone(), ToastKind::Success),
}
}
const fn duration(&self) -> Duration {
match self.kind {
ToastKind::Success => Duration::from_secs(2),
ToastKind::Warning | ToastKind::Error => Duration::from_secs(4),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToggleKind {
Pause,
Maintenance,
}
impl ToggleKind {
fn label_on(self) -> &'static str {
match self {
Self::Pause => "Pipeline paused",
Self::Maintenance => "Maintainer enabled",
}
}
fn label_off(self) -> &'static str {
match self {
Self::Pause => "Pipeline resumed",
Self::Maintenance => "Maintainer disabled",
}
}
fn label_err(self) -> &'static str {
match self {
Self::Pause => "Failed to toggle pipeline pause",
Self::Maintenance => "Failed to toggle maintainer",
}
}
async fn persist_to_store(self, name: String, state: bool) -> Result<(), String> {
let store = crate::workspace::store();
match self {
Self::Pause => store
.set_paused(&name, state)
.await
.map_err(|e| e.to_string()),
Self::Maintenance => store
.set_maintenance_enabled(&name, state)
.await
.map_err(|e| e.to_string()),
}
}
}
#[derive(Debug, Clone)]
#[allow(private_interfaces)]
pub enum Message {
Boot(Result<(), String>),
Navigation(Page),
Tick,
Shutdown,
CloseRequested(window::Id),
WindowEvent(window::Id, window::Event),
FocusSearch,
EscapePressed,
UpdateBot,
UpdateResult(Result<String, String>),
Toggle(ToggleKind),
ToggleResult(ToggleKind, Result<(), String>, String, bool),
CheckpointAndExit,
WorkspaceStatesRefreshed(Vec<(String, bool, bool)>),
Nop,
BootWorkspaces(HashMap<String, WorkspaceInfo>, Option<String>),
Home(home::HomeMessage),
Logs(logs::LogMessage),
Board(board::BoardMessage),
Sessions(sessions::SessionsMessage),
DiffModal(diff::DiffMessage),
Git(git::GitMessage),
Shell(shell::ShellMessage),
Editor(editor::EditorMessage),
Settings(settings::SettingsMessage),
OpenDiffModal(Option<String>),
CloseDiffModal,
}
impl Message {
pub(crate) fn as_toast(&self) -> Option<&ToastMessage> {
match self {
Message::Home(home::HomeMessage::Toast(tm))
| Message::Board(board::BoardMessage::Toast(tm))
| Message::DiffModal(diff::DiffMessage::Toast(tm))
| Message::Git(git::GitMessage::Toast(tm))
| Message::Editor(editor::EditorMessage::Toast(tm))
| Message::Settings(
settings::SettingsMessage::WorkspaceMsg(workspaces::WorkspacesMessage::Toast(tm))
| settings::SettingsMessage::UserMsg(users::UsersMessage::Toast(tm)),
) => Some(tm),
_ => None,
}
}
pub(crate) fn as_link_url(&self) -> Option<&str> {
match self {
Message::Board(board::BoardMessage::LinkClicked(url))
| Message::Sessions(sessions::SessionsMessage::LinkClicked(url))
| Message::Settings(settings::SettingsMessage::WorkspaceMsg(
workspaces::WorkspacesMessage::LinkClicked(url),
)) => Some(url.as_str()),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct KeyboardMods {
pub is_cmd: bool,
pub is_platform_mod: bool,
pub ctrl_held: bool,
pub is_emacs_ctrl: bool,
pub altgr_active: bool,
}
impl KeyboardMods {
#[must_use]
pub(crate) fn is_nav_platform_mod(self) -> bool {
if cfg!(target_os = "macos") {
self.is_cmd
} else {
self.is_platform_mod
}
}
#[must_use]
pub(crate) fn is_text_platform_mod(self) -> bool {
if cfg!(target_os = "macos") {
self.is_cmd && !self.ctrl_held
} else {
self.is_platform_mod && !self.altgr_active
}
}
#[must_use]
pub(crate) fn is_shortcut_platform_mod(self) -> bool {
self.is_platform_mod && !self.is_emacs_ctrl && !self.altgr_active
}
}
pub(crate) fn detect_keyboard_mods(modifiers: keyboard::Modifiers) -> KeyboardMods {
let is_cmd = modifiers.command();
let is_platform_mod = modifiers.command() || modifiers.control();
let ctrl_held = modifiers.control();
let is_emacs_ctrl = cfg!(target_os = "macos") && modifiers.control() && !modifiers.command();
let altgr_active = !cfg!(target_os = "macos") && modifiers.alt() && modifiers.control();
KeyboardMods {
is_cmd,
is_platform_mod,
ctrl_held,
is_emacs_ctrl,
altgr_active,
}
}
pub static BOOT_LOG_STORE: OnceLock<LogStore> = OnceLock::new();
#[derive(Debug, Clone)]
struct WorkspaceInfo {
path: String,
paused: bool,
maintenance_enabled: bool,
}
pub struct Dashboard {
ready: bool,
boot_error: Option<String>,
page: Page,
log_store: Option<LogStore>,
last_size: iced::Size,
last_position: iced::Point,
toasts: Vec<Toast>,
workspaces: HashMap<String, WorkspaceInfo>,
selected_workspace_name: Option<String>,
selected_user_name: Option<String>,
update_status: UpdateStatus,
logs_state: logs::LogsState,
board_state: board::BoardState,
sessions_state: sessions::SessionsState,
diff_state: diff::DiffState,
home_state: home::HomeState,
shell_state: shell::ShellState,
editor_state: editor::EditorState,
settings_state: settings::SettingsState,
show_diff_modal: bool,
git_state: git::GitState,
}
impl Dashboard {
#[must_use]
pub fn loading(update_available: bool) -> Self {
Self {
ready: false,
boot_error: None,
page: Page::Home,
log_store: None,
last_size: iced::Size::new(1500.0, 800.0),
last_position: iced::Point::new(-1.0, -1.0),
toasts: Vec::new(),
workspaces: HashMap::new(),
selected_workspace_name: None,
selected_user_name: None,
update_status: if update_available {
UpdateStatus::Available
} else {
UpdateStatus::Unavailable
},
logs_state: logs::LogsState::new(),
board_state: board::BoardState::new(),
sessions_state: sessions::SessionsState::new(),
diff_state: diff::DiffState::new(),
home_state: home::HomeState::new(),
shell_state: shell::ShellState::new(),
editor_state: editor::EditorState::new(),
settings_state: settings::SettingsState::new(),
show_diff_modal: false,
git_state: git::GitState::new(),
}
}
fn finish_boot(&mut self, result: Result<(), String>) -> Task<Message> {
match result {
Ok(()) => {
let log_store = BOOT_LOG_STORE
.get()
.cloned()
.expect("BOOT_LOG_STORE set before Boot(Ok)");
self.ready = true;
self.boot_error = None;
let refresh_logs = self.logs_state.refresh(&log_store);
let refresh_board = self.board_state.refresh();
self.log_store = Some(log_store);
let prev = read_window_state();
self.selected_user_name = prev.selected_user;
let boot_workspaces = Task::perform(
load_workspace_options(prev.selected_workspace),
std::convert::identity,
);
Task::batch([
refresh_logs.map(Message::Logs),
refresh_board.map(Message::Board),
boot_workspaces,
])
}
Err(e) => {
self.boot_error = Some(e);
Task::none()
}
}
}
fn selected_workspace_info(&self) -> Option<&WorkspaceInfo> {
self.selected_workspace_name
.as_ref()
.and_then(|name| self.workspaces.get(name))
}
fn paused(&self) -> bool {
self.selected_workspace_info().is_some_and(|w| w.paused)
}
fn maintenance_enabled(&self) -> bool {
self.selected_workspace_info()
.is_some_and(|w| w.maintenance_enabled)
}
pub const fn theme(&self) -> iced::Theme {
iced::Theme::Dark
}
fn persist_window_state(&self) {
save_window_state(
self.last_position,
self.last_size,
self.selected_workspace_name.as_deref(),
self.selected_user_name.as_deref(),
);
}
fn save_and_exit(&self) -> Task<Message> {
self.persist_window_state();
Task::perform(crate::checkpoint::checkpoint_all_databases(), |()| {
Message::CheckpointAndExit
})
}
pub fn title(&self) -> String {
let page_name = self.page.label();
format!("MahBot — {page_name}")
}
fn apply_boot_workspaces(
&mut self,
workspaces: HashMap<String, WorkspaceInfo>,
restored_name: &str,
) -> Task<Message> {
self.workspaces = workspaces;
if let Some(ref user_name) = self.selected_user_name {
self.home_state.selected_user = Some(user_name.clone());
}
let load_users = self.home_state.load_users().map(Message::Home);
let ws_name = if restored_name.is_empty() {
self.selected_workspace_name = None;
String::new()
} else {
self.selected_workspace_name = Some(restored_name.to_owned());
restored_name.to_owned()
};
Task::batch([self.propagate_workspace_selection(&ws_name), load_users])
}
fn navigate_to(&mut self, page: Page) -> Task<Message> {
self.page = page;
self.sessions_state.set_page_active(page == Page::Sessions);
match page {
Page::Logs | Page::Shell | Page::Editor => Task::none(),
Page::Home => {
let load_users = self.home_state.load_users().map(Message::Home);
let snap = iced::widget::operation::snap_to_end::<Message>(home::CHAT_SCROLL_ID);
let board_refresh = self.board_state.refresh().map(Message::Board);
Task::batch([load_users, snap, board_refresh])
}
Page::Sessions => self.sessions_state.refresh().map(Message::Sessions),
Page::Settings => {
self.settings_state.refresh();
let refresh_workspaces = self
.settings_state
.workspaces_state
.refresh()
.map(|msg| Message::Settings(settings::SettingsMessage::WorkspaceMsg(msg)));
let refresh_users = self
.settings_state
.users_state
.refresh()
.map(|msg| Message::Settings(settings::SettingsMessage::UserMsg(msg)));
Task::batch([refresh_workspaces, refresh_users])
}
}
}
fn toggle_workspace_state(&mut self, kind: ToggleKind) -> Task<Message> {
let current_state = match kind {
ToggleKind::Pause => self.paused(),
ToggleKind::Maintenance => self.maintenance_enabled(),
};
let Some(ws_name) = self.active_workspace_name() else {
self.toasts.push(Toast::new(
"No workspace selected — select a workspace first".to_string(),
ToastKind::Warning,
));
return Task::none();
};
let new_state = !current_state;
let ws_name_clone = ws_name.clone();
Task::perform(
kind.persist_to_store(ws_name_clone, new_state),
move |result| Message::ToggleResult(kind, result, ws_name, new_state),
)
}
fn finish_toggle(
&mut self,
kind: ToggleKind,
result: Result<(), String>,
ws_name: &str,
intended_state: bool,
) -> Task<Message> {
match result {
Ok(()) => {
let label = if intended_state {
kind.label_on()
} else {
kind.label_off()
};
self.toasts.push(Toast::new(
format!("{label} for {ws_name}"),
ToastKind::Success,
));
refresh_workspace_states_task()
}
Err(e) => {
self.toasts.push(Toast::new(
format!("{}: {e}", kind.label_err()),
ToastKind::Error,
));
Task::none()
}
}
}
fn process_tick(&mut self) -> Task<Message> {
let now = Instant::now();
self.toasts
.retain(|t| now.duration_since(t.created_at) < t.duration());
let ws_refresh = if self.has_active_workspace() {
refresh_workspace_states_task()
} else {
Task::none()
};
let page_task = match self.page {
Page::Home if !self.board_state.load_state.loading() => {
self.board_state.load_state.start_loading();
self.board_state.refresh().map(Message::Board)
}
Page::Sessions if !self.sessions_state.load_state.loading() => {
self.sessions_state.load_state.start_loading();
self.sessions_state.refresh().map(Message::Sessions)
}
Page::Settings => {
let ws_loading = self.settings_state.workspaces_state.load_state.loading();
let us_loading = self.settings_state.users_state.load_state.loading();
let ws = if !ws_loading {
self.settings_state
.workspaces_state
.load_state
.start_loading();
self.settings_state
.workspaces_state
.refresh()
.map(|msg| Message::Settings(settings::SettingsMessage::WorkspaceMsg(msg)))
} else {
Task::none()
};
let us = if !us_loading {
self.settings_state.users_state.load_state.start_loading();
self.settings_state
.users_state
.refresh()
.map(|msg| Message::Settings(settings::SettingsMessage::UserMsg(msg)))
} else {
Task::none()
};
Task::batch([ws, us])
}
_ => Task::none(),
};
let git_tasks = self.git_state.update_tick().map(Message::Git);
Task::batch([ws_refresh, page_task, git_tasks])
}
fn process_escape(&mut self) -> Task<Message> {
if self.show_diff_modal {
self.show_diff_modal = false;
Task::done(Message::DiffModal(diff::DiffMessage::ClearCommitState))
} else if self.git_state.is_modal_open() {
self.git_state
.update(git::GitMessage::CloseModal)
.map(Message::Git)
} else {
match self.page {
Page::Home => self
.board_state
.update(board::BoardMessage::Escape)
.map(Message::Board),
Page::Shell => Task::none(),
Page::Logs => self
.logs_state
.update(
logs::LogMessage::Escape,
self.log_store.as_ref().expect("ready"),
)
.map(Message::Logs),
Page::Sessions => self
.sessions_state
.update(sessions::SessionsMessage::Escape)
.map(Message::Sessions),
Page::Editor => self
.editor_state
.update(editor::EditorMessage::Escape)
.map(Message::Editor),
Page::Settings => self
.settings_state
.update(settings::SettingsMessage::Escape)
.map(Message::Settings),
}
}
}
fn process_settings_message(&mut self, msg: settings::SettingsMessage) -> Task<Message> {
let mut intercept_task: Option<Task<Message>> = None;
if let settings::SettingsMessage::UserMsg(ref inner) = msg {
match inner {
users::UsersMessage::SwitchUser(user) => {
intercept_task = Some(
Task::done(home::HomeMessage::UserSelected(user.clone()))
.map(Message::Home),
);
}
users::UsersMessage::DeleteResult(Ok(()), deleted_user) => {
if self.selected_user_name.as_deref() == Some(deleted_user.as_str()) {
self.selected_user_name = Some("admin".to_string());
self.persist_window_state();
intercept_task = Some(
Task::done(home::HomeMessage::UserSelected("admin".to_string()))
.map(Message::Home),
);
}
}
users::UsersMessage::UpdateWorkspace(sender, ws)
if self.selected_user_name.as_deref() == Some(sender.as_str()) =>
{
intercept_task = Some(self.select_workspace(ws));
}
_ => {}
}
}
let needs_global_reload = matches!(
msg,
settings::SettingsMessage::WorkspaceMsg(workspaces::WorkspacesMessage::DeleteResult(
Ok(())
)) | settings::SettingsMessage::AddWorkspaceResult(Ok(_))
);
let settings_task = self.settings_state.update(msg).map(Message::Settings);
let tasks = [
intercept_task,
Some(settings_task),
needs_global_reload.then(|| self.reload_workspace_options()),
];
Task::batch(tasks.into_iter().flatten())
}
#[allow(clippy::too_many_lines)]
pub fn update(&mut self, message: Message) -> Task<Message> {
if self.ready {
if let Some(tm) = message.as_toast() {
self.toasts.push(Toast::from_toast_msg(tm));
return Task::none();
}
if let Some(url) = message.as_link_url() {
open_url(url);
return Task::none();
}
}
match message {
Message::Boot(result) => self.finish_boot(result),
Message::BootWorkspaces(workspaces, restored_name) => {
self.apply_boot_workspaces(workspaces, restored_name.as_deref().unwrap_or(""))
}
Message::Shutdown | Message::CloseRequested(_) => self.save_and_exit(),
Message::CheckpointAndExit => iced::exit(),
Message::WindowEvent(_id, event) => {
match event {
window::Event::Resized(new_size) => self.last_size = new_size,
window::Event::Moved(new_pos) => self.last_position = new_pos,
_ => {}
}
Task::none()
}
Message::CloseDiffModal => {
self.show_diff_modal = false;
Task::done(Message::DiffModal(diff::DiffMessage::ClearCommitState))
}
Message::Navigation(_) if !self.ready => Task::none(),
Message::Navigation(page) => self.navigate_to(page),
_ if !self.ready => Task::none(),
Message::Tick => self.process_tick(),
Message::Home(msg) => {
if let home::HomeMessage::RequestWorkspaceChange(ref name) = msg {
return self.select_workspace(name);
}
if let home::HomeMessage::WorkspacePicked(ref name) = msg {
return self.select_workspace(name);
}
if let home::HomeMessage::UserSelected(ref user) = msg {
self.selected_user_name = Some(user.clone());
self.persist_window_state();
}
self.home_state.update(msg).map(Message::Home)
}
Message::Shell(msg) => self.shell_state.update(msg).map(Message::Shell),
Message::Logs(msg) => self
.logs_state
.update(msg, self.log_store.as_ref().expect("ready"))
.map(Message::Logs),
Message::Board(msg) => {
if let board::BoardMessage::ViewCommitDiff {
ref commit_hash, ..
} = msg
{
return self.open_diff_modal(Some(commit_hash.clone()));
}
self.board_state.update(msg).map(Message::Board)
}
Message::Sessions(msg) => self.sessions_state.update(msg).map(Message::Sessions),
Message::DiffModal(diff::DiffMessage::CloseModal) => {
self.show_diff_modal = false;
Task::none()
}
Message::DiffModal(msg) => self.diff_state.update(msg).map(Message::DiffModal),
Message::Editor(msg) => self.editor_state.update(msg).map(Message::Editor),
Message::Settings(msg) => self.process_settings_message(msg),
Message::OpenDiffModal(commit_hash) => self.open_diff_modal(commit_hash),
Message::Git(msg) => {
if matches!(msg, git::GitMessage::OpenModal) {
self.show_diff_modal = false;
}
self.git_state.update(msg).map(Message::Git)
}
Message::FocusSearch => match self.page {
Page::Logs => self
.logs_state
.update(
logs::LogMessage::FocusSearch,
self.log_store.as_ref().expect("ready"),
)
.map(Message::Logs),
_ => Task::none(),
},
Message::EscapePressed => self.process_escape(),
Message::UpdateBot => {
if self.update_status != UpdateStatus::Available {
return Task::none();
}
self.update_status = UpdateStatus::InProgress;
self.persist_window_state();
Task::perform(
async {
crate::self_update::execute_update()
.await
.map_err(|e| format!("{e:#}"))
.map(|()| "ok".to_string())
},
Message::UpdateResult,
)
}
Message::UpdateResult(result) => {
if let Err(err) = result {
self.update_status = UpdateStatus::Available;
self.toasts
.push(Toast::from_toast_msg(&ToastMessage::Error(err)));
}
Task::none()
}
Message::Toggle(kind) => self.toggle_workspace_state(kind),
Message::ToggleResult(kind, result, ws_name, intended_state) => {
self.finish_toggle(kind, result, &ws_name, intended_state)
}
Message::WorkspaceStatesRefreshed(states) => {
for (name, paused, maintenance_enabled) in states {
if let Some(info) = self.workspaces.get_mut(&name) {
info.paused = paused;
info.maintenance_enabled = maintenance_enabled;
} else {
self.workspaces.insert(
name,
WorkspaceInfo {
path: String::new(),
paused,
maintenance_enabled,
},
);
}
}
Task::none()
}
Message::Nop => Task::none(),
}
}
fn open_diff_modal(&mut self, commit_hash: Option<String>) -> Task<Message> {
let close_board = self
.board_state
.update(board::BoardMessage::CloseModal)
.map(Message::Board);
self.show_diff_modal = true;
let _ = self.git_state.update(git::GitMessage::CloseModal);
let ws = self.selected_workspace_name.clone().unwrap_or_default();
let diff_task = match commit_hash {
Some(hash) => Task::done(Message::DiffModal(diff::DiffMessage::NavigateToCommit(
ws, hash,
))),
None => Task::done(Message::DiffModal(diff::DiffMessage::BackToWorkingTree)),
};
Task::batch([close_board, diff_task])
}
fn select_workspace(&mut self, name: &str) -> Task<Message> {
self.selected_workspace_name = if name.is_empty() {
None
} else {
Some(name.to_string())
};
self.persist_window_state();
self.propagate_workspace_selection(name)
}
fn propagate_workspace_selection(&mut self, name: &str) -> Task<Message> {
let ws_path = self.workspaces.get(name).map(|w| w.path.clone());
self.board_state.workspace_name = Some(name.to_string());
let board_refresh = self.board_state.refresh().map(Message::Board);
let personal_path = if name.is_empty() {
self.selected_user_name.as_ref().map(|u| {
crate::users::personal_workspace_path(u)
.to_string_lossy()
.to_string()
})
} else {
None
};
let (page_name, page_path) =
resolve_dashboard_workspace_path(name, ws_path.as_deref(), personal_path.as_deref());
let editor_task: Task<Message> = Task::done(editor::EditorMessage::WorkspaceSelected(
page_name.clone(),
page_path.clone(),
))
.map(Message::Editor);
let diff_name = name.to_string();
let diff_path = personal_path.clone();
let resolved_path = ws_path.or_else(|| personal_path.clone());
let git_task: Task<Message> = self
.git_state
.set_workspace_path(resolved_path)
.map(Message::Git);
let diff_task: Task<Message> =
Task::done(diff::DiffMessage::WorkspaceSelected(diff_name, diff_path))
.map(Message::DiffModal);
let shell_task: Task<Message> =
Task::done(shell::ShellMessage::WorkspaceSelected(page_name, page_path))
.map(Message::Shell);
let home_name = name.to_string();
let home_task: Task<Message> =
Task::done(home::HomeMessage::WorkspaceChanged(Some(home_name))).map(Message::Home);
Task::batch([
board_refresh,
editor_task,
diff_task,
shell_task,
home_task,
git_task,
])
}
fn reload_workspace_options(&self) -> Task<Message> {
let prev_selection = self.selected_workspace_name.clone();
Task::perform(
load_workspace_options(prev_selection),
std::convert::identity,
)
}
fn active_workspace_name(&self) -> Option<String> {
match self.selected_workspace_name.as_deref() {
Some(n) if !n.is_empty() => Some(n.to_string()),
_ => None,
}
}
fn has_active_workspace(&self) -> bool {
self.selected_workspace_name
.as_deref()
.is_some_and(|n| !n.is_empty())
}
#[allow(clippy::too_many_lines)]
pub fn view(&self) -> Element<'_, Message> {
if let Some(err) = &self.boot_error {
return container(
column![
text("MahBot failed to start")
.size(20)
.color(theme::STATUS_ERROR),
text(err).size(14).color(theme::TEXT_SECONDARY),
]
.spacing(12)
.padding(24),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into();
}
if !self.ready {
return container(
column![
text("MahBot").size(24).color(theme::ACCENT),
text("Starting…").size(16).color(theme::TEXT_MUTED),
]
.spacing(16)
.align_x(Alignment::Center),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into();
}
let sidebar = self.sidebar_view();
let footer = self.footer_view();
let content = match self.page {
Page::Home => {
let home_view = self.home_state.view().map(Message::Home);
let sidebar = ticket_sidebar(&self.board_state);
let home_view: Element<'_, Message> = ContextMenu::new(
home_view,
vec![(
"Clear chat".into(),
Message::Home(home::HomeMessage::ClearChat),
)],
)
.into();
let sidebar: Element<'_, Message> = ContextMenu::new(
sidebar,
vec![(
"Archive done & cancelled".into(),
Message::Board(board::BoardMessage::ArchiveAllCompleted),
)],
)
.into();
let base = row![
container(home_view).width(Length::FillPortion(7)),
container(sidebar).width(Length::FillPortion(3))
];
let modal = self.board_state.render_modal_overlay().map(Message::Board);
iced::widget::stack([base.into(), modal]).into()
}
Page::Logs => self.logs_state.view().map(Message::Logs),
Page::Sessions => self.sessions_state.view().map(Message::Sessions),
Page::Shell => self.shell_state.view().map(Message::Shell),
Page::Editor => self.editor_state.view().map(Message::Editor),
Page::Settings => self
.settings_state
.view(self.selected_user_name.as_deref())
.map(Message::Settings),
};
let body = column![
row![sidebar, content]
.width(Length::Fill)
.height(Length::Fill),
footer,
]
.width(Length::Fill)
.height(Length::Fill);
let overlay: Element<'_, Message> = if self.toasts.is_empty() {
container(text(""))
.width(Length::Shrink)
.height(Length::Shrink)
.into()
} else {
let mut toast_col = Column::new().spacing(6).align_x(Alignment::Center);
for toast in &self.toasts {
let (color, _bg) = match toast.kind {
ToastKind::Success => (theme::STATUS_SUCCESS, theme::BG_ELEVATED),
ToastKind::Warning => (theme::STATUS_WARNING, theme::BG_ELEVATED),
ToastKind::Error => (theme::STATUS_ERROR, theme::BG_ELEVATED),
};
let pill = container(text(&toast.message).size(12).color(color))
.padding([6, 14])
.style(move |_theme: &iced::Theme| container::Style {
background: Some(iced::Background::Color(theme::BG_ELEVATED)),
border: iced::Border {
radius: 20.0.into(),
width: 1.0,
color: theme::BORDER,
},
..container::Style::default()
});
toast_col = toast_col.push(pill);
}
container(toast_col)
.width(Length::Fill)
.align_x(Alignment::Center)
.padding(iced::Padding {
bottom: 44.0,
..Default::default()
})
.align_bottom(Length::Fill)
.into()
};
let diff_overlay: Element<'_, Message> = if self.show_diff_modal {
render_diff_modal(&self.diff_state)
} else {
container(text(""))
.width(Length::Shrink)
.height(Length::Shrink)
.into()
};
let branch_overlay: Element<'_, Message> = if self.git_state.is_modal_open() {
let inner = self.git_state.view().map(Message::Git);
modal_overlay(inner, Message::Git(git::GitMessage::CloseModal))
} else {
container(text(""))
.width(Length::Shrink)
.height(Length::Shrink)
.into()
};
iced::widget::stack![body, diff_overlay, branch_overlay, overlay].into()
}
}
fn modal_overlay<'a>(
inner: impl Into<Element<'a, Message>>,
on_close: Message,
) -> Element<'a, Message> {
let dialog = container(inner)
.width(Length::Fill)
.height(Length::Fill)
.padding(16)
.style(theme::dialog_container_style);
let centered = row![
Space::new().width(Length::FillPortion(1)), dialog.width(Length::FillPortion(8)), Space::new().width(Length::FillPortion(1)), ]
.width(Length::Fill)
.height(Length::Fill);
widget_helpers::modal_backdrop(centered, on_close, 0.5)
}
fn render_diff_modal(diff_state: &diff::DiffState) -> Element<'_, Message> {
let viewing_commit = diff_state.is_viewing_commit();
let header: Element<'_, Message> = if viewing_commit {
let msg = diff_state
.commit_message()
.unwrap_or("(no commit message)")
.to_string();
let hash = diff_state.commit_short_hash().unwrap_or("????????");
column![
text(msg).size(18).color(theme::TEXT_PRIMARY),
text(hash).size(12).color(theme::TEXT_MUTED),
]
.spacing(2)
.padding(iced::Padding {
top: 0.0,
right: 0.0,
bottom: 12.0,
left: 0.0,
})
.into()
} else {
column![
text("Uncommitted changes")
.size(18)
.color(theme::TEXT_PRIMARY),
text("Working tree diff \u{2014} press Escape to close")
.size(11)
.color(theme::TEXT_MUTED),
]
.spacing(4)
.padding(iced::Padding {
top: 0.0,
right: 0.0,
bottom: 12.0,
left: 0.0,
})
.into()
};
let diff_content: Element<'_, diff::DiffMessage> = diff_state.view();
let inner = column![header, diff_content.map(Message::DiffModal)].spacing(0);
modal_overlay(inner, Message::CloseDiffModal)
}
fn ticket_sidebar(board_state: &board::BoardState) -> Element<'_, Message> {
let (pending, pipeline, completed) = board::BoardState::partition_tickets(&board_state.tickets);
let pinned: Vec<&Ticket> = pipeline
.iter()
.filter(|t| t.phase != TicketPhase::ReadyForDevelopment)
.copied()
.collect();
let ready: Vec<&Ticket> = pipeline
.iter()
.filter(|t| t.phase == TicketPhase::ReadyForDevelopment)
.copied()
.collect();
let is_empty = pending.is_empty() && pipeline.is_empty() && completed.is_empty();
let body: Element<'_, Message> = if !board_state.load_state.has_loaded() {
column![
Space::new().height(8),
text("Loading…").size(12).color(theme::TEXT_MUTED),
]
.spacing(4)
.padding([8, 0])
.into()
} else if is_empty {
column![
Space::new().height(8),
text("No tickets").size(12).color(theme::TEXT_MUTED),
]
.spacing(4)
.padding([8, 0])
.into()
} else {
let mut groups = Column::new().spacing(8);
if !pinned.is_empty() {
groups = groups.push(group_section("In Progress", &pinned, board_state));
}
if !ready.is_empty() {
groups = groups.push(group_section("Ready", &ready, board_state));
}
if !pending.is_empty() {
groups = groups.push(group_section("Pending", &pending, board_state));
}
if !completed.is_empty() {
groups = groups.push(group_section("Completed", &completed, board_state));
}
scrollable(groups)
.height(Length::Fill)
.direction(theme::vertical_scrollbar())
.style(theme::scrollbar_style)
.into()
};
let content = column![Space::new().height(8), body].spacing(0);
container(content)
.padding([8, 12])
.width(Length::Fill)
.height(Length::Fill)
.style(theme::surface_container_style)
.into()
}
fn group_section<'a>(
label: &'static str,
tickets: &[&'a Ticket],
board_state: &'a board::BoardState,
) -> Element<'a, Message> {
let header = text(label).size(11).color(theme::TEXT_SECONDARY);
let mut cards = Column::new().spacing(2);
for ticket in tickets {
cards = cards.push(board_state.render_ticket_card(ticket).map(Message::Board));
}
column![header, Space::new().height(4), cards]
.spacing(0)
.into()
}
impl Dashboard {
pub fn subscription(&self) -> iced::Subscription<Message> {
if !self.ready {
return iced::Subscription::none();
}
iced::Subscription::batch([
iced::time::every(Duration::from_secs(1)).map(|_| Message::Tick),
window::close_requests().map(Message::CloseRequested),
window::resize_events()
.map(|(id, size)| Message::WindowEvent(id, window::Event::Resized(size))),
window::events().filter_map(|(id, event)| {
matches!(
&event,
window::Event::Moved(_) | window::Event::Opened { .. }
)
.then_some(Message::WindowEvent(id, event))
}),
keyboard::listen().filter_map(|event| {
use keyboard::{Event, Key};
let pressed = matches!(event, Event::KeyPressed { .. });
if !pressed {
return None;
}
let Event::KeyPressed {
key,
modifiers,
physical_key,
..
} = event
else {
return None;
};
let km = detect_keyboard_mods(modifiers);
let latin = key.to_latin(physical_key);
if !km.altgr_active && km.is_cmd && !modifiers.shift() && latin == Some('f') {
return Some(Message::FocusSearch);
}
if let Key::Named(iced::keyboard::key::Named::Escape) = key {
Some(Message::EscapePressed)
} else if km.is_cmd && !km.altgr_active {
if let Some(digit) = latin.and_then(|c| c.to_digit(10)) {
let idx = digit as usize;
if idx >= 1 {
let pages = Page::sidebar_pages();
if let Some(page) = pages.get(idx - 1).copied() {
return Some(Message::Navigation(page));
}
}
}
None
} else {
None
}
}),
self.shell_state.subscription().map(Message::Shell),
self.logs_state.subscription().map(Message::Logs),
self.board_state.subscription().map(Message::Board),
self.sessions_state.subscription().map(Message::Sessions),
self.editor_state.subscription().map(Message::Editor),
self.home_state.subscription().map(Message::Home),
iced::Subscription::run(shutdown_subscription),
if self.show_diff_modal {
self.diff_state.subscription().map(Message::DiffModal)
} else {
iced::Subscription::none()
},
])
}
}
fn shutdown_subscription() -> impl futures_util::Stream<Item = Message> {
use iced::futures::channel::mpsc;
iced::stream::channel(1, |mut output: mpsc::Sender<Message>| async move {
crate::shutdown::shutdown_token().cancelled().await;
let _ = output.try_send(Message::Shutdown);
})
}
fn page_icon(page: Page, size: u32, color: Color) -> Element<'static, Message> {
let text = match page {
Page::Home => lucide::layout_dashboard::<iced::Theme, iced::Renderer>(),
Page::Editor => lucide::pencil_line::<iced::Theme, iced::Renderer>(),
Page::Shell => lucide::terminal::<iced::Theme, iced::Renderer>(),
Page::Sessions => lucide::scroll_text::<iced::Theme, iced::Renderer>(),
Page::Logs => lucide::activity::<iced::Theme, iced::Renderer>(),
Page::Settings => lucide::settings::<iced::Theme, iced::Renderer>(),
};
text.size(size).color(color).into()
}
fn render_sidebar_toggle<'a>(
icon: Element<'a, Message>,
tooltip_text: &'a str,
action: Option<Message>,
) -> Element<'a, Message> {
tooltip(
button(
container(icon)
.width(Length::Fill)
.center_x(Length::Fill)
.padding([4, 0]),
)
.width(Length::Fill)
.padding(0)
.style(theme::button_text)
.on_press_maybe(action),
text(tooltip_text).size(11),
tooltip::Position::Top,
)
.style(theme::tooltip_style)
.into()
}
impl Dashboard {
fn sidebar_view(&self) -> Element<'_, Message> {
container(
column![
self.render_sidebar_nav(),
Space::new().height(Length::Fill),
self.render_maintainer_toggle(),
self.render_pause_toggle(),
]
.spacing(2),
)
.width(Length::Fixed(56.0))
.height(Length::Fill)
.style(theme::surface_container_style)
.padding(12)
.into()
}
fn render_sidebar_nav(&self) -> Element<'_, Message> {
let mut col = Column::new().spacing(2);
for page in Page::sidebar_pages() {
let is_active = self.page == *page;
let has_any_workspace =
self.selected_workspace_name.is_some() || self.selected_user_name.is_some();
let requires_workspace = matches!(*page, Page::Editor | Page::Shell);
let disabled = requires_workspace && !has_any_workspace;
let color = if is_active {
theme::ACCENT
} else if disabled {
theme::TEXT_FAINT
} else {
theme::TEXT_MUTED
};
let icon: iced::Element<'_, Message> = page_icon(*page, 28, color);
let btn = button(
container(icon)
.width(Length::Fill)
.center_x(Length::Fill)
.padding([4, 0]),
)
.width(Length::Fill)
.padding(0)
.style(theme::button_text)
.on_press_maybe(if disabled {
None
} else {
Some(Message::Navigation(*page))
});
let tooltip_text = if disabled {
format!("Select a workspace to access {}", page.label())
} else {
page.label().to_string()
};
let nav_btn = tooltip(btn, text(tooltip_text).size(11), tooltip::Position::Right)
.style(theme::tooltip_style);
col = col.push(nav_btn);
}
col.into()
}
fn render_maintainer_toggle(&self) -> Element<'_, Message> {
let has_ws = self.has_active_workspace();
let maint_icon = column![
text("Maint").size(8).color(theme::TEXT_MUTED),
text(if self.maintenance_enabled() {
"ON"
} else {
"OFF"
})
.size(9)
.color(if self.maintenance_enabled() {
theme::ACCENT
} else {
theme::TEXT_MUTED
}),
]
.spacing(0)
.align_x(Alignment::Center);
let tooltip_text = if !has_ws {
"Select a workspace to toggle maintainer"
} else if self.maintenance_enabled() {
"Maintainer ON"
} else {
"Maintainer OFF"
};
render_sidebar_toggle(
maint_icon.into(),
tooltip_text,
if has_ws {
Some(Message::Toggle(ToggleKind::Maintenance))
} else {
None
},
)
}
fn render_pause_toggle(&self) -> Element<'_, Message> {
let has_ws = self.has_active_workspace();
let pause_icon = if !has_ws {
lucide::pause::<iced::Theme, iced::Renderer>()
.size(28)
.color(theme::TEXT_FAINT)
} else if self.paused() {
lucide::play::<iced::Theme, iced::Renderer>()
.size(28)
.color(theme::ACCENT)
} else {
lucide::pause::<iced::Theme, iced::Renderer>()
.size(28)
.color(theme::TEXT_MUTED)
};
let tooltip_text = if !has_ws {
"Select a workspace to pause"
} else if self.paused() {
"Resume pipeline"
} else {
"Pause pipeline"
};
render_sidebar_toggle(
pause_icon.into(),
tooltip_text,
if has_ws {
Some(Message::Toggle(ToggleKind::Pause))
} else {
None
},
)
}
fn render_update_button(&self) -> Option<Element<'_, Message>> {
let (update_color, tooltip_text, clickable) = match self.update_status {
UpdateStatus::Unavailable => return None,
UpdateStatus::Available => (theme::ACCENT, "Update MahBot", true),
UpdateStatus::InProgress => (theme::TEXT_FAINT, "Updating…", false),
};
let update_icon = lucide::refresh_cw::<iced::Theme, iced::Renderer>()
.size(24)
.color(update_color);
let update_btn = button(update_icon)
.style(theme::button_text)
.padding(3)
.on_press_maybe(if clickable {
Some(Message::UpdateBot)
} else {
None
});
let update_tooltip = tooltip_text;
Some(
tooltip(
update_btn,
text(update_tooltip).size(11),
tooltip::Position::Top,
)
.style(theme::tooltip_style)
.into(),
)
}
fn render_nav_icons(&self) -> Element<'_, Message> {
let mut icons: Vec<Element<'_, Message>> = Vec::with_capacity(3);
for page in Page::footer_pages() {
let is_active = self.page == *page;
let color = if is_active {
theme::ACCENT
} else {
theme::TEXT_MUTED
};
let icon: Element<'_, Message> = page_icon(*page, 24, color);
let btn = button(icon)
.style(theme::button_text)
.padding(3)
.on_press(Message::Navigation(*page));
icons.push(
tooltip(btn, text(page.label()).size(11), tooltip::Position::Top)
.style(theme::tooltip_style)
.into(),
);
}
Row::with_children(icons)
.spacing(6)
.align_y(Alignment::Center)
.into()
}
fn render_git_divider() -> Element<'static, Message> {
rule::vertical(1)
.style(|_: &iced::Theme| rule::Style {
color: theme::TEXT_MUTED,
radius: 0.0.into(),
fill_mode: rule::FillMode::Padded(8),
snap: true,
})
.into()
}
fn render_git_branch(&self) -> Option<Element<'_, Message>> {
let b = self.git_state.current_branch()?;
let truncated = if b.len() > 20 {
let mut end = 19;
while !b.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &b[..end])
} else {
b.to_string()
};
let branch_content = row![
lucide::git_branch::<iced::Theme, iced::Renderer>()
.size(24)
.color(theme::ACCENT),
text(truncated).size(16).color(theme::ACCENT),
]
.spacing(6)
.align_y(Alignment::Center);
Some(
button(branch_content)
.style(theme::button_text)
.padding(3)
.on_press(Message::Git(git::GitMessage::OpenModal))
.into(),
)
}
fn render_git_sync(&self) -> Option<Element<'_, Message>> {
let (behind, ahead) = self.git_state.behind_ahead()?;
if behind == 0 && ahead == 0 {
return None;
}
let sync_text_label: Element<'_, Message> = {
let mut parts: Vec<Element<'_, Message>> = Vec::new();
if ahead > 0 {
parts.push(
lucide::arrow_up::<iced::Theme, iced::Renderer>()
.size(16)
.color(theme::TEXT_MUTED)
.into(),
);
parts.push(
text(format!("{ahead}"))
.size(16)
.color(theme::TEXT_MUTED)
.into(),
);
}
if behind > 0 {
if ahead > 0 {
parts.push(Space::new().width(8).into());
}
parts.push(
lucide::arrow_down::<iced::Theme, iced::Renderer>()
.size(16)
.color(theme::TEXT_MUTED)
.into(),
);
parts.push(
text(format!("{behind}"))
.size(16)
.color(theme::TEXT_MUTED)
.into(),
);
}
Row::with_children(parts)
.spacing(2)
.align_y(Alignment::Center)
.into()
};
let sync_icon_color = if self.git_state.is_syncing() {
theme::TEXT_MUTED
} else {
theme::ACCENT
};
let sync_content = row![
lucide::refresh_cw::<iced::Theme, iced::Renderer>()
.size(24)
.color(sync_icon_color),
sync_text_label,
]
.spacing(6)
.align_y(Alignment::Center);
Some(
button(sync_content)
.style(theme::button_text)
.padding(3)
.on_press_maybe(if self.git_state.is_syncing() {
None
} else {
Some(Message::Git(git::GitMessage::Sync))
})
.into(),
)
}
fn render_git_diff_stats(&self) -> Option<Element<'_, Message>> {
let (added, removed) = self.git_state.diff_stats()?;
if added == 0 && removed == 0 {
return None;
}
let stats_row = widgets::diff_stats_row::<Message>(added, removed, 15.0);
Some(
button(stats_row)
.style(theme::button_text)
.padding(3)
.on_press(Message::OpenDiffModal(None))
.into(),
)
}
fn render_git_block(&self) -> Option<Element<'_, Message>> {
if !self.git_state.has_filesystem_path() {
return None;
}
let mut elements: Vec<Element<'_, Message>> = Vec::with_capacity(4);
elements.push(Self::render_git_divider());
if let Some(el) = self.render_git_branch() {
elements.push(el);
}
if let Some(el) = self.render_git_sync() {
elements.push(el);
}
if let Some(el) = self.render_git_diff_stats() {
elements.push(el);
}
Some(
Row::with_children(elements)
.spacing(6)
.align_y(Alignment::Center)
.into(),
)
}
fn render_active_agents() -> Element<'static, Message> {
let handles = crate::registry::AGENT_REGISTRY.list();
let mut role_counts: std::collections::BTreeMap<&str, usize> =
std::collections::BTreeMap::new();
for h in &handles {
*role_counts.entry(h.role.as_str()).or_insert(0) += 1;
}
if role_counts.is_empty() {
return text("").into();
}
let mut icons: Vec<Element<'_, Message>> = Vec::new();
for (role_str, count) in &role_counts {
let role: crate::Role = role_str.parse().unwrap_or(crate::Role::Engineer);
let (color, _bg) = theme::role_badge_color_for(&role);
let icon = theme::role_icon(&role).size(24).color(color);
if *count > 1 {
let label = text(format!("×{count}")).size(15).color(color);
icons.push(
container(row![icon, label].spacing(3).align_y(Alignment::Center))
.padding(iced::Padding {
left: 3.0,
right: 3.0,
top: 0.0,
bottom: 0.0,
})
.into(),
);
} else {
icons.push(
container(icon)
.padding(iced::Padding {
left: 3.0,
right: 3.0,
top: 0.0,
bottom: 0.0,
})
.into(),
);
}
}
Row::with_children(icons)
.spacing(12)
.align_y(Alignment::Center)
.into()
}
fn footer_view(&self) -> Element<'_, Message> {
let mut left_elements: Vec<Element<'_, Message>> = Vec::with_capacity(3);
if let Some(el) = self.render_update_button() {
left_elements.push(el);
}
left_elements.push(self.render_nav_icons());
if let Some(el) = self.render_git_block() {
left_elements.push(el);
}
let left = Row::with_children(left_elements)
.spacing(6)
.align_y(Alignment::Center);
let right = Self::render_active_agents();
let footer_row = row![left, Space::new().width(Length::Fill), right]
.align_y(Alignment::Center)
.padding(iced::Padding {
top: 3.0,
right: 18.0,
bottom: 3.0,
left: 18.0,
});
container(footer_row)
.align_y(Alignment::Center)
.height(Length::Fixed(42.0))
.width(Length::Fill)
.style(theme::surface_container_style)
.into()
}
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct WindowState {
pub width: f32,
pub height: f32,
pub x: i32,
pub y: i32,
#[serde(default)]
pub selected_workspace: Option<String>,
#[serde(default)]
pub selected_user: Option<String>,
}
impl WindowState {
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub const fn position(&self) -> iced::window::Position {
iced::window::Position::Specific(iced::Point::new(self.x as f32, self.y as f32))
}
}
impl Default for WindowState {
fn default() -> Self {
Self {
width: 1500.0,
height: 800.0,
x: -1,
y: -1,
selected_workspace: None,
selected_user: None,
}
}
}
#[must_use]
pub fn read_window_state() -> WindowState {
let dir = std::env::var("HOME")
.map(|h| std::path::PathBuf::from(h).join(".mahbot"))
.ok();
let path = dir.map(|d| d.join("window-state.json"));
path.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default()
}
#[allow(clippy::cast_possible_truncation)]
fn save_window_state(
pos: iced::Point,
size: iced::Size,
selected_workspace: Option<&str>,
selected_user: Option<&str>,
) {
let mut state = serde_json::json!({
"width": size.width,
"height": size.height,
"x": pos.x as i32,
"y": pos.y as i32,
});
if let Some(ws) = selected_workspace {
state["selected_workspace"] = serde_json::Value::String(ws.to_string());
}
if let Some(user) = selected_user {
state["selected_user"] = serde_json::Value::String(user.to_string());
}
if let Ok(dir) = std::env::var("HOME") {
let path = std::path::PathBuf::from(dir)
.join(".mahbot")
.join("window-state.json");
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, state.to_string());
}
}
fn refresh_workspace_states_task() -> Task<Message> {
Task::perform(
async {
let store = crate::workspace::store();
match store.list_states().await {
Ok(states) => Message::WorkspaceStatesRefreshed(states),
Err(e) => {
tracing::warn!("Failed to refresh workspace states: {e}");
Message::Nop
}
}
},
std::convert::identity,
)
}
async fn load_workspace_options(prev_selection: Option<String>) -> Message {
let store = crate::workspace::store();
let mut workspaces = HashMap::new();
let mut restored_name = None;
if let Ok(ws_list) = store.list().await {
for ws in &ws_list {
workspaces.insert(
ws.name.clone(),
WorkspaceInfo {
path: ws.path.clone(),
paused: ws.paused,
maintenance_enabled: ws.maintenance_enabled,
},
);
}
}
if let Some(ref name) = prev_selection {
if name.is_empty() || workspaces.contains_key(name.as_str()) {
restored_name = Some(name.clone());
}
}
if restored_name.is_none() {
restored_name = Some(String::new());
}
Message::BootWorkspaces(workspaces, restored_name)
}
fn open_url(url: &str) {
let _ = if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(url).spawn()
} else if cfg!(target_os = "linux") {
std::process::Command::new("xdg-open").arg(url).spawn()
} else if cfg!(target_os = "windows") {
std::process::Command::new("cmd")
.args(["/c", "start", url])
.spawn()
} else {
return;
};
}
fn resolve_dashboard_workspace_path(
name: &str,
ws_path: Option<&str>,
personal_path: Option<&str>,
) -> (String, Option<String>) {
if let Some(p) = ws_path {
(name.to_string(), Some(p.to_string()))
} else if let Some(p) = personal_path {
(name.to_string(), Some(p.to_string()))
} else if name.is_empty() {
(String::new(), None)
} else {
tracing::warn!(
workspace = name,
"Workspace path not found in map — sending empty selection"
);
(String::new(), None)
}
}