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 {}