Files
taskwarrior-gpui/src/handler.rs
T
Ignacio Perez 1c4110caa9 feat: add task creation, deletion, and modal focus restoration
Implemented task creation flow with keyboard shortcut (c) and UI button. Added task deletion with confirmation dialog,
accessible via Delete key or per-row delete button. Fixed modal close behavior to restore
focus and reselect the appropriate task (created task for new tasks, original task for view/edit).
Enhanced tag and project autocomplete with hierarchical project suggestions and tag filtering
2026-01-04 11:29:26 -03:00

615 lines
20 KiB
Rust

use crate::{
app::DeleteConfirmState,
components::toast::ToastKind,
keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord},
task::{self, TaskSummary},
view::{status_bar::SyncState, task_detail_modal::TaskEditUpdate},
};
use super::App;
impl App {
pub(super) fn handle_sync(&mut self, cx: &mut gpui::Context<Self>) {
self.status_bar.update(cx, |bar, cx| {
bar.set_sync_state(SyncState::Syncing, cx);
bar.set_last_sync_message("Syncing...".to_string(), cx);
});
match self.task_service.get_all_tasks() {
Ok(all_tasks) => {
let summaries: Vec<TaskSummary> = all_tasks.iter().map(TaskSummary::from).collect();
self.update_ui_from_tasks(summaries, cx);
self.status_bar.update(cx, |bar, cx| {
bar.set_sync_state(SyncState::Success, cx);
bar.set_last_sync_message("Synced".to_string(), cx);
});
}
Err(e) => {
log::error!("[App] Sync failed: {}", e);
self.status_bar.update(cx, |bar, cx| {
bar.set_sync_state(SyncState::Error, cx);
bar.set_last_sync_message(format!("Error: {}", e), cx);
});
}
}
}
pub(super) fn handle_key_down(
&mut self,
event: &gpui::KeyDownEvent,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) {
if self.delete_confirm.is_some() {
let key = event.keystroke.key.as_str().to_lowercase();
let mods = &event.keystroke.modifiers;
let has_mods = mods.control || mods.alt || mods.shift || mods.platform;
if !has_mods {
match key.as_str() {
"enter" | "y" => {
self.confirm_delete_task(cx);
return;
}
"escape" | "n" => {
self.cancel_delete_confirm(cx);
return;
}
_ => {}
}
}
return;
}
if let Some(chord) = KeyChord::from_gpui(event) {
let context = self.active_context(cx);
if let Some(command) = self.keymap.resolve(context, &chord) {
let modal_is_open = self.task_detail_modal.read(cx).is_open();
if modal_is_open {
let mut handled = false;
self.task_detail_modal.update(cx, |modal, cx| {
handled = modal.dispatch_command(command, Some(window), cx);
});
if handled {
return;
}
if command != Command::Sync {
return;
}
}
match command {
Command::FocusSearch => {
let from_headers = matches!(self.focus_target, FocusTarget::TableHeaders);
self.focus_target = FocusTarget::Table;
self.task_table.update(cx, |table, cx| {
if from_headers {
table.blur_table_headers(cx);
}
table.focus_search_input(window, cx);
});
cx.notify();
}
Command::FocusTableHeaders => {
self.focus_target = FocusTarget::TableHeaders;
self.task_table.update(cx, |table, cx| {
table.blur_search_input(window, cx);
table.set_filter_bar_focus(
crate::view::task_table::FilterBarFocus::None,
cx,
);
table.focus_table_headers(window, cx);
});
cx.notify();
}
Command::FocusTable => {
self.focus_target = FocusTarget::Table;
self.task_table.update(cx, |table, cx| match context {
ContextId::TextInput | ContextId::FilterBar => {
table.blur_search_input(window, cx);
table.set_filter_bar_focus(
crate::view::task_table::FilterBarFocus::None,
cx,
);
}
ContextId::TableHeaders => {
table.blur_table_headers(cx);
}
_ => {}
});
cx.notify();
}
Command::FocusFilterNext | Command::FocusFilterPrev => {
self.task_table.update(cx, |table, cx| {
use crate::view::task_table::FilterBarFocus;
let was_on_input =
matches!(table.get_filter_bar_focus(), FilterBarFocus::SearchInput);
if command == Command::FocusFilterNext {
table.focus_filter_next(cx);
} else {
table.focus_filter_prev(cx);
}
if was_on_input {
table.blur_search_input(window, cx);
}
let now_on_input =
matches!(table.get_filter_bar_focus(), FilterBarFocus::SearchInput);
if now_on_input && !was_on_input {
table.focus_search_input(window, cx);
}
});
}
_ => {
self.dispatch(command, cx);
}
}
}
}
}
pub(super) fn open_selected_task(
&mut self,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
self.open_selected_task_mode(false, window, cx);
}
pub(super) fn open_selected_task_edit(
&mut self,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
self.open_selected_task_mode(true, window, cx);
}
fn open_selected_task_mode(
&mut self,
edit_mode: bool,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
if self.task_detail_modal.read(cx).is_open() {
return;
}
let task_id = self.task_table.read(cx).selected_task_uuid();
let Some(task_id) = task_id else {
return;
};
self.open_task_detail(task_id, edit_mode, window, cx);
}
pub(super) fn open_task_detail(
&mut self,
task_id: uuid::Uuid,
edit_mode: bool,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
self.focus_before_modal = self.focus_target;
let tasks = self.tasks.clone();
match self.task_service.get_task_detail(task_id, &tasks) {
Ok(detail) => {
self.task_detail_modal.update(cx, |modal, cx| {
if edit_mode {
modal.open_with_detail_edit(detail, window, cx);
} else {
modal.open_with_detail(detail, window, cx);
}
});
}
Err(e) => {
self.task_detail_modal.update(cx, |modal, cx| {
modal.open_with_error(task_id, e.to_string(), window, cx);
});
}
}
cx.notify();
}
pub(super) fn open_task_create(&mut self, cx: &mut gpui::Context<Self>) {
if self.task_detail_modal.read(cx).is_open() {
return;
}
self.focus_before_modal = self.focus_target;
self.task_detail_modal.update(cx, |modal, cx| {
modal.open_create(None, cx);
});
cx.notify();
}
pub(super) fn request_delete_task(
&mut self,
task_id: uuid::Uuid,
cx: &mut gpui::Context<Self>,
) {
if self.delete_confirm.is_some() {
return;
}
if self.task_detail_modal.read(cx).is_editing() {
self.task_detail_modal.update(cx, |modal, cx| {
modal.cancel_edit(None, cx);
});
}
let Some(task) = self.tasks.iter().find(|task| task.uuid == task_id) else {
return;
};
let id_display = task
.working_id
.or(task.id)
.map(|id| format!("#{}", id))
.unwrap_or_else(|| task_id.to_string());
let mut tags: Vec<String> = task.tags.iter().cloned().collect();
tags.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
self.delete_confirm = Some(DeleteConfirmState {
task_id,
id_display,
description: task.description.clone(),
project: task.project.clone(),
tags,
});
cx.notify();
}
pub(super) fn cancel_delete_confirm(&mut self, cx: &mut gpui::Context<Self>) {
if self.delete_confirm.take().is_some() {
cx.notify();
}
}
pub(super) fn confirm_delete_task(&mut self, cx: &mut gpui::Context<Self>) {
let Some(confirm) = self.delete_confirm.take() else {
return;
};
let next_selection = self
.task_table
.read(cx)
.next_selection_after_delete(confirm.task_id);
if let Err(e) = self.task_service.delete_task(confirm.task_id) {
log::error!("[App] Failed to delete task: {}", e);
self.toast_host.update(cx, |host, cx| {
host.push(
ToastKind::Error,
format!("Failed to delete task: {}", e),
cx,
);
});
return;
}
let updated_task = match self.task_service.get_task(confirm.task_id) {
Ok(task) => task,
Err(e) => {
log::error!("[App] Failed to reload deleted task: {}", e);
None
}
};
if let Some(task) = updated_task {
self.upsert_task_summary(task, cx);
} else {
self.tasks.retain(|task| task.uuid != confirm.task_id);
let summaries = self.tasks.clone();
self.update_ui_from_tasks(summaries, cx);
}
self.task_table.update(cx, |table, cx| {
if let Some(next_uuid) = next_selection {
if !table.select_task_by_uuid(next_uuid, cx) {
table.clear_selection(cx);
}
} else {
table.clear_selection(cx);
}
});
let modal_task_id = self.task_detail_modal.read(cx).task_id();
if modal_task_id == Some(confirm.task_id) {
self.task_detail_modal.update(cx, |modal, cx| {
modal.close(None, cx);
});
self.toast_host.update(cx, |host, cx| {
host.push(ToastKind::Info, "Task deleted", cx);
});
}
self.status_bar.update(cx, |bar, cx| {
bar.set_dirty(cx);
});
}
fn apply_task_update(&mut self, task: task::Task, cx: &mut gpui::Context<Self>) {
let summary = TaskSummary::from(&task);
if let Some(existing) = self.tasks.iter_mut().find(|t| t.uuid == task.uuid) {
*existing = summary;
} else {
self.tasks.push(summary);
}
let summaries = self.tasks.clone();
self.update_ui_from_tasks(summaries, cx);
let detail = task::TaskDetailVm::from_task(&task, &self.tasks);
self.task_detail_modal.update(cx, |modal, cx| {
modal.apply_saved_detail(detail, cx);
});
}
fn upsert_task_summary(&mut self, task: task::Task, cx: &mut gpui::Context<Self>) {
let summary = TaskSummary::from(&task);
if let Some(existing) = self.tasks.iter_mut().find(|t| t.uuid == task.uuid) {
*existing = summary;
} else {
self.tasks.push(summary);
}
let summaries = self.tasks.clone();
self.update_ui_from_tasks(summaries, cx);
}
fn sync_task_detail(&mut self, task: task::Task, cx: &mut gpui::Context<Self>) {
let summary = TaskSummary::from(&task);
if let Some(existing) = self.tasks.iter_mut().find(|t| t.uuid == task.uuid) {
*existing = summary;
} else {
self.tasks.push(summary);
}
let summaries = self.tasks.clone();
self.update_ui_from_tasks(summaries, cx);
let detail = task::TaskDetailVm::from_task(&task, &self.tasks);
self.task_detail_modal.update(cx, |modal, cx| {
modal.set_detail(detail, cx);
});
}
pub(super) fn handle_save_task_edits(
&mut self,
task_id: uuid::Uuid,
update: TaskEditUpdate,
cx: &mut gpui::Context<Self>,
) {
let TaskEditUpdate {
description,
project,
priority,
status,
due,
tags,
annotations_add,
annotations_delete,
} = update;
let mut latest_task: Option<task::Task> = None;
if description.is_some()
|| project.is_some()
|| priority.is_some()
|| tags.is_some()
|| due.is_some()
{
match self.task_service.update_task(
task_id,
description,
project,
priority,
tags,
due,
None,
) {
Ok(task) => latest_task = Some(task),
Err(e) => {
log::error!("[App] Failed to update task: {}", e);
self.toast_host.update(cx, |host, cx| {
host.push(
ToastKind::Error,
format!("Failed to update task: {}", e),
cx,
);
});
return;
}
}
}
if let Some(status) = status {
let status_result = match status {
task::TaskStatus::Completed => self.task_service.complete_task(task_id),
task::TaskStatus::Pending => self.task_service.reopen_task(task_id),
task::TaskStatus::Deleted => {
self.task_service.delete_task(task_id).and_then(|_| {
self.task_service
.get_task(task_id)
.and_then(|task| task.ok_or(task::TaskError::NotFound(task_id)))
})
}
_ => self
.task_service
.get_task(task_id)
.and_then(|task| task.ok_or(task::TaskError::NotFound(task_id))),
};
match status_result {
Ok(task) => latest_task = Some(task),
Err(e) => {
log::error!("[App] Failed to update status: {}", e);
self.toast_host.update(cx, |host, cx| {
host.push(
ToastKind::Error,
format!("Failed to update status: {}", e),
cx,
);
});
if let Some(task) = latest_task {
self.apply_task_update(task, cx);
}
return;
}
}
}
for text in annotations_add {
match self.task_service.add_annotation(task_id, text) {
Ok(task) => latest_task = Some(task),
Err(e) => {
log::error!("[App] Failed to add annotation: {}", e);
self.toast_host.update(cx, |host, cx| {
host.push(
ToastKind::Error,
format!("Failed to add annotation: {}", e),
cx,
);
});
if let Some(task) = latest_task {
self.sync_task_detail(task, cx);
}
return;
}
}
}
for entry in annotations_delete {
match self.task_service.remove_annotation(task_id, entry) {
Ok(task) => latest_task = Some(task),
Err(e) => {
log::error!("[App] Failed to delete annotation: {}", e);
self.toast_host.update(cx, |host, cx| {
host.push(
ToastKind::Error,
format!("Failed to delete annotation: {}", e),
cx,
);
});
if let Some(task) = latest_task {
self.sync_task_detail(task, cx);
}
return;
}
}
}
if let Some(task) = latest_task {
self.apply_task_update(task, cx);
} else {
self.task_detail_modal.update(cx, |modal, cx| {
modal.cancel_edit(None, cx);
});
}
}
pub(super) fn handle_create_task(
&mut self,
draft: task::TaskDraft,
annotations: Vec<String>,
cx: &mut gpui::Context<Self>,
) {
let created = match self.task_service.create_task(draft) {
Ok(task) => task,
Err(e) => {
log::error!("[App] Failed to create task: {}", e);
self.toast_host.update(cx, |host, cx| {
host.push(
ToastKind::Error,
format!("Failed to create task: {}", e),
cx,
);
});
return;
}
};
let mut latest_task = created;
for text in annotations {
match self.task_service.add_annotation(latest_task.uuid, text) {
Ok(task) => {
latest_task = task;
}
Err(e) => {
log::error!("[App] Failed to add annotation: {}", e);
self.toast_host.update(cx, |host, cx| {
host.push(
ToastKind::Error,
format!("Failed to add annotation: {}", e),
cx,
);
});
break;
}
}
}
let created_uuid = latest_task.uuid;
self.created_task_uuid = Some(created_uuid);
self.upsert_task_summary(latest_task, cx);
self.task_detail_modal.update(cx, |modal, cx| {
modal.close(None, cx);
});
self.status_bar.update(cx, |bar, cx| {
bar.set_dirty(cx);
});
}
pub(super) fn handle_modal_closed(
&mut self,
task_id: Option<uuid::Uuid>,
was_creating: bool,
cx: &mut gpui::Context<Self>,
) {
self.focus_target = self.focus_before_modal;
self.task_table.update(cx, |table, cx| {
table.blur_filter_bar(cx);
table.blur_table_headers(cx);
let uuid_to_select = if was_creating {
self.created_task_uuid.take()
} else {
task_id
};
if let Some(uuid) = uuid_to_select {
let _ = table.select_task_by_uuid(uuid, cx);
}
});
self.needs_focus_restore = true;
cx.notify();
}
fn active_context(&self, cx: &gpui::Context<Self>) -> ContextId {
if self.task_detail_modal.read(cx).is_open() {
return self.task_detail_modal.read(cx).active_context();
}
if matches!(self.focus_target, FocusTarget::Table) {
let filter_context = self.task_table.read(cx).get_active_filter_context();
if let Some(context) = filter_context {
return context;
}
}
self.focus_target.to_context()
}
}