use crate::Workspace;
use crate::workspace::truncate_workspace_notes;
use iced::Task;
use iced::widget::{markdown, text_editor};
use std::collections::{HashMap, HashSet};
#[must_use]
pub(crate) fn next_maintenance_label(ws: &Workspace) -> Option<String> {
if !ws.maintenance_enabled {
return None;
}
let Some(ref last_str) = ws.maintainer_last_run_at else {
return Some("Next maintenance: pending".to_string());
};
let last_time = match crate::turso::parse_utc_timestamp(last_str) {
Ok(dt) => dt,
Err(e) => {
tracing::warn!(maintainer_last_run_at = %last_str, error = %e, "Failed to parse maintainer_last_run_at in workspace label, showing 'pending'");
return Some("Next maintenance: pending".to_string());
}
};
let now = chrono::Utc::now();
let next_run = last_time
+ chrono::Duration::minutes(
ws.maintainer_debounce_mins
.clamp(0, Workspace::MAX_MAINTAINER_DEBOUNCE_MINS),
);
let remaining = next_run - now;
let mins = remaining.num_minutes();
if mins <= 0 {
Some("Next maintenance: due now".to_string())
} else {
let hours = (mins / 60).cast_unsigned();
let minutes = (mins % 60).cast_unsigned();
if hours > 0 {
Some(format!("Next maintenance in {hours}h {minutes}min"))
} else {
Some(format!("Next maintenance in {minutes} min"))
}
}
}
#[derive(Debug, Clone)]
#[expect(private_interfaces)] pub enum WorkspacesMessage {
Refreshed(Vec<Workspace>),
RefreshError(String),
DeleteWorkspace(String),
ConfirmDelete(String),
CancelDelete,
DeleteResult(Result<(), String>),
Reanalyze(String),
ReanalyzeResult(Result<(), String>),
ToggleMaintainer(String, bool),
ToggleResult(Result<(), String>),
ViewContext(String, String),
ViewGeneralContext(String),
ContextViewed(String, ContextKind, Result<Option<String>, String>),
LinkClicked(String),
ShowDiagnostics(String),
DiagnosticsFieldEdited(String, usize, String),
SaveDiagnostics(String),
DiagnosticsSaved(String, Result<(), String>),
RediscoverDiagnostics(String),
RediscoverDiagnosticsResult(String, Result<(), String>),
Escape,
Toast(super::ToastMessage),
ToggleNotes(String),
NotesEdited(String, text_editor::Action),
SaveNotes(String),
NotesSaved(String, Result<(), String>),
NotesCancel(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ContextKind {
Role(String),
General,
}
pub struct WorkspacesState {
pub(crate) workspaces: Vec<Workspace>,
pub(crate) load_state: super::common::AsyncLoadState,
pub(crate) delete_target: Option<String>,
pub(crate) deleting: bool,
pub(crate) context_view: Option<(String, ContextKind, Option<Vec<markdown::Item>>)>,
pub(crate) context_view_error: Option<String>,
pub(crate) diagnostics_modal: Option<String>,
pub(crate) diagnostics_edit_buffers:
HashMap<String, [String; crate::DiagnosticsCommands::COMMAND_COUNT]>,
pub(crate) diagnostics_busy: bool,
pub(crate) diagnostics_error: Option<String>,
pub(crate) notes_editor_content: HashMap<String, text_editor::Content>,
pub(crate) notes_open: HashSet<String>,
}
impl WorkspacesState {
#[must_use]
pub fn new() -> Self {
Self {
workspaces: Vec::new(),
load_state: super::common::AsyncLoadState::new(),
delete_target: None,
deleting: false,
context_view: None,
context_view_error: None,
diagnostics_modal: None,
diagnostics_edit_buffers: HashMap::new(),
diagnostics_busy: false,
diagnostics_error: None,
notes_editor_content: HashMap::new(),
notes_open: HashSet::new(),
}
}
#[allow(clippy::unused_self)]
pub fn refresh(&self) -> Task<WorkspacesMessage> {
Task::perform(
async {
crate::workspace::store()
.list()
.await
.map_err(|e| e.to_string())
},
|res| match res {
Ok(ws_list) => WorkspacesMessage::Refreshed(ws_list),
Err(e) => WorkspacesMessage::RefreshError(e),
},
)
}
#[expect(clippy::too_many_lines)]
pub fn update(&mut self, msg: WorkspacesMessage) -> Task<WorkspacesMessage> {
#[expect(clippy::match_same_arms)]
match msg {
WorkspacesMessage::Refreshed(ws_list) => {
self.workspaces = ws_list;
self.load_state.finish_loading();
Task::none()
}
WorkspacesMessage::RefreshError(e) => {
self.load_state.fail(e);
Task::none()
}
WorkspacesMessage::DeleteWorkspace(name) => {
self.delete_target = Some(name);
Task::none()
}
WorkspacesMessage::ConfirmDelete(name) => {
self.delete_target = None;
self.deleting = true;
Task::perform(
async move {
crate::workspace::store()
.delete(&name)
.await
.map_err(|e| e.to_string())
},
WorkspacesMessage::DeleteResult,
)
}
WorkspacesMessage::CancelDelete => {
self.delete_target = None;
Task::none()
}
WorkspacesMessage::DeleteResult(Ok(())) => {
self.deleting = false;
self.load_state.clear_error();
self.refresh()
}
WorkspacesMessage::DeleteResult(Err(e)) => {
self.deleting = false;
self.load_state.fail(e.clone());
Task::done(WorkspacesMessage::Toast(super::ToastMessage::Error(e)))
}
WorkspacesMessage::Reanalyze(name) => Task::perform(
async move {
crate::workspace::store()
.rediscover(&name)
.await
.map_err(|e| e.to_string())
},
WorkspacesMessage::ReanalyzeResult,
),
WorkspacesMessage::ReanalyzeResult(Ok(())) => self.refresh(),
WorkspacesMessage::ReanalyzeResult(Err(e)) => {
self.load_state.fail(e.clone());
Task::done(WorkspacesMessage::Toast(super::ToastMessage::Error(e)))
}
WorkspacesMessage::ToggleMaintainer(name, enabled) => Task::perform(
async move {
crate::workspace::store()
.set_maintenance_enabled(&name, enabled)
.await
.map_err(|e| e.to_string())
},
WorkspacesMessage::ToggleResult,
),
WorkspacesMessage::ToggleResult(Ok(())) => self.refresh(),
WorkspacesMessage::ToggleResult(Err(e)) => {
self.load_state.fail(e.clone());
Task::done(WorkspacesMessage::Toast(super::ToastMessage::Error(e)))
}
WorkspacesMessage::ViewContext(ws_name, role) => {
self.context_view = Some((ws_name.clone(), ContextKind::Role(role.clone()), None));
self.context_view_error = None;
let ws_name2 = ws_name.clone();
let role2 = role.clone();
Task::perform(
async move {
let content = crate::workspace::store()
.get_context(&ws_name, &role)
.await
.map_err(|e| e.to_string())?;
Ok::<_, String>((ws_name, role, content))
},
move |res| match res {
Ok((name, role, content)) => WorkspacesMessage::ContextViewed(
name,
ContextKind::Role(role),
Ok(content),
),
Err(e) => WorkspacesMessage::ContextViewed(
ws_name2,
ContextKind::Role(role2),
Err(e),
),
},
)
}
WorkspacesMessage::ViewGeneralContext(ws_name) => {
self.context_view = Some((ws_name.clone(), ContextKind::General, None));
self.context_view_error = None;
let ws_name2 = ws_name.clone();
Task::perform(
async move {
let content = crate::workspace::store()
.get_general_context(&ws_name)
.await
.map_err(|e| e.to_string())?;
Ok::<_, String>((ws_name, content))
},
move |res| match res {
Ok((name, content)) => WorkspacesMessage::ContextViewed(
name,
ContextKind::General,
Ok(content),
),
Err(e) => {
WorkspacesMessage::ContextViewed(ws_name2, ContextKind::General, Err(e))
}
},
)
}
WorkspacesMessage::ContextViewed(ws_name, kind, Ok(Some(content))) => {
let md_items: Vec<markdown::Item> = markdown::parse(&content).collect();
self.context_view = Some((ws_name, kind, Some(md_items)));
self.context_view_error = None;
Task::none()
}
WorkspacesMessage::ContextViewed(ws_name, kind, Ok(None)) => {
self.context_view = Some((ws_name, kind, Some(Vec::new())));
self.context_view_error = None;
Task::none()
}
WorkspacesMessage::ContextViewed(ws_name, kind, Err(e)) => {
self.context_view = Some((ws_name, kind, Some(Vec::new())));
self.context_view_error = Some(e);
Task::none()
}
WorkspacesMessage::LinkClicked(_url) => {
Task::none()
}
WorkspacesMessage::ShowDiagnostics(name) => {
self.diagnostics_modal = Some(name.clone());
self.diagnostics_busy = false;
self.diagnostics_error = None;
let fields = self
.workspaces
.iter()
.find(|w| w.name == name)
.and_then(|w| w.diagnostics.as_deref())
.and_then(|json| serde_json::from_str::<crate::DiagnosticsCommands>(json).ok())
.map_or(
[const { String::new() }; crate::DiagnosticsCommands::COMMAND_COUNT],
|cmds| {
let mut arr = [const { String::new() };
crate::DiagnosticsCommands::COMMAND_COUNT];
for (i, (_, cmd_opt)) in cmds.commands().iter().enumerate() {
if let Some(cmd) = cmd_opt {
arr[i] = cmd.to_string();
}
}
arr
},
);
self.diagnostics_edit_buffers.insert(name, fields);
Task::none()
}
WorkspacesMessage::DiagnosticsFieldEdited(name, idx, value) => {
if let Some(buffers) = self.diagnostics_edit_buffers.get_mut(&name) {
if idx < crate::DiagnosticsCommands::COMMAND_COUNT {
buffers[idx] = value;
}
}
Task::none()
}
WorkspacesMessage::SaveDiagnostics(name) => {
self.diagnostics_busy = true;
self.diagnostics_error = None;
let buffers = self.diagnostics_edit_buffers.get(&name).cloned().unwrap_or(
[const { String::new() }; crate::DiagnosticsCommands::COMMAND_COUNT],
);
let cmds = crate::DiagnosticsCommands::from_buffers(&buffers);
let name_clone = name.clone();
Task::perform(
async move {
crate::workspace::store()
.set_diagnostics(&name_clone, &cmds)
.await
.map_err(|e| e.to_string())
},
move |result| WorkspacesMessage::DiagnosticsSaved(name, result),
)
}
WorkspacesMessage::DiagnosticsSaved(name, Ok(())) => {
self.diagnostics_busy = false;
self.diagnostics_edit_buffers.remove(&name);
self.diagnostics_modal = None;
self.refresh()
}
WorkspacesMessage::DiagnosticsSaved(_name, Err(e)) => {
self.diagnostics_busy = false;
self.diagnostics_error = Some(e.clone());
Task::done(WorkspacesMessage::Toast(super::ToastMessage::Error(e)))
}
WorkspacesMessage::RediscoverDiagnostics(name) => {
self.diagnostics_busy = true;
self.diagnostics_error = None;
let name_for_task = name.clone();
Task::perform(
async move {
crate::workspace::store()
.rediscover_diagnostics(&name_for_task)
.await
.map_err(|e| e.to_string())
},
move |result| WorkspacesMessage::RediscoverDiagnosticsResult(name, result),
)
}
WorkspacesMessage::RediscoverDiagnosticsResult(name, Ok(())) => {
self.diagnostics_busy = false;
self.diagnostics_modal = None;
self.diagnostics_edit_buffers.remove(&name);
self.refresh()
}
WorkspacesMessage::RediscoverDiagnosticsResult(_name, Err(e)) => {
self.diagnostics_busy = false;
self.diagnostics_error = Some(e.clone());
Task::done(WorkspacesMessage::Toast(super::ToastMessage::Error(e)))
}
WorkspacesMessage::ToggleNotes(name) => {
if self.notes_open.contains(&name) {
self.notes_open.remove(&name);
self.notes_editor_content.remove(&name);
} else {
let notes = self
.workspaces
.iter()
.find(|w| w.name == name)
.map_or("", |w| w.notes.as_str());
self.notes_open.insert(name.clone());
self.notes_editor_content
.insert(name, text_editor::Content::with_text(notes));
}
Task::none()
}
WorkspacesMessage::NotesEdited(name, action) => {
let name_for_entry = name.clone();
let content = self
.notes_editor_content
.entry(name_for_entry)
.or_insert_with(|| {
self.workspaces
.iter()
.find(|w| w.name == name)
.map(|w| text_editor::Content::with_text(&w.notes))
.unwrap_or_default()
});
content.perform(action);
let current = content.text().clone();
let truncated = truncate_workspace_notes(¤t);
if truncated.len() < current.len() {
*content = text_editor::Content::with_text(&truncated);
}
Task::none()
}
WorkspacesMessage::SaveNotes(name) => {
let notes = self
.notes_editor_content
.get(&name)
.map(|c| c.text().clone())
.unwrap_or_default();
let name_clone = name.clone();
Task::perform(
async move {
crate::workspace::store()
.set_notes(&name_clone, ¬es)
.await
.map_err(|e| e.to_string())
},
move |result| WorkspacesMessage::NotesSaved(name, result),
)
}
WorkspacesMessage::NotesSaved(name, Ok(())) => {
self.notes_open.remove(&name);
self.notes_editor_content.remove(&name);
self.refresh()
}
WorkspacesMessage::NotesSaved(_name, Err(e)) => {
self.load_state.fail(e.clone());
Task::done(WorkspacesMessage::Toast(super::ToastMessage::Error(e)))
}
WorkspacesMessage::NotesCancel(name) => {
self.notes_open.remove(&name);
self.notes_editor_content.remove(&name);
Task::none()
}
WorkspacesMessage::Escape => {
self.delete_target = None;
self.context_view = None;
self.context_view_error = None;
self.diagnostics_modal = None;
self.diagnostics_edit_buffers.clear();
self.diagnostics_busy = false;
self.diagnostics_error = None;
self.notes_open.clear();
self.notes_editor_content.clear();
Task::none()
}
WorkspacesMessage::Toast(_) => Task::none(),
}
}
}