From 1c4110caa9934081e5cf3ab87f5c0fd9346eea5a Mon Sep 17 00:00:00 2001 From: Ignacio Perez Date: Sun, 4 Jan 2026 11:29:26 -0300 Subject: [PATCH] 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 --- docs/keyboard-shortcuts.md | 49 +++- src/app.rs | 136 +++++++++- src/components/confirm_dialog.rs | 11 +- src/components/input/mod.rs | 34 +++ src/dispatcher.rs | 5 + src/handler.rs | 235 ++++++++++++++++ src/keymap/command.rs | 6 + src/keymap/defaults.rs | 10 + src/task/mod.rs | 4 +- src/task/model.rs | 21 +- src/task/service.rs | 104 +++++-- src/view/status_bar.rs | 6 + src/view/task_detail_modal/mod.rs | 299 +++++++++++++++++++-- src/view/task_detail_modal/render/mod.rs | 1 + src/view/task_detail_modal/render/panel.rs | 34 ++- src/view/task_detail_modal/state.rs | 2 + src/view/task_table.rs | 115 +++++++- 17 files changed, 995 insertions(+), 77 deletions(-) diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index b10da31..c325214 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -69,6 +69,9 @@ These shortcuts work when the task table has focus: | Shortcut | Action | |----------|--------| | `Enter` | Open selected task details | +| `e` | Edit selected task | +| `c` | Create new task | +| `Delete` | Delete selected task (with confirmation) | | `←` | Collapse current project | | `→` | Expand current project | @@ -203,16 +206,56 @@ These shortcuts work when a filter dropdown (Status/Priority/Due) has focus: | `Ctrl+H` | Focus previous filter element | | `Ctrl+J` | Focus table headers | -## Modal (Task Details) +## Modal (Task Details - View Mode) These shortcuts work when viewing task details: | Shortcut | Action | |----------|--------| -| `Escape` | Cancel edit (when editing) or close modal | +| `Escape` | Close modal | +| `e` | Enter edit mode | | `j` / `↓` | Scroll down | | `k` / `↑` | Scroll up | -| `Ctrl+Enter` | Save edits (when editing) or close modal | + +## Modal (Task Details - Edit Mode - Navigating) + +These shortcuts work when editing a task but not actively typing in a field: + +| Shortcut | Action | +|----------|--------| +| `Escape` | Close modal (prompts if unsaved changes) | +| `Ctrl+S` | Save changes | +| `j` / `↓` / `Tab` | Focus next field | +| `k` / `↑` / `Shift+Tab` | Focus previous field | +| `Enter` / `Space` | Edit focused field or item | +| `Delete` / `Backspace` | Delete selected item (tag/annotation) | +| `h` / `l` | Navigate between items (tags/annotations) | +| `u` | Undo last change | +| `r` | Redo last undone change | +| `y` | Confirm action (in delete dialogs) | +| `n` | Cancel action (in delete dialogs) | + +## Modal (Task Details - Edit Mode - Typing) + +These shortcuts work when actively typing in an input field: + +| Shortcut | Action | +|----------|--------| +| `Escape` | Exit field (return to navigation) | +| `Ctrl+S` | Save all changes | +| `Enter` | Submit field or exit (depends on field type) | + +## Modal (Task Details - Edit Mode - Dropdown Open) + +These shortcuts work when a dropdown (Priority/Status) is open: + +| Shortcut | Action | +|----------|--------| +| `Escape` | Close dropdown | +| `Ctrl+S` | Save all changes | +| `Enter` / `Space` | Select option and close dropdown | +| `j` / `↓` | Select next option | +| `k` / `↑` | Select previous option | ## Search Input Editing diff --git a/src/app.rs b/src/app.rs index e07666c..736f2fc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,13 @@ use std::collections::HashMap; +use std::sync::Arc; use gpui::prelude::*; use crate::{ - components::toast::{ToastGlobal, ToastHost}, + components::{ + confirm_dialog::ConfirmDialog, + toast::{ToastGlobal, ToastHost}, + }, keymap::{FocusTarget, KeymapStack}, models::{FilterState, ProjectTree}, task::{self, TaskOverview, TaskService, TaskSummary}, @@ -17,6 +21,14 @@ use crate::{ }, }; +pub(super) struct DeleteConfirmState { + pub(super) task_id: uuid::Uuid, + pub(super) id_display: String, + pub(super) description: String, + pub(super) project: Option, + pub(super) tags: Vec, +} + pub(super) struct App { pub(super) focus_handle: gpui::FocusHandle, pub(super) focus_target: FocusTarget, @@ -30,16 +42,24 @@ pub(super) struct App { pub(super) task_service: TaskService, pub(super) tasks: Vec, pub(super) focus_before_modal: FocusTarget, + pub(super) delete_confirm: Option, + pub(super) created_task_uuid: Option, + pub(super) needs_focus_restore: bool, } impl gpui::Render for App { fn render( &mut self, - _window: &mut gpui::Window, + window: &mut gpui::Window, cx: &mut gpui::Context, ) -> impl gpui::IntoElement { let theme = cx.theme(); + if self.needs_focus_restore { + self.needs_focus_restore = false; + window.focus(&self.focus_handle); + } + let on_root_key_down = cx.listener(|app, event: &gpui::KeyDownEvent, window, cx| { app.handle_key_down(event, window, cx); }); @@ -66,6 +86,52 @@ impl gpui::Render for App { None }; + let delete_dialog = self.delete_confirm.as_ref().map(|state| { + let cancel_handler = Arc::new(cx.listener(|app, _event, _window, cx| { + app.cancel_delete_confirm(cx); + })); + let confirm_handler = Arc::new(cx.listener(|app, _event, _window, cx| { + app.confirm_delete_task(cx); + })); + + let mut message = format!( + "This will permanently delete: {} \"{}\"", + state.id_display, + state.description.trim() + ); + + if let Some(project) = state + .project + .as_ref() + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + { + message.push_str(&format!(" | Project: {}", project)); + } + + if !state.tags.is_empty() { + message.push_str(&format!(" | Tags: {}", state.tags.join(", "))); + } + + ConfirmDialog::new("Delete task?") + .message(message) + .hint("Enter/y = delete | Esc/n = cancel") + .cancel("Cancel (Esc)", cancel_handler.clone()) + .danger("Delete (Enter)", confirm_handler.clone()) + .on_backdrop_click(cancel_handler) + .render(theme) + .into_any_element() + }); + + let overlay = match (modal, delete_dialog) { + (None, None) => None, + (Some(modal), None) => Some(modal), + (None, Some(dialog)) => Some(dialog), + (Some(modal), Some(dialog)) => { + Some(gpui::div().child(modal).child(dialog).into_any_element()) + } + }; + app_layout::render_app_layout( theme, &self.focus_handle, @@ -77,7 +143,7 @@ impl gpui::Render for App { on_root_key_down, on_sidebar_mouse_down, on_table_mouse_down, - modal, + overlay, ) } } @@ -120,6 +186,16 @@ impl App { all_tasks: Vec, cx: &mut gpui::Context, ) { + let previous_selection = self.task_table.read(cx).selected_task_uuid(); + 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; let (projects, tags) = Self::build_sidebar_data(&self.tasks); @@ -132,10 +208,15 @@ impl App { }); let tasks = self.tasks.clone(); - self.task_table - .update(cx, |table, cx| table.reload_tasks_from_all(tasks, cx)); + self.task_table.update(cx, |table, 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_tag_suggestions(cx); } fn update_modal_project_suggestions(&self, cx: &mut gpui::Context) { @@ -153,6 +234,21 @@ impl App { }); } + fn update_modal_tag_suggestions(&self, cx: &mut gpui::Context) { + let mut tags: Vec = self + .tasks + .iter() + .flat_map(|task| task.tags.iter().cloned()) + .collect(); + + tags.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + tags.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + + self.task_detail_modal.update(cx, |modal, cx| { + modal.set_tag_suggestions(tags, cx); + }); + } + fn reload_tasks(&mut self, cx: &mut gpui::Context) { match self.task_service.get_all_tasks() { Ok(all_tasks) => { @@ -241,6 +337,16 @@ impl App { modal.set_project_suggestions(project_suggestions, cx); }); + let mut tag_suggestions: Vec = task_summaries + .iter() + .flat_map(|task| task.tags.iter().cloned()) + .collect(); + tag_suggestions.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + tag_suggestions.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + task_detail_modal.update(cx, |modal, cx| { + modal.set_tag_suggestions(tag_suggestions, cx); + }); + let mut keymap = KeymapStack::new(); keymap.push_layer(crate::keymap::defaults::build_default_keymap()); @@ -257,6 +363,9 @@ impl App { task_service, tasks: task_summaries, focus_before_modal: FocusTarget::Table, + delete_confirm: None, + created_task_uuid: None, + needs_focus_restore: false, }; window.focus(&app_instance.focus_handle); @@ -290,17 +399,28 @@ impl App { app.open_task_detail(*task_id, false, None, cx); } } + TaskTableEvent::CreateRequested => { + app.open_task_create(cx); + } + TaskTableEvent::DeleteRequested(task_id) => { + app.request_delete_task(*task_id, cx); + } }) .detach(); cx.subscribe(&modal_events, |app, _modal, event, cx| match event { - TaskDetailModalEvent::Closed => { - app.focus_target = app.focus_before_modal; - cx.notify(); + TaskDetailModalEvent::Closed { + task_id, + was_creating, + } => { + app.handle_modal_closed(*task_id, *was_creating, cx); } TaskDetailModalEvent::SaveEdits { task_id, update } => { app.handle_save_task_edits(*task_id, update.clone(), cx); } + TaskDetailModalEvent::CreateTask { draft, annotations } => { + app.handle_create_task(draft.clone(), annotations.clone(), cx); + } }) .detach(); diff --git a/src/components/confirm_dialog.rs b/src/components/confirm_dialog.rs index 520e8f5..51a4d65 100644 --- a/src/components/confirm_dialog.rs +++ b/src/components/confirm_dialog.rs @@ -1,4 +1,3 @@ -use gpui::prelude::*; use std::sync::Arc; use crate::components::dialog::{Dialog, DialogButton, DialogButtonVariant}; @@ -7,6 +6,7 @@ use crate::theme::Theme; #[derive(Clone)] pub struct ConfirmDialog { title: String, + message: Option, hint: Option, cancel: Option<( String, @@ -25,6 +25,7 @@ impl ConfirmDialog { pub fn new(title: impl Into) -> Self { Self { title: title.into(), + message: None, hint: None, cancel: None, confirm: None, @@ -32,6 +33,11 @@ impl ConfirmDialog { } } + pub fn message(mut self, message: impl Into) -> Self { + self.message = Some(message.into()); + self + } + pub fn hint(mut self, hint: impl Into) -> Self { self.hint = Some(hint.into()); self @@ -74,6 +80,9 @@ impl ConfirmDialog { pub fn render(self, theme: &Theme) -> gpui::Div { let mut dialog = Dialog::new(self.title); + if let Some(message) = self.message { + dialog = dialog.message(message); + } if let Some(hint) = self.hint { dialog = dialog.hint(hint); } diff --git a/src/components/input/mod.rs b/src/components/input/mod.rs index 6d13879..28e9456 100644 --- a/src/components/input/mod.rs +++ b/src/components/input/mod.rs @@ -18,6 +18,7 @@ pub struct Input { suggestions: Vec, suggestions_open: bool, + accepted_suggestion: bool, active_suggestion: usize, suggest: Option Vec + Send + Sync>>, on_change: Option) + Send + Sync>>, @@ -42,6 +43,7 @@ impl Input { suggestions: vec![], suggestions_open: false, + accepted_suggestion: false, active_suggestion: 0, suggest: None, on_change: None, @@ -89,6 +91,12 @@ impl Input { &self.id } + pub fn consume_suggestion_accept(&mut self) -> bool { + let accepted = self.accepted_suggestion; + self.accepted_suggestion = false; + accepted + } + pub fn has_suggestions_open(&self) -> bool { self.suggestions_open } @@ -224,6 +232,20 @@ impl Input { self.accept_suggestion(cx); } + fn mark_suggestion_accept(&mut self, cx: &mut gpui::Context) { + if self.accepted_suggestion { + return; + } + + self.accepted_suggestion = true; + let entity = cx.entity().clone(); + cx.defer(move |cx| { + let _ = entity.update(cx, |input, _cx| { + input.accepted_suggestion = false; + }); + }); + } + fn submit(&mut self, cx: &mut gpui::Context) { self.suggestions_open = false; if let Some(on_submit) = self.on_submit.clone() { @@ -369,6 +391,7 @@ impl Input { // For single-line, check suggestions then submit if self.suggestions_open { + self.mark_suggestion_accept(cx); self.accept_suggestion(cx); } else { self.submit(cx); @@ -396,6 +419,17 @@ impl Input { "up" => self.move_suggestion(-1, cx), "down" => self.move_suggestion(1, cx), + "space" => { + if ctrl { + return; + } + if self.suggestions_open { + self.mark_suggestion_accept(cx); + self.accept_suggestion(cx); + } else { + self.insert_text(" ", cx); + } + } "left" => { if ctrl { diff --git a/src/dispatcher.rs b/src/dispatcher.rs index b545a2b..d0a1101 100644 --- a/src/dispatcher.rs +++ b/src/dispatcher.rs @@ -163,6 +163,11 @@ impl CommandDispatcher for App { .update(cx, |table, cx| table.dispatch(command, cx)); true } + Command::CreateTask | Command::DeleteSelectedTask => { + self.task_table + .update(cx, |table, cx| table.dispatch(command, cx)); + true + } Command::ToggleDropdown | Command::SelectNextOption | Command::SelectPrevOption diff --git a/src/handler.rs b/src/handler.rs index 3b8510b..0956828 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -1,4 +1,5 @@ use crate::{ + app::DeleteConfirmState, components::toast::ToastKind, keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord}, task::{self, TaskSummary}, @@ -40,6 +41,28 @@ impl App { window: &mut gpui::Window, cx: &mut gpui::Context, ) { + 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); @@ -197,6 +220,125 @@ impl App { cx.notify(); } + pub(super) fn open_task_create(&mut self, cx: &mut gpui::Context) { + 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, + ) { + 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 = 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) { + if self.delete_confirm.take().is_some() { + cx.notify(); + } + } + + pub(super) fn confirm_delete_task(&mut self, cx: &mut gpui::Context) { + 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) { let summary = TaskSummary::from(&task); if let Some(existing) = self.tasks.iter_mut().find(|t| t.uuid == task.uuid) { @@ -214,6 +356,18 @@ impl App { }); } + fn upsert_task_summary(&mut self, task: task::Task, cx: &mut gpui::Context) { + 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) { let summary = TaskSummary::from(&task); if let Some(existing) = self.tasks.iter_mut().find(|t| t.uuid == task.uuid) { @@ -364,6 +518,87 @@ impl App { } } + pub(super) fn handle_create_task( + &mut self, + draft: task::TaskDraft, + annotations: Vec, + cx: &mut gpui::Context, + ) { + 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, + was_creating: bool, + cx: &mut gpui::Context, + ) { + 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) -> ContextId { if self.task_detail_modal.read(cx).is_open() { return self.task_detail_modal.read(cx).active_context(); diff --git a/src/keymap/command.rs b/src/keymap/command.rs index a2b0411..f3d652f 100644 --- a/src/keymap/command.rs +++ b/src/keymap/command.rs @@ -12,6 +12,8 @@ pub enum Command { // Actions OpenSelectedTask, OpenTaskEdit, + CreateTask, + DeleteSelectedTask, Sync, // Focus @@ -79,6 +81,8 @@ impl Command { "ClearSelection" => Some(Self::ClearSelection), "OpenSelectedTask" => Some(Self::OpenSelectedTask), "OpenTaskEdit" => Some(Self::OpenTaskEdit), + "CreateTask" => Some(Self::CreateTask), + "DeleteSelectedTask" => Some(Self::DeleteSelectedTask), "Sync" => Some(Self::Sync), "FocusSearch" => Some(Self::FocusSearch), "FocusTable" => Some(Self::FocusTable), @@ -137,6 +141,8 @@ impl Command { Self::ClearSelection => "ClearSelection", Self::OpenSelectedTask => "OpenSelectedTask", Self::OpenTaskEdit => "OpenTaskEdit", + Self::CreateTask => "CreateTask", + Self::DeleteSelectedTask => "DeleteSelectedTask", Self::Sync => "Sync", Self::FocusSearch => "FocusSearch", Self::FocusTable => "FocusTable", diff --git a/src/keymap/defaults.rs b/src/keymap/defaults.rs index 52a7db0..6a74d75 100644 --- a/src/keymap/defaults.rs +++ b/src/keymap/defaults.rs @@ -136,6 +136,16 @@ pub fn build_default_keymap() -> KeymapLayer { KeyChord::new(Key::Char('e'), Mods::none()), Command::OpenTaskEdit, ); + layer.bind( + ContextId::Table, + KeyChord::new(Key::Char('c'), Mods::none()), + Command::CreateTask, + ); + layer.bind( + ContextId::Table, + KeyChord::new(Key::Delete, Mods::none()), + Command::DeleteSelectedTask, + ); layer.bind( ContextId::Table, KeyChord::new(Key::ArrowLeft, Mods::none()), diff --git a/src/task/mod.rs b/src/task/mod.rs index 87d00db..8067055 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -6,7 +6,7 @@ pub mod service; pub use error::{TaskError, TaskResult}; pub use filter::{DueDateFilter, TagsFilterMode, TaskFilter}; pub use model::{ - Task, TaskAnnotation, TaskDetailState, TaskDetailVm, TaskOverview, TaskPriority, TaskStatus, - TaskSummary, TaskUpdate, + Task, TaskAnnotation, TaskDetailState, TaskDetailVm, TaskDraft, TaskOverview, TaskPriority, + TaskStatus, TaskSummary, TaskUpdate, }; pub use service::{SyncResult, TaskService}; diff --git a/src/task/model.rs b/src/task/model.rs index 49c4537..66896aa 100644 --- a/src/task/model.rs +++ b/src/task/model.rs @@ -447,6 +447,15 @@ impl TaskDetailVm { impl From for Task { fn from(task: taskchampion::Task) -> Self { + let mut tags: HashSet = task + .get_taskmap() + .keys() + .filter_map(|key| key.strip_prefix("tag_").map(|tag| tag.to_string())) + .collect(); + for tag in task.get_tags().filter(|tag| tag.is_synthetic()) { + tags.insert(tag.to_string()); + } + Self { uuid: task.get_uuid(), id: task.get_value("ID").map(|value| value.parse().unwrap()), @@ -454,7 +463,7 @@ impl From for Task { description: task.get_description().to_string(), project: task.get_value("project").map(|v| v.to_string()), priority: task.get_priority().into(), - tags: task.get_tags().map(|t| t.to_string()).collect(), + tags, due: task.get_due().map(Into::into), wait: task.get_wait().map(Into::into), entry: task.get_entry().map(Into::into), @@ -479,6 +488,16 @@ pub struct TaskUpdate { pub dependencies: Option>, } +#[derive(Debug, Clone)] +pub struct TaskDraft { + pub description: String, + pub project: Option, + pub priority: Option, + pub status: Option, + pub tags: HashSet, + pub due: Option>, +} + #[derive(Debug, Clone)] pub struct TaskOverview { pub tasks: Vec, diff --git a/src/task/service.rs b/src/task/service.rs index 3f2fd4c..3286cf4 100644 --- a/src/task/service.rs +++ b/src/task/service.rs @@ -2,14 +2,12 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use chrono::{DateTime, Utc}; -use taskchampion::{ - Operations, Replica, ServerConfig, Status, StorageConfig, Tag, storage::AccessMode, -}; +use taskchampion::{Operations, Replica, ServerConfig, Status, StorageConfig, storage::AccessMode}; use uuid::Uuid; use super::error::{TaskError, TaskResult}; use super::filter::TaskFilter; -use super::model::{Task, TaskDetailVm, TaskOverview, TaskStatus, TaskSummary}; +use super::model::{Task, TaskDetailVm, TaskDraft, TaskOverview, TaskStatus, TaskSummary}; pub struct TaskService { replica: Replica, @@ -95,7 +93,7 @@ impl TaskService { }) } - pub fn create_task(&mut self, description: String) -> TaskResult { + pub fn create_task(&mut self, draft: TaskDraft) -> TaskResult { let uuid = Uuid::new_v4(); let mut ops = Operations::new(); @@ -104,14 +102,74 @@ impl TaskService { .create_task(uuid, &mut ops) .map_err(|e| TaskError::Storage(e.to_string()))?; + let entry_time = Utc::now(); tc_task - .set_description(description, &mut ops) + .set_status(Status::Pending, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + tc_task + .set_entry(Some(entry_time), &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + tc_task + .set_description(draft.description, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + if let Some(project) = draft.project { + tc_task + .set_value("project", Some(project), &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + } + + if let Some(priority) = draft.priority { + let pri: String = priority.into(); + tc_task + .set_priority(pri, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + } + + if let Some(due) = draft.due { + tc_task + .set_due(Some(due), &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + } + + for tag_str in draft.tags { + let key = format!("tag_{}", tag_str); + tc_task + .set_value(key, Some(String::new()), &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + } + + if let Some(status) = draft.status { + match status { + TaskStatus::Completed => { + tc_task + .done(&mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + } + TaskStatus::Deleted => { + tc_task + .set_status(Status::Deleted, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + } + TaskStatus::Pending => {} + TaskStatus::Recurring | TaskStatus::Unknown(_) => {} + } + } + + let modified_time = Utc::now(); + tc_task + .set_modified(modified_time, &mut ops) .map_err(|e| TaskError::Storage(e.to_string()))?; self.replica .commit_operations(ops) .map_err(|e| TaskError::Storage(e.to_string()))?; + self.replica + .rebuild_working_set(false) + .map_err(|e| TaskError::Storage(e.to_string()))?; + let working_set = self .replica .working_set() @@ -296,22 +354,24 @@ impl TaskService { } if let Some(new_tags) = tags { - let current_tags: HashSet = tc_task.get_tags().map(|t| t.to_string()).collect(); + let current_tags: HashSet = tc_task + .get_taskmap() + .keys() + .filter_map(|key| key.strip_prefix("tag_").map(|tag| tag.to_string())) + .collect(); for tag_str in current_tags.difference(&new_tags) { - if let Ok(tag) = Tag::try_from(tag_str.as_str()) { - tc_task - .remove_tag(&tag, &mut ops) - .map_err(|e| TaskError::Storage(e.to_string()))?; - } + let key = format!("tag_{}", tag_str); + tc_task + .set_value(key, None, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; } for tag_str in new_tags.difference(¤t_tags) { - if let Ok(tag) = Tag::try_from(tag_str.as_str()) { - tc_task - .add_tag(&tag, &mut ops) - .map_err(|e| TaskError::Storage(e.to_string()))?; - } + let key = format!("tag_{}", tag_str); + tc_task + .set_value(key, Some(String::new()), &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; } } @@ -431,10 +491,9 @@ impl TaskService { .map_err(|e| TaskError::Storage(e.to_string()))? .ok_or(TaskError::NotFound(uuid))?; - let tag = Tag::try_from(tag_str).map_err(|_| TaskError::InvalidTag(tag_str.to_string()))?; - + let key = format!("tag_{}", tag_str); tc_task - .add_tag(&tag, &mut ops) + .set_value(key, Some(String::new()), &mut ops) .map_err(|e| TaskError::Storage(e.to_string()))?; self.replica @@ -453,10 +512,9 @@ impl TaskService { .map_err(|e| TaskError::Storage(e.to_string()))? .ok_or(TaskError::NotFound(uuid))?; - let tag = Tag::try_from(tag_str).map_err(|_| TaskError::InvalidTag(tag_str.to_string()))?; - + let key = format!("tag_{}", tag_str); tc_task - .remove_tag(&tag, &mut ops) + .set_value(key, None, &mut ops) .map_err(|e| TaskError::Storage(e.to_string()))?; self.replica diff --git a/src/view/status_bar.rs b/src/view/status_bar.rs index 5a2826c..ce9d8b6 100644 --- a/src/view/status_bar.rs +++ b/src/view/status_bar.rs @@ -49,6 +49,12 @@ impl StatusBar { cx.notify(); } + pub fn set_dirty(&mut self, cx: &mut Context) { + self.last_sync_message = "Needs sync".to_string(); + self.sync_state = SyncState::Idle; + cx.notify(); + } + pub fn set_error(&mut self, message: String, cx: &mut Context) { self.error_message = Some(message); cx.notify(); diff --git a/src/view/task_detail_modal/mod.rs b/src/view/task_detail_modal/mod.rs index 02eaa04..d8416a7 100644 --- a/src/view/task_detail_modal/mod.rs +++ b/src/view/task_detail_modal/mod.rs @@ -1,5 +1,6 @@ -use chrono::Utc; +use chrono::{NaiveDate, TimeZone, Utc}; use gpui::prelude::*; +use std::collections::HashSet; use std::sync::{Arc, Mutex}; use crate::components::button::{Dropdown, DropdownItem}; @@ -7,6 +8,7 @@ use crate::components::input::Input; use crate::components::toast::{ToastGlobal, ToastKind}; use crate::keymap::{Command, CommandDispatcher, ContextId}; use crate::task::{self, TaskDetailVm}; +use crate::ui::DATE_FORMAT; mod annotations; mod bindings; @@ -25,11 +27,18 @@ use self::history::FormHistory; use self::state::{ConfirmAction, InlineEditTarget, ModalMode, TaskModalState}; pub enum TaskDetailModalEvent { - Closed, + Closed { + task_id: Option, + was_creating: bool, + }, SaveEdits { task_id: uuid::Uuid, update: TaskEditUpdate, }, + CreateTask { + draft: task::TaskDraft, + annotations: Vec, + }, } struct ModalEntities { @@ -54,6 +63,7 @@ pub struct TaskDetailModal { scroll_handle: gpui::ScrollHandle, entities: ModalEntities, project_suggestions: Arc>>, + tag_suggestions: Arc>>, form_history: FormHistory, } @@ -61,6 +71,7 @@ impl TaskDetailModal { pub fn new(cx: &mut gpui::Context) -> Self { let modal_entity = cx.entity().clone(); let project_suggestions: Arc>> = Arc::new(Mutex::new(Vec::new())); + let tag_suggestions: Arc>> = Arc::new(Mutex::new(Vec::new())); let annotation_input = cx.new(|cx| { let on_change_entity = modal_entity.clone(); @@ -100,23 +111,58 @@ impl TaskDetailModal { }; let needle = query.to_lowercase(); + let mut candidates = Vec::new(); + let mut seen = HashSet::new(); + + for project in list.iter() { + let parts: Vec<&str> = project + .split('.') + .filter(|part| !part.trim().is_empty()) + .collect(); + if parts.is_empty() { + continue; + } + + let mut prefix = String::new(); + for (idx, part) in parts.iter().enumerate() { + if !prefix.is_empty() { + prefix.push('.'); + } + prefix.push_str(part); + + let key = prefix.to_lowercase(); + if seen.insert(key) { + candidates.push(prefix.clone()); + } + + if idx + 1 < parts.len() { + let mut with_dot = prefix.clone(); + with_dot.push('.'); + let key = with_dot.to_lowercase(); + if seen.insert(key) { + candidates.push(with_dot); + } + } + } + } + let mut level_matches = Vec::new(); let mut prefix_matches = Vec::new(); let mut contains_matches = Vec::new(); - for project in list.iter() { - let hay = project.to_lowercase(); + for candidate in candidates { + let hay = candidate.to_lowercase(); if hay.starts_with(&needle) { let boundary = hay.len() == needle.len() || hay.as_bytes().get(needle.len()) == Some(&b'.') || needle.ends_with('.'); if boundary { - level_matches.push(project.clone()); + level_matches.push(candidate); } else { - prefix_matches.push(project.clone()); + prefix_matches.push(candidate); } } else if hay.contains(&needle) { - contains_matches.push(project.clone()); + contains_matches.push(candidate); } } @@ -138,24 +184,72 @@ impl TaskDetailModal { let due_input = cx.new(|cx| Input::new("task-edit-due", cx, "Due (YYYY-MM-DD)")); - let tags_input = cx.new(|cx| { + let tags_input = { let on_change_entity = modal_entity.clone(); + let suggestions = tag_suggestions.clone(); - Input::new("task-edit-tags", cx, "Add tag").with_on_change(Arc::new( - move |value, cx| { - cx.defer({ - let entity = on_change_entity.clone(); - let v = value.to_string(); - move |cx| { - let _ = entity.update(cx, |modal, cx| { - modal.state.form.tag_draft = v; - cx.notify(); - }); + cx.new(|cx| { + Input::new("task-edit-tags", cx, "Add tag") + .with_suggest(Arc::new(move |query| { + let (prefix, needle) = match query + .char_indices() + .rev() + .find(|(_, ch)| ch.is_whitespace() || *ch == ',') + { + Some((idx, _)) => (&query[..=idx], &query[idx + 1..]), + None => ("", query), + }; + let needle = needle.trim(); + if needle.is_empty() { + return Vec::new(); } - }); - }, - )) - }); + + let Ok(list) = suggestions.lock() else { + return Vec::new(); + }; + + let needle_lower = needle.to_lowercase(); + let mut prefix_matches = Vec::new(); + let mut contains_matches = Vec::new(); + + for tag in list.iter() { + let hay = tag.to_lowercase(); + if hay.starts_with(&needle_lower) { + prefix_matches.push(tag.clone()); + } else if hay.contains(&needle_lower) { + contains_matches.push(tag.clone()); + } + } + + prefix_matches.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + contains_matches.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + + prefix_matches + .into_iter() + .chain(contains_matches) + .take(8) + .map(|tag| { + let mut insert = String::new(); + insert.push_str(prefix); + insert.push_str(&tag); + crate::components::input::Suggestion::new(tag, insert) + }) + .collect() + })) + .with_on_change(Arc::new(move |value, cx| { + cx.defer({ + let entity = on_change_entity.clone(); + let v = value.to_string(); + move |cx| { + let _ = entity.update(cx, |modal, cx| { + modal.state.form.tag_draft = v; + cx.notify(); + }); + } + }); + })) + }) + }; let status_items = vec![ DropdownItem::new("Pending"), @@ -190,6 +284,7 @@ impl TaskDetailModal { scroll_handle: gpui::ScrollHandle::new(), entities, project_suggestions, + tag_suggestions, form_history: FormHistory::default(), } } @@ -212,6 +307,35 @@ impl TaskDetailModal { } } + pub fn set_tag_suggestions(&mut self, tags: Vec, _cx: &mut gpui::Context) { + if let Ok(mut list) = self.tag_suggestions.lock() { + *list = tags; + } + } + + fn placeholder_detail() -> TaskDetailVm { + let task = task::Task::new( + uuid::Uuid::nil(), + None, + task::TaskStatus::Pending, + String::new(), + None, + task::TaskPriority::None, + HashSet::new(), + None, + None, + None, + None, + vec![], + HashSet::new(), + false, + false, + None, + ); + + TaskDetailVm::from_task(&task, &[]) + } + pub fn open_with_detail( &mut self, detail: TaskDetailVm, @@ -230,6 +354,31 @@ impl TaskDetailModal { self.open_with_detail_mode(detail, true, window, cx); } + pub fn open_create(&mut self, window: Option<&mut gpui::Window>, cx: &mut gpui::Context) { + self.reset_open_state(); + self.state.task_id = None; + self.state.loading = false; + self.state.error = None; + self.state.original = Some(Self::placeholder_detail()); + self.state.form = TaskForm::default(); + self.state.errors.clear(); + self.state.is_create = true; + self.state.mode = ModalMode::Edit; + self.state.edit_state = EditState::Navigating; + self.state.modal_focus = ModalFocus::Description; + self.state.annotations = AnnotationState::default(); + self.reset_pending_state(); + self.apply_form_inputs(cx); + self.form_history.clear(); + self.form_history.push(self.state.form.clone()); + + if let Some(window) = window { + window.focus(&self.form_focus_handle); + } + + cx.notify(); + } + fn reset_open_state(&mut self) { self.scroll_handle = gpui::ScrollHandle::new(); self.scroll_handle.scroll_to_item(0); @@ -253,6 +402,7 @@ impl TaskDetailModal { self.state.form = TaskForm::default(); self.state.errors.clear(); self.state.mode = ModalMode::View; + self.state.is_create = false; self.state.edit_state = EditState::Navigating; self.state.annotations = AnnotationState::default(); self.state.error = error; @@ -272,6 +422,7 @@ impl TaskDetailModal { self.state.error = None; self.state.original = Some(detail.clone()); self.state.errors.clear(); + self.state.is_create = false; self.reset_pending_state(); self.sync_from_detail(&detail, cx, true); @@ -333,6 +484,7 @@ impl TaskDetailModal { self.state.loading = false; self.state.error = None; self.state.original = Some(detail.clone()); + self.state.is_create = false; self.sync_from_detail(&detail, cx, reset_form); cx.notify(); } @@ -368,11 +520,16 @@ impl TaskDetailModal { self.state.loading = false; self.state.original = Some(detail); self.state.error = None; + self.state.is_create = false; self.reset_pending_state(); cx.notify(); } fn enter_edit_mode(&mut self, window: Option<&mut gpui::Window>, cx: &mut gpui::Context) { + if self.state.is_create { + return; + } + let Some(detail) = self.state.original.clone() else { return; }; @@ -393,6 +550,11 @@ impl TaskDetailModal { } pub fn cancel_edit(&mut self, window: Option<&mut gpui::Window>, cx: &mut gpui::Context) { + if self.state.is_create { + self.close(window, cx); + return; + } + let Some(detail) = self.state.original.clone() else { return; }; @@ -412,7 +574,15 @@ impl TaskDetailModal { } pub fn is_editing(&self) -> bool { - self.state.mode == ModalMode::Edit + self.state.mode == ModalMode::Edit && !self.state.is_create + } + + pub fn is_creating(&self) -> bool { + self.state.mode == ModalMode::Edit && self.state.is_create + } + + pub fn task_id(&self) -> Option { + self.state.task_id } fn apply_form_inputs(&mut self, cx: &mut gpui::Context) { @@ -526,6 +696,11 @@ impl TaskDetailModal { return; } + if self.state.is_create { + self.submit_create(cx); + return; + } + if self.state.original.is_none() { return; } @@ -553,6 +728,69 @@ impl TaskDetailModal { }); } + fn submit_create(&mut self, cx: &mut gpui::Context) { + if self.state.mode != ModalMode::Edit || !self.state.is_create { + return; + } + + self.sync_form_from_inputs(cx); + self.state.errors = self.state.form.validate(); + if !self.state.errors.is_empty() { + cx.notify(); + return; + } + + let description = self.state.form.description.trim().to_string(); + let project = match self.state.form.project.trim() { + "" => None, + value => Some(value.to_string()), + }; + + let due = if self.state.form.due.trim().is_empty() { + None + } else { + NaiveDate::parse_from_str(self.state.form.due.trim(), DATE_FORMAT) + .ok() + .and_then(|date| date.and_hms_opt(0, 0, 0)) + .map(|date| Utc.from_utc_datetime(&date)) + }; + + let priority = match self.state.form.priority { + task::TaskPriority::None => None, + value => Some(value), + }; + + let status = match self.state.form.status.clone() { + task::TaskStatus::Pending => None, + value => Some(value), + }; + + let tags = self.state.form.tags.iter().cloned().collect(); + + let annotations = self + .state + .annotations + .items + .iter() + .filter_map(|item| match item.origin { + AnnotationOrigin::Added => Some(item.text.to_string()), + _ => None, + }) + .collect(); + + cx.emit(TaskDetailModalEvent::CreateTask { + draft: task::TaskDraft { + description, + project, + priority, + status, + tags, + due, + }, + annotations, + }); + } + fn update_field_error(&mut self, field: FieldId) { match self.state.form.validate_field(field) { Some(message) => { @@ -631,8 +869,13 @@ impl TaskDetailModal { .update(cx, |input, cx| input.blur(window, cx)); } + let task_id = self.state.task_id; + let was_creating = self.state.is_create; self.state = TaskModalState::default(); - cx.emit(TaskDetailModalEvent::Closed); + cx.emit(TaskDetailModalEvent::Closed { + task_id, + was_creating, + }); cx.notify(); } @@ -1093,7 +1336,13 @@ impl TaskDetailModal { CommandResult::Handled } ModalFocus::Project => { - if self.project_suggestions_open(cx) { + let accepted = self + .entities + .project_input + .update(cx, |input, _cx| input.consume_suggestion_accept()); + if accepted { + CommandResult::Handled + } else if self.project_suggestions_open(cx) { CommandResult::NotHandled } else { self.exit_edit_field(window, cx); diff --git a/src/view/task_detail_modal/render/mod.rs b/src/view/task_detail_modal/render/mod.rs index e34c1a2..e28d3e9 100644 --- a/src/view/task_detail_modal/render/mod.rs +++ b/src/view/task_detail_modal/render/mod.rs @@ -45,6 +45,7 @@ pub(super) fn render_task_detail_modal( panel::render_task_detail_panel( detail, state.mode, + state.is_create, state.edit_state, &state.form, &state.errors, diff --git a/src/view/task_detail_modal/render/panel.rs b/src/view/task_detail_modal/render/panel.rs index 7ce7478..825c0ee 100644 --- a/src/view/task_detail_modal/render/panel.rs +++ b/src/view/task_detail_modal/render/panel.rs @@ -104,6 +104,7 @@ where pub(super) fn render_task_detail_panel( detail: &task::TaskDetailVm, mode: ModalMode, + is_create: bool, edit_state: EditState, form: &TaskForm, errors: &HashMap, @@ -129,6 +130,7 @@ where OnCloseClick: Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, { let is_editing = mode == ModalMode::Edit; + let is_creating = is_create; let is_navigating = edit_state == EditState::Navigating; let status_label = if is_editing { form.status.clone().into() @@ -204,7 +206,7 @@ where ); } - if is_editing { + if is_editing && !is_creating { badges.push( Chip::new("Editing") .variant(ChipVariant::Info) @@ -224,7 +226,11 @@ where } else { detail.overview.description.clone() }; - let title = format!("{} {}", id_label, title_description); + let title = if is_creating { + "New task".to_string() + } else { + format!("{} {}", id_label, title_description) + }; let on_close_click = Arc::new(on_close_click); let header = render_header(title, badges, is_editing, theme, cx, on_close_click.clone()); @@ -303,6 +309,7 @@ where let footer = render_footer( detail, is_editing, + is_creating, is_navigating, errors, form, @@ -1015,6 +1022,7 @@ fn render_extras_section(detail: &TaskDetailVm, value_color: gpui::Rgba) -> Opti fn render_footer( detail: &TaskDetailVm, is_editing: bool, + is_creating: bool, is_navigating: bool, errors: &HashMap, form: &TaskForm, @@ -1027,7 +1035,27 @@ fn render_footer( ) -> gpui::Div { let mut action_row = gpui::div().flex().items_center().gap_2(); - if is_editing { + if is_creating { + let cancel_create_handler = Arc::new(cx.listener(|modal, _event, window, cx| { + modal.close(Some(window), cx); + })); + + let create_handler = Arc::new(cx.listener(|modal, _event, _window, cx| { + modal.submit_create(cx); + })); + + let cancel_button = + ActionButton::new("Cancel (Esc)").on_click(move |event, window, app| { + (cancel_create_handler)(event, window, app); + }); + + let create_button = + ActionButton::new("Create (Ctrl+S)").on_click(move |event, window, app| { + (create_handler)(event, window, app); + }); + + action_row = action_row.child(cancel_button).child(create_button); + } else if is_editing { let cancel_edit_handler = Arc::new(cx.listener(|modal, _event, window, cx| { modal.cancel_edit(Some(window), cx); })); diff --git a/src/view/task_detail_modal/state.rs b/src/view/task_detail_modal/state.rs index b9c50f3..3d97424 100644 --- a/src/view/task_detail_modal/state.rs +++ b/src/view/task_detail_modal/state.rs @@ -73,6 +73,7 @@ pub(super) struct TaskModalState { pub(super) form: TaskForm, pub(super) errors: HashMap, pub(super) mode: ModalMode, + pub(super) is_create: bool, pub(super) edit_state: EditState, pub(super) annotations: AnnotationState, pub(super) error: Option, @@ -98,6 +99,7 @@ impl Default for TaskModalState { form: TaskForm::default(), errors: HashMap::new(), mode: ModalMode::default(), + is_create: false, edit_state: EditState::default(), annotations: AnnotationState::default(), error: None, diff --git a/src/view/task_table.rs b/src/view/task_table.rs index e0b6afb..1670e2d 100644 --- a/src/view/task_table.rs +++ b/src/view/task_table.rs @@ -7,6 +7,7 @@ use gpui::prelude::*; use crate::{ components::{ self, + action_button::ActionButton, button::{Dropdown, DropdownItem}, input::Input, }, @@ -751,6 +752,45 @@ impl TaskTable { .map(|task| task.uuid) } + pub fn select_task_by_uuid(&mut self, uuid: uuid::Uuid, cx: &mut gpui::Context) -> bool { + let Some(idx) = self.cached_tasks.iter().position(|task| task.uuid == uuid) else { + self.selected_page_idx = None; + self.selected_global_idx = None; + cx.notify(); + return false; + }; + + let page = idx / self.pagination.page_size + 1; + self.pagination.current_page(page); + let page_first_idx = self.pagination.first_item_index(); + self.selected_global_idx = Some(idx); + self.selected_page_idx = Some(idx - page_first_idx); + cx.notify(); + true + } + + pub fn next_selection_after_delete(&self, deleted_uuid: uuid::Uuid) -> Option { + let selected_uuid = self.selected_task_uuid(); + if selected_uuid != Some(deleted_uuid) { + return selected_uuid; + } + + let idx = self + .cached_tasks + .iter() + .position(|task| task.uuid == deleted_uuid)?; + + if idx + 1 < self.cached_tasks.len() { + return Some(self.cached_tasks[idx + 1].uuid); + } + + if idx > 0 { + return Some(self.cached_tasks[idx - 1].uuid); + } + + None + } + pub fn focus_search_input(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context) { self.filter_bar_focus = FilterBarFocus::SearchInput; self.search_input.update(cx, |input, cx| { @@ -1059,6 +1099,13 @@ impl TaskTable { .child(status_wrapper) .child(priority_wrapper) .child(due_wrapper) + .child( + ActionButton::new("+ New") + .id("task-new") + .on_click(cx.listener(|_table, _event, _window, cx| { + cx.emit(TaskTableEvent::CreateRequested); + })), + ) .child(clear_button); gpui::div() @@ -1175,6 +1222,11 @@ impl TaskTable { .w(gpui::rems(6.0)) .child(self.render_header_column(SortColumn::Status, "header-status", cx)), ) + .child( + gpui::div() + .w(gpui::rems(2.0)) + .child(components::label::Label::new("").text_color(theme.muted)), + ) } fn render_row(&self, idx: usize, row: &TaskRow, cx: &gpui::Context) -> gpui::Div { @@ -1182,19 +1234,32 @@ impl TaskTable { let selected = self.selected_page_idx == Some(idx); let row_uuid = row.uuid; - gpui::div() + let delete_button = gpui::div() + .id(("delete-btn", idx)) .flex() .items_center() + .justify_center() + .w(gpui::rems(2.0)) + .h(gpui::rems(2.0)) + .rounded_md() + .text_color(theme.error) + .cursor_pointer() + .hover(|s| s.bg(theme.hover).text_color(theme.error)) + .active(|s| s.bg(theme.selection)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |table, _event: &gpui::MouseDownEvent, _window, cx| { + table.select_row(idx, cx); + cx.emit(TaskTableEvent::DeleteRequested(row_uuid)); + }), + ) + .child(components::label::Label::new("✕").text_sm()); + + let row_content = gpui::div() + .flex() + .flex_1() + .items_center() .gap_2() - .px_4() - .py_1() - .border_b_1() - .border_color(theme.divider) - .text_color(theme.foreground) - .when(selected, |d| { - d.bg(theme.selection).text_color(theme.selection_foreground) - }) - .when(!selected, |d| d.hover(|s| s.bg(theme.hover))) .cursor_pointer() .on_mouse_down( gpui::MouseButton::Left, @@ -1249,7 +1314,23 @@ impl TaskTable { components::label::Label::new(row.status.clone()) .text_color(self.status_color(row, cx)), ), - ) + ); + + gpui::div() + .flex() + .items_center() + .gap_2() + .px_4() + .py_1() + .border_b_1() + .border_color(theme.divider) + .text_color(theme.foreground) + .when(selected, |d| { + d.bg(theme.selection).text_color(theme.selection_foreground) + }) + .when(!selected, |d| d.hover(|s| s.bg(theme.hover))) + .child(row_content) + .child(delete_button) } fn render_footer(&self, cx: &gpui::Context) -> gpui::Div { @@ -1395,6 +1476,16 @@ impl CommandDispatcher for TaskTable { self.blur_filter_bar(cx); true } + Command::CreateTask => { + cx.emit(TaskTableEvent::CreateRequested); + true + } + Command::DeleteSelectedTask => { + if let Some(task_id) = self.selected_task_uuid() { + cx.emit(TaskTableEvent::DeleteRequested(task_id)); + } + true + } Command::ExpandProject | Command::CollapseProject => false, _ => false, } @@ -1403,6 +1494,8 @@ impl CommandDispatcher for TaskTable { pub enum TaskTableEvent { OpenTask(uuid::Uuid), + CreateRequested, + DeleteRequested(uuid::Uuid), } impl gpui::EventEmitter for TaskTable {}