diff --git a/src/app.rs b/src/app.rs index 02907f9..e07666c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -287,7 +287,7 @@ impl App { cx.subscribe(&task_table_events, |app, _table, event, cx| match event { TaskTableEvent::OpenTask(task_id) => { if !app.task_detail_modal.read(cx).is_open() { - app.open_task_detail(*task_id, None, cx); + app.open_task_detail(*task_id, false, None, cx); } } }) diff --git a/src/components/action_button.rs b/src/components/action_button.rs new file mode 100644 index 0000000..efa7ca0 --- /dev/null +++ b/src/components/action_button.rs @@ -0,0 +1,133 @@ +use gpui::prelude::*; +use std::sync::Arc; + +use crate::components::label::Label; +use crate::theme::{ActiveTheme, Theme}; + +#[derive(Debug, Clone, Copy)] +pub enum ActionButtonVariant { + Normal, + Danger, + Ghost, +} + +#[derive(Clone, IntoElement)] +pub struct ActionButton { + id: Option, + label: gpui::SharedString, + variant: ActionButtonVariant, + enabled: bool, + on_click: + Option>, +} + +impl ActionButton { + pub fn new(label: impl Into) -> Self { + Self { + id: None, + label: label.into(), + variant: ActionButtonVariant::Normal, + enabled: true, + on_click: None, + } + } + + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + pub fn variant(mut self, variant: ActionButtonVariant) -> Self { + self.variant = variant; + self + } + + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + pub fn on_click( + mut self, + handler: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, + ) -> Self { + self.on_click = Some(Arc::new(handler)); + self + } +} + +impl RenderOnce for ActionButton { + fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement { + let theme = cx.theme(); + let (bg, fg, hover_bg) = match self.variant { + ActionButtonVariant::Normal => (theme.raised, theme.foreground, theme.hover), + ActionButtonVariant::Danger => ( + theme.error, + theme.selection_foreground, + Theme::alpha(theme.error, 0.8), + ), + ActionButtonVariant::Ghost => (gpui::rgba(0x00000000), theme.foreground, theme.hover), + }; + + if let Some(id) = self.id { + let mut button = gpui::div() + .id(id) + .px(gpui::rems(0.75)) + .py(gpui::rems(0.35)) + .rounded_md() + .text_sm(); + + match self.variant { + ActionButtonVariant::Normal | ActionButtonVariant::Ghost => { + button = button.border_1().border_color(theme.divider); + } + ActionButtonVariant::Danger => {} + } + + if self.enabled { + button = button.bg(bg).text_color(fg); + if let Some(on_click) = self.on_click { + button = button + .cursor_pointer() + .hover(move |s| s.bg(hover_bg)) + .on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { + (on_click)(event, window, app); + }); + } + } else { + button = button.bg(bg).text_color(theme.muted); + } + + return button.child(Label::new(self.label)).into_any_element(); + } + + let mut button = gpui::div() + .px(gpui::rems(0.75)) + .py(gpui::rems(0.35)) + .rounded_md() + .text_sm(); + + match self.variant { + ActionButtonVariant::Normal | ActionButtonVariant::Ghost => { + button = button.border_1().border_color(theme.divider); + } + ActionButtonVariant::Danger => {} + } + + if self.enabled { + button = button.bg(bg).text_color(fg); + if let Some(on_click) = self.on_click { + button = button + .cursor_pointer() + .hover(move |s| s.bg(hover_bg)) + .on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { + (on_click)(event, window, app); + }); + } + } else { + button = button.bg(bg).text_color(theme.muted); + } + + button.child(Label::new(self.label)).into_any_element() + } +} diff --git a/src/components/button/dropdown.rs b/src/components/button/dropdown.rs index a87afac..f6d913f 100644 --- a/src/components/button/dropdown.rs +++ b/src/components/button/dropdown.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use gpui::prelude::*; -use gpui::{Corner, anchored, deferred, px, point}; +use gpui::{Corner, anchored, deferred, point, px}; use crate::components::button::Button; use crate::components::label::Label; @@ -288,7 +288,7 @@ impl Dropdown { .collect(); let menu = gpui::div() - .w_full() // Same width as trigger + .w_full() // Same width as trigger .p_1() .border_1() .border_color(theme.border) diff --git a/src/components/chip.rs b/src/components/chip.rs new file mode 100644 index 0000000..9befeeb --- /dev/null +++ b/src/components/chip.rs @@ -0,0 +1,124 @@ +use gpui::prelude::*; +use std::sync::Arc; + +use crate::theme::{ActiveTheme, Theme}; + +#[derive(Debug, Clone, Copy)] +pub enum ChipVariant { + Info, + Success, + Warning, + Danger, + Muted, + Accent, + Custom { + background: gpui::Rgba, + foreground: gpui::Rgba, + }, +} + +impl ChipVariant { + fn colors(self, theme: &Theme) -> (gpui::Rgba, gpui::Rgba) { + match self { + ChipVariant::Info => (Theme::alpha(theme.info, 0.18), theme.info), + ChipVariant::Success => (Theme::alpha(theme.success, 0.18), theme.success), + ChipVariant::Warning => (Theme::alpha(theme.warning, 0.18), theme.warning), + ChipVariant::Danger => (Theme::alpha(theme.error, 0.18), theme.error), + ChipVariant::Muted => (Theme::alpha(theme.muted, 0.18), theme.muted), + ChipVariant::Accent => (Theme::alpha(theme.accent, 0.15), theme.accent), + ChipVariant::Custom { + background, + foreground, + } => (background, foreground), + } + } +} + +#[derive(Clone, IntoElement)] +pub struct Chip { + label: gpui::SharedString, + variant: ChipVariant, + selected: bool, + on_remove: + Option>, +} + +impl Chip { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + variant: ChipVariant::Info, + selected: false, + on_remove: None, + } + } + + pub fn variant(mut self, variant: ChipVariant) -> Self { + self.variant = variant; + self + } + + pub fn custom(self, background: gpui::Rgba, foreground: gpui::Rgba) -> Self { + self.variant(ChipVariant::Custom { + background, + foreground, + }) + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub fn removable( + mut self, + on_remove: Arc, + ) -> Self { + self.on_remove = Some(on_remove); + self + } +} + +impl RenderOnce for Chip { + fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement { + let theme = cx.theme(); + let (bg, fg) = self.variant.colors(theme); + + 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)) + .text_xs() + .font_weight(gpui::FontWeight::MEDIUM); + + if self.selected { + chip = chip + .bg(theme.accent) + .text_color(theme.selection_foreground) + .border_2() + .border_color(theme.focus_ring); + } else { + chip = chip.bg(bg).text_color(fg); + } + + chip = chip.child(self.label); + + if let Some(on_remove) = self.on_remove { + chip = chip.child( + gpui::div() + .text_color(theme.muted) + .cursor_pointer() + .hover(|s| s.text_color(theme.error)) + .on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { + (on_remove)(event, window, app); + }) + .child("×"), + ); + } + + chip + } +} diff --git a/src/components/confirm_dialog.rs b/src/components/confirm_dialog.rs new file mode 100644 index 0000000..520e8f5 --- /dev/null +++ b/src/components/confirm_dialog.rs @@ -0,0 +1,97 @@ +use gpui::prelude::*; +use std::sync::Arc; + +use crate::components::dialog::{Dialog, DialogButton, DialogButtonVariant}; +use crate::theme::Theme; + +#[derive(Clone)] +pub struct ConfirmDialog { + title: String, + hint: Option, + cancel: Option<( + String, + Arc, + )>, + confirm: Option<( + String, + DialogButtonVariant, + Arc, + )>, + on_backdrop_click: + Option>, +} + +impl ConfirmDialog { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + hint: None, + cancel: None, + confirm: None, + on_backdrop_click: None, + } + } + + pub fn hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + pub fn cancel( + mut self, + label: impl Into, + on_click: Arc, + ) -> Self { + self.cancel = Some((label.into(), on_click)); + self + } + + pub fn primary( + mut self, + label: impl Into, + on_click: Arc, + ) -> Self { + self.confirm = Some((label.into(), DialogButtonVariant::Primary, on_click)); + self + } + + pub fn danger( + mut self, + label: impl Into, + on_click: Arc, + ) -> Self { + self.confirm = Some((label.into(), DialogButtonVariant::Danger, on_click)); + self + } + + pub fn on_backdrop_click( + mut self, + handler: Arc, + ) -> Self { + self.on_backdrop_click = Some(handler); + self + } + + pub fn render(self, theme: &Theme) -> gpui::Div { + let mut dialog = Dialog::new(self.title); + if let Some(hint) = self.hint { + dialog = dialog.hint(hint); + } + if let Some((label, on_click)) = self.cancel { + dialog = dialog.button(DialogButton::default(label, on_click)); + } + if let Some((label, variant, on_click)) = self.confirm { + let button = match variant { + DialogButtonVariant::Primary => DialogButton::primary(label, on_click), + DialogButtonVariant::Danger => DialogButton::danger(label, on_click), + DialogButtonVariant::Default => DialogButton::default(label, on_click), + }; + dialog = dialog.button(button); + } + if let Some(handler) = self.on_backdrop_click { + dialog = dialog.on_backdrop_click(handler); + } + + dialog.render(theme) + } +} diff --git a/src/components/dialog.rs b/src/components/dialog.rs new file mode 100644 index 0000000..1d819cf --- /dev/null +++ b/src/components/dialog.rs @@ -0,0 +1,209 @@ +use crate::components::label::Label; +use crate::theme::Theme; +use gpui::prelude::*; +use std::sync::Arc; + +pub struct DialogButton { + label: String, + variant: DialogButtonVariant, + on_click: Arc, +} + +#[derive(Clone, Copy)] +pub enum DialogButtonVariant { + Default, + Primary, + Danger, +} + +impl DialogButton { + pub fn new( + label: impl Into, + variant: DialogButtonVariant, + on_click: Arc, + ) -> Self { + Self { + label: label.into(), + variant, + on_click, + } + } + + pub fn default( + label: impl Into, + on_click: Arc, + ) -> Self { + Self::new(label, DialogButtonVariant::Default, on_click) + } + + pub fn primary( + label: impl Into, + on_click: Arc, + ) -> Self { + Self::new(label, DialogButtonVariant::Primary, on_click) + } + + pub fn danger( + label: impl Into, + on_click: Arc, + ) -> Self { + Self::new(label, DialogButtonVariant::Danger, on_click) + } +} + +pub struct Dialog { + title: String, + message: Option, + hint: Option, + buttons: Vec, + on_backdrop_click: + Option>, +} + +impl Dialog { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + message: None, + hint: None, + buttons: Vec::new(), + on_backdrop_click: None, + } + } + + 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 + } + + pub fn button(mut self, button: DialogButton) -> Self { + self.buttons.push(button); + self + } + + pub fn on_backdrop_click( + mut self, + handler: Arc, + ) -> Self { + self.on_backdrop_click = Some(handler); + self + } + + pub fn render(self, theme: &Theme) -> gpui::Div { + let card = gpui::div() + .flex() + .flex_col() + .gap_3() + .p(gpui::rems(1.0)) + .min_w(gpui::rems(20.0)) + .bg(theme.panel) + .border_2() + .border_color(theme.error) + .rounded_md() + .shadow_lg() + .child( + Label::new(&self.title) + .text_color(theme.foreground) + .text_sm() + .font_weight(gpui::FontWeight::MEDIUM), + ); + + let card = if let Some(message) = &self.message { + card.child(Label::new(message).text_color(theme.foreground).text_sm()) + } else { + card + }; + + let card = if let Some(hint) = &self.hint { + card.child( + gpui::div() + .flex() + .gap_2() + .text_xs() + .text_color(theme.muted) + .child(hint.clone()), + ) + } else { + card + }; + + let card = if !self.buttons.is_empty() { + let button_row = self.buttons.into_iter().fold( + gpui::div().flex().gap_2().justify_end(), + |row, button| { + let (bg_color, text_color, hover_bg) = match button.variant { + DialogButtonVariant::Default => { + (theme.raised, theme.foreground, theme.hover) + } + DialogButtonVariant::Primary => ( + theme.accent, + theme.selection_foreground, + Theme::alpha(theme.accent, 0.8), + ), + DialogButtonVariant::Danger => ( + theme.error, + theme.selection_foreground, + Theme::alpha(theme.error, 0.8), + ), + }; + + let btn = gpui::div() + .px(gpui::rems(0.75)) + .py(gpui::rems(0.35)) + .rounded_md() + .text_sm() + .cursor_pointer(); + + let btn = match button.variant { + DialogButtonVariant::Default => btn + .border_1() + .border_color(theme.divider) + .bg(bg_color) + .text_color(text_color) + .hover(move |s| s.bg(hover_bg)), + _ => btn + .bg(bg_color) + .text_color(text_color) + .hover(move |s| s.bg(hover_bg)), + }; + + let handler = button.on_click.clone(); + let btn = btn + .on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { + (handler)(event, window, app); + }) + .child(Label::new(&button.label)); + + row.child(btn) + }, + ); + card.child(button_row) + } else { + card + }; + + let card = card.on_mouse_down(gpui::MouseButton::Left, |_event, _window, _app| {}); + + let backdrop = gpui::div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(Theme::alpha(theme.backdrop, 0.7)) + .child(card); + + if let Some(on_backdrop_click) = self.on_backdrop_click { + backdrop.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { + (on_backdrop_click)(event, window, app); + }) + } else { + backdrop + } + } +} diff --git a/src/components/field_row.rs b/src/components/field_row.rs new file mode 100644 index 0000000..6d322fc --- /dev/null +++ b/src/components/field_row.rs @@ -0,0 +1,104 @@ +use gpui::prelude::*; + +use crate::components::label::Label; +use crate::theme::{ActiveTheme, Theme}; + +#[derive(IntoElement)] +pub struct FieldRow { + label: gpui::SharedString, + value: gpui::AnyElement, + error: Option, + label_width: gpui::Length, + style: gpui::StyleRefinement, +} + +impl FieldRow { + pub fn new(label: impl Into, value: impl IntoElement) -> Self { + Self { + label: label.into(), + value: value.into_any_element(), + error: None, + label_width: gpui::rems(10.0).into(), + style: gpui::StyleRefinement::default(), + } + } + + pub fn error(mut self, error: Option) -> Self { + self.error = error; + self + } + + pub fn label_width(mut self, width: gpui::Length) -> Self { + self.label_width = width; + self + } +} + +impl Styled for FieldRow { + fn style(&mut self) -> &mut gpui::StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for FieldRow { + fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement { + let theme = cx.theme(); + let label_color = Theme::alpha(theme.foreground, 0.72); + + let row = gpui::div() + .flex() + .items_start() + .gap_3() + .child( + Label::new(self.label) + .text_color(label_color) + .text_sm() + .w(self.label_width), + ) + .child(gpui::div().flex_1().min_w_0().child(self.value)); + + let mut container = gpui::div().flex().flex_col().gap_1().child(row); + if let Some(error) = self.error { + container = container.child( + gpui::div() + .flex() + .items_start() + .gap_3() + .child(gpui::div().w(self.label_width)) + .child(Label::new(error).text_xs().text_color(theme.error)), + ); + } + + container.style().refine(&self.style); + + container + } +} + +#[derive(IntoElement)] +pub struct KvRow { + label: gpui::SharedString, + value: gpui::AnyElement, + label_width: gpui::Length, +} + +impl KvRow { + pub fn new(label: impl Into, value: impl IntoElement) -> Self { + Self { + label: label.into(), + value: value.into_any_element(), + label_width: gpui::rems(10.0).into(), + } + } + + pub fn label_width(mut self, width: gpui::Length) -> Self { + self.label_width = width; + self + } +} + +impl RenderOnce for KvRow { + fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement { + FieldRow::new(self.label, self.value).label_width(self.label_width) + } +} diff --git a/src/components/input/mod.rs b/src/components/input/mod.rs index a2dd7f7..6d13879 100644 --- a/src/components/input/mod.rs +++ b/src/components/input/mod.rs @@ -2,7 +2,7 @@ mod suggestion; use crate::theme::{ActiveTheme, Theme}; use gpui::prelude::*; -use gpui::{Corner, anchored, deferred, px, point}; +use gpui::{Corner, anchored, deferred, point, px}; use std::sync::Arc; pub use suggestion::Suggestion; @@ -348,6 +348,9 @@ impl Input { _window: &mut gpui::Window, cx: &mut gpui::Context, ) { + // Note: This handler is only attached when the input has real focus + // (see render() method), so we don't need to check is_focused here anymore + let key = event.keystroke.key.as_str(); let ctrl = event.keystroke.modifiers.control; let shift = event.keystroke.modifiers.shift; @@ -358,11 +361,13 @@ impl Input { match key { "enter" => { - if self.multiline && shift { + // For multiline, Enter inserts newline (modal handles exit via commands) + if self.multiline { self.insert_text("\n", cx); return; } + // For single-line, check suggestions then submit if self.suggestions_open { self.accept_suggestion(cx); } else { @@ -481,7 +486,10 @@ impl Input { } } - pub fn render_suggestions_external(&self, cx: &gpui::Context) -> Option { + pub fn render_suggestions_external( + &self, + cx: &gpui::Context, + ) -> Option { if !self.suggestions_open { return None; } @@ -514,15 +522,17 @@ impl Input { }) .collect(); - Some(gpui::div() - .mt_1() - .border_1() - .border_color(theme.border) - .bg(theme.panel) - .rounded_md() - .overflow_hidden() - .children(items) - .into_any_element()) + 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 { @@ -706,11 +716,10 @@ impl gpui::Render for Input { let focus_handle = self.focus.clone(); - gpui::div() + let base = gpui::div() .id(self.id.clone()) .key_context("Input") .track_focus(&self.focus) - .on_key_down(cx.listener(Self::handle_key_down)) .on_mouse_down(gpui::MouseButton::Left, move |_ev, window, _cx| { window.focus(&focus_handle); }) @@ -728,7 +737,16 @@ impl gpui::Render for Input { .p_2() .cursor(gpui::CursorStyle::IBeam) .child(content) - .child(self.render_suggestions(cx)) + .child(self.render_suggestions(cx)); + + // Only attach key handler when this input actually has focus + // This prevents the input from intercepting keyboard events + // when it only has "visual focus" (focus ring) but not real focus + if is_focused { + base.on_key_down(cx.listener(Self::handle_key_down)) + } else { + base + } } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 30cab54..45da24f 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,9 +1,15 @@ +pub mod action_button; pub mod button; +pub mod chip; +pub mod confirm_dialog; +pub mod dialog; pub mod divider; +pub mod field_row; pub mod icon; pub mod input; pub mod label; pub mod list; pub mod modal; pub mod panel; +pub mod section_card; pub mod toast; diff --git a/src/components/section_card.rs b/src/components/section_card.rs new file mode 100644 index 0000000..bf67ed8 --- /dev/null +++ b/src/components/section_card.rs @@ -0,0 +1,73 @@ +use gpui::prelude::*; + +use crate::components::label::Label; +use crate::theme::{ActiveTheme, Theme}; + +#[derive(IntoElement)] +pub struct SectionCard { + title: gpui::SharedString, + content: Vec, + style: gpui::StyleRefinement, +} + +impl SectionCard { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + content: Vec::new(), + style: gpui::StyleRefinement::default(), + } + } + + pub fn child(mut self, child: impl IntoElement) -> Self { + self.content.push(child.into_any_element()); + self + } + + pub fn children(mut self, children: impl IntoIterator) -> Self + where + E: IntoElement, + { + self.content + .extend(children.into_iter().map(|c| c.into_any_element())); + self + } +} + +impl Styled for SectionCard { + fn style(&mut self) -> &mut gpui::StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for SectionCard { + fn render(mut self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement { + let theme = cx.theme(); + let title = self.title.to_string().to_uppercase(); + let section_title_color = Theme::alpha(theme.foreground, 0.88); + + let header = Label::new(title) + .text_sm() + .text_color(section_title_color) + .font_weight(gpui::FontWeight::BOLD); + + let children: Vec = self.content.drain(..).collect(); + + let mut container = gpui::div() + .flex() + .flex_col() + .gap_2() + .bg(theme.raised) + .border_1() + .border_color(theme.divider) + .rounded_md() + .px(gpui::rems(0.75)) + .py(gpui::rems(0.5)) + .child(header) + .children(children); + + container.style().refine(&self.style); + + container + } +} diff --git a/src/dispatcher.rs b/src/dispatcher.rs index 9ab44b8..b545a2b 100644 --- a/src/dispatcher.rs +++ b/src/dispatcher.rs @@ -4,12 +4,16 @@ use crate::{ }; impl App { - fn close_task_detail(&mut self, cx: &mut gpui::Context) { + fn close_task_detail( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { self.task_detail_modal.update(cx, |modal, cx| { if modal.is_editing() { - modal.cancel_edit(cx); + modal.cancel_edit(window, cx); } else { - modal.close(cx); + modal.close(window, cx); } }); } @@ -111,12 +115,26 @@ impl CommandDispatcher for App { } true } + Command::OpenTaskEdit => { + match self.focus_target { + FocusTarget::SidebarProjects | FocusTarget::SidebarTags => { + // For sidebar, just open in view mode (or ignore) + self.sidebar.update(cx, |sidebar, cx| { + sidebar.dispatch(Command::OpenSelectedTask, cx) + }); + } + _ => { + self.open_selected_task_edit(None, cx); + } + } + true + } Command::CloseModal => { - self.close_task_detail(cx); + self.close_task_detail(None, cx); true } Command::SaveModal => { - self.close_task_detail(cx); + self.close_task_detail(None, cx); true } Command::ModalScrollUp => { diff --git a/src/handler.rs b/src/handler.rs index 3408d3c..3b8510b 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -137,6 +137,23 @@ impl App { &mut self, window: Option<&mut gpui::Window>, cx: &mut gpui::Context, + ) { + self.open_selected_task_mode(false, window, cx); + } + + pub(super) fn open_selected_task_edit( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + self.open_selected_task_mode(true, window, cx); + } + + fn open_selected_task_mode( + &mut self, + edit_mode: bool, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, ) { if self.task_detail_modal.read(cx).is_open() { return; @@ -147,12 +164,13 @@ impl App { return; }; - self.open_task_detail(task_id, window, cx); + self.open_task_detail(task_id, edit_mode, window, cx); } pub(super) fn open_task_detail( &mut self, task_id: uuid::Uuid, + edit_mode: bool, window: Option<&mut gpui::Window>, cx: &mut gpui::Context, ) { @@ -162,7 +180,11 @@ impl App { 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); + if edit_mode { + modal.open_with_detail_edit(detail, window, cx); + } else { + modal.open_with_detail(detail, window, cx); + } }); } Err(e) => { @@ -337,7 +359,7 @@ impl App { self.apply_task_update(task, cx); } else { self.task_detail_modal.update(cx, |modal, cx| { - modal.cancel_edit(cx); + modal.cancel_edit(None, cx); }); } } diff --git a/src/keymap/command.rs b/src/keymap/command.rs index a93b633..a2b0411 100644 --- a/src/keymap/command.rs +++ b/src/keymap/command.rs @@ -11,6 +11,7 @@ pub enum Command { // Actions OpenSelectedTask, + OpenTaskEdit, Sync, // Focus @@ -27,6 +28,21 @@ pub enum Command { SaveModal, ModalScrollUp, ModalScrollDown, + EnterEditMode, + ExitEditField, + EnterEditField, + ModalFocusNext, + ModalFocusPrev, + SubmitOrExitField, + ModalItemPrev, + ModalItemNext, + DeleteSelectedItem, + EditSelectedItem, + CopySelectedItem, + ConfirmYes, + ConfirmNo, + Undo, + Redo, // Filter ApplySearch, @@ -62,6 +78,7 @@ impl Command { "PrevPage" => Some(Self::PrevPage), "ClearSelection" => Some(Self::ClearSelection), "OpenSelectedTask" => Some(Self::OpenSelectedTask), + "OpenTaskEdit" => Some(Self::OpenTaskEdit), "Sync" => Some(Self::Sync), "FocusSearch" => Some(Self::FocusSearch), "FocusTable" => Some(Self::FocusTable), @@ -74,6 +91,21 @@ impl Command { "SaveModal" => Some(Self::SaveModal), "ModalScrollUp" => Some(Self::ModalScrollUp), "ModalScrollDown" => Some(Self::ModalScrollDown), + "EnterEditMode" => Some(Self::EnterEditMode), + "ExitEditField" => Some(Self::ExitEditField), + "EnterEditField" => Some(Self::EnterEditField), + "ModalFocusNext" => Some(Self::ModalFocusNext), + "ModalFocusPrev" => Some(Self::ModalFocusPrev), + "SubmitOrExitField" => Some(Self::SubmitOrExitField), + "ModalItemPrev" => Some(Self::ModalItemPrev), + "ModalItemNext" => Some(Self::ModalItemNext), + "DeleteSelectedItem" => Some(Self::DeleteSelectedItem), + "EditSelectedItem" => Some(Self::EditSelectedItem), + "CopySelectedItem" => Some(Self::CopySelectedItem), + "ConfirmYes" => Some(Self::ConfirmYes), + "ConfirmNo" => Some(Self::ConfirmNo), + "Undo" => Some(Self::Undo), + "Redo" => Some(Self::Redo), "ApplySearch" => Some(Self::ApplySearch), "ClearFilters" => Some(Self::ClearFilters), "ClearAllFilters" => Some(Self::ClearAllFilters), @@ -104,6 +136,7 @@ impl Command { Self::PrevPage => "PrevPage", Self::ClearSelection => "ClearSelection", Self::OpenSelectedTask => "OpenSelectedTask", + Self::OpenTaskEdit => "OpenTaskEdit", Self::Sync => "Sync", Self::FocusSearch => "FocusSearch", Self::FocusTable => "FocusTable", @@ -116,6 +149,21 @@ impl Command { Self::SaveModal => "SaveModal", Self::ModalScrollUp => "ModalScrollUp", Self::ModalScrollDown => "ModalScrollDown", + Self::EnterEditMode => "EnterEditMode", + Self::ExitEditField => "ExitEditField", + Self::EnterEditField => "EnterEditField", + Self::ModalFocusNext => "ModalFocusNext", + Self::ModalFocusPrev => "ModalFocusPrev", + Self::SubmitOrExitField => "SubmitOrExitField", + Self::ModalItemPrev => "ModalItemPrev", + Self::ModalItemNext => "ModalItemNext", + Self::DeleteSelectedItem => "DeleteSelectedItem", + Self::EditSelectedItem => "EditSelectedItem", + Self::CopySelectedItem => "CopySelectedItem", + Self::ConfirmYes => "ConfirmYes", + Self::ConfirmNo => "ConfirmNo", + Self::Undo => "Undo", + Self::Redo => "Redo", Self::ApplySearch => "ApplySearch", Self::ClearFilters => "ClearFilters", Self::ClearAllFilters => "ClearAllFilters", diff --git a/src/keymap/context.rs b/src/keymap/context.rs index abd7e34..30637ec 100644 --- a/src/keymap/context.rs +++ b/src/keymap/context.rs @@ -6,6 +6,7 @@ pub enum ContextId { SidebarProjects, SidebarTags, Modal, + ModalEditNav, ModalInput, ModalDropdown, FilterBar, @@ -21,6 +22,7 @@ impl ContextId { "sidebarprojects" | "SidebarProjects" => Some(Self::SidebarProjects), "sidebartags" | "SidebarTags" => Some(Self::SidebarTags), "modal" | "Modal" => Some(Self::Modal), + "modaleditnav" | "ModalEditNav" => Some(Self::ModalEditNav), "modalinput" | "ModalInput" => Some(Self::ModalInput), "modaldropdown" | "ModalDropdown" => Some(Self::ModalDropdown), "filterbar" | "FilterBar" => Some(Self::FilterBar), @@ -37,6 +39,7 @@ impl ContextId { Self::SidebarProjects => "SidebarProjects", Self::SidebarTags => "SidebarTags", Self::Modal => "Modal", + Self::ModalEditNav => "ModalEditNav", Self::ModalInput => "ModalInput", Self::ModalDropdown => "ModalDropdown", Self::FilterBar => "FilterBar", diff --git a/src/keymap/defaults.rs b/src/keymap/defaults.rs index 40608b7..52a7db0 100644 --- a/src/keymap/defaults.rs +++ b/src/keymap/defaults.rs @@ -131,6 +131,11 @@ pub fn build_default_keymap() -> KeymapLayer { KeyChord::new(Key::Enter, Mods::none()), Command::OpenSelectedTask, ); + layer.bind( + ContextId::Table, + KeyChord::new(Key::Char('e'), Mods::none()), + Command::OpenTaskEdit, + ); layer.bind( ContextId::Table, KeyChord::new(Key::ArrowLeft, Mods::none()), @@ -397,7 +402,7 @@ pub fn build_default_keymap() -> KeymapLayer { Command::BlurInput, ); - // Modal + // Modal (View mode) layer.bind( ContextId::Modal, KeyChord::new(Key::Escape, Mods::none()), @@ -428,36 +433,137 @@ pub fn build_default_keymap() -> KeymapLayer { KeyChord::new(Key::Enter, Mods::ctrl()), Command::SaveModal, ); - + // Enter edit mode with 'e' layer.bind( - ContextId::ModalInput, + ContextId::Modal, + KeyChord::new(Key::Char('e'), Mods::none()), + Command::EnterEditMode, + ); + + // ModalEditNav - Edit mode, navigating between fields (not typing) + layer.bind( + ContextId::ModalEditNav, KeyChord::new(Key::Escape, Mods::none()), Command::CloseModal, ); layer.bind( - ContextId::ModalInput, - KeyChord::new(Key::Enter, Mods::ctrl()), + ContextId::ModalEditNav, + KeyChord::new(Key::Char('s'), Mods::ctrl()), Command::SaveModal, ); layer.bind( - ContextId::ModalInput, + ContextId::ModalEditNav, + KeyChord::new(Key::Char('j'), Mods::none()), + Command::ModalFocusNext, + ); + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::Char('k'), Mods::none()), + Command::ModalFocusPrev, + ); + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::ArrowDown, Mods::none()), + Command::ModalFocusNext, + ); + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::ArrowUp, Mods::none()), + Command::ModalFocusPrev, + ); + layer.bind( + ContextId::ModalEditNav, KeyChord::new(Key::Tab, Mods::none()), - Command::FocusFilterNext, + Command::ModalFocusNext, + ); + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::Tab, Mods::shift()), + Command::ModalFocusPrev, + ); + // Enter/Space - Edit selected item (if any), or enter field (fallback) + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::Enter, Mods::none()), + Command::EditSelectedItem, + ); + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::Space, Mods::none()), + Command::EditSelectedItem, + ); + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::Char('u'), Mods::none()), + Command::Undo, + ); + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::Char('r'), Mods::none()), + Command::Redo, + ); + // h/l - Navigate items within tags + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::Char('h'), Mods::none()), + Command::ModalItemPrev, + ); + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::Char('l'), Mods::none()), + Command::ModalItemNext, + ); + // Delete/Backspace - Delete selected item + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::Delete, Mods::none()), + Command::DeleteSelectedItem, + ); + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::Backspace, Mods::none()), + Command::DeleteSelectedItem, + ); + // y - Copy selected annotation OR confirm in delete dialog + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::Char('y'), Mods::none()), + Command::ConfirmYes, + ); + // n - Cancel delete dialog + layer.bind( + ContextId::ModalEditNav, + KeyChord::new(Key::Char('n'), Mods::none()), + Command::ConfirmNo, + ); + + // ModalInput - Edit mode, actively typing in an input + layer.bind( + ContextId::ModalInput, + KeyChord::new(Key::Escape, Mods::none()), + Command::ExitEditField, ); layer.bind( ContextId::ModalInput, - KeyChord::new(Key::Tab, Mods::shift()), - Command::FocusFilterPrev, + KeyChord::new(Key::Char('s'), Mods::ctrl()), + Command::SaveModal, + ); + // Enter submits or exits field depending on field type + layer.bind( + ContextId::ModalInput, + KeyChord::new(Key::Enter, Mods::none()), + Command::SubmitOrExitField, ); + // ModalDropdown - Edit mode, dropdown is open layer.bind( ContextId::ModalDropdown, KeyChord::new(Key::Escape, Mods::none()), - Command::CloseModal, + Command::ExitEditField, ); layer.bind( ContextId::ModalDropdown, - KeyChord::new(Key::Enter, Mods::ctrl()), + KeyChord::new(Key::Char('s'), Mods::ctrl()), Command::SaveModal, ); layer.bind( @@ -490,16 +596,6 @@ pub fn build_default_keymap() -> KeymapLayer { 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/view/task_detail_modal.rs b/src/view/task_detail_modal.rs deleted file mode 100644 index 06d84e4..0000000 --- a/src/view/task_detail_modal.rs +++ /dev/null @@ -1,2293 +0,0 @@ -use chrono::{DateTime, NaiveDate, TimeZone, Utc}; -use gpui::prelude::*; -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, 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: 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: 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.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, - window: Option<&mut gpui::Window>, - cx: &mut gpui::Context, - ) { - if let Some(window) = window { - window.focus(&self.focus_handle); - } - - self.scroll_handle = gpui::ScrollHandle::new(); - self.scroll_handle.scroll_to_item(0); - 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(); - } - - pub fn open_with_error( - &mut self, - task_id: uuid::Uuid, - error: String, - window: Option<&mut gpui::Window>, - cx: &mut gpui::Context, - ) { - if let Some(window) = window { - window.focus(&self.focus_handle); - } - - self.scroll_handle = gpui::ScrollHandle::new(); - self.scroll_handle.scroll_to_item(0); - 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(); - } - - pub fn open_loading( - &mut self, - task_id: uuid::Uuid, - window: Option<&mut gpui::Window>, - cx: &mut gpui::Context, - ) { - if let Some(window) = window { - window.focus(&self.focus_handle); - } - - self.scroll_handle = gpui::ScrollHandle::new(); - self.scroll_handle.scroll_to_item(0); - 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) { - 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.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(); - } - - 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; - } - - 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 { - handle.bottom_item() - } else { - handle.top_item() - }; - let next = if delta > 0 { - current.saturating_add(1) - } else { - current.saturating_sub(1) - }; - - handle.scroll_to_item(next); - cx.notify(); - } -} - -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 { - fn render( - &mut self, - _window: &mut gpui::Window, - cx: &mut gpui::Context, - ) -> impl gpui::IntoElement { - if !self.state.open { - return gpui::div().into_any_element(); - } - - let on_close_backdrop = cx.listener(|modal, _event: &gpui::MouseDownEvent, _window, cx| { - modal.close(cx); - }); - let on_close_click = cx.listener(|modal, _event: &gpui::MouseDownEvent, _window, cx| { - modal.close(cx); - }); - - 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, - cx, - on_close_backdrop, - on_close_click, - ) - } -} - -fn render_task_detail_modal( - 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, - 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 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) - .panel(panel) - .on_close(on_close_out) - .into_any_element() -} - -fn render_task_detail_placeholder_panel( - title: &str, - message: &str, - theme: &Theme, - on_close_click: OnCloseClick, -) -> gpui::AnyElement -where - OnCloseClick: Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, -{ - let on_close_click = Arc::new(on_close_click); - let on_close_header = on_close_click.clone(); - let close_button = gpui::div() - .id("task-detail-close") - .px(gpui::rems(0.5)) - .py(gpui::rems(0.25)) - .rounded_md() - .text_color(theme.muted) - .cursor_pointer() - .hover(|s| s.bg(theme.hover).text_color(theme.foreground)) - .on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { - (on_close_header)(event, window, app); - }) - .child("X"); - - let header = gpui::div() - .flex() - .items_center() - .justify_between() - .px(gpui::rems(1.0)) - .py(gpui::rems(0.75)) - .border_b_1() - .border_color(theme.divider) - .child(Label::new(title.to_string()).text_color(theme.foreground)) - .child(close_button); - - let body = gpui::div() - .flex() - .flex_col() - .flex_1() - .min_h_0() - .items_center() - .justify_center() - .text_color(theme.muted) - .child(message.to_string()); - - let on_close_footer = on_close_click.clone(); - let footer_button = 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("Close (Esc)")); - - gpui::div() - .id("task-detail-panel") - .flex() - .flex_col() - .w(gpui::rems(48.0)) - .h(gpui::rems(40.0)) - .bg(theme.panel) - .border_1() - .border_color(theme.border) - .rounded_md() - .block_mouse_except_scroll() - .child(header) - .child(body) - .child( - gpui::div() - .flex() - .items_center() - .justify_end() - .px(gpui::rems(1.0)) - .py(gpui::rems(0.5)) - .border_t_1() - .border_color(theme.divider) - .child(footer_button), - ) - .into_any_element() -} - -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 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 = if is_editing { - form.priority.into() - } else { - detail.overview.priority.into() - }; - - let chip = |label: &str, bg: gpui::Rgba, fg: gpui::Rgba| { - gpui::div() - .px(gpui::rems(0.5)) - .py(gpui::rems(0.125)) - .rounded(gpui::rems(0.25)) - .bg(bg) - .text_color(fg) - .text_xs() - .font_weight(gpui::FontWeight::MEDIUM) - .child(label.to_string()) - }; - - let status_color = match status_label.as_str() { - "Active" => theme.success, - "Pending" => theme.warning, - "Completed" => theme.muted, - "Deleted" => theme.error, - "Recurring" => theme.info, - _ => theme.muted, - }; - - 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, - task::TaskPriority::None => theme.muted, - }; - - let mut badges = vec![chip( - &status_label, - Theme::alpha(status_color, 0.18), - status_color, - )]; - - if priority_label != "None" { - badges.push(chip( - &priority_label, - Theme::alpha(priority_color, 0.18), - priority_color, - )); - } - - 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( - 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 - .or(detail.identity.id) - .map(|id| format!("#{}", id)) - .unwrap_or_else(|| format!("#{}", detail.identity.uuid)); - - 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(); - let close_button = gpui::div() - .id("task-detail-close") - .px(gpui::rems(0.5)) - .py(gpui::rems(0.25)) - .rounded_md() - .text_color(theme.muted) - .cursor_pointer() - .hover(|s| s.bg(theme.hover).text_color(theme.foreground)) - .on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { - (on_close_header)(event, window, app); - }) - .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() - .justify_between() - .gap_4() - .px(gpui::rems(1.0)) - .py(gpui::rems(0.75)) - .border_b_1() - .border_color(theme.divider) - .child( - gpui::div() - .flex() - .flex_col() - .gap_2() - .child( - Label::new(title) - .text_color(theme.foreground) - .font_weight(gpui::FontWeight::BOLD), - ) - .child(gpui::div().flex().gap_2().children(badges)), - ) - .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 field_row = |label: &str, value: gpui::AnyElement, error: Option<&gpui::SharedString>| { - let row = gpui::div() - .flex() - .items_start() - .gap_3() - .child( - Label::new(label.to_string()) - .text_color(label_color) - .text_sm() - .w(label_width), - ) - .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() - .text_color(section_title_color) - .font_weight(gpui::FontWeight::BOLD) - }; - - let section = |title: &str, content: gpui::Div| { - gpui::div() - .flex() - .flex_col() - .gap_2() - .bg(theme.raised) - .border_1() - .border_color(theme.divider) - .rounded_md() - .px(gpui::rems(0.75)) - .py(gpui::rems(0.5)) - .child(section_header(title)) - .child(content) - }; - - let due_text = detail - .dates - .due - .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(field_row("Status", status_value, None)) - .child(field_row( - "Description", - description_value, - errors.get(&FieldId::Description), - )) - .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); - - if !detail.dependencies.blocked_by.is_empty() || !detail.dependencies.blocking.is_empty() { - let mut info = Vec::new(); - if !detail.dependencies.blocked_by.is_empty() { - info.push(format!( - "Blocked by {} task(s)", - detail.dependencies.blocked_by.len() - )); - } - if !detail.dependencies.blocking.is_empty() { - info.push(format!( - "Blocking {} task(s)", - detail.dependencies.blocking.len() - )); - } - - overview_section = overview_section.child( - gpui::div() - .text_sm() - .text_color(label_color) - .child(info.join(" / ")), - ); - } - - 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 = - detail.tags.tags.iter().map(|tag| { - chip(tag, Theme::alpha(theme.info, 0.18), theme.info).into_any_element() - }); - gpui::div() - .flex() - .gap_2() - .children(chips) - .into_any_element() - }; - - let tags_section = section( - "Tags", - gpui::div() - .flex() - .flex_col() - .gap_2() - .child(tags_content) - .when(!detail.tags.virtual_tags.is_empty(), |div| { - let vchips = detail.tags.virtual_tags.iter().map(|tag| { - chip(tag, Theme::alpha(theme.muted, 0.2), theme.muted).into_any_element() - }); - div.child(gpui::div().flex().gap_2().children(vchips).text_sm()) - }), - ); - - let format_dt = |value: Option>| { - value - .map(|d| d.format(DATE_TIME_FORMAT).to_string()) - .unwrap_or_else(|| "-".to_string()) - }; - - let dates_grid = gpui::div() - .flex() - .flex_col() - .gap_2() - .child(kv_row("Entry", value_label(format_dt(detail.dates.entry)))) - .child(kv_row( - "Modified", - value_label(format_dt(detail.dates.modified)), - )) - .child(kv_row("Start", value_label(format_dt(detail.dates.start)))) - .child(kv_row("End", value_label(format_dt(detail.dates.end)))) - .child(kv_row( - "Scheduled", - value_label(format_dt(detail.dates.scheduled)), - )) - .child(kv_row("Wait", value_label(format_dt(detail.dates.wait)))) - .child(kv_row("Until", value_label(format_dt(detail.dates.until)))); - - let dates_section = section("Dates", dates_grid); - - let uuid_value = detail.identity.uuid.to_string(); - let id_value = detail - .identity - .working_id - .or(detail.identity.id) - .map(|id| id.to_string()) - .unwrap_or_else(|| "-".to_string()); - - let mut meta_grid = gpui::div() - .flex() - .flex_col() - .gap_2() - .child(kv_row("UUID", value_label(uuid_value))) - .child(kv_row("ID", value_label(id_value))); - - if let Some(urgency) = detail.metrics.urgency { - meta_grid = meta_grid.child(kv_row("Urgency", value_label(format!("{:.2}", urgency)))); - } - - let meta_section = section("Metadata", meta_grid); - - let format_link = |link: &TaskLinkVm| { - let id = link - .id - .map(|id| format!("#{}", id)) - .unwrap_or_else(|| link.uuid.to_string()); - let status: String = link.status.clone().into(); - format!("{} {} ({})", id, link.description, status) - }; - - let render_links = |links: &[TaskLinkVm]| { - if links.is_empty() { - value_label("-".to_string()) - } else { - let items = links.iter().map(|link| { - Label::new(format_link(link)) - .text_sm() - .text_color(value_color) - .into_any_element() - }); - gpui::div() - .flex() - .flex_col() - .gap_1() - .min_w_0() - .children(items) - .into_any_element() - } - }; - - let deps_grid = gpui::div() - .flex() - .flex_col() - .gap_2() - .child(kv_row( - "Depends On", - render_links(&detail.dependencies.depends_on), - )) - .child(kv_row( - "Blocked By", - render_links(&detail.dependencies.blocked_by), - )) - .child(kv_row( - "Blocking", - render_links(&detail.dependencies.blocking), - )); - - let deps_section = section("Dependencies", deps_grid); - - let mut sections = vec![overview_section, tags_section, deps_section]; - - 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 = visible_annotations.len(); - let items = visible_annotations - .iter() - .enumerate() - .map(|(index, annotation)| { - 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) - .cursor_pointer() - .hover(|s| s.text_color(theme.accent)) - .on_mouse_down(gpui::MouseButton::Left, move |_event, _window, app| { - app.write_to_clipboard(gpui::ClipboardItem::new_string( - content_for_copy.clone(), - )); - let toast_host = app.global::().host.clone(); - app.update_entity(&toast_host, |host, cx| { - host.push(ToastKind::Info, "Annotation copied", cx); - }); - }) - .child(Label::new("Copy")); - - 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() - .text_color(value_color) - .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() - .gap_1() - .min_w_0() - .child( - gpui::div() - .flex() - .items_center() - .justify_between() - .child(Label::new(timestamp).text_xs().text_color(theme.muted)) - .child(actions), - ) - .child(gpui::div().flex().flex_col().gap_1().children(lines)); - - if index + 1 < count { - item = item.child(gpui::div().mt_2().h(gpui::px(1.0)).bg(theme.divider)); - } - - item.into_any_element() - }); - - 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); - sections.push(meta_section); - - if !detail.udas.is_empty() { - let rows = detail - .udas - .iter() - .map(|(key, value)| kv_row(key, value_label(value.clone())).into_any_element()); - let udas_section = section( - "Extras", - gpui::div().flex().flex_col().gap_2().children(rows), - ); - sections.push(udas_section); - } - - let body = gpui::div() - .id("task-detail-body") - .flex() - .flex_col() - .flex_1() - .min_h_0() - .overflow_y_scroll() - .track_scroll(scroll_handle) - .px(gpui::rems(1.0)) - .py(gpui::rems(0.75)) - .gap_4() - .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() - .justify_end() - .px(gpui::rems(1.0)) - .py(gpui::rems(0.5)) - .border_t_1() - .border_color(theme.divider) - .child(action_row); - - // Panel uses deferred() + anchored() for dropdowns, so clipping is not an issue - gpui::div() - .id("task-detail-panel") - .flex() - .flex_col() - .w(gpui::rems(48.0)) - .h(gpui::rems(40.0)) - .bg(theme.panel) - .border_1() - .border_color(theme.border) - .rounded_md() - .block_mouse_except_scroll() - .child(header) - .child(body) - .child(footer) - .into_any_element() -} diff --git a/src/view/task_detail_modal/annotations.rs b/src/view/task_detail_modal/annotations.rs new file mode 100644 index 0000000..aa9bad5 --- /dev/null +++ b/src/view/task_detail_modal/annotations.rs @@ -0,0 +1,99 @@ +use chrono::{DateTime, Utc}; +use gpui::SharedString; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use crate::task::TaskDetailVm; + +pub(super) type AnnotationId = u64; + +#[derive(Debug, Clone)] +pub(super) struct AnnotationState { + pub(super) items: Vec, + pub(super) draft: SharedString, +} + +impl Default for AnnotationState { + fn default() -> Self { + Self { + items: Vec::new(), + draft: SharedString::default(), + } + } +} + +#[derive(Debug, Clone)] +pub(super) struct AnnotationView { + pub(super) id: AnnotationId, + pub(super) created_at: DateTime, + pub(super) text: SharedString, + pub(super) origin: AnnotationOrigin, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) 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 { + pub(super) 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: SharedString::default(), + } + } + + pub(super) fn set_draft(&mut self, value: &str) { + self.draft = value.to_string().into(); + } + + pub(super) fn clear_draft(&mut self) { + self.draft = SharedString::default(); + } + + pub(super) fn add_local(&mut self, text: 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, + }); + } + + pub(super) 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) + } +} diff --git a/src/view/task_detail_modal/bindings.rs b/src/view/task_detail_modal/bindings.rs new file mode 100644 index 0000000..96f24aa --- /dev/null +++ b/src/view/task_detail_modal/bindings.rs @@ -0,0 +1,29 @@ +use super::state::ModalFocus; + +pub(super) struct FocusMap; + +impl FocusMap { + pub(super) fn section_index(focus: ModalFocus) -> usize { + match focus { + ModalFocus::None + | ModalFocus::StatusDropdown + | ModalFocus::Description + | ModalFocus::Project + | ModalFocus::PriorityDropdown + | ModalFocus::Due => 0, + ModalFocus::TagsInput => 1, + ModalFocus::AnnotationsInput => 3, + } + } + + pub(super) fn wants_input_focus(focus: ModalFocus) -> bool { + matches!( + focus, + ModalFocus::Description + | ModalFocus::Project + | ModalFocus::Due + | ModalFocus::TagsInput + | ModalFocus::AnnotationsInput + ) + } +} diff --git a/src/view/task_detail_modal/form.rs b/src/view/task_detail_modal/form.rs new file mode 100644 index 0000000..3f38c0c --- /dev/null +++ b/src/view/task_detail_modal/form.rs @@ -0,0 +1,341 @@ +use chrono::{DateTime, NaiveDate, TimeZone, Utc}; +use gpui::SharedString; +use std::collections::{HashMap, HashSet}; + +use crate::task::{self, TaskDetailVm}; +use crate::ui::DATE_FORMAT; + +use super::annotations::{AnnotationOrigin, AnnotationState}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(super) enum FieldId { + Description, + Due, +} + +#[derive(Debug, Clone, Default)] +pub(super) struct TaskForm { + pub(super) description: String, + pub(super) project: String, + pub(super) priority: task::TaskPriority, + pub(super) status: task::TaskStatus, + pub(super) due: String, + pub(super) tags: Vec, + pub(super) tag_draft: String, +} + +impl TaskForm { + pub(super) 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(), + } + } + + pub(super) 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 + } + + pub(super) 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 + } + } + } + } + + pub(super) 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 + } + + pub(super) 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 + } + + pub(super) 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 { + pub(super) 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() + } +} + +pub(super) fn build_task_update( + original: &TaskDetailVm, + draft: &TaskForm, + annotations: &AnnotationState, +) -> TaskEditUpdate { + 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 &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 +} + +#[cfg(test)] +mod tests { + use super::super::annotations::AnnotationState; + use super::*; + use crate::task::model::{ + TaskAnnotation, TaskDatesVm, TaskDependenciesVm, TaskDetailVm, TaskIdentityVm, + TaskMetricsVm, TaskOverviewVm, TaskTagsVm, + }; + use crate::task::{TaskPriority, TaskStatus}; + use chrono::{TimeZone, Utc}; + + fn base_detail() -> TaskDetailVm { + TaskDetailVm { + identity: TaskIdentityVm { + uuid: uuid::Uuid::nil(), + id: None, + working_id: None, + }, + overview: TaskOverviewVm { + description: "Test task".to_string(), + status: TaskStatus::Pending, + project: Some("proj".to_string()), + priority: TaskPriority::None, + is_active: false, + }, + dates: TaskDatesVm { + entry: None, + modified: None, + start: None, + end: None, + due: None, + scheduled: None, + wait: None, + until: None, + }, + tags: TaskTagsVm { + tags: vec!["a".to_string(), "b".to_string()], + virtual_tags: Vec::new(), + }, + dependencies: TaskDependenciesVm { + depends_on: Vec::new(), + blocked_by: Vec::new(), + blocking: Vec::new(), + }, + annotations: Vec::new(), + udas: Vec::new(), + metrics: TaskMetricsVm::default(), + } + } + + #[test] + fn build_update_project_cleared() { + let detail = base_detail(); + let mut draft = TaskForm::from_detail(&detail); + draft.project = " ".to_string(); + + let annotations = AnnotationState::default(); + let update = build_task_update(&detail, &draft, &annotations); + + assert_eq!(update.project, Some(None)); + } + + #[test] + fn build_update_due_invalid_is_ignored() { + let detail = base_detail(); + let mut draft = TaskForm::from_detail(&detail); + draft.due = "2023-99-99".to_string(); + + let annotations = AnnotationState::default(); + let update = build_task_update(&detail, &draft, &annotations); + + assert!(update.due.is_none()); + } + + #[test] + fn build_update_tags_ignore_order() { + let detail = base_detail(); + let mut draft = TaskForm::from_detail(&detail); + draft.tags = vec!["b".to_string(), "a".to_string()]; + + let annotations = AnnotationState::default(); + let update = build_task_update(&detail, &draft, &annotations); + + assert!(update.tags.is_none()); + } + + #[test] + fn build_update_annotations_added_deleted() { + let mut detail = base_detail(); + let deleted_at = Utc.with_ymd_and_hms(2023, 5, 2, 12, 0, 0).unwrap(); + detail.annotations = vec![TaskAnnotation { + entry: deleted_at, + content: "Original".to_string(), + }]; + + let mut annotations = AnnotationState::from_detail(&detail); + let original_id = annotations.items[0].id; + annotations.mark_deleted(original_id); + annotations.add_local("Added".into(), Utc::now()); + + let draft = TaskForm::from_detail(&detail); + let update = build_task_update(&detail, &draft, &annotations); + + assert_eq!(update.annotations_add, vec!["Added".to_string()]); + assert_eq!(update.annotations_delete, vec![deleted_at]); + } +} diff --git a/src/view/task_detail_modal/history.rs b/src/view/task_detail_modal/history.rs new file mode 100644 index 0000000..422adc2 --- /dev/null +++ b/src/view/task_detail_modal/history.rs @@ -0,0 +1,66 @@ +use super::form::TaskForm; + +/// History stack for undo/redo functionality +#[derive(Debug, Clone)] +pub(super) struct FormHistory { + states: Vec, + current_index: usize, + max_size: usize, +} + +impl Default for FormHistory { + fn default() -> Self { + Self { + states: Vec::new(), + current_index: 0, + max_size: 50, + } + } +} + +impl FormHistory { + pub(super) fn clear(&mut self) { + self.states.clear(); + self.current_index = 0; + } + + pub(super) fn push(&mut self, state: TaskForm) { + if self.current_index < self.states.len() { + self.states.truncate(self.current_index); + } + + self.states.push(state); + + if self.states.len() > self.max_size { + self.states.remove(0); + } else { + self.current_index = self.states.len(); + } + } + + pub(super) fn undo(&mut self) -> Option<&TaskForm> { + if self.current_index > 1 { + self.current_index -= 1; + self.states.get(self.current_index - 1) + } else { + None + } + } + + pub(super) fn redo(&mut self) -> Option<&TaskForm> { + if self.current_index < self.states.len() { + self.current_index += 1; + self.states.get(self.current_index - 1) + } else { + None + } + } + + pub(super) fn can_undo(&self) -> bool { + self.current_index > 1 + } + + pub(super) fn can_redo(&self) -> bool { + self.current_index < self.states.len() + } +} diff --git a/src/view/task_detail_modal/mod.rs b/src/view/task_detail_modal/mod.rs new file mode 100644 index 0000000..02eaa04 --- /dev/null +++ b/src/view/task_detail_modal/mod.rs @@ -0,0 +1,1827 @@ +use chrono::Utc; +use gpui::prelude::*; +use std::sync::{Arc, Mutex}; + +use crate::components::button::{Dropdown, DropdownItem}; +use crate::components::input::Input; +use crate::components::toast::{ToastGlobal, ToastKind}; +use crate::keymap::{Command, CommandDispatcher, ContextId}; +use crate::task::{self, TaskDetailVm}; + +mod annotations; +mod bindings; +mod form; +mod history; +mod render; +mod state; + +pub use form::TaskEditUpdate; +pub use state::{EditState, ModalFocus}; + +use self::annotations::{AnnotationId, AnnotationOrigin, AnnotationState}; +use self::bindings::FocusMap; +use self::form::{FieldId, TaskForm, build_task_update}; +use self::history::FormHistory; +use self::state::{ConfirmAction, InlineEditTarget, ModalMode, TaskModalState}; + +pub enum TaskDetailModalEvent { + Closed, + SaveEdits { + task_id: uuid::Uuid, + update: TaskEditUpdate, + }, +} + +struct ModalEntities { + 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, +} + +enum CommandResult { + Handled, + NotHandled, +} + +pub struct TaskDetailModal { + state: TaskModalState, + focus_handle: gpui::FocusHandle, + form_focus_handle: gpui::FocusHandle, + scroll_handle: gpui::ScrollHandle, + entities: ModalEntities, + project_suggestions: Arc>>, + form_history: FormHistory, +} + +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(); + + Input::new("annotation-input", cx, "Add annotation...") + .multiline() + .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.annotations.draft = v.into(); + cx.notify(); + }); + } + }); + })) + }); + + let description_input = + cx.new(|cx| Input::new("task-edit-description", cx, "Description").multiline()); + + let project_input = { + 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() + }, + )) + }) + }; + + let due_input = cx.new(|cx| Input::new("task-edit-due", cx, "Due (YYYY-MM-DD)")); + + let tags_input = cx.new(|cx| { + let on_change_entity = modal_entity.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(); + }); + } + }); + }, + )) + }); + + let status_items = vec![ + DropdownItem::new("Pending"), + DropdownItem::new("Completed"), + DropdownItem::new("Deleted"), + ]; + let status_dropdown = cx.new(|_cx| Dropdown::new("task-edit-status").items(status_items)); + + let priority_items = vec![ + DropdownItem::new("High"), + DropdownItem::new("Medium"), + DropdownItem::new("Low"), + DropdownItem::new("None"), + ]; + let priority_dropdown = + cx.new(|_cx| Dropdown::new("task-edit-priority").items(priority_items)); + + let entities = ModalEntities { + annotation_input, + description_input, + project_input, + due_input, + tags_input, + status_dropdown, + priority_dropdown, + }; + + Self { + state: TaskModalState::default(), + focus_handle: cx.focus_handle(), + form_focus_handle: cx.focus_handle(), + scroll_handle: gpui::ScrollHandle::new(), + entities, + project_suggestions, + form_history: FormHistory::default(), + } + } + + pub fn is_open(&self) -> bool { + 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, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + self.open_with_detail_mode(detail, false, window, cx); + } + + pub fn open_with_detail_edit( + &mut self, + detail: TaskDetailVm, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + self.open_with_detail_mode(detail, true, window, cx); + } + + fn reset_open_state(&mut self) { + self.scroll_handle = gpui::ScrollHandle::new(); + self.scroll_handle.scroll_to_item(0); + self.state.open = true; + } + + fn reset_pending_state(&mut self) { + self.state.pending_confirm = None; + self.form_history.clear(); + } + + fn apply_empty_state( + &mut self, + task_id: uuid::Uuid, + loading: bool, + error: Option, + ) { + self.state.task_id = Some(task_id); + self.state.loading = loading; + self.state.original = None; + self.state.form = TaskForm::default(); + self.state.errors.clear(); + self.state.mode = ModalMode::View; + self.state.edit_state = EditState::Navigating; + self.state.annotations = AnnotationState::default(); + self.state.error = error; + self.reset_pending_state(); + } + + fn open_with_detail_mode( + &mut self, + detail: TaskDetailVm, + edit_mode: bool, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + self.reset_open_state(); + self.state.task_id = Some(detail.identity.uuid); + self.state.loading = false; + self.state.error = None; + self.state.original = Some(detail.clone()); + self.state.errors.clear(); + self.reset_pending_state(); + self.sync_from_detail(&detail, cx, true); + + if edit_mode { + self.state.mode = ModalMode::Edit; + self.state.edit_state = EditState::Navigating; + self.state.modal_focus = ModalFocus::StatusDropdown; + self.form_history.push(self.state.form.clone()); + + if let Some(window) = window { + window.focus(&self.form_focus_handle); + } + } else { + self.state.mode = ModalMode::View; + self.state.edit_state = EditState::Navigating; + + if let Some(window) = window { + window.focus(&self.focus_handle); + } + } + + cx.notify(); + } + + pub fn open_with_error( + &mut self, + task_id: uuid::Uuid, + error: String, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + if let Some(window) = window { + window.focus(&self.focus_handle); + } + + self.reset_open_state(); + self.apply_empty_state(task_id, false, Some(error.into())); + cx.notify(); + } + + pub fn open_loading( + &mut self, + task_id: uuid::Uuid, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + if let Some(window) = window { + window.focus(&self.focus_handle); + } + + self.reset_open_state(); + self.apply_empty_state(task_id, true, None); + cx.notify(); + } + + pub fn set_detail(&mut self, detail: TaskDetailVm, cx: &mut gpui::Context) { + 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.apply_empty_state(task_id, false, Some(error.into())); + cx.notify(); + } + + fn sync_from_detail( + &mut self, + detail: &TaskDetailVm, + cx: &mut gpui::Context, + reset_edit: bool, + ) { + self.state.annotations = AnnotationState::from_detail(detail); + self.entities.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.state.edit_state = EditState::Navigating; + 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; + self.reset_pending_state(); + cx.notify(); + } + + fn enter_edit_mode(&mut self, window: Option<&mut gpui::Window>, cx: &mut gpui::Context) { + let Some(detail) = self.state.original.clone() else { + return; + }; + + self.state.mode = ModalMode::Edit; + self.state.edit_state = EditState::Navigating; + self.state.modal_focus = ModalFocus::StatusDropdown; + self.sync_from_detail(&detail, cx, true); + self.form_history.clear(); + self.form_history.push(self.state.form.clone()); + + // Focus the form handle so we can navigate with j/k + if let Some(window) = window { + window.focus(&self.form_focus_handle); + } + + cx.notify(); + } + + pub fn cancel_edit(&mut self, window: Option<&mut gpui::Window>, cx: &mut gpui::Context) { + let Some(detail) = self.state.original.clone() else { + return; + }; + + self.state.mode = ModalMode::View; + self.state.edit_state = EditState::Navigating; + self.state.modal_focus = ModalFocus::None; + self.sync_from_detail(&detail, cx, true); + self.reset_pending_state(); + + // Restore focus to modal main handle + if let Some(window) = window { + window.focus(&self.focus_handle); + } + + 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.entities.description_input.update(cx, |input, cx| { + input.set_value_silent(form.description, cx); + }); + + self.entities.project_input.update(cx, |input, cx| { + input.set_value_silent(form.project, cx); + }); + + self.entities.due_input.update(cx, |input, cx| { + input.set_value_silent(form.due, cx); + }); + + self.entities.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.entities.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.entities.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(); + } + + fn sync_form_from_inputs(&mut self, cx: &gpui::Context) { + self.state.form.description = self.entities.description_input.read(cx).value().to_string(); + self.state.form.project = self.entities.project_input.read(cx).value().to_string(); + self.state.form.due = self.entities.due_input.read(cx).value().to_string(); + self.state.form.tag_draft = self.entities.tags_input.read(cx).value().to_string(); + self.state.annotations.draft = self + .entities + .annotation_input + .read(cx) + .value() + .to_string() + .into(); + } + + pub fn submit_edits(&mut self, cx: &mut gpui::Context) { + if self.state.mode != ModalMode::Edit { + self.close(None, cx); + return; + } + + if self.state.original.is_none() { + return; + } + + // Sync form from inputs before validating + self.sync_form_from_inputs(cx); + + self.state.errors = self.state.form.validate(); + if !self.state.errors.is_empty() { + cx.notify(); + return; + } + + let detail = self.state.original.as_ref().unwrap(); + let update = build_task_update(detail, &self.state.form, &self.state.annotations); + + if update.is_empty() { + self.cancel_edit(None, cx); + return; + } + + cx.emit(TaskDetailModalEvent::SaveEdits { + task_id: detail.identity.uuid, + 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.entities.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.trim().to_string(); + if text.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.into(), Utc::now()); + self.state.annotations.clear_draft(); + self.entities.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, window: Option<&mut gpui::Window>, cx: &mut gpui::Context) { + if !self.state.open { + return; + } + + if let Some(window) = window { + self.entities + .description_input + .update(cx, |input, cx| input.blur(window, cx)); + self.entities + .project_input + .update(cx, |input, cx| input.blur(window, cx)); + self.entities + .due_input + .update(cx, |input, cx| input.blur(window, cx)); + self.entities + .tags_input + .update(cx, |input, cx| input.blur(window, cx)); + self.entities + .annotation_input + .update(cx, |input, cx| input.blur(window, cx)); + } + + self.state = TaskModalState::default(); + cx.emit(TaskDetailModalEvent::Closed); + cx.notify(); + } + + pub fn set_modal_focus( + &mut self, + focus: ModalFocus, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + self.set_focus(focus, window, cx); + } + + pub fn set_modal_focus_and_edit( + &mut self, + focus: ModalFocus, + mut window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + let window_ref = window.as_deref_mut(); + self.set_modal_focus(focus, window_ref, cx); + + if self.state.edit_state == EditState::Navigating { + self.enter_edit_field(window, cx); + } + } + + fn set_focus( + &mut self, + focus: ModalFocus, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + if self.state.modal_focus != focus { + self.close_all_dropdowns(cx); + } + + self.state.modal_focus = focus; + + if let Some(window) = window { + self.apply_focus(focus, window, cx); + } + + self.apply_scroll(focus); + cx.notify(); + } + + fn apply_focus( + &mut self, + focus: ModalFocus, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) { + if self.state.edit_state == EditState::Navigating { + window.focus(&self.form_focus_handle); + return; + } + + if FocusMap::wants_input_focus(focus) { + match focus { + ModalFocus::Description => { + self.entities + .description_input + .update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::Project => { + self.entities + .project_input + .update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::Due => { + self.entities + .due_input + .update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::TagsInput => { + self.entities + .tags_input + .update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::AnnotationsInput => { + self.entities + .annotation_input + .update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::None | ModalFocus::StatusDropdown | ModalFocus::PriorityDropdown => {} + } + return; + } + + match focus { + ModalFocus::StatusDropdown | ModalFocus::PriorityDropdown => { + window.focus(&self.form_focus_handle); + } + ModalFocus::None => { + window.focus(&self.focus_handle); + } + ModalFocus::Description + | ModalFocus::Project + | ModalFocus::Due + | ModalFocus::TagsInput + | ModalFocus::AnnotationsInput => {} + } + } + + fn apply_scroll(&mut self, focus: ModalFocus) { + let section_index = FocusMap::section_index(focus); + self.scroll_handle.scroll_to_item(section_index); + } + + pub fn get_modal_focus(&self) -> ModalFocus { + self.state.modal_focus + } + + fn close_all_dropdowns(&mut self, cx: &mut gpui::Context) { + self.entities + .status_dropdown + .update(cx, |d, cx| d.close(cx)); + self.entities + .priority_dropdown + .update(cx, |d, cx| d.close(cx)); + } + + fn has_open_dropdown(&self, cx: &gpui::Context) -> bool { + let status_open = self.entities.status_dropdown.read(cx).is_open(); + let priority_open = self.entities.priority_dropdown.read(cx).is_open(); + status_open || priority_open + } + + pub fn toggle_focused_dropdown(&mut self, cx: &mut gpui::Context) { + match self.state.modal_focus { + ModalFocus::StatusDropdown => { + let was_open = self.entities.status_dropdown.read(cx).is_open(); + self.entities.status_dropdown.update(cx, |d, cx| { + if d.is_open() { + d.accept_selection(cx); + } else { + d.open(cx); + } + }); + if was_open { + if let Some(index) = self + .entities + .status_dropdown + .read(cx) + .selected_index_value() + { + self.update_edit_status(index, cx); + } + } + } + ModalFocus::PriorityDropdown => { + let was_open = self.entities.priority_dropdown.read(cx).is_open(); + self.entities.priority_dropdown.update(cx, |d, cx| { + if d.is_open() { + d.accept_selection(cx); + } else { + d.open(cx); + } + }); + if was_open { + if let Some(index) = self + .entities + .priority_dropdown + .read(cx) + .selected_index_value() + { + self.update_edit_priority(index, cx); + } + } + } + _ => {} + } + cx.notify(); + } + + 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.entities.status_dropdown, cx), + ModalFocus::PriorityDropdown => select_next(&self.entities.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.entities.status_dropdown, cx), + ModalFocus::PriorityDropdown => select_prev(&self.entities.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 | StatusDropdown => Description, + Description => Project, + Project => PriorityDropdown, + PriorityDropdown => Due, + Due => TagsInput, + TagsInput => AnnotationsInput, + AnnotationsInput => StatusDropdown, + }; + + 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 | StatusDropdown => AnnotationsInput, + AnnotationsInput => TagsInput, + TagsInput => Due, + Due => PriorityDropdown, + PriorityDropdown => Project, + Project => Description, + Description => StatusDropdown, + }; + + if matches!(self.state.modal_focus, None | StatusDropdown) + && prev == AnnotationsInput + && self.state.annotation_selected.is_none() + { + let visible_count = self + .state + .annotations + .items + .iter() + .filter(|a| a.origin != AnnotationOrigin::Deleted) + .count(); + if visible_count > 0 { + self.state.annotation_selected = Some(visible_count - 1); + } + } + + self.set_modal_focus(prev, window, cx); + } + + pub fn active_context(&self) -> ContextId { + if !self.state.open { + return ContextId::Global; + } + + match self.state.mode { + ModalMode::View => ContextId::Modal, + ModalMode::Edit => match self.state.edit_state { + EditState::Navigating => ContextId::ModalEditNav, + EditState::Editing => ContextId::ModalInput, + EditState::DropdownOpen => ContextId::ModalDropdown, + }, + } + } + + pub fn get_edit_state(&self) -> EditState { + self.state.edit_state + } + + pub fn dispatch_command( + &mut self, + command: Command, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) -> bool { + if !self.state.open { + return false; + } + + matches!( + self.handle_command(command, window, cx), + CommandResult::Handled + ) + } + + fn handle_command( + &mut self, + command: Command, + mut window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) -> CommandResult { + let command = if self.state.pending_confirm.is_some() { + match command { + Command::ConfirmYes => Command::ConfirmYes, + Command::EditSelectedItem => Command::ConfirmYes, + Command::ConfirmNo | Command::CloseModal => { + self.state.pending_confirm = None; + cx.notify(); + return CommandResult::Handled; + } + _ => { + return CommandResult::NotHandled; + } + } + } else { + command + }; + + match command { + Command::CloseModal => { + self.handle_close_or_escape(window.as_deref_mut(), cx); + CommandResult::Handled + } + Command::SaveModal => self.handle_save_modal(cx), + Command::EnterEditMode => self.handle_enter_edit_mode(window.as_deref_mut(), cx), + Command::EnterEditField => self.handle_enter_edit_field(window.as_deref_mut(), cx), + Command::ExitEditField => self.handle_exit_edit_field(window.as_deref_mut(), cx), + Command::SubmitOrExitField => { + self.handle_submit_or_exit_field(window.as_deref_mut(), cx) + } + Command::ModalFocusNext => self.handle_modal_focus_next(window.as_deref_mut(), cx), + Command::ModalFocusPrev => self.handle_modal_focus_prev(window.as_deref_mut(), cx), + Command::Undo => self.handle_undo(cx), + Command::Redo => self.handle_redo(cx), + Command::ModalItemNext => self.handle_modal_item_next(cx), + Command::ModalItemPrev => self.handle_modal_item_prev(cx), + Command::DeleteSelectedItem => self.handle_delete_selected_item(cx), + Command::ConfirmYes => self.handle_confirm_yes(window.as_deref_mut(), cx), + Command::ConfirmNo => self.handle_confirm_no(cx), + Command::CopySelectedItem => self.handle_copy_selected_item(cx), + Command::EditSelectedItem => self.handle_edit_selected_item(window.as_deref_mut(), cx), + Command::ModalScrollDown => self.handle_scroll_command(1, cx), + Command::ModalScrollUp => self.handle_scroll_command(-1, cx), + Command::FocusFilterNext => self.handle_focus_filter_next(window.as_deref_mut(), cx), + Command::FocusFilterPrev => self.handle_focus_filter_prev(window.as_deref_mut(), cx), + Command::ToggleDropdown => self.handle_toggle_dropdown(cx), + Command::SelectNextOption => self.handle_select_next_option(cx), + Command::SelectPrevOption => self.handle_select_prev_option(cx), + _ => CommandResult::NotHandled, + } + } + + fn handle_save_modal(&mut self, cx: &mut gpui::Context) -> CommandResult { + if self.state.mode == ModalMode::Edit { + self.submit_edits(cx); + CommandResult::Handled + } else { + CommandResult::NotHandled + } + } + + fn handle_enter_edit_mode( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) -> CommandResult { + if self.state.mode == ModalMode::View { + self.enter_edit_mode(window, cx); + CommandResult::Handled + } else { + CommandResult::NotHandled + } + } + + fn handle_enter_edit_field( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) -> CommandResult { + if self.state.mode == ModalMode::Edit && self.state.edit_state == EditState::Navigating { + self.enter_edit_field(window, cx); + CommandResult::Handled + } else { + CommandResult::NotHandled + } + } + + fn handle_exit_edit_field( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) -> CommandResult { + if self.state.mode == ModalMode::Edit && self.state.edit_state != EditState::Navigating { + self.exit_edit_field(window, cx); + CommandResult::Handled + } else { + CommandResult::NotHandled + } + } + + fn handle_submit_or_exit_field( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) -> CommandResult { + if self.state.mode != ModalMode::Edit || self.state.edit_state != EditState::Editing { + return CommandResult::NotHandled; + } + + match self.state.modal_focus { + ModalFocus::TagsInput => { + self.sync_form_from_inputs(cx); + + if let Some(InlineEditTarget::Tag(i)) = self.state.inline_edit { + let new_value = self.state.form.tag_draft.trim(); + if !new_value.is_empty() && i < self.state.form.tags.len() { + self.state.form.tags[i] = new_value.to_string(); + } + + self.state.inline_edit = None; + self.state.form.tag_draft.clear(); + self.entities + .tags_input + .update(cx, |input, cx| input.clear(cx)); + + self.exit_edit_field(window, cx); + } else { + self.submit_tag_draft(cx); + } + CommandResult::Handled + } + ModalFocus::AnnotationsInput => { + self.sync_form_from_inputs(cx); + + if let Some(InlineEditTarget::Annotation(i)) = self.state.inline_edit { + let new_value = self.state.annotations.draft.trim().to_string(); + if !new_value.is_empty() && i < self.state.annotations.items.len() { + self.state.annotations.items[i].text = new_value.into(); + } + + self.state.inline_edit = None; + self.state.annotations.clear_draft(); + self.entities + .annotation_input + .update(cx, |input, cx| input.clear(cx)); + + self.exit_edit_field(window, cx); + } else { + self.submit_annotation(cx); + } + CommandResult::Handled + } + ModalFocus::Project => { + if self.project_suggestions_open(cx) { + CommandResult::NotHandled + } else { + self.exit_edit_field(window, cx); + CommandResult::Handled + } + } + ModalFocus::Description => CommandResult::NotHandled, + _ => { + self.exit_edit_field(window, cx); + CommandResult::Handled + } + } + } + + fn handle_modal_focus_next( + &mut self, + mut window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) -> CommandResult { + if self.state.mode != ModalMode::Edit { + return CommandResult::NotHandled; + } + + if self.state.edit_state == EditState::Editing { + self.exit_edit_field(window.as_deref_mut(), cx); + } + + if self.state.edit_state == EditState::Navigating { + if self.state.modal_focus == ModalFocus::AnnotationsInput { + if self.move_annotation_selection(true, cx) { + return CommandResult::Handled; + } + } + self.focus_next_field(window.as_deref_mut(), cx); + } + + CommandResult::Handled + } + + fn handle_modal_focus_prev( + &mut self, + mut window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) -> CommandResult { + if self.state.mode != ModalMode::Edit { + return CommandResult::NotHandled; + } + + if self.state.edit_state == EditState::Editing { + self.exit_edit_field(window.as_deref_mut(), cx); + } + + if self.state.edit_state == EditState::Navigating { + if self.state.modal_focus == ModalFocus::AnnotationsInput { + if self.move_annotation_selection(false, cx) { + return CommandResult::Handled; + } + } + self.focus_prev_field(window.as_deref_mut(), cx); + } + + CommandResult::Handled + } + + fn handle_undo(&mut self, cx: &mut gpui::Context) -> CommandResult { + if self.state.mode == ModalMode::Edit { + self.undo(cx); + CommandResult::Handled + } else { + CommandResult::NotHandled + } + } + + fn handle_redo(&mut self, cx: &mut gpui::Context) -> CommandResult { + if self.state.mode == ModalMode::Edit { + self.redo(cx); + CommandResult::Handled + } else { + CommandResult::NotHandled + } + } + + fn handle_modal_item_next(&mut self, cx: &mut gpui::Context) -> CommandResult { + if self.state.mode != ModalMode::Edit || self.state.edit_state != EditState::Navigating { + return CommandResult::NotHandled; + } + + match self.state.modal_focus { + ModalFocus::TagsInput => { + if self.state.form.tags.is_empty() { + return CommandResult::NotHandled; + } + + let len = self.state.form.tags.len(); + self.state.tag_selected = match self.state.tag_selected { + None => Some(0), + Some(i) => { + let next = i + 1; + if next >= len { None } else { Some(next) } + } + }; + cx.notify(); + CommandResult::Handled + } + _ => CommandResult::NotHandled, + } + } + + fn handle_modal_item_prev(&mut self, cx: &mut gpui::Context) -> CommandResult { + if self.state.mode != ModalMode::Edit || self.state.edit_state != EditState::Navigating { + return CommandResult::NotHandled; + } + + match self.state.modal_focus { + ModalFocus::TagsInput => { + if self.state.form.tags.is_empty() { + return CommandResult::NotHandled; + } + + let len = self.state.form.tags.len(); + self.state.tag_selected = match self.state.tag_selected { + None => Some(len - 1), + Some(0) => None, + Some(i) => Some(i - 1), + }; + cx.notify(); + CommandResult::Handled + } + _ => CommandResult::NotHandled, + } + } + + fn handle_delete_selected_item(&mut self, cx: &mut gpui::Context) -> CommandResult { + if self.state.mode != ModalMode::Edit + || self.state.edit_state != EditState::Navigating + || self.state.pending_confirm.is_some() + { + return CommandResult::NotHandled; + } + + match self.state.modal_focus { + ModalFocus::TagsInput => { + if let Some(i) = self.state.tag_selected { + if i < self.state.form.tags.len() { + let text = self.state.form.tags[i].clone(); + self.state.pending_confirm = + Some(ConfirmAction::DeleteTag { index: i, text }); + cx.notify(); + return CommandResult::Handled; + } + } + CommandResult::NotHandled + } + ModalFocus::AnnotationsInput => { + if let Some(i) = self.state.annotation_selected { + let visible: Vec<_> = self + .state + .annotations + .items + .iter() + .enumerate() + .filter(|(_, a)| a.origin != AnnotationOrigin::Deleted) + .collect(); + + if i < visible.len() { + let (actual_index, ann) = visible[i]; + let text = ann.text.to_string(); + let text_single_line = text.replace('\n', " ").replace('\r', " "); + let text_single_line = text_single_line.trim().to_string(); + let text_preview = if text_single_line.len() > 50 { + format!("{}...", &text_single_line[..47]) + } else { + text_single_line + }; + self.state.pending_confirm = Some(ConfirmAction::DeleteAnnotation { + index: actual_index, + text: text_preview, + }); + cx.notify(); + return CommandResult::Handled; + } + } + CommandResult::NotHandled + } + _ => CommandResult::NotHandled, + } + } + + fn handle_confirm_yes( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) -> CommandResult { + if let Some(confirm) = self.state.pending_confirm.take() { + match confirm { + ConfirmAction::DeleteTag { index, .. } => { + if index < self.state.form.tags.len() { + self.state.form.tags.remove(index); + // Adjust selection + if self.state.form.tags.is_empty() { + self.state.tag_selected = None; + } else if let Some(sel) = self.state.tag_selected { + self.state.tag_selected = Some(sel.min(self.state.form.tags.len() - 1)); + } + } + } + ConfirmAction::DeleteAnnotation { index, .. } => { + if index < self.state.annotations.items.len() { + self.state.annotations.items[index].origin = AnnotationOrigin::Deleted; + + let visible_count = self + .state + .annotations + .items + .iter() + .filter(|a| a.origin != AnnotationOrigin::Deleted) + .count(); + + if visible_count == 0 { + self.state.annotation_selected = None; + } else if let Some(sel) = self.state.annotation_selected { + self.state.annotation_selected = Some(sel.min(visible_count - 1)); + } + } + } + ConfirmAction::DiscardUnsavedChanges => { + self.cancel_edit(window, cx); + } + ConfirmAction::DiscardUnsavedChangesAndClose => { + self.close(window, cx); + } + } + cx.notify(); + return CommandResult::Handled; + } + + self.handle_command(Command::CopySelectedItem, window, cx) + } + + fn handle_confirm_no(&mut self, cx: &mut gpui::Context) -> CommandResult { + if self.state.pending_confirm.take().is_some() { + cx.notify(); + CommandResult::Handled + } else { + CommandResult::NotHandled + } + } + + fn handle_copy_selected_item(&mut self, cx: &mut gpui::Context) -> CommandResult { + if self.state.mode != ModalMode::Edit || self.state.edit_state != EditState::Navigating { + return CommandResult::NotHandled; + } + + match self.state.modal_focus { + ModalFocus::AnnotationsInput => { + if let Some(i) = self.state.annotation_selected { + let visible: Vec<_> = self + .state + .annotations + .items + .iter() + .filter(|a| a.origin != AnnotationOrigin::Deleted) + .collect(); + + if i < visible.len() { + let text = visible[i].text.to_string(); + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); + + let toast_host = cx.global::().host.clone(); + cx.update_entity(&toast_host, |host, cx| { + host.push(ToastKind::Info, "Annotation copied", cx); + }); + + return CommandResult::Handled; + } + } + CommandResult::NotHandled + } + _ => CommandResult::NotHandled, + } + } + + fn handle_edit_selected_item( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) -> CommandResult { + if self.state.mode != ModalMode::Edit || self.state.edit_state != EditState::Navigating { + return CommandResult::NotHandled; + } + + match self.state.modal_focus { + ModalFocus::TagsInput => { + if let Some(i) = self.state.tag_selected { + if i < self.state.form.tags.len() { + self.state.inline_edit = Some(InlineEditTarget::Tag(i)); + + let tag_value = self.state.form.tags[i].clone(); + self.entities.tags_input.update(cx, |input, cx| { + input.set_value(tag_value, cx); + }); + + self.enter_edit_field(window, cx); + + return CommandResult::Handled; + } + } + + self.enter_edit_field(window, cx); + CommandResult::Handled + } + ModalFocus::AnnotationsInput => { + if let Some(i) = self.state.annotation_selected { + let visible: Vec<_> = self + .state + .annotations + .items + .iter() + .enumerate() + .filter(|(_, a)| a.origin != AnnotationOrigin::Deleted) + .collect(); + + if i < visible.len() { + let (actual_index, ann) = visible[i]; + + if ann.origin == AnnotationOrigin::Added { + self.state.inline_edit = + Some(InlineEditTarget::Annotation(actual_index)); + + let ann_value = ann.text.to_string(); + self.entities.annotation_input.update(cx, |input, cx| { + input.set_value(ann_value, cx); + }); + + self.enter_edit_field(window, cx); + + return CommandResult::Handled; + } else { + let toast_host = cx.global::().host.clone(); + cx.update_entity(&toast_host, |host, cx| { + host.push(ToastKind::Error, "Cannot edit original annotations", cx); + }); + return CommandResult::Handled; + } + } + } + + self.enter_edit_field(window, cx); + CommandResult::Handled + } + _ => { + self.enter_edit_field(window, cx); + CommandResult::Handled + } + } + } + + fn handle_scroll_command(&mut self, delta: i32, cx: &mut gpui::Context) -> CommandResult { + if self.state.mode == ModalMode::View { + self.scroll(delta, cx); + CommandResult::Handled + } else { + CommandResult::NotHandled + } + } + + fn handle_focus_filter_next( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) -> CommandResult { + if self.state.mode == ModalMode::Edit { + self.focus_next_field(window, cx); + CommandResult::Handled + } else { + CommandResult::NotHandled + } + } + + fn handle_focus_filter_prev( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) -> CommandResult { + if self.state.mode == ModalMode::Edit { + self.focus_prev_field(window, cx); + CommandResult::Handled + } else { + CommandResult::NotHandled + } + } + + fn handle_toggle_dropdown(&mut self, cx: &mut gpui::Context) -> CommandResult { + if self.state.mode == ModalMode::Edit + && matches!( + self.state.modal_focus, + ModalFocus::StatusDropdown | ModalFocus::PriorityDropdown + ) + { + if self.state.edit_state == EditState::DropdownOpen { + self.toggle_focused_dropdown(cx); + self.state.edit_state = EditState::Navigating; + } else { + self.state.edit_state = EditState::DropdownOpen; + self.toggle_focused_dropdown(cx); + } + CommandResult::Handled + } else { + CommandResult::NotHandled + } + } + + fn handle_select_next_option(&mut self, cx: &mut gpui::Context) -> CommandResult { + if self.state.mode == ModalMode::Edit && self.state.edit_state == EditState::DropdownOpen { + self.select_next_dropdown_option(cx); + CommandResult::Handled + } else { + CommandResult::NotHandled + } + } + + fn handle_select_prev_option(&mut self, cx: &mut gpui::Context) -> CommandResult { + if self.state.mode == ModalMode::Edit && self.state.edit_state == EditState::DropdownOpen { + self.select_prev_dropdown_option(cx); + CommandResult::Handled + } else { + CommandResult::NotHandled + } + } + + pub fn scroll(&self, delta: i32, cx: &mut gpui::Context) { + let handle = &self.scroll_handle; + let current = if delta > 0 { + handle.bottom_item() + } else { + handle.top_item() + }; + let next = if delta > 0 { + current.saturating_add(1) + } else { + current.saturating_sub(1) + }; + + handle.scroll_to_item(next); + cx.notify(); + } + + fn move_annotation_selection(&mut self, next: bool, cx: &mut gpui::Context) -> bool { + if self.state.mode != ModalMode::Edit || self.state.edit_state != EditState::Navigating { + return false; + } + + let visible_count = self + .state + .annotations + .items + .iter() + .filter(|a| a.origin != AnnotationOrigin::Deleted) + .count(); + + if visible_count == 0 { + return false; + } + + let (next_selection, consumed) = match self.state.annotation_selected { + None => { + if next { + (Some(0), true) + } else { + (None, false) + } + } + Some(i) => { + if next { + let next_index = i + 1; + if next_index >= visible_count { + (None, false) + } else { + (Some(next_index), true) + } + } else if i == 0 { + (None, false) + } else { + (Some(i - 1), true) + } + } + }; + + self.state.annotation_selected = next_selection; + cx.notify(); + consumed + } + + fn enter_edit_field( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + if self.state.mode != ModalMode::Edit { + return; + } + + match self.state.modal_focus { + ModalFocus::StatusDropdown | ModalFocus::PriorityDropdown => { + self.state.edit_state = EditState::DropdownOpen; + self.toggle_focused_dropdown(cx); + } + ModalFocus::Description + | ModalFocus::Project + | ModalFocus::Due + | ModalFocus::TagsInput + | ModalFocus::AnnotationsInput => { + self.state.edit_state = EditState::Editing; + + if let Some(window) = window { + match self.state.modal_focus { + ModalFocus::Description => { + self.entities + .description_input + .update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::Project => { + self.entities + .project_input + .update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::Due => { + self.entities + .due_input + .update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::TagsInput => { + self.entities + .tags_input + .update(cx, |i, cx| i.focus(window, cx)); + } + ModalFocus::AnnotationsInput => { + self.entities + .annotation_input + .update(cx, |i, cx| i.focus(window, cx)); + } + _ => {} + } + } + } + ModalFocus::None => { + self.state.modal_focus = ModalFocus::Description; + self.state.edit_state = EditState::Editing; + if let Some(window) = window { + self.entities + .description_input + .update(cx, |i, cx| i.focus(window, cx)); + } + } + } + cx.notify(); + } + + fn project_suggestions_open(&self, cx: &gpui::Context) -> bool { + self.entities.project_input.read(cx).has_suggestions_open() + } + + fn exit_edit_field(&mut self, window: Option<&mut gpui::Window>, cx: &mut gpui::Context) { + if self.state.mode != ModalMode::Edit { + return; + } + + match self.state.edit_state { + EditState::Editing => { + if self.state.inline_edit.is_some() { + self.state.inline_edit = None; + + match self.state.modal_focus { + ModalFocus::TagsInput => { + self.state.form.tag_draft.clear(); + self.entities + .tags_input + .update(cx, |input, cx| input.clear(cx)); + } + ModalFocus::AnnotationsInput => { + self.state.annotations.clear_draft(); + self.entities + .annotation_input + .update(cx, |input, cx| input.clear(cx)); + } + _ => {} + } + } else { + self.sync_form_from_inputs(cx); + } + + self.state.edit_state = EditState::Navigating; + if let Some(window) = window { + window.focus(&self.form_focus_handle); + } + + if self.state.inline_edit.is_none() { + self.form_history.push(self.state.form.clone()); + } + } + EditState::DropdownOpen => { + self.close_all_dropdowns(cx); + self.state.edit_state = EditState::Navigating; + if let Some(window) = window { + window.focus(&self.form_focus_handle); + } + } + EditState::Navigating => { + // Already navigating, nothing to do + } + } + cx.notify(); + } + + fn undo(&mut self, cx: &mut gpui::Context) { + if let Some(form) = self.form_history.undo().cloned() { + self.state.form = form; + self.apply_form_inputs(cx); + cx.notify(); + } + } + + fn redo(&mut self, cx: &mut gpui::Context) { + if let Some(form) = self.form_history.redo().cloned() { + self.state.form = form; + self.apply_form_inputs(cx); + cx.notify(); + } + } + + fn has_unsaved_changes(&mut self, cx: &gpui::Context) -> bool { + self.sync_form_from_inputs(cx); + if let Some(original) = &self.state.original { + self.state.form.is_dirty(original) + } else { + false + } + } + + fn handle_close_or_escape( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + match self.state.mode { + ModalMode::View => { + self.close(window, cx); + } + ModalMode::Edit => match self.state.edit_state { + EditState::Editing => { + self.exit_edit_field(window, cx); + } + EditState::DropdownOpen => { + self.close_all_dropdowns(cx); + self.state.edit_state = EditState::Navigating; + if let Some(window) = window { + window.focus(&self.form_focus_handle); + } + cx.notify(); + } + EditState::Navigating => { + if self.has_unsaved_changes(cx) { + self.state.pending_confirm = Some(ConfirmAction::DiscardUnsavedChanges); + cx.notify(); + } else { + self.cancel_edit(window, cx); + } + } + }, + } + } + + fn request_close(&mut self, window: Option<&mut gpui::Window>, cx: &mut gpui::Context) { + if self.state.pending_confirm.is_some() { + self.state.pending_confirm = None; + cx.notify(); + return; + } + + if self.state.mode == ModalMode::Edit && self.has_unsaved_changes(cx) { + self.state.pending_confirm = Some(ConfirmAction::DiscardUnsavedChangesAndClose); + cx.notify(); + } else { + self.close(window, cx); + } + } +} + +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 { + fn render( + &mut self, + _window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + if !self.state.open { + return gpui::div().into_any_element(); + } + + let on_close_backdrop = cx.listener(|modal, _event: &gpui::MouseDownEvent, window, cx| { + modal.request_close(Some(window), cx); + }); + let on_close_click = cx.listener(|modal, _event: &gpui::MouseDownEvent, window, cx| { + modal.request_close(Some(window), cx); + }); + + render::render_task_detail_modal( + &self.state, + &self.entities.annotation_input, + &self.entities.description_input, + &self.entities.project_input, + &self.entities.due_input, + &self.entities.tags_input, + &self.entities.status_dropdown, + &self.entities.priority_dropdown, + &self.focus_handle, + &self.form_focus_handle, + &self.scroll_handle, + cx, + on_close_backdrop, + on_close_click, + ) + } +} diff --git a/src/view/task_detail_modal/render/mod.rs b/src/view/task_detail_modal/render/mod.rs new file mode 100644 index 0000000..e34c1a2 --- /dev/null +++ b/src/view/task_detail_modal/render/mod.rs @@ -0,0 +1,82 @@ +use gpui::prelude::*; + +use crate::components::button::Dropdown; +use crate::components::input::Input; +use crate::components::modal::ModalFrame; +use crate::theme::ActiveTheme; + +use super::TaskDetailModal; +use super::state::TaskModalState; + +mod panel; + +pub(super) fn render_task_detail_modal( + 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, + form_focus_handle: &gpui::FocusHandle, + scroll_handle: &gpui::ScrollHandle, + 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 theme = cx.theme().clone(); + let panel = if let Some(message) = state.error.as_ref() { + panel::render_task_detail_placeholder_panel( + "Task Details", + message.as_ref(), + &theme, + on_close_click, + ) + } else if state.loading || state.original.is_none() { + panel::render_task_detail_placeholder_panel( + "Task Details", + "Loading task...", + &theme, + on_close_click, + ) + } else if let Some(detail) = state.original.as_ref() { + panel::render_task_detail_panel( + detail, + state.mode, + state.edit_state, + &state.form, + &state.errors, + &state.annotations, + state.modal_focus, + state.tag_selected, + state.annotation_selected, + &state.pending_confirm, + annotation_input, + description_input, + project_input, + due_input, + tags_input, + status_dropdown, + priority_dropdown, + form_focus_handle, + scroll_handle, + &theme, + cx, + on_close_click, + ) + } else { + panel::render_task_detail_placeholder_panel( + "Task Details", + "Loading task...", + &theme, + on_close_click, + ) + }; + + ModalFrame::new("task-detail-modal", focus_handle.clone(), theme.backdrop) + .panel(panel) + .on_close(on_close_out) + .into_any_element() +} diff --git a/src/view/task_detail_modal/render/panel.rs b/src/view/task_detail_modal/render/panel.rs new file mode 100644 index 0000000..7ce7478 --- /dev/null +++ b/src/view/task_detail_modal/render/panel.rs @@ -0,0 +1,1097 @@ +use gpui::prelude::*; +use std::collections::HashMap; +use std::sync::Arc; + +use crate::components::action_button::ActionButton; +use crate::components::button::Dropdown; +use crate::components::chip::{Chip, ChipVariant}; +use crate::components::confirm_dialog::ConfirmDialog; +use crate::components::field_row::{FieldRow, KvRow}; +use crate::components::input::Input; +use crate::components::label::Label; +use crate::components::section_card::SectionCard; +use crate::components::toast::{ToastGlobal, ToastKind}; +use crate::keymap::Command; +use crate::task::model::TaskLinkVm; +use crate::task::{self, TaskDetailVm}; +use crate::theme::Theme; +use crate::ui::{DATE_FORMAT, DATE_TIME_FORMAT}; + +use super::super::TaskDetailModal; +use super::super::annotations::{AnnotationOrigin, AnnotationState}; +use super::super::form::{FieldId, TaskForm}; +use super::super::state::{ConfirmAction, EditState, ModalFocus, ModalMode}; + +pub(super) fn render_task_detail_placeholder_panel( + title: &str, + message: &str, + theme: &Theme, + on_close_click: OnCloseClick, +) -> gpui::AnyElement +where + OnCloseClick: Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, +{ + let on_close_click = Arc::new(on_close_click); + let on_close_header = on_close_click.clone(); + let close_button = gpui::div() + .id("task-detail-close") + .px(gpui::rems(0.5)) + .py(gpui::rems(0.25)) + .rounded_md() + .text_color(theme.muted) + .cursor_pointer() + .hover(|s| s.bg(theme.hover).text_color(theme.foreground)) + .on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { + (on_close_header)(event, window, app); + }) + .child("X"); + + let header = gpui::div() + .flex() + .items_center() + .justify_between() + .px(gpui::rems(1.0)) + .py(gpui::rems(0.75)) + .border_b_1() + .border_color(theme.divider) + .child(Label::new(title.to_string()).text_color(theme.foreground)) + .child(close_button); + + let body = gpui::div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .items_center() + .justify_center() + .text_color(theme.muted) + .child(message.to_string()); + + let on_close_footer = on_close_click.clone(); + let footer_button = ActionButton::new("Close (Esc)") + .id("task-detail-cancel") + .on_click(move |event, window, app| { + (on_close_footer)(event, window, app); + }); + + gpui::div() + .id("task-detail-panel") + .flex() + .flex_col() + .w(gpui::rems(48.0)) + .h(gpui::rems(40.0)) + .bg(theme.panel) + .border_1() + .border_color(theme.border) + .rounded_md() + .block_mouse_except_scroll() + .child(header) + .child(body) + .child( + gpui::div() + .flex() + .items_center() + .justify_end() + .px(gpui::rems(1.0)) + .py(gpui::rems(0.5)) + .border_t_1() + .border_color(theme.divider) + .child(footer_button), + ) + .into_any_element() +} + +pub(super) fn render_task_detail_panel( + detail: &task::TaskDetailVm, + mode: ModalMode, + edit_state: EditState, + form: &TaskForm, + errors: &HashMap, + annotations: &AnnotationState, + modal_focus: ModalFocus, + tag_selected: Option, + annotation_selected: Option, + pending_confirm: &Option, + 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, + form_focus_handle: &gpui::FocusHandle, + 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 is_editing = mode == ModalMode::Edit; + let is_navigating = edit_state == EditState::Navigating; + 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 = if is_editing { + form.priority.into() + } else { + detail.overview.priority.into() + }; + + let priority_value = if is_editing { + form.priority + } else { + detail.overview.priority + }; + + let status_variant = match status_label.as_str() { + "Active" => ChipVariant::Success, + "Pending" => ChipVariant::Warning, + "Completed" => ChipVariant::Muted, + "Deleted" => ChipVariant::Danger, + "Recurring" => ChipVariant::Info, + _ => ChipVariant::Muted, + }; + + let priority_variant = match priority_value { + task::TaskPriority::High => ChipVariant::Danger, + task::TaskPriority::Medium => ChipVariant::Warning, + task::TaskPriority::Low => ChipVariant::Success, + task::TaskPriority::None => ChipVariant::Muted, + }; + + let mut badges: Vec = vec![ + Chip::new(status_label.clone()) + .variant(status_variant) + .into_any_element(), + ]; + + if priority_label != "None" { + badges.push( + Chip::new(priority_label.clone()) + .variant(priority_variant) + .into_any_element(), + ); + } + + 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::new(label.to_string()) + .variant(ChipVariant::Accent) + .into_any_element(), + ); + } + } + } else if is_editing && !form.project.trim().is_empty() { + badges.push( + Chip::new(form.project.trim().to_string()) + .variant(ChipVariant::Accent) + .into_any_element(), + ); + } + + if is_editing { + badges.push( + Chip::new("Editing") + .variant(ChipVariant::Info) + .into_any_element(), + ); + } + + let id_label = detail + .identity + .working_id + .or(detail.identity.id) + .map(|id| format!("#{}", id)) + .unwrap_or_else(|| format!("#{}", detail.identity.uuid)); + + 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 header = render_header(title, badges, is_editing, theme, cx, on_close_click.clone()); + + let overview_section = render_overview_section( + detail, + is_editing, + form, + errors, + modal_focus, + &status_label, + &priority_label, + description_input, + project_input, + due_input, + status_dropdown, + priority_dropdown, + theme, + cx, + ); + + let tags_section = render_tags_section( + detail, + is_editing, + edit_state, + form, + modal_focus, + tag_selected, + tags_input, + theme, + cx, + ); + + let deps_section = render_dependencies_section(detail, theme.foreground); + let annotations_section = render_annotations_section( + annotations, + is_editing, + edit_state, + modal_focus, + annotation_selected, + annotation_input, + theme, + theme.foreground, + cx, + ); + let dates_section = render_dates_section(detail, theme.foreground); + let meta_section = render_metadata_section(detail, theme.foreground); + let extras_section = render_extras_section(detail, theme.foreground); + + let mut sections = vec![ + overview_section, + tags_section, + deps_section, + annotations_section, + ]; + sections.push(dates_section); + sections.push(meta_section); + if let Some(extras_section) = extras_section { + sections.push(extras_section); + } + + let body = gpui::div() + .id("task-detail-body") + .flex() + .flex_col() + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(scroll_handle) + .track_focus(form_focus_handle) + .px(gpui::rems(1.0)) + .py(gpui::rems(0.75)) + .gap_4() + .children(sections); + + let footer = render_footer( + detail, + is_editing, + is_navigating, + errors, + form, + annotations, + modal_focus, + pending_confirm, + theme, + cx, + on_close_click, + ); + + // Panel uses deferred() + anchored() for dropdowns, so clipping is not an issue + gpui::div() + .id("task-detail-panel") + .relative() + .flex() + .flex_col() + .w(gpui::rems(48.0)) + .h(gpui::rems(40.0)) + .bg(theme.panel) + .border_1() + .border_color(theme.border) + .rounded_md() + .block_mouse_except_scroll() + .child(header) + .child(body) + .child(footer) + .when(pending_confirm.is_some(), |div| { + let (title, button_label) = match pending_confirm { + Some(ConfirmAction::DeleteTag { text, .. }) => { + (format!("Delete tag '{}'?", text), "Delete (Enter)") + } + Some(ConfirmAction::DeleteAnnotation { text, .. }) => { + (format!("Delete annotation '{}'?", text), "Delete (Enter)") + } + Some(ConfirmAction::DiscardUnsavedChanges) + | Some(ConfirmAction::DiscardUnsavedChangesAndClose) => { + ("Discard unsaved changes?".to_string(), "Discard (Enter)") + } + None => (String::new(), ""), + }; + + let cancel_handler = Arc::new(cx.listener(|modal, _event, _window, cx| { + modal.dispatch_command(Command::ConfirmNo, None, cx); + })); + + let confirm_handler = Arc::new(cx.listener(|modal, _event, _window, cx| { + modal.dispatch_command(Command::ConfirmYes, None, cx); + })); + + let backdrop_handler = cancel_handler.clone(); + + div.child( + ConfirmDialog::new(title) + .hint("Enter/y = confirm • Esc/n = cancel") + .cancel("Cancel (Esc)", cancel_handler) + .danger(button_label, confirm_handler) + .on_backdrop_click(backdrop_handler) + .render(theme), + ) + }) + .into_any_element() +} + +fn render_header( + title: String, + badges: Vec, + is_editing: bool, + theme: &Theme, + cx: &mut gpui::Context, + on_close_click: Arc, +) -> gpui::Div { + let on_close_header = on_close_click.clone(); + let close_button = gpui::div() + .id("task-detail-close") + .px(gpui::rems(0.5)) + .py(gpui::rems(0.25)) + .rounded_md() + .text_color(theme.muted) + .cursor_pointer() + .hover(|s| s.bg(theme.hover).text_color(theme.foreground)) + .on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { + (on_close_header)(event, window, app); + }) + .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(Some(window), 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); + + gpui::div() + .flex() + .items_start() + .justify_between() + .gap_4() + .px(gpui::rems(1.0)) + .py(gpui::rems(0.75)) + .border_b_1() + .border_color(theme.divider) + .child( + gpui::div() + .flex() + .flex_col() + .gap_2() + .child( + Label::new(title) + .text_color(theme.foreground) + .font_weight(gpui::FontWeight::BOLD), + ) + .child(gpui::div().flex().gap_2().children(badges)), + ) + .child(header_actions) +} + +#[allow(clippy::too_many_arguments)] +fn render_overview_section( + detail: &TaskDetailVm, + is_editing: bool, + form: &TaskForm, + errors: &HashMap, + modal_focus: ModalFocus, + status_label: &str, + priority_label: &str, + description_input: &gpui::Entity, + project_input: &gpui::Entity, + due_input: &gpui::Entity, + status_dropdown: &gpui::Entity, + priority_dropdown: &gpui::Entity, + theme: &Theme, + cx: &mut gpui::Context, +) -> SectionCard { + let due_text = detail + .dates + .due + .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); + 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.to_string(), theme.foreground) + }; + + 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_and_edit(ModalFocus::Description, Some(window), cx); + }), + ) + .into_any_element() + } else { + value_label(detail.overview.description.clone(), theme.foreground) + }; + + 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_and_edit(ModalFocus::Project, Some(window), cx); + }), + ) + .into_any_element() + } else { + value_label( + detail + .overview + .project + .clone() + .unwrap_or_else(|| "-".to_string()), + theme.foreground, + ) + }; + + 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); + 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.to_string(), theme.foreground) + }; + + 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_and_edit(ModalFocus::Due, Some(window), cx); + }), + ) + .into_any_element() + } else { + value_label(due_text, theme.foreground) + }; + + let overview_grid = gpui::div() + .flex() + .flex_col() + .gap_2() + .child(FieldRow::new("Status", status_value)) + .child( + FieldRow::new("Description", description_value) + .error(errors.get(&FieldId::Description).cloned()), + ) + .child(FieldRow::new("Project", project_value)) + .child(FieldRow::new("Priority", priority_value)) + .child(FieldRow::new("Due", due_value).error(errors.get(&FieldId::Due).cloned())); + + let mut overview_section = SectionCard::new("Overview").child(overview_grid); + + if !detail.dependencies.blocked_by.is_empty() || !detail.dependencies.blocking.is_empty() { + let mut info = Vec::new(); + + if !detail.dependencies.blocked_by.is_empty() { + info.push(format!( + "Blocked by {} task(s)", + detail.dependencies.blocked_by.len() + )); + } + + if !detail.dependencies.blocking.is_empty() { + info.push(format!( + "Blocking {} task(s)", + detail.dependencies.blocking.len() + )); + } + + overview_section = overview_section.child( + gpui::div() + .text_sm() + .text_color(Theme::alpha(theme.foreground, 0.72)) + .child(info.join(" / ")), + ); + } + + overview_section +} + +fn render_tags_section( + detail: &TaskDetailVm, + is_editing: bool, + edit_state: EditState, + form: &TaskForm, + modal_focus: ModalFocus, + tag_selected: Option, + tags_input: &gpui::Entity, + theme: &Theme, + cx: &mut gpui::Context, +) -> SectionCard { + let tags_content = if is_editing { + let chips = form.tags.iter().enumerate().map(|(i, tag)| { + let selected = modal_focus == ModalFocus::TagsInput && tag_selected == Some(i); + let tag_value = tag.clone(); + let on_remove = Arc::new(cx.listener(move |modal, _event, _window, cx| { + modal.remove_tag(tag_value.clone(), cx); + })); + + Chip::new(tag) + .variant(ChipVariant::Info) + .selected(selected) + .removable(on_remove) + .into_any_element() + }); + + let focused = modal_focus == ModalFocus::TagsInput + && (tag_selected.is_none() || edit_state == EditState::Editing); + + 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_and_edit(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(), theme.foreground) + } else { + let chips = detail + .tags + .tags + .iter() + .map(|tag| Chip::new(tag).variant(ChipVariant::Info).into_any_element()); + + gpui::div() + .flex() + .gap_2() + .children(chips) + .into_any_element() + }; + + SectionCard::new("Tags").child( + gpui::div() + .flex() + .flex_col() + .gap_2() + .child(tags_content) + .when(!detail.tags.virtual_tags.is_empty(), |div| { + let vchips = detail.tags.virtual_tags.iter().map(|tag| { + Chip::new(tag) + .custom(Theme::alpha(theme.muted, 0.2), theme.muted) + .into_any_element() + }); + div.child(gpui::div().flex().gap_2().children(vchips).text_sm()) + }), + ) +} + +fn render_dates_section(detail: &TaskDetailVm, value_color: gpui::Rgba) -> SectionCard { + let format_dt = |value: Option>| { + value + .map(|d| d.format(DATE_TIME_FORMAT).to_string()) + .unwrap_or_else(|| "-".to_string()) + }; + + let dates_grid = gpui::div() + .flex() + .flex_col() + .gap_2() + .child(KvRow::new( + "Entry", + value_label(format_dt(detail.dates.entry), value_color), + )) + .child(KvRow::new( + "Modified", + value_label(format_dt(detail.dates.modified), value_color), + )) + .child(KvRow::new( + "Start", + value_label(format_dt(detail.dates.start), value_color), + )) + .child(KvRow::new( + "End", + value_label(format_dt(detail.dates.end), value_color), + )) + .child(KvRow::new( + "Scheduled", + value_label(format_dt(detail.dates.scheduled), value_color), + )) + .child(KvRow::new( + "Wait", + value_label(format_dt(detail.dates.wait), value_color), + )) + .child(KvRow::new( + "Until", + value_label(format_dt(detail.dates.until), value_color), + )); + + SectionCard::new("Dates").child(dates_grid) +} + +fn render_metadata_section(detail: &TaskDetailVm, value_color: gpui::Rgba) -> SectionCard { + let uuid_value = detail.identity.uuid.to_string(); + let id_value = detail + .identity + .working_id + .or(detail.identity.id) + .map(|id| id.to_string()) + .unwrap_or_else(|| "-".to_string()); + + let mut meta_grid = gpui::div() + .flex() + .flex_col() + .gap_2() + .child(KvRow::new("UUID", value_label(uuid_value, value_color))) + .child(KvRow::new("ID", value_label(id_value, value_color))); + + if let Some(urgency) = detail.metrics.urgency { + meta_grid = meta_grid.child(KvRow::new( + "Urgency", + value_label(format!("{:.2}", urgency), value_color), + )); + } + + SectionCard::new("Metadata").child(meta_grid) +} + +fn render_dependencies_section(detail: &TaskDetailVm, value_color: gpui::Rgba) -> SectionCard { + let format_link = |link: &TaskLinkVm| { + let id = link + .id + .map(|id| format!("#{}", id)) + .unwrap_or_else(|| link.uuid.to_string()); + let status: String = link.status.clone().into(); + format!("{} {} ({})", id, link.description, status) + }; + + let render_links = |links: &[TaskLinkVm]| { + if links.is_empty() { + value_label("-".to_string(), value_color) + } else { + let items = links.iter().map(|link| { + Label::new(format_link(link)) + .text_sm() + .text_color(value_color) + .into_any_element() + }); + + gpui::div() + .flex() + .flex_col() + .gap_1() + .min_w_0() + .children(items) + .into_any_element() + } + }; + + let deps_grid = gpui::div() + .flex() + .flex_col() + .gap_2() + .child(KvRow::new( + "Depends On", + render_links(&detail.dependencies.depends_on), + )) + .child(KvRow::new( + "Blocked By", + render_links(&detail.dependencies.blocked_by), + )) + .child(KvRow::new( + "Blocking", + render_links(&detail.dependencies.blocking), + )); + + SectionCard::new("Dependencies").child(deps_grid) +} + +#[allow(clippy::too_many_arguments)] +fn render_annotations_section( + annotations: &AnnotationState, + is_editing: bool, + edit_state: EditState, + modal_focus: ModalFocus, + annotation_selected: Option, + annotation_input: &gpui::Entity, + theme: &Theme, + value_color: gpui::Rgba, + cx: &mut gpui::Context, +) -> SectionCard { + let visible_count = annotations + .items + .iter() + .filter(|item| item.origin != AnnotationOrigin::Deleted) + .count(); + + let mut annotations_content = gpui::div().flex().flex_col().gap_3(); + if is_editing { + let can_add = !annotations.draft.as_ref().trim().is_empty(); + let add_button = ActionButton::new("Add") + .enabled(can_add) + .on_click(cx.listener(|modal, _event, _window, cx| { + modal.submit_annotation(cx); + })); + + let focused = modal_focus == ModalFocus::AnnotationsInput + && (annotation_selected.is_none() || edit_state == EditState::Editing); + + 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_and_edit(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), + ); + } + + if visible_count == 0 { + annotations_content = annotations_content.child( + gpui::div() + .text_sm() + .text_color(theme.muted) + .child("No annotations"), + ); + } else { + let mut visible_index = 0; + + let items = annotations.items.iter().filter_map(|annotation| { + if annotation.origin == AnnotationOrigin::Deleted { + return None; + } + + let index = visible_index; + + visible_index += 1; + + let selected = + modal_focus == ModalFocus::AnnotationsInput && annotation_selected == Some(index); + + 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) + .cursor_pointer() + .hover(|s| s.text_color(theme.accent)) + .on_mouse_down(gpui::MouseButton::Left, move |_event, _window, app| { + app.write_to_clipboard(gpui::ClipboardItem::new_string( + content_for_copy.clone(), + )); + + let toast_host = app.global::().host.clone(); + + app.update_entity(&toast_host, |host, cx| { + host.push(ToastKind::Info, "Annotation copied", cx); + }); + }) + .child(Label::new("Copy")); + + 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() + .text_color(value_color) + .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() + .gap_1() + .min_w_0() + .p(gpui::rems(0.5)) + .rounded(gpui::rems(0.25)); + + if selected { + item = item + .bg(Theme::alpha(theme.accent, 0.1)) + .border_2() + .border_color(theme.focus_ring); + } else { + item = item.bg(theme.raised).border_1().border_color(theme.divider); + } + + item = item + .child( + gpui::div() + .flex() + .items_center() + .justify_between() + .child(Label::new(timestamp).text_xs().text_color(theme.muted)) + .child(actions), + ) + .child(gpui::div().flex().flex_col().gap_1().children(lines)); + + if index + 1 < visible_count { + item = item.child(gpui::div().mt_2().h(gpui::px(1.0)).bg(theme.divider)); + } + + Some(item.into_any_element()) + }); + + annotations_content = annotations_content.children(items); + } + + SectionCard::new("Annotations").child(annotations_content) +} + +fn render_extras_section(detail: &TaskDetailVm, value_color: gpui::Rgba) -> Option { + if detail.udas.is_empty() { + return None; + } + + let rows = detail.udas.iter().map(|(key, value)| { + KvRow::new(key, value_label(value.clone(), value_color)).into_any_element() + }); + + Some(SectionCard::new("Extras").child(gpui::div().flex().flex_col().gap_2().children(rows))) +} + +fn render_footer( + detail: &TaskDetailVm, + is_editing: bool, + is_navigating: bool, + errors: &HashMap, + form: &TaskForm, + annotations: &AnnotationState, + modal_focus: ModalFocus, + pending_confirm: &Option, + theme: &Theme, + cx: &mut gpui::Context, + on_close_click: Arc, +) -> gpui::Div { + 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(Some(window), 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 = + ActionButton::new("Cancel (Esc)").on_click(move |event, window, app| { + (cancel_edit_handler)(event, window, app); + }); + + let save_button = ActionButton::new("Save (Ctrl+S)") + .enabled(can_save) + .on_click(move |event, window, app| { + (save_handler)(event, window, app); + }); + + action_row = action_row.child(cancel_button).child(save_button); + } else { + let close_button = ActionButton::new("Close (Esc)").on_click(move |event, window, app| { + (on_close_click)(event, window, app); + }); + + action_row = action_row.child(close_button); + } + + let mode_indicator = if is_editing && is_navigating && pending_confirm.is_none() { + Some(gpui::div().text_color(theme.muted).text_xs().child(format!( + "Navigate: j/k | Edit: Enter | Focus: {}", + match modal_focus { + ModalFocus::Description => "Description", + ModalFocus::Project => "Project", + ModalFocus::StatusDropdown => "Status", + ModalFocus::PriorityDropdown => "Priority", + ModalFocus::Due => "Due", + ModalFocus::TagsInput => "Tags", + ModalFocus::AnnotationsInput => "Annotations", + ModalFocus::None => "None", + } + ))) + } else { + None + }; + + gpui::div() + .flex() + .items_center() + .justify_between() + .px(gpui::rems(1.0)) + .py(gpui::rems(0.5)) + .border_t_1() + .border_color(theme.divider) + .child(gpui::div().children(mode_indicator)) + .child(action_row) +} + +fn value_label(value: String, value_color: gpui::Rgba) -> gpui::AnyElement { + Label::new(value).text_color(value_color).into_any_element() +} diff --git a/src/view/task_detail_modal/state.rs b/src/view/task_detail_modal/state.rs new file mode 100644 index 0000000..b9c50f3 --- /dev/null +++ b/src/view/task_detail_modal/state.rs @@ -0,0 +1,111 @@ +use std::collections::HashMap; + +use gpui::SharedString; +use uuid::Uuid; + +use crate::task::TaskDetailVm; + +use super::annotations::AnnotationState; +use super::form::{FieldId, TaskForm}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ModalMode { + View, + Edit, +} + +impl Default for ModalMode { + fn default() -> Self { + Self::View + } +} + +/// State within Edit mode - determines how keyboard input is handled +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum EditState { + /// Navigating between fields with j/k, not typing + #[default] + Navigating, + /// Actively typing in an input field + Editing, + /// A dropdown menu is open + DropdownOpen, +} + +#[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::StatusDropdown + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum InlineEditTarget { + Tag(usize), + Annotation(usize), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ConfirmAction { + DeleteTag { index: usize, text: String }, + DeleteAnnotation { index: usize, text: String }, + DiscardUnsavedChanges, + DiscardUnsavedChangesAndClose, +} + +#[derive(Debug, Clone)] +pub(super) struct TaskModalState { + pub(super) open: bool, + pub(super) task_id: Option, + pub(super) loading: bool, + pub(super) original: Option, + pub(super) form: TaskForm, + pub(super) errors: HashMap, + pub(super) mode: ModalMode, + pub(super) edit_state: EditState, + pub(super) annotations: AnnotationState, + pub(super) error: Option, + pub(super) modal_focus: ModalFocus, + + /// Which tag is selected for h/l navigation (None = input focused) + pub(super) tag_selected: Option, + /// Which annotation is selected for h/l navigation (None = input focused) + pub(super) annotation_selected: Option, + /// Currently editing an existing item (not creating new) + pub(super) inline_edit: Option, + /// Pending confirmation action + pub(super) pending_confirm: Option, +} + +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(), + edit_state: EditState::default(), + annotations: AnnotationState::default(), + error: None, + modal_focus: ModalFocus::default(), + tag_selected: None, + annotation_selected: None, + inline_edit: None, + pending_confirm: None, + } + } +}