diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index b312e5a..b10da31 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -209,10 +209,10 @@ These shortcuts work when viewing task details: | Shortcut | Action | |----------|--------| -| `Escape` | Close modal | +| `Escape` | Cancel edit (when editing) or close modal | | `j` / `↓` | Scroll down | | `k` / `↑` | Scroll up | -| `Ctrl+Enter` | Close modal (same as Esc) | +| `Ctrl+Enter` | Save edits (when editing) or close modal | ## Search Input Editing diff --git a/src/app.rs b/src/app.rs index db6f168..02907f9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,14 +4,14 @@ use gpui::prelude::*; use crate::{ components::toast::{ToastGlobal, ToastHost}, - keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord, KeymapStack}, + keymap::{FocusTarget, KeymapStack}, models::{FilterState, ProjectTree}, task::{self, TaskOverview, TaskService, TaskSummary}, theme::ActiveTheme, view::{ app_layout, sidebar::{Sidebar, SidebarEvent, SidebarSection, TagItem}, - status_bar::{StatusBar, StatusBarEvent, SyncState}, + status_bar::{StatusBar, StatusBarEvent}, task_detail_modal::{TaskDetailModal, TaskDetailModalEvent}, task_table::{TaskTable, TaskTableEvent}, }, @@ -115,7 +115,7 @@ impl App { (projects, tag_items) } - fn update_ui_from_tasks( + pub(super) fn update_ui_from_tasks( &mut self, all_tasks: Vec, cx: &mut gpui::Context, @@ -134,6 +134,23 @@ impl App { let tasks = self.tasks.clone(); self.task_table .update(cx, |table, cx| table.reload_tasks_from_all(tasks, cx)); + + self.update_modal_project_suggestions(cx); + } + + fn update_modal_project_suggestions(&self, cx: &mut gpui::Context) { + let mut projects: Vec = self + .tasks + .iter() + .filter_map(|task| task.project.clone()) + .collect(); + + projects.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + projects.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + + self.task_detail_modal.update(cx, |modal, cx| { + modal.set_project_suggestions(projects, cx); + }); } fn reload_tasks(&mut self, cx: &mut gpui::Context) { @@ -155,182 +172,6 @@ impl App { } } - pub(super) fn handle_sync(&mut self, cx: &mut gpui::Context) { - self.status_bar.update(cx, |bar, cx| { - bar.set_sync_state(SyncState::Syncing, cx); - bar.set_last_sync_message("Syncing...".to_string(), cx); - }); - - match self.task_service.get_all_tasks() { - Ok(all_tasks) => { - let summaries: Vec = all_tasks.iter().map(TaskSummary::from).collect(); - self.update_ui_from_tasks(summaries, cx); - - self.status_bar.update(cx, |bar, cx| { - bar.set_sync_state(SyncState::Success, cx); - bar.set_last_sync_message("Synced".to_string(), cx); - }); - } - Err(e) => { - log::error!("[App] Sync failed: {}", e); - self.status_bar.update(cx, |bar, cx| { - bar.set_sync_state(SyncState::Error, cx); - bar.set_last_sync_message(format!("Error: {}", e), cx); - }); - } - } - } - - fn handle_key_down( - &mut self, - event: &gpui::KeyDownEvent, - window: &mut gpui::Window, - cx: &mut gpui::Context, - ) { - if let Some(chord) = KeyChord::from_gpui(event) { - let context = self.active_context(cx); - - if let Some(command) = self.keymap.resolve(context, &chord) { - let modal_is_open = self.task_detail_modal.read(cx).is_open(); - - if modal_is_open { - match command { - Command::CloseModal - | Command::SaveModal - | Command::Sync - | Command::ModalScrollUp - | Command::ModalScrollDown => {} - _ => return, - } - } - - match command { - Command::FocusSearch => { - let from_headers = matches!(self.focus_target, FocusTarget::TableHeaders); - self.focus_target = FocusTarget::Table; - self.task_table.update(cx, |table, cx| { - if from_headers { - table.blur_table_headers(cx); - } - table.focus_search_input(window, cx); - }); - cx.notify(); - } - Command::FocusTableHeaders => { - self.focus_target = FocusTarget::TableHeaders; - self.task_table.update(cx, |table, cx| { - table.blur_search_input(window, cx); - table.set_filter_bar_focus( - crate::view::task_table::FilterBarFocus::None, - cx, - ); - table.focus_table_headers(window, cx); - }); - cx.notify(); - } - Command::FocusTable => { - self.focus_target = FocusTarget::Table; - self.task_table.update(cx, |table, cx| match context { - ContextId::TextInput | ContextId::FilterBar => { - table.blur_search_input(window, cx); - table.set_filter_bar_focus( - crate::view::task_table::FilterBarFocus::None, - cx, - ); - } - ContextId::TableHeaders => { - table.blur_table_headers(cx); - } - _ => {} - }); - cx.notify(); - } - Command::FocusFilterNext | Command::FocusFilterPrev => { - self.task_table.update(cx, |table, cx| { - use crate::view::task_table::FilterBarFocus; - let was_on_input = - matches!(table.get_filter_bar_focus(), FilterBarFocus::SearchInput); - - if command == Command::FocusFilterNext { - table.focus_filter_next(cx); - } else { - table.focus_filter_prev(cx); - } - - if was_on_input { - table.blur_search_input(window, cx); - } - - let now_on_input = - matches!(table.get_filter_bar_focus(), FilterBarFocus::SearchInput); - if now_on_input && !was_on_input { - table.focus_search_input(window, cx); - } - }); - } - _ => { - self.dispatch(command, cx); - } - } - } - } - } - - pub(super) fn open_selected_task( - &mut self, - window: Option<&mut gpui::Window>, - cx: &mut gpui::Context, - ) { - if self.task_detail_modal.read(cx).is_open() { - return; - } - - let task_id = self.task_table.read(cx).selected_task_uuid(); - let Some(task_id) = task_id else { - return; - }; - - self.open_task_detail(task_id, window, cx); - } - - fn open_task_detail( - &mut self, - task_id: uuid::Uuid, - window: Option<&mut gpui::Window>, - cx: &mut gpui::Context, - ) { - self.focus_before_modal = self.focus_target; - - let tasks = self.tasks.clone(); - match self.task_service.get_task_detail(task_id, &tasks) { - Ok(detail) => { - self.task_detail_modal.update(cx, |modal, cx| { - modal.open_with_detail(detail, window, cx); - }); - } - Err(e) => { - self.task_detail_modal.update(cx, |modal, cx| { - modal.open_with_error(task_id, e.to_string(), window, cx); - }); - } - } - - cx.notify(); - } - - fn active_context(&self, cx: &gpui::Context) -> ContextId { - if self.task_detail_modal.read(cx).is_open() { - return ContextId::Modal; - } - if matches!(self.focus_target, FocusTarget::Table) { - let filter_context = self.task_table.read(cx).get_active_filter_context(); - if let Some(context) = filter_context { - return context; - } - } - self.focus_target.to_context() - } - pub fn run() -> () { let app = gpui::Application::new(); @@ -390,6 +231,15 @@ impl App { task_table.update(cx, |table, cx| { table.reload_tasks_from_all(task_summaries.clone(), cx); }); + let mut project_suggestions: Vec = task_summaries + .iter() + .filter_map(|task| task.project.clone()) + .collect(); + project_suggestions.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + project_suggestions.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + task_detail_modal.update(cx, |modal, cx| { + modal.set_project_suggestions(project_suggestions, cx); + }); let mut keymap = KeymapStack::new(); keymap.push_layer(crate::keymap::defaults::build_default_keymap()); @@ -448,6 +298,9 @@ impl App { app.focus_target = app.focus_before_modal; cx.notify(); } + TaskDetailModalEvent::SaveEdits { task_id, update } => { + app.handle_save_task_edits(*task_id, update.clone(), cx); + } }) .detach(); diff --git a/src/components/button/dropdown.rs b/src/components/button/dropdown.rs index ed5f638..a87afac 100644 --- a/src/components/button/dropdown.rs +++ b/src/components/button/dropdown.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use gpui::prelude::*; +use gpui::{Corner, anchored, deferred, px, point}; use crate::components::button::Button; use crate::components::label::Label; @@ -41,6 +42,7 @@ pub struct Dropdown { selected_index: Option, disabled: bool, loading: bool, + inline_menu: bool, placeholder: gpui::SharedString, label_prefix: Option, on_select: Option) + Send + Sync>>, @@ -56,7 +58,8 @@ impl Dropdown { selected_index: None, disabled: false, loading: false, - placeholder: "Seleccionar".into(), + inline_menu: false, + placeholder: "Select".into(), label_prefix: None, on_select: None, } @@ -117,6 +120,11 @@ impl Dropdown { self } + pub fn inline_menu(mut self) -> Self { + self.inline_menu = true; + self + } + pub fn on_select( mut self, handler: Arc) + Send + Sync>, @@ -279,14 +287,8 @@ impl Dropdown { }) .collect(); - gpui::div() - .absolute() - .top_full() - .left_0() - .min_w(gpui::rems(12.0)) - .min_w_full() - .mt_1() - .occlude() + let menu = gpui::div() + .w_full() // Same width as trigger .p_1() .border_1() .border_color(theme.border) @@ -294,8 +296,22 @@ impl Dropdown { .rounded_md() .overflow_hidden() .shadow_lg() - .children(items) + .occlude() + .children(items); + + if self.inline_menu { + menu.into_any_element() + } else { + deferred( + anchored() + .anchor(Corner::TopLeft) + .offset(point(px(0.0), px(4.0))) + .snap_to_window() + .child(menu), + ) + .with_priority(1) .into_any_element() + } } } @@ -352,7 +368,6 @@ impl gpui::Render for Dropdown { let mut container = gpui::div() .id(self.id.clone()) - .relative() .flex() .flex_col() .child(trigger_wrap) diff --git a/src/components/input/mod.rs b/src/components/input/mod.rs index a71bade..a2dd7f7 100644 --- a/src/components/input/mod.rs +++ b/src/components/input/mod.rs @@ -1,7 +1,8 @@ mod suggestion; -use crate::theme::ActiveTheme; +use crate::theme::{ActiveTheme, Theme}; use gpui::prelude::*; +use gpui::{Corner, anchored, deferred, px, point}; use std::sync::Arc; pub use suggestion::Suggestion; @@ -13,14 +14,15 @@ pub struct Input { placeholder: gpui::SharedString, cursor_pos: usize, + multiline: bool, suggestions: Vec, suggestions_open: bool, active_suggestion: usize, - suggest: Option Vec + Send + Sync>>, on_change: Option) + Send + Sync>>, on_submit: Option) + Send + Sync>>, + external_suggestions: bool, } impl Input { @@ -36,17 +38,23 @@ impl Input { placeholder: placeholder.into(), cursor_pos: 0, + multiline: false, suggestions: vec![], suggestions_open: false, active_suggestion: 0, - suggest: None, on_change: None, on_submit: None, + external_suggestions: false, } } + pub fn external_suggestions(mut self) -> Self { + self.external_suggestions = true; + self + } + pub fn with_suggest(mut self, f: Arc Vec + Send + Sync>) -> Self { self.suggest = Some(f); self @@ -60,6 +68,11 @@ impl Input { self } + pub fn multiline(mut self) -> Self { + self.multiline = true; + self + } + pub fn with_on_submit( mut self, f: Arc) + Send + Sync>, @@ -72,6 +85,14 @@ impl Input { &self.value } + pub fn element_id(&self) -> &gpui::ElementId { + &self.id + } + + pub fn has_suggestions_open(&self) -> bool { + self.suggestions_open + } + pub fn set_value(&mut self, value: impl Into, cx: &mut gpui::Context) { self.value = value.into(); self.cursor_pos = self.value.len(); @@ -79,8 +100,16 @@ impl Input { cx.notify(); } + pub fn set_value_silent(&mut self, value: impl Into, cx: &mut gpui::Context) { + self.value = value.into(); + self.cursor_pos = self.value.len(); + self.suggestions_open = false; + self.suggestions.clear(); + cx.notify(); + } + pub fn clear(&mut self, cx: &mut gpui::Context) { - self.set_value("", cx); + self.set_value_silent("", cx); } pub fn focus(&self, window: &mut gpui::Window, cx: &mut gpui::Context) { @@ -163,7 +192,7 @@ impl Input { if let Some(suggest) = &self.suggest { self.suggestions = suggest(&self.value); self.active_suggestion = 0; - self.suggestions_open = !self.suggestions.is_empty(); + self.suggestions_open = !self.suggestions.is_empty() && !self.value.is_empty(); cx.notify(); } } @@ -295,6 +324,24 @@ impl Input { cx.notify(); } + fn cursor_line_info(&self) -> (usize, usize) { + let mut line = 0; + let mut line_start = 0; + + for (idx, ch) in self.value.char_indices() { + if idx >= self.cursor_pos { + break; + } + + if ch == '\n' { + line += 1; + line_start = idx + ch.len_utf8(); + } + } + + (line, self.cursor_pos.saturating_sub(line_start)) + } + fn handle_key_down( &mut self, event: &gpui::KeyDownEvent, @@ -311,6 +358,11 @@ impl Input { match key { "enter" => { + if self.multiline && shift { + self.insert_text("\n", cx); + return; + } + if self.suggestions_open { self.accept_suggestion(cx); } else { @@ -429,8 +481,52 @@ impl Input { } } - fn render_suggestions(&self, cx: &gpui::Context) -> impl IntoElement { + pub fn render_suggestions_external(&self, cx: &gpui::Context) -> Option { if !self.suggestions_open { + return None; + } + + let theme = cx.theme(); + let items: Vec = self + .suggestions + .iter() + .enumerate() + .map(|(i, s)| { + let is_active = i == self.active_suggestion; + gpui::div() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |this, _e, _w, cx| { + this.click_suggestion(i, cx); + }), + ) + .cursor_pointer() + .px_2() + .py_1() + .when(is_active, |el| el.bg(theme.selection)) + .text_color(if is_active { + theme.selection_foreground + } else { + theme.foreground + }) + .child(s.label.clone()) + .into_any_element() + }) + .collect(); + + Some(gpui::div() + .mt_1() + .border_1() + .border_color(theme.border) + .bg(theme.panel) + .rounded_md() + .overflow_hidden() + .children(items) + .into_any_element()) + } + + fn render_suggestions(&self, cx: &gpui::Context) -> impl IntoElement { + if !self.suggestions_open || self.external_suggestions { return gpui::div().into_any_element(); } @@ -462,18 +558,84 @@ impl Input { }) .collect(); - gpui::div() - .absolute() - .top_full() - .left_0() - .right_0() - .mt_1() + let menu = gpui::div() + .w_full() .border_1() .border_color(theme.border) .bg(theme.panel) .rounded_md() .overflow_hidden() - .children(items) + .shadow_lg() + .occlude() + .children(items); + + deferred( + anchored() + .anchor(Corner::TopLeft) + .offset(point(px(0.0), px(4.0))) + .snap_to_window() + .child(menu), + ) + .with_priority(1) + .into_any_element() + } + + fn render_multiline_content(&self, is_focused: bool, theme: &Theme) -> gpui::AnyElement { + if self.value.is_empty() { + let cursor = if is_focused { + gpui::div().w_px().h_4().bg(theme.accent).into_any_element() + } else { + gpui::div().into_any_element() + }; + + return gpui::div() + .id(self.id.clone()) + .flex() + .flex_col() + .items_start() + .child( + gpui::div().flex().items_center().child(cursor).child( + gpui::div() + .text_color(theme.muted) + .child(self.placeholder.clone()), + ), + ) + .into_any_element(); + } + + let (cursor_line, cursor_col) = self.cursor_line_info(); + let lines: Vec<&str> = self.value.split('\n').collect(); + let mut rows = Vec::with_capacity(lines.len()); + + for (idx, line) in lines.iter().enumerate() { + if is_focused && idx == cursor_line { + let col = cursor_col.min(line.len()); + let (before, after) = line.split_at(col); + let after_text = if after.is_empty() { " " } else { after }; + + rows.push( + gpui::div() + .flex() + .items_center() + .child(before.to_string()) + .child(gpui::div().w_px().h_4().bg(theme.accent)) + .child(after_text.to_string()) + .into_any_element(), + ); + } else { + let text = if line.is_empty() { " " } else { line }; + rows.push(gpui::div().child(text.to_string()).into_any_element()); + } + } + + gpui::div() + .id(self.id.clone()) + .flex() + .flex_col() + .items_start() + .gap_1() + .text_color(theme.foreground) + .children(rows) .into_any_element() } } @@ -487,9 +649,9 @@ impl gpui::Render for Input { let theme = cx.theme(); let is_focused = self.focus.is_focused(window); - let show_placeholder = self.value.is_empty(); - - let content = if show_placeholder { + let content = if self.multiline { + self.render_multiline_content(is_focused, theme) + } else if self.value.is_empty() { let cursor = if is_focused { gpui::div().w_px().h_4().bg(theme.accent).into_any_element() } else { @@ -554,6 +716,7 @@ impl gpui::Render for Input { }) .relative() .min_w(gpui::rems(12.)) + .when(self.multiline, |el| el.min_h(gpui::rems(4.0))) .border_1() .border_color(if is_focused { theme.accent diff --git a/src/components/modal.rs b/src/components/modal.rs index b18b1c8..d5bb6c8 100644 --- a/src/components/modal.rs +++ b/src/components/modal.rs @@ -67,13 +67,13 @@ impl gpui::RenderOnce for ModalFrame { .left_0(); if let Some(handler) = self.on_close { - backdrop = backdrop.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { - (handler)(event, window, app); - }); + backdrop = + backdrop.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { + (handler)(event, window, app); + }); } - root.child(backdrop) - .child( + root.child(backdrop).child( gpui::div() .size_full() .absolute() diff --git a/src/components/toast.rs b/src/components/toast.rs index 2786a18..25606f1 100644 --- a/src/components/toast.rs +++ b/src/components/toast.rs @@ -82,9 +82,9 @@ impl gpui::Render for ToastHost { .flex() .items_center() .justify_center() - .w(gpui::rems(1.5)) - .h(gpui::rems(1.5)) - .text_sm() + .w(gpui::rems(1.75)) + .h(gpui::rems(1.75)) + .text_xs() .text_color(theme.muted) .cursor_pointer() .hover(|s| s.text_color(theme.accent)) @@ -94,17 +94,17 @@ impl gpui::Render for ToastHost { host.dismiss(toast_id, cx); }), ) - .child(Label::new("X")); + .child(Label::new("×")); - let background = mix_color(theme.background, accent, 0.2); - let border = Theme::alpha(accent, 0.45); + let background = mix_color(theme.raised, accent, 0.28); + let border = Theme::alpha(accent, 0.5); gpui::div() .flex() .items_center() .gap_3() - .px_5() - .py_3() + .px(gpui::rems(1.25)) + .py(gpui::rems(0.75)) .occlude() .border_1() .border_color(border) @@ -113,13 +113,13 @@ impl gpui::Render for ToastHost { .shadow_lg() .child( gpui::div() - .w(gpui::px(6.0)) + .w(gpui::px(8.0)) .h_full() - .bg(Theme::alpha(accent, 0.9)) + .bg(Theme::alpha(accent, 0.85)) .rounded_md(), ) .child( - gpui::div().flex_1().min_w(gpui::rems(18.0)).child( + gpui::div().flex_1().min_w(gpui::rems(22.0)).child( Label::new(toast.message.clone()) .text_sm() .text_color(theme.foreground) @@ -135,6 +135,7 @@ impl gpui::Render for ToastHost { .absolute() .top(gpui::rems(1.0)) .right(gpui::rems(1.0)) + .occlude() .flex() .flex_col() .gap_2() diff --git a/src/dispatcher.rs b/src/dispatcher.rs index c51d016..9ab44b8 100644 --- a/src/dispatcher.rs +++ b/src/dispatcher.rs @@ -6,7 +6,11 @@ use crate::{ impl App { fn close_task_detail(&mut self, cx: &mut gpui::Context) { self.task_detail_modal.update(cx, |modal, cx| { - modal.close(cx); + if modal.is_editing() { + modal.cancel_edit(cx); + } else { + modal.close(cx); + } }); } diff --git a/src/handler.rs b/src/handler.rs new file mode 100644 index 0000000..3408d3c --- /dev/null +++ b/src/handler.rs @@ -0,0 +1,357 @@ +use crate::{ + components::toast::ToastKind, + keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord}, + task::{self, TaskSummary}, + view::{status_bar::SyncState, task_detail_modal::TaskEditUpdate}, +}; + +use super::App; + +impl App { + pub(super) fn handle_sync(&mut self, cx: &mut gpui::Context) { + self.status_bar.update(cx, |bar, cx| { + bar.set_sync_state(SyncState::Syncing, cx); + bar.set_last_sync_message("Syncing...".to_string(), cx); + }); + + match self.task_service.get_all_tasks() { + Ok(all_tasks) => { + let summaries: Vec = all_tasks.iter().map(TaskSummary::from).collect(); + self.update_ui_from_tasks(summaries, cx); + + self.status_bar.update(cx, |bar, cx| { + bar.set_sync_state(SyncState::Success, cx); + bar.set_last_sync_message("Synced".to_string(), cx); + }); + } + Err(e) => { + log::error!("[App] Sync failed: {}", e); + self.status_bar.update(cx, |bar, cx| { + bar.set_sync_state(SyncState::Error, cx); + bar.set_last_sync_message(format!("Error: {}", e), cx); + }); + } + } + } + + pub(super) fn handle_key_down( + &mut self, + event: &gpui::KeyDownEvent, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) { + if let Some(chord) = KeyChord::from_gpui(event) { + let context = self.active_context(cx); + + if let Some(command) = self.keymap.resolve(context, &chord) { + let modal_is_open = self.task_detail_modal.read(cx).is_open(); + + if modal_is_open { + let mut handled = false; + self.task_detail_modal.update(cx, |modal, cx| { + handled = modal.dispatch_command(command, Some(window), cx); + }); + + if handled { + return; + } + + if command != Command::Sync { + return; + } + } + + match command { + Command::FocusSearch => { + let from_headers = matches!(self.focus_target, FocusTarget::TableHeaders); + self.focus_target = FocusTarget::Table; + self.task_table.update(cx, |table, cx| { + if from_headers { + table.blur_table_headers(cx); + } + table.focus_search_input(window, cx); + }); + cx.notify(); + } + Command::FocusTableHeaders => { + self.focus_target = FocusTarget::TableHeaders; + self.task_table.update(cx, |table, cx| { + table.blur_search_input(window, cx); + table.set_filter_bar_focus( + crate::view::task_table::FilterBarFocus::None, + cx, + ); + table.focus_table_headers(window, cx); + }); + cx.notify(); + } + Command::FocusTable => { + self.focus_target = FocusTarget::Table; + self.task_table.update(cx, |table, cx| match context { + ContextId::TextInput | ContextId::FilterBar => { + table.blur_search_input(window, cx); + table.set_filter_bar_focus( + crate::view::task_table::FilterBarFocus::None, + cx, + ); + } + ContextId::TableHeaders => { + table.blur_table_headers(cx); + } + _ => {} + }); + cx.notify(); + } + Command::FocusFilterNext | Command::FocusFilterPrev => { + self.task_table.update(cx, |table, cx| { + use crate::view::task_table::FilterBarFocus; + let was_on_input = + matches!(table.get_filter_bar_focus(), FilterBarFocus::SearchInput); + + if command == Command::FocusFilterNext { + table.focus_filter_next(cx); + } else { + table.focus_filter_prev(cx); + } + + if was_on_input { + table.blur_search_input(window, cx); + } + + let now_on_input = + matches!(table.get_filter_bar_focus(), FilterBarFocus::SearchInput); + if now_on_input && !was_on_input { + table.focus_search_input(window, cx); + } + }); + } + _ => { + self.dispatch(command, cx); + } + } + } + } + } + + pub(super) fn open_selected_task( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + if self.task_detail_modal.read(cx).is_open() { + return; + } + + let task_id = self.task_table.read(cx).selected_task_uuid(); + let Some(task_id) = task_id else { + return; + }; + + self.open_task_detail(task_id, window, cx); + } + + pub(super) fn open_task_detail( + &mut self, + task_id: uuid::Uuid, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + self.focus_before_modal = self.focus_target; + + let tasks = self.tasks.clone(); + match self.task_service.get_task_detail(task_id, &tasks) { + Ok(detail) => { + self.task_detail_modal.update(cx, |modal, cx| { + modal.open_with_detail(detail, window, cx); + }); + } + Err(e) => { + self.task_detail_modal.update(cx, |modal, cx| { + modal.open_with_error(task_id, e.to_string(), window, cx); + }); + } + } + + cx.notify(); + } + + 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) { + *existing = summary; + } else { + self.tasks.push(summary); + } + + let summaries = self.tasks.clone(); + self.update_ui_from_tasks(summaries, cx); + + let detail = task::TaskDetailVm::from_task(&task, &self.tasks); + self.task_detail_modal.update(cx, |modal, cx| { + modal.apply_saved_detail(detail, cx); + }); + } + + fn 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) { + *existing = summary; + } else { + self.tasks.push(summary); + } + + let summaries = self.tasks.clone(); + self.update_ui_from_tasks(summaries, cx); + + let detail = task::TaskDetailVm::from_task(&task, &self.tasks); + self.task_detail_modal.update(cx, |modal, cx| { + modal.set_detail(detail, cx); + }); + } + + pub(super) fn handle_save_task_edits( + &mut self, + task_id: uuid::Uuid, + update: TaskEditUpdate, + cx: &mut gpui::Context, + ) { + let TaskEditUpdate { + description, + project, + priority, + status, + due, + tags, + annotations_add, + annotations_delete, + } = update; + let mut latest_task: Option = None; + + if description.is_some() + || project.is_some() + || priority.is_some() + || tags.is_some() + || due.is_some() + { + match self.task_service.update_task( + task_id, + description, + project, + priority, + tags, + due, + None, + ) { + Ok(task) => latest_task = Some(task), + Err(e) => { + log::error!("[App] Failed to update task: {}", e); + self.toast_host.update(cx, |host, cx| { + host.push( + ToastKind::Error, + format!("Failed to update task: {}", e), + cx, + ); + }); + return; + } + } + } + + if let Some(status) = status { + let status_result = match status { + task::TaskStatus::Completed => self.task_service.complete_task(task_id), + task::TaskStatus::Pending => self.task_service.reopen_task(task_id), + task::TaskStatus::Deleted => { + self.task_service.delete_task(task_id).and_then(|_| { + self.task_service + .get_task(task_id) + .and_then(|task| task.ok_or(task::TaskError::NotFound(task_id))) + }) + } + _ => self + .task_service + .get_task(task_id) + .and_then(|task| task.ok_or(task::TaskError::NotFound(task_id))), + }; + + match status_result { + Ok(task) => latest_task = Some(task), + Err(e) => { + log::error!("[App] Failed to update status: {}", e); + self.toast_host.update(cx, |host, cx| { + host.push( + ToastKind::Error, + format!("Failed to update status: {}", e), + cx, + ); + }); + if let Some(task) = latest_task { + self.apply_task_update(task, cx); + } + return; + } + } + } + + for text in annotations_add { + match self.task_service.add_annotation(task_id, text) { + Ok(task) => latest_task = Some(task), + Err(e) => { + log::error!("[App] Failed to add annotation: {}", e); + self.toast_host.update(cx, |host, cx| { + host.push( + ToastKind::Error, + format!("Failed to add annotation: {}", e), + cx, + ); + }); + if let Some(task) = latest_task { + self.sync_task_detail(task, cx); + } + return; + } + } + } + + for entry in annotations_delete { + match self.task_service.remove_annotation(task_id, entry) { + Ok(task) => latest_task = Some(task), + Err(e) => { + log::error!("[App] Failed to delete annotation: {}", e); + self.toast_host.update(cx, |host, cx| { + host.push( + ToastKind::Error, + format!("Failed to delete annotation: {}", e), + cx, + ); + }); + if let Some(task) = latest_task { + self.sync_task_detail(task, cx); + } + return; + } + } + } + + if let Some(task) = latest_task { + self.apply_task_update(task, cx); + } else { + self.task_detail_modal.update(cx, |modal, cx| { + modal.cancel_edit(cx); + }); + } + } + + 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(); + } + if matches!(self.focus_target, FocusTarget::Table) { + let filter_context = self.task_table.read(cx).get_active_filter_context(); + if let Some(context) = filter_context { + return context; + } + } + self.focus_target.to_context() + } +} diff --git a/src/keymap/context.rs b/src/keymap/context.rs index 3ce49b3..abd7e34 100644 --- a/src/keymap/context.rs +++ b/src/keymap/context.rs @@ -6,6 +6,8 @@ pub enum ContextId { SidebarProjects, SidebarTags, Modal, + ModalInput, + ModalDropdown, FilterBar, TextInput, } @@ -19,6 +21,8 @@ impl ContextId { "sidebarprojects" | "SidebarProjects" => Some(Self::SidebarProjects), "sidebartags" | "SidebarTags" => Some(Self::SidebarTags), "modal" | "Modal" => Some(Self::Modal), + "modalinput" | "ModalInput" => Some(Self::ModalInput), + "modaldropdown" | "ModalDropdown" => Some(Self::ModalDropdown), "filterbar" | "FilterBar" => Some(Self::FilterBar), "textinput" | "TextInput" => Some(Self::TextInput), _ => None, @@ -33,6 +37,8 @@ impl ContextId { Self::SidebarProjects => "SidebarProjects", Self::SidebarTags => "SidebarTags", Self::Modal => "Modal", + Self::ModalInput => "ModalInput", + Self::ModalDropdown => "ModalDropdown", Self::FilterBar => "FilterBar", Self::TextInput => "TextInput", } diff --git a/src/keymap/defaults.rs b/src/keymap/defaults.rs index 3a6877d..40608b7 100644 --- a/src/keymap/defaults.rs +++ b/src/keymap/defaults.rs @@ -429,5 +429,77 @@ pub fn build_default_keymap() -> KeymapLayer { Command::SaveModal, ); + layer.bind( + ContextId::ModalInput, + KeyChord::new(Key::Escape, Mods::none()), + Command::CloseModal, + ); + layer.bind( + ContextId::ModalInput, + KeyChord::new(Key::Enter, Mods::ctrl()), + Command::SaveModal, + ); + layer.bind( + ContextId::ModalInput, + KeyChord::new(Key::Tab, Mods::none()), + Command::FocusFilterNext, + ); + layer.bind( + ContextId::ModalInput, + KeyChord::new(Key::Tab, Mods::shift()), + Command::FocusFilterPrev, + ); + + layer.bind( + ContextId::ModalDropdown, + KeyChord::new(Key::Escape, Mods::none()), + Command::CloseModal, + ); + layer.bind( + ContextId::ModalDropdown, + KeyChord::new(Key::Enter, Mods::ctrl()), + Command::SaveModal, + ); + layer.bind( + ContextId::ModalDropdown, + KeyChord::new(Key::Enter, Mods::none()), + Command::ToggleDropdown, + ); + layer.bind( + ContextId::ModalDropdown, + KeyChord::new(Key::Space, Mods::none()), + Command::ToggleDropdown, + ); + layer.bind( + ContextId::ModalDropdown, + KeyChord::new(Key::Char('j'), Mods::none()), + Command::SelectNextOption, + ); + layer.bind( + ContextId::ModalDropdown, + KeyChord::new(Key::Char('k'), Mods::none()), + Command::SelectPrevOption, + ); + layer.bind( + ContextId::ModalDropdown, + KeyChord::new(Key::ArrowDown, Mods::none()), + Command::SelectNextOption, + ); + layer.bind( + ContextId::ModalDropdown, + KeyChord::new(Key::ArrowUp, Mods::none()), + Command::SelectPrevOption, + ); + layer.bind( + ContextId::ModalDropdown, + KeyChord::new(Key::Tab, Mods::none()), + Command::FocusFilterNext, + ); + layer.bind( + ContextId::ModalDropdown, + KeyChord::new(Key::Tab, Mods::shift()), + Command::FocusFilterPrev, + ); + layer } diff --git a/src/main.rs b/src/main.rs index 124d3f3..f8b97d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use crate::app::App; mod app; mod components; mod dispatcher; +mod handler; mod keymap; mod models; mod task; diff --git a/src/task/filter.rs b/src/task/filter.rs index ca52f72..bf0c225 100644 --- a/src/task/filter.rs +++ b/src/task/filter.rs @@ -155,8 +155,14 @@ impl TaskFilter { None => return false, Some(task_project) => { if self.project_include_children { - if !task_project.starts_with(project) { - return false; + // Match exact project or children (separated by '.') + // e.g., "gpui.task" should NOT match "gpui.task-warrior" + // but SHOULD match "gpui.task" and "gpui.task.subtask" + if task_project != project { + let prefix_with_dot = format!("{}.", project); + if !task_project.starts_with(&prefix_with_dot) { + return false; + } } } else if task_project != project { return false; diff --git a/src/ui.rs b/src/ui.rs index 322f468..4bc729e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -180,3 +180,17 @@ pub fn mix_color(base: Color, tint: Color, amount: f32) -> Color { a: 1.0, } } + +pub fn focus_wrap(child: impl IntoElement, focused: bool, theme: &Theme) -> gpui::Div { + let border_color = if focused { + theme.focus_ring + } else { + gpui::rgba(0x00000000) + }; + + gpui::div() + .border_2() + .border_color(border_color) + .rounded_md() + .child(child) +} diff --git a/src/view/app_layout.rs b/src/view/app_layout.rs index a7cc4f1..a97d6d2 100644 --- a/src/view/app_layout.rs +++ b/src/view/app_layout.rs @@ -72,6 +72,15 @@ pub fn render_app_layout( .child(sidebar) .child(main); + let main = gpui::div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .gap(SECTION_GAP) + .child(content) + .child(status_bar); + let mut root = gpui::div() .flex() .flex_col() @@ -79,11 +88,9 @@ pub fn render_app_layout( .relative() .bg(theme.background) .p(ROOT_PADDING) - .gap(SECTION_GAP) .track_focus(focus_handle) .on_key_down(on_root_key_down) - .child(content) - .child(status_bar); + .child(main); if let Some(modal) = modal { root = root.child(modal); diff --git a/src/view/task_detail_modal.rs b/src/view/task_detail_modal.rs index 4713468..06d84e4 100644 --- a/src/view/task_detail_modal.rs +++ b/src/view/task_detail_modal.rs @@ -1,43 +1,552 @@ +use chrono::{DateTime, NaiveDate, TimeZone, Utc}; use gpui::prelude::*; -use std::sync::Arc; +use std::collections::{HashMap, HashSet, hash_map::DefaultHasher}; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex}; +use crate::components::button::{Dropdown, DropdownItem}; +use crate::components::input::Input; use crate::components::label::Label; use crate::components::modal::ModalFrame; use crate::components::toast::{ToastGlobal, ToastKind}; +use crate::keymap::{Command, CommandDispatcher, ContextId}; use crate::task::model::TaskLinkVm; -use crate::task::{self, TaskDetailState, TaskDetailVm}; +use crate::task::{self, TaskDetailVm}; use crate::theme::{ActiveTheme, Theme}; use crate::ui::{DATE_FORMAT, DATE_TIME_FORMAT}; pub enum TaskDetailModalEvent { Closed, + SaveEdits { + task_id: uuid::Uuid, + update: TaskEditUpdate, + }, +} + +type AnnotationId = u64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ModalMode { + View, + Edit, +} + +impl Default for ModalMode { + fn default() -> Self { + Self::View + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum FieldId { + Description, + Due, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModalFocus { + None, + Description, + Project, + StatusDropdown, + PriorityDropdown, + Due, + TagsInput, + AnnotationsInput, +} + +impl Default for ModalFocus { + fn default() -> Self { + Self::None + } +} + +#[derive(Debug, Clone)] +struct TaskModalState { + open: bool, + task_id: Option, + loading: bool, + original: Option, + form: TaskForm, + errors: HashMap, + mode: ModalMode, + annotations: AnnotationState, + error: Option, + modal_focus: ModalFocus, +} + +impl Default for TaskModalState { + fn default() -> Self { + Self { + open: false, + task_id: None, + loading: false, + original: None, + form: TaskForm::default(), + errors: HashMap::new(), + mode: ModalMode::default(), + annotations: AnnotationState::default(), + error: None, + modal_focus: ModalFocus::default(), + } + } +} + +#[derive(Debug, Clone, Default)] +struct TaskForm { + description: String, + project: String, + priority: task::TaskPriority, + status: task::TaskStatus, + due: String, + tags: Vec, + tag_draft: String, +} + +impl TaskForm { + fn from_detail(detail: &TaskDetailVm) -> Self { + let project = detail.overview.project.clone().unwrap_or_default(); + let due = detail + .dates + .due + .map(|date| date.format(DATE_FORMAT).to_string()) + .unwrap_or_default(); + + Self { + description: detail.overview.description.clone(), + project, + priority: detail.overview.priority, + status: detail.overview.status.clone(), + due, + tags: detail.tags.tags.clone(), + tag_draft: String::new(), + } + } + + fn is_dirty(&self, original: &TaskDetailVm) -> bool { + let original_due = original + .dates + .due + .map(|date| date.format(DATE_FORMAT).to_string()) + .unwrap_or_default(); + let original_project = original.overview.project.clone().unwrap_or_default(); + + if self.description != original.overview.description { + return true; + } + + if self.project.trim() != original_project.trim() { + return true; + } + + if self.priority != original.overview.priority { + return true; + } + + if self.status != original.overview.status { + return true; + } + + if self.due.trim() != original_due.trim() { + return true; + } + + let original_tags: HashSet = original.tags.tags.iter().cloned().collect(); + let draft_tags: HashSet = self.tags.iter().cloned().collect(); + draft_tags != original_tags + } + + fn validate_field(&self, field: FieldId) -> Option { + match field { + FieldId::Description => { + if self.description.trim().is_empty() { + Some("Description is required".into()) + } else { + None + } + } + FieldId::Due => { + if self.due.trim().is_empty() { + None + } else if NaiveDate::parse_from_str(self.due.trim(), DATE_FORMAT).is_err() { + Some("Use YYYY-MM-DD".into()) + } else { + None + } + } + } + } + + fn validate(&self) -> HashMap { + let mut errors = HashMap::new(); + for field in [FieldId::Description, FieldId::Due] { + if let Some(message) = self.validate_field(field) { + errors.insert(field, message); + } + } + errors + } + + fn add_tags(&mut self, raw: &str) -> bool { + let mut added = false; + for tag in raw.split(|ch: char| ch.is_whitespace() || ch == ',') { + let tag = tag.trim(); + if tag.is_empty() { + continue; + } + if !self.tags.iter().any(|t| t == tag) { + self.tags.push(tag.to_string()); + added = true; + } + } + if added { + self.tags + .sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + } + added + } + + fn remove_tag(&mut self, tag: &str) -> bool { + let before = self.tags.len(); + self.tags.retain(|t| t != tag); + before != self.tags.len() + } +} + +#[derive(Debug, Clone, Default)] +pub struct TaskEditUpdate { + pub description: Option, + pub project: Option>, + pub priority: Option, + pub status: Option, + pub due: Option>>, + pub tags: Option>, + pub annotations_add: Vec, + pub annotations_delete: Vec>, +} + +impl TaskEditUpdate { + fn is_empty(&self) -> bool { + self.description.is_none() + && self.project.is_none() + && self.priority.is_none() + && self.status.is_none() + && self.due.is_none() + && self.tags.is_none() + && self.annotations_add.is_empty() + && self.annotations_delete.is_empty() + } +} + +#[derive(Debug, Clone)] +struct AnnotationState { + items: Vec, + draft: gpui::SharedString, +} + +impl Default for AnnotationState { + fn default() -> Self { + Self { + items: Vec::new(), + draft: gpui::SharedString::default(), + } + } +} + +#[derive(Debug, Clone)] +struct AnnotationView { + id: AnnotationId, + created_at: DateTime, + text: gpui::SharedString, + origin: AnnotationOrigin, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AnnotationOrigin { + Original, + Added, + Deleted, +} + +fn annotation_id(entry: DateTime, text: &str, index: usize) -> AnnotationId { + let mut hasher = DefaultHasher::new(); + entry.timestamp_millis().hash(&mut hasher); + text.hash(&mut hasher); + index.hash(&mut hasher); + hasher.finish() +} + +impl AnnotationState { + fn from_detail(detail: &TaskDetailVm) -> Self { + let items: Vec = detail + .annotations + .iter() + .enumerate() + .map(|(index, annotation)| AnnotationView { + id: annotation_id(annotation.entry, &annotation.content, index), + created_at: annotation.entry, + text: annotation.content.clone().into(), + origin: AnnotationOrigin::Original, + }) + .collect(); + + Self { + items, + draft: gpui::SharedString::default(), + } + } + + fn set_draft(&mut self, value: &str) { + self.draft = value.to_string().into(); + } + + fn clear_draft(&mut self) { + self.draft = gpui::SharedString::default(); + } + + fn add_local(&mut self, text: gpui::SharedString, created_at: DateTime) { + let id = annotation_id(created_at, text.as_ref(), self.items.len()); + self.items.push(AnnotationView { + id, + created_at, + text, + origin: AnnotationOrigin::Added, + }); + } + + fn mark_deleted(&mut self, id: AnnotationId) -> Option> { + let index = self.items.iter().position(|item| item.id == id)?; + let item = &mut self.items[index]; + if item.origin == AnnotationOrigin::Added { + self.items.remove(index); + return None; + } + if item.origin == AnnotationOrigin::Deleted { + return None; + } + item.origin = AnnotationOrigin::Deleted; + Some(item.created_at) + } } pub struct TaskDetailModal { - state: TaskDetailState, - is_open: bool, + state: TaskModalState, focus_handle: gpui::FocusHandle, + form_focus_handle: gpui::FocusHandle, scroll_handle: gpui::ScrollHandle, + annotation_input: gpui::Entity, + description_input: gpui::Entity, + project_input: gpui::Entity, + due_input: gpui::Entity, + tags_input: gpui::Entity, + status_dropdown: gpui::Entity, + priority_dropdown: gpui::Entity, + project_suggestions: Arc>>, } 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 annotation_input = cx.new(|cx| { + let on_change_entity = modal_entity.clone(); + let on_submit_entity = modal_entity.clone(); + + Input::new("annotation-input", cx, "Add annotation...") + .multiline() + .with_on_change(Arc::new( + move |value: &str, cx: &mut gpui::Context| { + cx.update_entity(&on_change_entity, |modal, cx| { + modal.update_annotation_draft(value, cx); + }); + }, + )) + .with_on_submit(Arc::new( + move |_value: &str, cx: &mut gpui::Context| { + cx.update_entity(&on_submit_entity, |modal, cx| { + modal.submit_annotation(cx); + }); + }, + )) + }); + + let description_input = { + let modal_entity = modal_entity.clone(); + cx.new(|cx| { + Input::new("task-edit-description", cx, "Description").with_on_change(Arc::new( + move |value: &str, cx: &mut gpui::Context| { + cx.update_entity(&modal_entity, |modal, cx| { + modal.update_edit_description(value, cx); + }); + }, + )) + }) + }; + + let project_input = { + let modal_entity = modal_entity.clone(); + let suggestions = project_suggestions.clone(); + cx.new(|cx| { + Input::new("task-edit-project", cx, "Project") + .with_suggest(Arc::new(move |query| { + let query = query.trim(); + if query.is_empty() { + return Vec::new(); + } + + let Ok(list) = suggestions.lock() else { + return Vec::new(); + }; + + let needle = query.to_lowercase(); + 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(); + 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()); + } else { + prefix_matches.push(project.clone()); + } + } else if hay.contains(&needle) { + contains_matches.push(project.clone()); + } + } + + level_matches.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + 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())); + + level_matches + .into_iter() + .chain(prefix_matches) + .chain(contains_matches) + .take(8) + .map(crate::components::input::Suggestion::simple) + .collect() + })) + .with_on_change(Arc::new( + move |value: &str, cx: &mut gpui::Context| { + cx.update_entity(&modal_entity, |modal, cx| { + modal.update_edit_project(value, cx); + }); + }, + )) + }) + }; + + let due_input = { + let modal_entity = modal_entity.clone(); + cx.new(|cx| { + Input::new("task-edit-due", cx, "Due (YYYY-MM-DD)").with_on_change(Arc::new( + move |value: &str, cx: &mut gpui::Context| { + cx.update_entity(&modal_entity, |modal, cx| { + modal.update_edit_due(value, cx); + }); + }, + )) + }) + }; + + let tags_input = { + let modal_entity = modal_entity.clone(); + cx.new(|cx| { + let on_submit_entity = modal_entity.clone(); + Input::new("task-edit-tags", cx, "Add tag") + .with_on_change(Arc::new( + move |value: &str, cx: &mut gpui::Context| { + cx.update_entity(&modal_entity, |modal, cx| { + modal.update_edit_tags(value, cx); + }); + }, + )) + .with_on_submit(Arc::new( + move |_value: &str, cx: &mut gpui::Context| { + cx.update_entity(&on_submit_entity, |modal, cx| { + modal.submit_tag_draft(cx); + }); + }, + )) + }) + }; + + let status_items = vec![ + DropdownItem::new("Pending"), + DropdownItem::new("Completed"), + DropdownItem::new("Deleted"), + ]; + let status_dropdown = { + let modal_entity = modal_entity.clone(); + cx.new(|_cx| { + Dropdown::new("task-edit-status") + .items(status_items) + .on_select(Arc::new(move |index, _item, cx| { + cx.update_entity(&modal_entity, |modal, cx| { + modal.update_edit_status(index, cx); + }); + })) + }) + }; + + let priority_items = vec![ + DropdownItem::new("High"), + DropdownItem::new("Medium"), + DropdownItem::new("Low"), + DropdownItem::new("None"), + ]; + let priority_dropdown = { + let modal_entity = modal_entity.clone(); + cx.new(|_cx| { + Dropdown::new("task-edit-priority") + .items(priority_items) + .on_select(Arc::new(move |index, _item, cx| { + cx.update_entity(&modal_entity, |modal, cx| { + modal.update_edit_priority(index, cx); + }); + })) + }) + }; + Self { - state: TaskDetailState::default(), - is_open: false, + state: TaskModalState::default(), focus_handle: cx.focus_handle(), + form_focus_handle: cx.focus_handle(), scroll_handle: gpui::ScrollHandle::new(), + annotation_input, + description_input, + project_input, + due_input, + tags_input, + status_dropdown, + priority_dropdown, + project_suggestions, } } pub fn is_open(&self) -> bool { - self.is_open + self.state.open } pub fn focus_handle(&self) -> &gpui::FocusHandle { &self.focus_handle } + pub fn set_project_suggestions( + &mut self, + projects: Vec, + _cx: &mut gpui::Context, + ) { + if let Ok(mut list) = self.project_suggestions.lock() { + *list = projects; + } + } + pub fn open_with_detail( &mut self, detail: TaskDetailVm, @@ -48,10 +557,16 @@ impl TaskDetailModal { window.focus(&self.focus_handle); } - self.is_open = true; self.scroll_handle = gpui::ScrollHandle::new(); self.scroll_handle.scroll_to_item(0); - self.state = TaskDetailState::Ready(detail); + self.state.open = true; + self.state.task_id = Some(detail.identity.uuid); + self.state.loading = false; + self.state.error = None; + self.state.original = Some(detail.clone()); + self.state.mode = ModalMode::View; + self.state.errors.clear(); + self.sync_from_detail(&detail, cx, true); cx.notify(); } @@ -66,10 +581,17 @@ impl TaskDetailModal { window.focus(&self.focus_handle); } - self.is_open = true; self.scroll_handle = gpui::ScrollHandle::new(); self.scroll_handle.scroll_to_item(0); - self.state = TaskDetailState::Error(task_id, error); + self.state.open = true; + self.state.task_id = Some(task_id); + self.state.loading = false; + self.state.original = None; + self.state.form = TaskForm::default(); + self.state.errors.clear(); + self.state.mode = ModalMode::View; + self.state.annotations = AnnotationState::default(); + self.state.error = Some(error.into()); cx.notify(); } @@ -83,34 +605,616 @@ impl TaskDetailModal { window.focus(&self.focus_handle); } - self.is_open = true; self.scroll_handle = gpui::ScrollHandle::new(); self.scroll_handle.scroll_to_item(0); - self.state = TaskDetailState::Loading(task_id); + self.state.open = true; + self.state.task_id = Some(task_id); + self.state.loading = true; + self.state.original = None; + self.state.form = TaskForm::default(); + self.state.errors.clear(); + self.state.mode = ModalMode::View; + self.state.annotations = AnnotationState::default(); + self.state.error = None; cx.notify(); } pub fn set_detail(&mut self, detail: TaskDetailVm, cx: &mut gpui::Context) { - self.state = TaskDetailState::Ready(detail); + let reset_form = self.state.mode == ModalMode::View; + self.state.task_id = Some(detail.identity.uuid); + self.state.loading = false; + self.state.error = None; + self.state.original = Some(detail.clone()); + self.sync_from_detail(&detail, cx, reset_form); cx.notify(); } pub fn set_error(&mut self, task_id: uuid::Uuid, error: String, cx: &mut gpui::Context) { - self.state = TaskDetailState::Error(task_id, error); + self.state.task_id = Some(task_id); + self.state.loading = false; + self.state.original = None; + self.state.form = TaskForm::default(); + self.state.errors.clear(); + self.state.mode = ModalMode::View; + self.state.annotations = AnnotationState::default(); + self.state.error = Some(error.into()); cx.notify(); } - pub fn close(&mut self, cx: &mut gpui::Context) { - if !self.is_open { + fn sync_from_detail( + &mut self, + detail: &TaskDetailVm, + cx: &mut gpui::Context, + reset_edit: bool, + ) { + self.state.annotations = AnnotationState::from_detail(detail); + self.annotation_input.update(cx, |input, cx| { + input.clear(cx); + }); + + if reset_edit { + self.state.form = TaskForm::from_detail(detail); + self.state.errors.clear(); + self.apply_form_inputs(cx); + } + } + + pub fn apply_saved_detail(&mut self, detail: TaskDetailVm, cx: &mut gpui::Context) { + self.state.mode = ModalMode::View; + self.sync_from_detail(&detail, cx, true); + self.state.task_id = Some(detail.identity.uuid); + self.state.loading = false; + self.state.original = Some(detail); + self.state.error = None; + cx.notify(); + } + + fn enter_edit_mode(&mut self, cx: &mut gpui::Context) { + let Some(detail) = self.state.original.clone() else { + return; + }; + + self.state.mode = ModalMode::Edit; + self.state.form = TaskForm::from_detail(&detail); + self.state.errors.clear(); + self.state.annotations = AnnotationState::from_detail(&detail); + self.annotation_input.update(cx, |input, cx| { + input.clear(cx); + }); + self.apply_form_inputs(cx); + cx.notify(); + } + + pub fn cancel_edit(&mut self, cx: &mut gpui::Context) { + let Some(detail) = self.state.original.clone() else { + return; + }; + + self.state.mode = ModalMode::View; + self.state.form = TaskForm::from_detail(&detail); + self.state.errors.clear(); + self.state.annotations = AnnotationState::from_detail(&detail); + self.annotation_input.update(cx, |input, cx| { + input.clear(cx); + }); + self.apply_form_inputs(cx); + cx.notify(); + } + + pub fn is_editing(&self) -> bool { + self.state.mode == ModalMode::Edit + } + + fn apply_form_inputs(&mut self, cx: &mut gpui::Context) { + let form = self.state.form.clone(); + + self.description_input.update(cx, |input, cx| { + input.set_value_silent(form.description, cx); + }); + + self.project_input.update(cx, |input, cx| { + input.set_value_silent(form.project, cx); + }); + + self.due_input.update(cx, |input, cx| { + input.set_value_silent(form.due, cx); + }); + + self.tags_input.update(cx, |input, cx| { + input.set_value_silent(form.tag_draft, cx); + }); + + let status_index = match form.status { + task::TaskStatus::Pending => Some(0), + task::TaskStatus::Completed => Some(1), + task::TaskStatus::Deleted => Some(2), + _ => None, + }; + if let Some(status_index) = status_index { + self.status_dropdown.update(cx, |dropdown, cx| { + dropdown.set_selected_index(status_index, cx); + }); + } + + let priority_index = match form.priority { + task::TaskPriority::High => 0, + task::TaskPriority::Medium => 1, + task::TaskPriority::Low => 2, + task::TaskPriority::None => 3, + }; + self.priority_dropdown.update(cx, |dropdown, cx| { + dropdown.set_selected_index(priority_index, cx); + }); + } + + fn update_annotation_draft(&mut self, value: &str, cx: &mut gpui::Context) { + self.state.annotations.set_draft(value); + cx.notify(); + } + + fn update_edit_description(&mut self, value: &str, cx: &mut gpui::Context) { + self.state.form.description = value.to_string(); + self.update_field_error(FieldId::Description); + cx.notify(); + } + + fn update_edit_project(&mut self, value: &str, cx: &mut gpui::Context) { + self.state.form.project = value.to_string(); + cx.notify(); + } + + fn update_edit_due(&mut self, value: &str, cx: &mut gpui::Context) { + self.state.form.due = value.to_string(); + self.update_field_error(FieldId::Due); + cx.notify(); + } + + fn update_edit_tags(&mut self, value: &str, cx: &mut gpui::Context) { + self.state.form.tag_draft = value.to_string(); + cx.notify(); + } + + fn update_edit_status(&mut self, index: usize, cx: &mut gpui::Context) { + let status = match index { + 0 => task::TaskStatus::Pending, + 1 => task::TaskStatus::Completed, + 2 => task::TaskStatus::Deleted, + _ => task::TaskStatus::Pending, + }; + self.state.form.status = status; + cx.notify(); + } + + fn update_edit_priority(&mut self, index: usize, cx: &mut gpui::Context) { + let priority = match index { + 0 => task::TaskPriority::High, + 1 => task::TaskPriority::Medium, + 2 => task::TaskPriority::Low, + _ => task::TaskPriority::None, + }; + self.state.form.priority = priority; + cx.notify(); + } + + pub fn submit_edits(&mut self, cx: &mut gpui::Context) { + if self.state.mode != ModalMode::Edit { + self.close(cx); return; } - self.is_open = false; - self.state = TaskDetailState::Idle; + let Some(detail) = self.state.original.as_ref() else { + return; + }; + + self.state.errors = self.state.form.validate(); + if !self.state.errors.is_empty() { + cx.notify(); + return; + } + + let update = self.build_task_update(detail); + + if update.is_empty() { + self.cancel_edit(cx); + return; + } + + cx.emit(TaskDetailModalEvent::SaveEdits { + task_id: detail.identity.uuid, + update, + }); + } + + fn build_task_update(&self, original: &TaskDetailVm) -> TaskEditUpdate { + let draft = &self.state.form; + let mut update = TaskEditUpdate::default(); + + if draft.description != original.overview.description { + update.description = Some(draft.description.clone()); + } + + let draft_project = draft.project.trim(); + let original_project = original.overview.project.as_deref().unwrap_or("").trim(); + if draft_project != original_project { + update.project = Some(if draft_project.is_empty() { + None + } else { + Some(draft_project.to_string()) + }); + } + + if draft.priority != original.overview.priority { + let priority: String = draft.priority.into(); + update.priority = Some(priority); + } + + if draft.status != original.overview.status { + update.status = Some(draft.status.clone()); + } + + let draft_due = draft.due.trim(); + let original_due = original + .dates + .due + .map(|date| date.format(DATE_FORMAT).to_string()) + .unwrap_or_default(); + if draft_due != original_due.trim() { + if draft_due.is_empty() { + update.due = Some(None); + } else { + if let Ok(date) = NaiveDate::parse_from_str(draft_due, DATE_FORMAT) { + let due = Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap()); + update.due = Some(Some(due)); + } + } + } + + let draft_tags: HashSet = draft.tags.iter().cloned().collect(); + let original_tags: HashSet = original.tags.tags.iter().cloned().collect(); + if draft_tags != original_tags { + update.tags = Some(draft_tags); + } + + for annotation in &self.state.annotations.items { + match annotation.origin { + AnnotationOrigin::Added => { + update.annotations_add.push(annotation.text.to_string()); + } + AnnotationOrigin::Deleted => { + update.annotations_delete.push(annotation.created_at); + } + AnnotationOrigin::Original => {} + } + } + + update + } + + fn update_field_error(&mut self, field: FieldId) { + match self.state.form.validate_field(field) { + Some(message) => { + self.state.errors.insert(field, message); + } + None => { + self.state.errors.remove(&field); + } + } + } + + fn submit_tag_draft(&mut self, cx: &mut gpui::Context) { + let draft = self.state.form.tag_draft.clone(); + if draft.trim().is_empty() { + return; + } + + self.state.form.add_tags(&draft); + self.state.form.tag_draft.clear(); + self.tags_input.update(cx, |input, cx| { + input.clear(cx); + }); + cx.notify(); + } + + fn remove_tag(&mut self, tag: String, cx: &mut gpui::Context) { + if self.state.form.remove_tag(&tag) { + cx.notify(); + } + } + + fn submit_annotation(&mut self, cx: &mut gpui::Context) { + let text = self.state.annotations.draft.to_string(); + if text.trim().is_empty() { + let toast_host = cx.global::().host.clone(); + cx.update_entity(&toast_host, |host, cx| { + host.push(ToastKind::Error, "Annotation cannot be empty", cx); + }); + return; + } + + self.state + .annotations + .add_local(text.clone().into(), Utc::now()); + self.state.annotations.clear_draft(); + self.annotation_input.update(cx, |input, cx| { + input.clear(cx); + }); + cx.notify(); + } + + fn delete_annotation(&mut self, id: AnnotationId, cx: &mut gpui::Context) { + if self.state.annotations.mark_deleted(id).is_some() { + cx.notify(); + } + } + + pub fn close(&mut self, cx: &mut gpui::Context) { + if !self.state.open { + return; + } + + self.state = TaskModalState::default(); cx.emit(TaskDetailModalEvent::Closed); cx.notify(); } + // Focus management methods + + pub fn set_modal_focus( + &mut self, + focus: ModalFocus, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + // Close dropdowns if changing focus (not if clicking same dropdown) + if self.state.modal_focus != focus { + self.close_all_dropdowns(cx); + } + self.state.modal_focus = focus; + + if let Some(window) = window { + match focus { + ModalFocus::Description => { + self.description_input.update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::Project => { + self.project_input.update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::Due => { + self.due_input.update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::TagsInput => { + self.tags_input.update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::AnnotationsInput => { + self.annotation_input.update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::StatusDropdown | ModalFocus::PriorityDropdown => { + window.focus(&self.form_focus_handle); + } + ModalFocus::None => { + window.focus(&self.focus_handle); + } + } + } + + cx.notify(); + } + + pub fn get_modal_focus(&self) -> ModalFocus { + self.state.modal_focus + } + + fn close_all_dropdowns(&mut self, cx: &mut gpui::Context) { + self.status_dropdown.update(cx, |d, cx| d.close(cx)); + self.priority_dropdown.update(cx, |d, cx| d.close(cx)); + } + + fn has_open_dropdown(&self, cx: &gpui::Context) -> bool { + let status_open = self.status_dropdown.read(cx).is_open(); + let priority_open = self.priority_dropdown.read(cx).is_open(); + status_open || priority_open + } + + pub fn toggle_focused_dropdown(&mut self, cx: &mut gpui::Context) { + let toggle = |d: &gpui::Entity, cx: &mut gpui::Context| { + d.update(cx, |d, cx| { + if d.is_open() { + d.accept_selection(cx); + } else { + d.open(cx); + } + }); + cx.notify(); + }; + + match self.state.modal_focus { + ModalFocus::StatusDropdown => toggle(&self.status_dropdown, cx), + ModalFocus::PriorityDropdown => toggle(&self.priority_dropdown, cx), + _ => {} + } + } + + pub fn select_next_dropdown_option(&mut self, cx: &mut gpui::Context) { + let select_next = |d: &gpui::Entity, cx: &mut gpui::Context| { + d.update(cx, |d, cx| { + if !d.is_open() { + d.open(cx); + } + d.select_next_item(cx); + }); + cx.notify(); + }; + + match self.state.modal_focus { + ModalFocus::StatusDropdown => select_next(&self.status_dropdown, cx), + ModalFocus::PriorityDropdown => select_next(&self.priority_dropdown, cx), + _ => {} + } + } + + pub fn select_prev_dropdown_option(&mut self, cx: &mut gpui::Context) { + let select_prev = |d: &gpui::Entity, cx: &mut gpui::Context| { + d.update(cx, |d, cx| { + if !d.is_open() { + d.open(cx); + } + d.select_prev_item(cx); + }); + cx.notify(); + }; + + match self.state.modal_focus { + ModalFocus::StatusDropdown => select_prev(&self.status_dropdown, cx), + ModalFocus::PriorityDropdown => select_prev(&self.priority_dropdown, cx), + _ => {} + } + } + + pub fn focus_next_field( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + use ModalFocus::*; + + let next = match self.state.modal_focus { + None | Description => Project, + Project => StatusDropdown, + StatusDropdown => PriorityDropdown, + PriorityDropdown => Due, + Due => TagsInput, + TagsInput => AnnotationsInput, + AnnotationsInput => Description, + }; + + self.set_modal_focus(next, window, cx); + } + + pub fn focus_prev_field( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + use ModalFocus::*; + + let prev = match self.state.modal_focus { + None | Description => AnnotationsInput, + AnnotationsInput => TagsInput, + TagsInput => Due, + Due => PriorityDropdown, + PriorityDropdown => StatusDropdown, + StatusDropdown => Project, + Project => Description, + }; + + self.set_modal_focus(prev, window, cx); + } + + pub fn active_context(&self) -> ContextId { + if !self.state.open { + return ContextId::Global; + } + + if self.state.mode != ModalMode::Edit { + return ContextId::Modal; + } + + match self.state.modal_focus { + ModalFocus::Description + | ModalFocus::Project + | ModalFocus::Due + | ModalFocus::TagsInput + | ModalFocus::AnnotationsInput => ContextId::ModalInput, + ModalFocus::StatusDropdown | ModalFocus::PriorityDropdown => ContextId::ModalDropdown, + ModalFocus::None => ContextId::Modal, + } + } + + pub fn dispatch_command( + &mut self, + command: Command, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) -> bool { + if !self.state.open { + return false; + } + + match command { + Command::CloseModal => { + // Escape behavior: dropdown > editing > close + if self.state.mode == ModalMode::Edit && self.has_open_dropdown(cx) { + // First: close dropdown if open + self.close_all_dropdowns(cx); + } else if self.state.mode == ModalMode::Edit { + // Second: cancel edit if editing + self.cancel_edit(cx); + } else { + // Third: close modal + self.close(cx); + } + true + } + Command::SaveModal => { + if self.state.mode == ModalMode::Edit { + self.submit_edits(cx); + true + } else { + false + } + } + Command::ModalScrollDown => { + self.scroll(1, cx); + true + } + Command::ModalScrollUp => { + self.scroll(-1, cx); + true + } + Command::FocusFilterNext => { + if self.state.mode == ModalMode::Edit { + self.focus_next_field(window, cx); + true + } else { + false + } + } + Command::FocusFilterPrev => { + if self.state.mode == ModalMode::Edit { + self.focus_prev_field(window, cx); + true + } else { + false + } + } + Command::ToggleDropdown => { + if self.state.mode == ModalMode::Edit { + self.toggle_focused_dropdown(cx); + true + } else { + false + } + } + Command::SelectNextOption => { + if self.state.mode == ModalMode::Edit { + self.select_next_dropdown_option(cx); + true + } else { + false + } + } + Command::SelectPrevOption => { + if self.state.mode == ModalMode::Edit { + self.select_prev_dropdown_option(cx); + true + } else { + false + } + } + _ => false, + } + } + pub fn scroll(&self, delta: i32, cx: &mut gpui::Context) { let handle = &self.scroll_handle; let current = if delta > 0 { @@ -129,6 +1233,12 @@ impl TaskDetailModal { } } +impl CommandDispatcher for TaskDetailModal { + fn dispatch(&mut self, command: Command, cx: &mut gpui::Context) -> bool { + self.dispatch_command(command, None, cx) + } +} + impl gpui::EventEmitter for TaskDetailModal {} impl gpui::Render for TaskDetailModal { @@ -137,12 +1247,10 @@ impl gpui::Render for TaskDetailModal { _window: &mut gpui::Window, cx: &mut gpui::Context, ) -> impl gpui::IntoElement { - if !self.is_open { + if !self.state.open { return gpui::div().into_any_element(); } - let theme = cx.theme(); - let on_close_backdrop = cx.listener(|modal, _event: &gpui::MouseDownEvent, _window, cx| { modal.close(cx); }); @@ -152,9 +1260,16 @@ impl gpui::Render for TaskDetailModal { render_task_detail_modal( &self.state, + &self.annotation_input, + &self.description_input, + &self.project_input, + &self.due_input, + &self.tags_input, + &self.status_dropdown, + &self.priority_dropdown, &self.focus_handle, &self.scroll_handle, - theme, + cx, on_close_backdrop, on_close_click, ) @@ -162,28 +1277,62 @@ impl gpui::Render for TaskDetailModal { } fn render_task_detail_modal( - detail_state: &TaskDetailState, + state: &TaskModalState, + annotation_input: &gpui::Entity, + description_input: &gpui::Entity, + project_input: &gpui::Entity, + due_input: &gpui::Entity, + tags_input: &gpui::Entity, + status_dropdown: &gpui::Entity, + priority_dropdown: &gpui::Entity, focus_handle: &gpui::FocusHandle, scroll_handle: &gpui::ScrollHandle, - theme: &Theme, + cx: &mut gpui::Context, on_close_out: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, on_close_click: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, ) -> gpui::AnyElement { - let panel = match detail_state { - TaskDetailState::Ready(detail) => { - render_task_detail_panel(detail, scroll_handle, theme, on_close_click) - } - TaskDetailState::Error(_, message) => { - render_task_detail_placeholder_panel("Task Details", message, theme, on_close_click) - } - TaskDetailState::Loading(_) | TaskDetailState::Idle => { - render_task_detail_placeholder_panel( - "Task Details", - "Loading task...", - theme, - on_close_click, - ) - } + let theme = cx.theme().clone(); + let panel = if let Some(message) = state.error.as_ref() { + render_task_detail_placeholder_panel( + "Task Details", + message.as_ref(), + &theme, + on_close_click, + ) + } else if state.loading || state.original.is_none() { + render_task_detail_placeholder_panel( + "Task Details", + "Loading task...", + &theme, + on_close_click, + ) + } else if let Some(detail) = state.original.as_ref() { + render_task_detail_panel( + detail, + state.mode, + &state.form, + &state.errors, + &state.annotations, + state.modal_focus, + annotation_input, + description_input, + project_input, + due_input, + tags_input, + status_dropdown, + priority_dropdown, + scroll_handle, + &theme, + cx, + on_close_click, + ) + } else { + render_task_detail_placeholder_panel( + "Task Details", + "Loading task...", + &theme, + on_close_click, + ) }; ModalFrame::new("task-detail-modal", focus_handle.clone(), theme.backdrop) @@ -252,7 +1401,7 @@ where .on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { (on_close_footer)(event, window, app); }) - .child(Label::new("Cancel (Esc)")); + .child(Label::new("Close (Esc)")); gpui::div() .id("task-detail-panel") @@ -283,20 +1432,40 @@ where fn render_task_detail_panel( detail: &task::TaskDetailVm, + mode: ModalMode, + form: &TaskForm, + errors: &HashMap, + annotations: &AnnotationState, + modal_focus: ModalFocus, + annotation_input: &gpui::Entity, + description_input: &gpui::Entity, + project_input: &gpui::Entity, + due_input: &gpui::Entity, + tags_input: &gpui::Entity, + status_dropdown: &gpui::Entity, + priority_dropdown: &gpui::Entity, scroll_handle: &gpui::ScrollHandle, theme: &Theme, + cx: &mut gpui::Context, on_close_click: OnCloseClick, ) -> gpui::AnyElement where OnCloseClick: Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, { - let status_label = if detail.overview.is_active { + let is_editing = mode == ModalMode::Edit; + let status_label = if is_editing { + form.status.clone().into() + } else if detail.overview.is_active { "Active".to_string() } else { detail.overview.status.clone().into() }; - let priority_label: String = detail.overview.priority.into(); + let priority_label: String = if is_editing { + form.priority.into() + } else { + detail.overview.priority.into() + }; let chip = |label: &str, bg: gpui::Rgba, fg: gpui::Rgba| { gpui::div() @@ -319,7 +1488,12 @@ where _ => theme.muted, }; - let priority_color = match detail.overview.priority { + let priority_value = if is_editing { + form.priority + } else { + detail.overview.priority + }; + let priority_color = match priority_value { task::TaskPriority::High => theme.high, task::TaskPriority::Medium => theme.medium, task::TaskPriority::Low => theme.low, @@ -341,13 +1515,28 @@ where } if let Some(project) = &detail.overview.project { + if !is_editing || !form.project.trim().is_empty() { + let label = if is_editing { + form.project.trim() + } else { + project.as_str() + }; + if !label.is_empty() { + badges.push(chip(label, Theme::alpha(theme.accent, 0.15), theme.accent)); + } + } + } else if is_editing && !form.project.trim().is_empty() { badges.push(chip( - project, + form.project.trim(), Theme::alpha(theme.accent, 0.15), theme.accent, )); } + if is_editing { + badges.push(chip("Editing", Theme::alpha(theme.info, 0.18), theme.info)); + } + let id_label = detail .identity .working_id @@ -355,7 +1544,12 @@ where .map(|id| format!("#{}", id)) .unwrap_or_else(|| format!("#{}", detail.identity.uuid)); - let title = format!("{} {}", id_label, detail.overview.description); + let title_description = if is_editing { + form.description.clone() + } else { + detail.overview.description.clone() + }; + let title = format!("{} {}", id_label, title_description); let on_close_click = Arc::new(on_close_click); let on_close_header = on_close_click.clone(); @@ -372,6 +1566,38 @@ where }) .child("X"); + let edit_button = if is_editing { + None + } else { + Some( + gpui::div() + .id("task-detail-edit") + .px(gpui::rems(0.6)) + .py(gpui::rems(0.25)) + .rounded_md() + .border_1() + .border_color(theme.divider) + .bg(theme.raised) + .text_color(theme.foreground) + .text_xs() + .cursor_pointer() + .hover(|s| s.bg(theme.hover)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|modal, _event, _window, cx| { + modal.enter_edit_mode(cx); + }), + ) + .child(Label::new("Edit")), + ) + }; + + let mut header_actions = gpui::div().flex().items_center().gap_2(); + if let Some(edit_button) = edit_button { + header_actions = header_actions.child(edit_button); + } + header_actions = header_actions.child(close_button); + let header = gpui::div() .flex() .items_start() @@ -393,16 +1619,17 @@ where ) .child(gpui::div().flex().gap_2().children(badges)), ) - .child(close_button); + .child(header_actions); let label_color = Theme::alpha(theme.foreground, 0.72); let value_color = theme.foreground; let section_title_color = Theme::alpha(theme.foreground, 0.88); + let label_width = gpui::rems(10.0); let value_label = |value: String| Label::new(value).text_color(value_color).into_any_element(); - let kv_row = |label: &str, value: gpui::AnyElement| { - gpui::div() + let field_row = |label: &str, value: gpui::AnyElement, error: Option<&gpui::SharedString>| { + let row = gpui::div() .flex() .items_start() .gap_3() @@ -410,11 +1637,30 @@ where Label::new(label.to_string()) .text_color(label_color) .text_sm() - .w(gpui::rems(10.0)), + .w(label_width), ) - .child(gpui::div().flex_1().min_w_0().child(value)) + .child(gpui::div().flex_1().min_w_0().child(value)); + + let mut container = gpui::div().flex().flex_col().gap_1().child(row); + if let Some(error) = error { + container = container.child( + gpui::div() + .flex() + .items_start() + .gap_3() + .child(gpui::div().w(label_width)) + .child( + Label::new(error.to_string()) + .text_xs() + .text_color(theme.error), + ), + ); + } + container }; + let kv_row = |label: &str, value: gpui::AnyElement| field_row(label, value, None); + let section_header = |title: &str| { Label::new(title.to_uppercase()) .text_sm() @@ -443,27 +1689,133 @@ where .map(|d| d.format(DATE_FORMAT).to_string()) .unwrap_or_else(|| "-".to_string()); + let status_editable = matches!( + form.status, + task::TaskStatus::Pending | task::TaskStatus::Completed | task::TaskStatus::Deleted + ); + let status_value = if is_editing && status_editable { + let focused = modal_focus == ModalFocus::StatusDropdown; + let theme_clone = theme.clone(); + let dropdown_clone = status_dropdown.clone(); + gpui::div() + .relative() + .when(focused, |d| { + d.border_2() + .border_color(theme_clone.focus_ring) + .rounded_md() + .p_px() + }) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |modal, _, window, cx| { + modal.set_modal_focus(ModalFocus::StatusDropdown, Some(window), cx); + // Open dropdown in defer so focus is set first + let dropdown = dropdown_clone.clone(); + cx.defer(move |cx| { + dropdown.update(cx, |d, cx| { + if !d.is_open() { + d.open(cx); + } + }); + }); + }), + ) + .child(status_dropdown.clone()) + .into_any_element() + } else { + value_label(status_label.clone()) + }; + let description_value = if is_editing { + let focused = modal_focus == ModalFocus::Description; + crate::ui::focus_wrap(description_input.clone(), focused, theme) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|modal, _, window, cx| { + modal.set_modal_focus(ModalFocus::Description, Some(window), cx); + }), + ) + .into_any_element() + } else { + value_label(detail.overview.description.clone()) + }; + let project_value = if is_editing { + let focused = modal_focus == ModalFocus::Project; + crate::ui::focus_wrap(project_input.clone(), focused, theme) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|modal, _, window, cx| { + modal.set_modal_focus(ModalFocus::Project, Some(window), cx); + }), + ) + .into_any_element() + } else { + value_label( + detail + .overview + .project + .clone() + .unwrap_or_else(|| "-".to_string()), + ) + }; + let priority_value = if is_editing { + let focused = modal_focus == ModalFocus::PriorityDropdown; + let theme_clone = theme.clone(); + let dropdown_clone = priority_dropdown.clone(); + gpui::div() + .relative() + .when(focused, |d| { + d.border_2() + .border_color(theme_clone.focus_ring) + .rounded_md() + .p_px() + }) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |modal, _, window, cx| { + modal.set_modal_focus(ModalFocus::PriorityDropdown, Some(window), cx); + // Open dropdown in defer so focus is set first + let dropdown = dropdown_clone.clone(); + cx.defer(move |cx| { + dropdown.update(cx, |d, cx| { + if !d.is_open() { + d.open(cx); + } + }); + }); + }), + ) + .child(priority_dropdown.clone()) + .into_any_element() + } else { + value_label(priority_label.clone()) + }; + let due_value = if is_editing { + let focused = modal_focus == ModalFocus::Due; + crate::ui::focus_wrap(due_input.clone(), focused, theme) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|modal, _, window, cx| { + modal.set_modal_focus(ModalFocus::Due, Some(window), cx); + }), + ) + .into_any_element() + } else { + value_label(due_text) + }; + let overview_grid = gpui::div() .flex() .flex_col() .gap_2() - .child(kv_row("Status", value_label(status_label.clone()))) - .child(kv_row( + .child(field_row("Status", status_value, None)) + .child(field_row( "Description", - value_label(detail.overview.description.clone()), + description_value, + errors.get(&FieldId::Description), )) - .child(kv_row( - "Project", - value_label( - detail - .overview - .project - .clone() - .unwrap_or_else(|| "-".to_string()), - ), - )) - .child(kv_row("Priority", value_label(priority_label.clone()))) - .child(kv_row("Due", value_label(due_text))); + .child(field_row("Project", project_value, None)) + .child(field_row("Priority", priority_value, None)) + .child(field_row("Due", due_value, errors.get(&FieldId::Due))); let mut overview_section = section("Overview", overview_grid); @@ -490,7 +1842,62 @@ where ); } - let tags_content = if detail.tags.tags.is_empty() { + let tag_chip = |label: &str, removable: bool| { + let mut chip = gpui::div() + .flex() + .items_center() + .gap_1() + .px(gpui::rems(0.5)) + .py(gpui::rems(0.125)) + .rounded(gpui::rems(0.25)) + .bg(Theme::alpha(theme.info, 0.18)) + .text_color(theme.info) + .text_xs() + .font_weight(gpui::FontWeight::MEDIUM) + .child(label.to_string()); + + if removable { + let tag_label = label.to_string(); + chip = chip.child( + gpui::div() + .text_color(theme.muted) + .cursor_pointer() + .hover(|s| s.text_color(theme.error)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |modal, _event, _window, cx| { + modal.remove_tag(tag_label.clone(), cx); + }), + ) + .child("×"), + ); + } + + chip + }; + + let tags_content = if is_editing { + let chips = form + .tags + .iter() + .map(|tag| tag_chip(tag, true).into_any_element()); + let focused = modal_focus == ModalFocus::TagsInput; + let tags_input_wrapped = crate::ui::focus_wrap(tags_input.clone(), focused, theme) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|modal, _, window, cx| { + modal.set_modal_focus(ModalFocus::TagsInput, Some(window), cx); + }), + ); + + gpui::div() + .flex() + .items_center() + .gap_2() + .children(chips) + .child(gpui::div().min_w(gpui::rems(8.0)).child(tags_input_wrapped)) + .into_any_element() + } else if detail.tags.tags.is_empty() { value_label("-".to_string()) } else { let chips = @@ -616,23 +2023,28 @@ where let mut sections = vec![overview_section, tags_section, deps_section]; - let annotations_section = if detail.annotations.is_empty() { - section( - "Annotations", + let visible_annotations: Vec<&AnnotationView> = annotations + .items + .iter() + .filter(|item| item.origin != AnnotationOrigin::Deleted) + .collect(); + + let mut annotations_content = gpui::div().flex().flex_col().gap_3(); + if visible_annotations.is_empty() { + annotations_content = annotations_content.child( gpui::div() .text_sm() .text_color(theme.muted) .child("No annotations"), - ) + ); } else { - let count = detail.annotations.len(); - let items = detail - .annotations + let count = visible_annotations.len(); + let items = visible_annotations .iter() .enumerate() .map(|(index, annotation)| { - let timestamp = annotation.entry.format(DATE_TIME_FORMAT).to_string(); - let content_for_copy = annotation.content.clone(); + let timestamp = annotation.created_at.format(DATE_TIME_FORMAT).to_string(); + let content_for_copy = annotation.text.to_string(); let copy_action = gpui::div() .text_xs() .text_color(theme.muted) @@ -649,7 +2061,27 @@ where }) .child(Label::new("Copy")); - let lines = annotation.content.split('\n').map(|line| { + let delete_id = annotation.id; + let delete_action = if is_editing { + Some( + gpui::div() + .text_xs() + .text_color(theme.muted) + .cursor_pointer() + .hover(|s| s.text_color(theme.error)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |modal, _event, _window, cx| { + modal.delete_annotation(delete_id, cx); + }), + ) + .child(Label::new("×")), + ) + } else { + None + }; + + let lines = annotation.text.as_ref().split('\n').map(|line| { let text = if line.is_empty() { " " } else { line }; Label::new(text.to_string()) .text_sm() @@ -657,6 +2089,11 @@ where .into_any_element() }); + let mut actions = gpui::div().flex().items_center().gap_2().child(copy_action); + if let Some(delete_action) = delete_action { + actions = actions.child(delete_action); + } + let mut item = gpui::div() .flex() .flex_col() @@ -668,7 +2105,7 @@ where .items_center() .justify_between() .child(Label::new(timestamp).text_xs().text_color(theme.muted)) - .child(copy_action), + .child(actions), ) .child(gpui::div().flex().flex_col().gap_1().children(lines)); @@ -679,11 +2116,68 @@ where item.into_any_element() }); - section( - "Annotations", - gpui::div().flex().flex_col().gap_3().children(items), - ) - }; + annotations_content = annotations_content.children(items); + } + + if is_editing { + let can_add = !annotations.draft.as_ref().trim().is_empty(); + let add_button = if can_add { + gpui::div() + .px(gpui::rems(0.75)) + .py(gpui::rems(0.35)) + .rounded_md() + .border_1() + .border_color(theme.divider) + .bg(theme.raised) + .text_color(theme.foreground) + .text_sm() + .cursor_pointer() + .hover(|s| s.bg(theme.hover)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|modal, _event, _window, cx| { + modal.submit_annotation(cx); + }), + ) + .child(Label::new("Add")) + } else { + gpui::div() + .px(gpui::rems(0.75)) + .py(gpui::rems(0.35)) + .rounded_md() + .border_1() + .border_color(theme.divider) + .bg(theme.raised) + .text_color(theme.muted) + .text_sm() + .child(Label::new("Add")) + }; + + let focused = modal_focus == ModalFocus::AnnotationsInput; + let annotation_input_wrapped = crate::ui::focus_wrap(annotation_input.clone(), focused, theme) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|modal, _, window, cx| { + modal.set_modal_focus(ModalFocus::AnnotationsInput, Some(window), cx); + }), + ); + + annotations_content = annotations_content.child( + gpui::div() + .flex() + .items_start() + .gap_2() + .child( + gpui::div() + .flex_1() + .min_w_0() + .child(annotation_input_wrapped), + ) + .child(add_button), + ); + } + + let annotations_section = section("Annotations", annotations_content); sections.push(annotations_section); sections.push(dates_section); @@ -715,6 +2209,61 @@ where .children(sections); let on_close_footer = on_close_click.clone(); + let action_button = |label: &str, + enabled: bool, + on_click: Option< + Arc, + >| { + let label = label.to_string(); + let mut button = gpui::div() + .px(gpui::rems(0.75)) + .py(gpui::rems(0.35)) + .rounded_md() + .border_1() + .border_color(theme.divider) + .bg(theme.raised) + .text_sm(); + + if enabled { + if let Some(on_click) = on_click { + button = button + .text_color(theme.foreground) + .cursor_pointer() + .hover(|s| s.bg(theme.hover)) + .on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { + (on_click)(event, window, app); + }); + } else { + button = button.text_color(theme.foreground); + } + } else { + button = button.text_color(theme.muted); + } + + button.child(Label::new(label)) + }; + + let mut action_row = gpui::div().flex().items_center().gap_2(); + if is_editing { + let cancel_edit_handler = Arc::new(cx.listener(|modal, _event, _window, cx| { + modal.cancel_edit(cx); + })); + let save_handler = Arc::new(cx.listener(|modal, _event, _window, cx| { + modal.submit_edits(cx); + })); + let annotations_dirty = annotations + .items + .iter() + .any(|item| item.origin != AnnotationOrigin::Original); + let can_save = errors.is_empty() && (form.is_dirty(detail) || annotations_dirty); + let cancel_button = action_button("Cancel (Esc)", true, Some(cancel_edit_handler)); + let save_button = action_button("Save (Ctrl+Enter)", can_save, Some(save_handler)); + action_row = action_row.child(cancel_button).child(save_button); + } else { + let close_button = action_button("Close (Esc)", true, Some(on_close_footer)); + action_row = action_row.child(close_button); + } + let footer = gpui::div() .flex() .items_center() @@ -723,24 +2272,9 @@ where .py(gpui::rems(0.5)) .border_t_1() .border_color(theme.divider) - .child( - gpui::div() - .id("task-detail-cancel") - .px(gpui::rems(0.75)) - .py(gpui::rems(0.35)) - .rounded_md() - .border_1() - .border_color(theme.divider) - .bg(theme.raised) - .text_color(theme.foreground) - .cursor_pointer() - .hover(|s| s.bg(theme.hover)) - .on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { - (on_close_footer)(event, window, app); - }) - .child(Label::new("Cancel (Esc)")), - ); + .child(action_row); + // Panel uses deferred() + anchored() for dropdowns, so clipping is not an issue gpui::div() .id("task-detail-panel") .flex() diff --git a/src/view/task_table.rs b/src/view/task_table.rs index 2724c6d..e0b6afb 100644 --- a/src/view/task_table.rs +++ b/src/view/task_table.rs @@ -12,7 +12,7 @@ use crate::{ }, keymap::{Command, CommandDispatcher}, models::{DueFilter, FilterState, PriorityFilter, StatusFilter}, - task::{self, TaskFilter, TaskService, TaskSummary}, + task::{self, TaskFilter}, theme::{self, ActiveTheme}, ui::{ DATE_FORMAT, TABLE_FILTER_BAR_INITIAL_HEIGHT, TABLE_MAX_DESCRIPTION_LENGTH, priority_badge, @@ -141,10 +141,6 @@ impl PaginationState { self.total_items = total_items; } - pub fn page_size(&mut self, page_size: usize) { - self.page_size = page_size; - } - pub fn current_page(&mut self, current_page: usize) { self.current_page = current_page; }