Fix task reload edge cases, selection behavior, and minor UX issues

- Only count projects and tags from pending tasks
- Stop reselecting tasks after sync to avoid unexpected page jumps
- Make delete flow resilient when task no longer exists after deletion
- Relax modifier detection for delete confirmation
- Improve date parsing consistency and validation message
- Add Recurring status to task detail modal
- Show "-" when task has no working ID
- Guard against out-of-bounds pagination slices
- Prevent division by zero when computing page index
This commit is contained in:
Ignacio Perez
2026-01-05 11:37:48 -03:00
parent 0adcb9e512
commit 0beb4f0921
6 changed files with 51 additions and 34 deletions
+3 -16
View File
@@ -154,10 +154,7 @@ impl App {
let mut tag_counts: HashMap<String, usize> = HashMap::new(); let mut tag_counts: HashMap<String, usize> = HashMap::new();
for task in tasks { for task in tasks {
if !matches!(task.status, task::TaskStatus::Pending) { if matches!(task.status, task::TaskStatus::Pending) {
continue;
}
if let Some(project) = &task.project { if let Some(project) = &task.project {
*project_counts.entry(project.clone()).or_insert(0) += 1; *project_counts.entry(project.clone()).or_insert(0) += 1;
} }
@@ -166,6 +163,7 @@ impl App {
*tag_counts.entry(tag.clone()).or_insert(0) += 1; *tag_counts.entry(tag.clone()).or_insert(0) += 1;
} }
} }
}
let mut projects: Vec<(String, usize)> = project_counts.into_iter().collect(); let mut projects: Vec<(String, usize)> = project_counts.into_iter().collect();
projects.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); projects.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
@@ -186,15 +184,7 @@ impl App {
all_tasks: Vec<task::TaskSummary>, all_tasks: Vec<task::TaskSummary>,
cx: &mut gpui::Context<Self>, cx: &mut gpui::Context<Self>,
) { ) {
let previous_selection = self.task_table.read(cx).selected_task_uuid(); let _filter_state = self.filter_state.read(cx).clone();
let filter_state = self.filter_state.read(cx).clone();
let filtered = task::TaskFilter::from(&filter_state).apply(&all_tasks);
let reselect_uuid = previous_selection.and_then(|uuid| {
filtered
.iter()
.any(|task| task.uuid == uuid)
.then_some(uuid)
});
self.tasks = all_tasks; self.tasks = all_tasks;
let (projects, tags) = Self::build_sidebar_data(&self.tasks); let (projects, tags) = Self::build_sidebar_data(&self.tasks);
@@ -210,9 +200,6 @@ impl App {
let tasks = self.tasks.clone(); let tasks = self.tasks.clone();
self.task_table.update(cx, |table, cx| { self.task_table.update(cx, |table, cx| {
table.reload_tasks_from_all(tasks, cx); table.reload_tasks_from_all(tasks, cx);
if let Some(uuid) = reselect_uuid {
let _ = table.select_task_by_uuid(uuid, cx);
}
}); });
self.update_modal_project_suggestions(cx); self.update_modal_project_suggestions(cx);
+20 -4
View File
@@ -44,7 +44,7 @@ impl App {
if self.delete_confirm.is_some() { if self.delete_confirm.is_some() {
let key = event.keystroke.key.as_str().to_lowercase(); let key = event.keystroke.key.as_str().to_lowercase();
let mods = &event.keystroke.modifiers; let mods = &event.keystroke.modifiers;
let has_mods = mods.control || mods.alt || mods.shift || mods.platform; let has_mods = mods.control || mods.alt || mods.shift;
if !has_mods { if !has_mods {
match key.as_str() { match key.as_str() {
@@ -300,8 +300,17 @@ impl App {
let updated_task = match self.task_service.get_task(confirm.task_id) { let updated_task = match self.task_service.get_task(confirm.task_id) {
Ok(task) => task, Ok(task) => task,
Err(ref e)
if e.to_string().contains("not found") || e.to_string().contains("No such") =>
{
log::info!(
"[App] Task {} not found after deletion (expected)",
confirm.task_id
);
None
}
Err(e) => { Err(e) => {
log::error!("[App] Failed to reload deleted task: {}", e); log::warn!("[App] Failed to reload task after delete: {}", e);
None None
} }
}; };
@@ -329,10 +338,11 @@ impl App {
self.task_detail_modal.update(cx, |modal, cx| { self.task_detail_modal.update(cx, |modal, cx| {
modal.close(None, cx); modal.close(None, cx);
}); });
}
self.toast_host.update(cx, |host, cx| { self.toast_host.update(cx, |host, cx| {
host.push(ToastKind::Info, "Task deleted", cx); host.push(ToastKind::Info, "Task deleted", cx);
}); });
}
self.status_bar.update(cx, |bar, cx| { self.status_bar.update(cx, |bar, cx| {
bar.set_dirty(cx); bar.set_dirty(cx);
@@ -498,7 +508,13 @@ impl App {
|| has_status || has_status
|| has_annotations || has_annotations
{ {
self.task_service.get_task(task_id).ok().flatten() match self.task_service.get_task(task_id) {
Ok(task) => task,
Err(e) => {
log::warn!("[App] Failed to sync task after update: {}", e);
None
}
}
} else { } else {
self.task_detail_modal.update(cx, |modal, cx| { self.task_detail_modal.update(cx, |modal, cx| {
modal.cancel_edit(None, cx); modal.cancel_edit(None, cx);
+1 -1
View File
@@ -172,7 +172,7 @@ impl DueFilter {
"this_week" => Some(Self::ThisWeek), "this_week" => Some(Self::ThisWeek),
"none" => Some(Self::NoDate), "none" => Some(Self::NoDate),
_ => value.strip_prefix("date:").and_then(|date| { _ => value.strip_prefix("date:").and_then(|date| {
NaiveDate::parse_from_str(date, "%Y-%m-%d") NaiveDate::parse_from_str(date, DATE_FORMAT)
.ok() .ok()
.map(Self::OnDate) .map(Self::OnDate)
}), }),
+1 -1
View File
@@ -90,7 +90,7 @@ impl TaskForm {
if self.due.trim().is_empty() { if self.due.trim().is_empty() {
None None
} else if NaiveDate::parse_from_str(self.due.trim(), DATE_FORMAT).is_err() { } else if NaiveDate::parse_from_str(self.due.trim(), DATE_FORMAT).is_err() {
Some("Use YYYY-MM-DD".into()) Some("Invalid date format. Use YYYY-MM-DD".into())
} else { } else {
None None
} }
+3 -1
View File
@@ -254,6 +254,7 @@ impl TaskDetailModal {
let status_items = vec![ let status_items = vec![
DropdownItem::new("Pending"), DropdownItem::new("Pending"),
DropdownItem::new("Completed"), DropdownItem::new("Completed"),
DropdownItem::new("Recurring"),
DropdownItem::new("Deleted"), DropdownItem::new("Deleted"),
]; ];
let status_dropdown = { let status_dropdown = {
@@ -268,7 +269,8 @@ impl TaskDetailModal {
modal.state.form.status = match index { modal.state.form.status = match index {
0 => task::TaskStatus::Pending, 0 => task::TaskStatus::Pending,
1 => task::TaskStatus::Completed, 1 => task::TaskStatus::Completed,
2 => task::TaskStatus::Deleted, 2 => task::TaskStatus::Recurring,
3 => task::TaskStatus::Deleted,
_ => task::TaskStatus::Pending, _ => task::TaskStatus::Pending,
}; };
cx.notify(); cx.notify();
+16 -4
View File
@@ -227,7 +227,10 @@ impl From<&task::TaskSummary> for TaskRow {
Self { Self {
uuid: value.uuid, uuid: value.uuid,
id_display: value.working_id.unwrap_or(0).to_string(), id_display: value
.working_id
.map(|id| id.to_string())
.unwrap_or_else(|| "-".to_string()),
description: Self::truncate(&value.description, TABLE_MAX_DESCRIPTION_LENGTH), description: Self::truncate(&value.description, TABLE_MAX_DESCRIPTION_LENGTH),
project: value.project.clone().unwrap_or(String::new()), project: value.project.clone().unwrap_or(String::new()),
due: Self::format_date(&value.due, value.is_due_today()), due: Self::format_date(&value.due, value.is_due_today()),
@@ -430,9 +433,18 @@ impl TaskTable {
fn get_current_page_rows(&self) -> &[TaskRow] { fn get_current_page_rows(&self) -> &[TaskRow] {
let start = self.pagination.first_item_index(); let start = self.pagination.first_item_index();
let end = self.pagination.last_item_index(); let end = self
.pagination
.last_item_index()
.min(self.cached_rows.len());
let safe_start = start.min(end).min(self.cached_rows.len());
let safe_end = end.min(self.cached_rows.len());
&self.cached_rows[start..end] if safe_start >= safe_end || self.cached_rows.is_empty() {
&[]
} else {
&self.cached_rows[safe_start..safe_end]
}
} }
pub fn reload_tasks_from_all( pub fn reload_tasks_from_all(
@@ -760,7 +772,7 @@ impl TaskTable {
return false; return false;
}; };
let page = idx / self.pagination.page_size + 1; let page = idx / self.pagination.page_size.max(1) + 1;
self.pagination.current_page(page); self.pagination.current_page(page);
let page_first_idx = self.pagination.first_item_index(); let page_first_idx = self.pagination.first_item_index();
self.selected_global_idx = Some(idx); self.selected_global_idx = Some(idx);