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::date_picker::DatePicker; 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, is_create: bool, 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_picker: &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, 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_creating = is_create; 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 && !is_creating { 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 = if is_creating { "New task".to_string() } else { format!("{} {}", id_label, title_description) }; let on_close_click = Arc::new(on_close_click); let header = render_header(title, badges, is_editing, theme, cx, on_close_click.clone()); let overview_section = render_overview_section( detail, is_editing, form, errors, modal_focus, &status_label, &priority_label, description_input, project_input, due_picker, 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(if mode == ModalMode::View { focus_handle } else { 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_creating, 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_picker: &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; let theme_clone = theme.clone(); let picker_clone = due_picker.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::Due, Some(window), cx); let picker = picker_clone.clone(); cx.defer(move |cx| { picker.update(cx, |d, cx| { if !d.is_open() { d.open(cx); } }); }); }), ) .child(due_picker.clone()) .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() .flex_wrap() .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() .flex_wrap() .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() .flex_wrap() .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_creating: 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_creating { let cancel_create_handler = Arc::new(cx.listener(|modal, _event, window, cx| { modal.close(Some(window), cx); })); let create_handler = Arc::new(cx.listener(|modal, _event, _window, cx| { modal.submit_create(cx); })); let cancel_button = ActionButton::new("Cancel (Esc)").on_click(move |event, window, app| { (cancel_create_handler)(event, window, app); }); let create_button = ActionButton::new("Create (Ctrl+S)").on_click(move |event, window, app| { (create_handler)(event, window, app); }); action_row = action_row.child(cancel_button).child(create_button); } else if is_editing { let cancel_edit_handler = Arc::new(cx.listener(|modal, _event, window, cx| { modal.cancel_edit(Some(window), cx); })); 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() }