diff --git a/src/app.rs b/src/app.rs index 8fe5acb..bdde28e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,25 +2,37 @@ use std::collections::HashMap; use gpui::prelude::*; -use crate::keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord, KeymapStack}; -use crate::models::{FilterState, ProjectTree}; -use crate::task::{self, TaskOverview, TaskService}; -use crate::theme::ActiveTheme; -use crate::ui::{ROOT_PADDING, SECTION_GAP, SIDEBAR_WIDTH}; -use crate::view::sidebar::{Sidebar, TagItem}; -use crate::view::status_bar::{StatusBar, StatusBarEvent, SyncState}; -use crate::view::task_table::TaskTable; -use gpui::div; +use crate::{ + components::modal::ModalState, + keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord, KeymapStack}, + models::{FilterState, ProjectTree}, + task::{self, TaskDetailState, TaskOverview, TaskService, TaskSummary}, + theme::ActiveTheme, + view::{ + app_layout, + sidebar::{Sidebar, SidebarEvent, SidebarSection, TagItem}, + status_bar::{StatusBar, StatusBarEvent, SyncState}, + task_detail_modal, + task_table::{TaskTable, TaskTableEvent}, + }, +}; -pub(crate) struct App { - focus_handle: gpui::FocusHandle, - focus_target: FocusTarget, - keymap: KeymapStack, - sidebar: gpui::Entity, - filter_state: gpui::Entity, - status_bar: gpui::Entity, - task_table: gpui::Entity, - task_service: TaskService, +pub(super) struct App { + pub(super) focus_handle: gpui::FocusHandle, + pub(super) focus_target: FocusTarget, + pub(super) keymap: KeymapStack, + pub(super) sidebar: gpui::Entity, + pub(super) filter_state: gpui::Entity, + pub(super) status_bar: gpui::Entity, + pub(super) task_table: gpui::Entity, + pub(super) task_service: TaskService, + pub(super) tasks: Vec, + pub(super) selected_task_id: Option, + pub(super) task_detail_state: TaskDetailState, + pub(super) modal_state: ModalState, + pub(super) modal_focus_handle: gpui::FocusHandle, + pub(super) focus_before_modal: FocusTarget, + pub(super) modal_scroll_handle: gpui::ScrollHandle, } impl gpui::Render for App { @@ -31,269 +43,63 @@ impl gpui::Render for App { ) -> impl gpui::IntoElement { let theme = cx.theme(); - let sidebar_focused = self.focus_target.is_sidebar(); + let on_root_key_down = cx.listener(|app, event: &gpui::KeyDownEvent, window, cx| { + app.handle_key_down(event, window, cx); + }); + let on_sidebar_mouse_down = + cx.listener(|app, _event: &gpui::MouseDownEvent, _window, cx| { + if !app.focus_target.is_sidebar() { + app.focus_target = FocusTarget::SidebarProjects; + app.sidebar.update(cx, |sidebar, cx| { + sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx); + }); + cx.notify(); + } + }); + let on_table_mouse_down = cx.listener(|app, _event: &gpui::MouseDownEvent, _window, cx| { + if !matches!(app.focus_target, FocusTarget::Table) { + app.focus_target = FocusTarget::Table; + cx.notify(); + } + }); - let sidebar_border_color = if sidebar_focused { - theme.focus_ring + let modal = if self.modal_state.open { + let on_close_backdrop = + cx.listener(|app, _event: &gpui::MouseDownEvent, window, cx| { + app.close_task_detail(Some(window), cx); + }); + let on_close_click = cx.listener(|app, _event: &gpui::MouseDownEvent, window, cx| { + app.close_task_detail(Some(window), cx); + }); + Some(task_detail_modal::render_task_detail_modal( + &self.task_detail_state, + &self.modal_focus_handle, + &self.modal_scroll_handle, + theme, + on_close_backdrop, + on_close_click, + )) } else { - theme.divider + None }; - let sidebar = div() - .bg(theme.card) - .border_2() - .border_color(sidebar_border_color) - .rounded(crate::ui::CARD_RADIUS) - .p(crate::ui::CARD_PADDING) - .w(SIDEBAR_WIDTH) - .h_full() - .flex_shrink_0() - .overflow_hidden() - .on_mouse_down( - gpui::MouseButton::Left, - cx.listener(|app, _event, _window, cx| { - if !app.focus_target.is_sidebar() { - app.focus_target = FocusTarget::SidebarProjects; - app.sidebar.update(cx, |sidebar, cx| { - sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx); - }); - cx.notify(); - } - }), - ) - .child(self.sidebar.clone()); - - let table_focused = matches!( + app_layout::render_app_layout( + theme, + &self.focus_handle, self.focus_target, - FocusTarget::Table | FocusTarget::TableHeaders - ); - - let table_border_color = if table_focused { - theme.focus_ring - } else { - theme.divider - }; - - let main = div() - .bg(theme.card) - .border_2() - .border_color(table_border_color) - .rounded(crate::ui::CARD_RADIUS) - .p(crate::ui::CARD_PADDING) - .flex_1() - .h_full() - .min_w_0() - .overflow_hidden() - .p_0() - .on_mouse_down( - gpui::MouseButton::Left, - cx.listener(|app, _event, _window, cx| { - if !matches!(app.focus_target, FocusTarget::Table) { - app.focus_target = FocusTarget::Table; - cx.notify(); - } - }), - ) - .child(self.task_table.clone()); - - let content = div() - .flex() - .flex_1() - .min_h_0() - .gap(SECTION_GAP) - .child(sidebar) - .child(main); - - div() - .flex() - .flex_col() - .size_full() - .bg(theme.background) - .p(ROOT_PADDING) - .gap(SECTION_GAP) - .track_focus(&self.focus_handle) - .on_key_down(cx.listener(|app, event, window, cx| { - app.handle_key_down(event, window, cx); - })) - .child(content) - .child(self.status_bar.clone()) - } -} - -impl CommandDispatcher for App { - fn dispatch(&mut self, command: Command, cx: &mut gpui::Context) -> bool { - match command { - Command::Sync => { - self.handle_sync(cx); - true - } - Command::FocusSearch => false, - Command::FocusTable => { - self.focus_target = match self.focus_target { - FocusTarget::Table => { - self.sidebar.update(cx, |sidebar, cx| { - sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx); - }); - FocusTarget::SidebarProjects - } - FocusTarget::SidebarProjects => { - self.sidebar.update(cx, |sidebar, cx| { - sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx); - }); - FocusTarget::SidebarTags - } - _ => FocusTarget::Table, - }; - cx.notify(); - true - } - Command::FocusSidebar => { - self.focus_target = match self.focus_target { - FocusTarget::Table => { - self.sidebar.update(cx, |sidebar, cx| { - sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx); - }); - FocusTarget::SidebarTags - } - FocusTarget::SidebarTags => { - self.sidebar.update(cx, |sidebar, cx| { - sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx); - }); - FocusTarget::SidebarProjects - } - _ => FocusTarget::Table, - }; - cx.notify(); - true - } - Command::FocusSidebarProjects => { - self.focus_target = FocusTarget::SidebarProjects; - self.sidebar.update(cx, |sidebar, cx| { - sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx); - }); - cx.notify(); - true - } - Command::FocusSidebarTags => { - self.focus_target = FocusTarget::SidebarTags; - self.sidebar.update(cx, |sidebar, cx| { - sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx); - }); - cx.notify(); - true - } - Command::SelectNextRow - | Command::SelectPrevRow - | Command::SelectFirstRow - | Command::SelectLastRow => { - match self.focus_target { - FocusTarget::SidebarProjects | FocusTarget::SidebarTags => { - self.sidebar - .update(cx, |sidebar, cx| sidebar.dispatch(command, cx)); - } - _ => { - self.task_table - .update(cx, |table, cx| table.dispatch(command, cx)); - } - } - true - } - Command::OpenSelectedTask => { - match self.focus_target { - FocusTarget::SidebarProjects | FocusTarget::SidebarTags => { - self.sidebar - .update(cx, |sidebar, cx| sidebar.dispatch(command, cx)); - } - _ => { - self.task_table - .update(cx, |table, cx| table.dispatch(command, cx)); - } - } - true - } - Command::ExpandProject | Command::CollapseProject => { - match self.focus_target { - FocusTarget::SidebarProjects => { - self.sidebar - .update(cx, |sidebar, cx| sidebar.dispatch(command, cx)); - } - _ => { - self.task_table - .update(cx, |table, cx| table.dispatch(command, cx)); - } - } - true - } - Command::NextPage | Command::PrevPage | Command::ClearSelection => { - self.task_table - .update(cx, |table, cx| table.dispatch(command, cx)); - true - } - Command::ToggleDropdown - | Command::SelectNextOption - | Command::SelectPrevOption - | Command::BlurInput => { - self.task_table - .update(cx, |table, cx| table.dispatch(command, cx)); - true - } - Command::ClearAllFilters => { - self.filter_state.update(cx, |state, cx| { - state.clear(); - cx.notify(); - }); - true - } - Command::ClearProjectFilter => { - self.filter_state.update(cx, |state, cx| { - state.clear_project(); - cx.notify(); - }); - true - } - Command::ClearTagFilter => { - self.filter_state.update(cx, |state, cx| { - state.clear_tags(); - cx.notify(); - }); - true - } - Command::ClearSearchAndDropdowns => { - self.filter_state.update(cx, |state, cx| { - state.clear_search_and_dropdowns(); - cx.notify(); - }); - self.task_table.update(cx, |table, cx| { - table.clear_search_input(cx); - table.reset_dropdowns(cx); - }); - true - } - Command::HeaderMoveNext => { - self.task_table.update(cx, |table, cx| { - table.header_move_next(cx); - }); - true - } - Command::HeaderMovePrev => { - self.task_table.update(cx, |table, cx| { - table.header_move_prev(cx); - }); - true - } - Command::HeaderCycleSortOrder => { - self.task_table.update(cx, |table, cx| { - table.header_cycle_sort_order(cx); - }); - true - } - _ => false, - } + self.sidebar.clone(), + self.task_table.clone(), + self.status_bar.clone(), + on_root_key_down, + on_sidebar_mouse_down, + on_table_mouse_down, + modal, + ) } } impl App { - fn build_sidebar_data(tasks: &[task::Task]) -> (Vec<(String, usize)>, Vec) { + fn build_sidebar_data(tasks: &[task::TaskSummary]) -> (Vec<(String, usize)>, Vec) { let mut project_counts: HashMap = HashMap::new(); let mut tag_counts: HashMap = HashMap::new(); @@ -325,8 +131,13 @@ impl App { (projects, tag_items) } - fn update_ui_from_tasks(&mut self, all_tasks: Vec, cx: &mut gpui::Context) { - let (projects, tags) = Self::build_sidebar_data(&all_tasks); + fn update_ui_from_tasks( + &mut self, + all_tasks: Vec, + cx: &mut gpui::Context, + ) { + self.tasks = all_tasks; + let (projects, tags) = Self::build_sidebar_data(&self.tasks); let mut project_tree = ProjectTree::new(); project_tree.build_from_projects(&projects); @@ -336,9 +147,9 @@ impl App { sidebar.update_tags(tags, cx); }); - self.task_table.update(cx, |table, cx| { - table.reload_tasks_from_all(all_tasks, cx); - }); + let tasks = self.tasks.clone(); + self.task_table + .update(cx, |table, cx| table.reload_tasks_from_all(tasks, cx)); } fn reload_tasks(&mut self, cx: &mut gpui::Context) { @@ -347,7 +158,8 @@ impl App { self.status_bar.update(cx, |bar, cx| { bar.clear_error(cx); }); - self.update_ui_from_tasks(all_tasks, cx); + let summaries: Vec = all_tasks.iter().map(TaskSummary::from).collect(); + self.update_ui_from_tasks(summaries, cx); } Err(e) => { log::error!("[App] Failed to load tasks: {}", e); @@ -359,7 +171,7 @@ impl App { } } - fn handle_sync(&mut self, cx: &mut gpui::Context) { + pub(super) fn handle_sync(&mut self, cx: &mut gpui::Context) { self.status_bar.update(cx, |bar, cx| { bar.set_sync_state(SyncState::Syncing, cx); bar.set_last_sync_message("Syncing...".to_string(), cx); @@ -367,7 +179,8 @@ impl App { match self.task_service.get_all_tasks() { Ok(all_tasks) => { - self.update_ui_from_tasks(all_tasks, cx); + let summaries: Vec = all_tasks.iter().map(TaskSummary::from).collect(); + self.update_ui_from_tasks(summaries, cx); self.status_bar.update(cx, |bar, cx| { bar.set_sync_state(SyncState::Success, cx); @@ -394,6 +207,17 @@ impl App { let context = self.active_context(cx); if let Some(command) = self.keymap.resolve(context, &chord) { + if self.modal_state.open { + match command { + Command::CloseModal + | Command::SaveModal + | Command::Sync + | Command::ModalScrollUp + | Command::ModalScrollDown => {} + _ => return, + } + } + match command { Command::FocusSearch => { let from_headers = matches!(self.focus_target, FocusTarget::TableHeaders); @@ -466,7 +290,102 @@ impl App { } } + pub(super) fn open_selected_task( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + if self.modal_state.open { + return; + } + + let task_id = self.task_table.read(cx).selected_task_uuid(); + let Some(task_id) = task_id else { + return; + }; + + self.open_task_detail(task_id, window, cx); + } + + fn open_task_detail( + &mut self, + task_id: uuid::Uuid, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + self.focus_before_modal = self.focus_target; + self.modal_state.open = true; + if let Some(window) = window { + window.focus(&self.modal_focus_handle); + } + self.modal_scroll_handle = gpui::ScrollHandle::new(); + self.modal_scroll_handle.scroll_to_item(0); + + if self.selected_task_id == Some(task_id) { + if matches!(self.task_detail_state, TaskDetailState::Ready(_)) { + cx.notify(); + return; + } + } + + self.selected_task_id = Some(task_id); + self.task_detail_state = TaskDetailState::Loading(task_id); + cx.notify(); + + let tasks = self.tasks.clone(); + match self.task_service.get_task_detail(task_id, &tasks) { + Ok(detail) => { + self.task_detail_state = TaskDetailState::Ready(detail); + } + Err(e) => { + self.task_detail_state = TaskDetailState::Error(task_id, e.to_string()); + } + } + + cx.notify(); + } + + pub(super) fn close_task_detail( + &mut self, + window: Option<&mut gpui::Window>, + cx: &mut gpui::Context, + ) { + if !self.modal_state.open { + return; + } + + self.modal_state.open = false; + self.selected_task_id = None; + self.focus_target = self.focus_before_modal; + + if let Some(window) = window { + window.focus(&self.focus_handle); + } + + cx.notify(); + } + + pub(super) fn scroll_task_detail(&self, delta: i32, cx: &mut gpui::Context) { + let handle = &self.modal_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 active_context(&self, cx: &gpui::Context) -> ContextId { + if self.modal_state.open { + return ContextId::Modal; + } if matches!(self.focus_target, FocusTarget::Table) { let filter_context = self.task_table.read(cx).get_active_filter_context(); if let Some(context) = filter_context { @@ -502,6 +421,9 @@ impl App { } }); + let task_summaries: Vec = + overview.tasks.iter().map(TaskSummary::from).collect(); + let mut project_tree = ProjectTree::new(); project_tree.build_from_projects(&overview.projects); @@ -518,9 +440,11 @@ impl App { let task_table = cx .new(|cx| TaskTable::new("main-task-table", filter_state.clone(), cx)); + let task_table_events = task_table.clone(); + let sidebar_events = sidebar.clone(); task_table.update(cx, |table, cx| { - table.reload_tasks(&mut task_service, cx); + table.reload_tasks_from_all(task_summaries.clone(), cx); }); let mut keymap = KeymapStack::new(); @@ -535,6 +459,13 @@ impl App { status_bar: status_bar.clone(), task_table, task_service, + tasks: task_summaries, + selected_task_id: None, + task_detail_state: TaskDetailState::default(), + modal_state: ModalState::default(), + modal_focus_handle: cx.focus_handle(), + focus_before_modal: FocusTarget::Table, + modal_scroll_handle: gpui::ScrollHandle::new(), }; window.focus(&app.focus_handle); @@ -550,6 +481,24 @@ impl App { } }) .detach(); + cx.subscribe(&sidebar_events, |app, _sidebar, event, cx| match event { + SidebarEvent::Focused(section) => { + app.focus_target = match section { + SidebarSection::Projects => FocusTarget::SidebarProjects, + SidebarSection::Tags => FocusTarget::SidebarTags, + }; + cx.notify(); + } + }) + .detach(); + cx.subscribe(&task_table_events, |app, _table, event, cx| match event { + TaskTableEvent::OpenTask(task_id) => { + if !app.modal_state.open { + app.open_task_detail(*task_id, None, cx); + } + } + }) + .detach(); app }) diff --git a/src/components/modal.rs b/src/components/modal.rs index f4942a1..ee063b2 100644 --- a/src/components/modal.rs +++ b/src/components/modal.rs @@ -2,376 +2,88 @@ use std::sync::Arc; use gpui::prelude::*; -use crate::components::button::{Button, ButtonVariants}; -use crate::theme::ActiveTheme; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ModalAction { - Close, - Cancel, - Save, -} - #[derive(Clone, Debug, Default)] pub struct ModalState { pub open: bool, - pub last_action: Option, } -pub struct Modal { +#[derive(gpui::IntoElement)] +pub struct ModalFrame { id: gpui::ElementId, focus: gpui::FocusHandle, - title: Option, - content: Vec gpui::AnyElement + Send + Sync>>, - footer: Vec gpui::AnyElement + Send + Sync>>, - width: Option, - open: bool, - close_on_backdrop: bool, - last_action: Option, - style: gpui::StyleRefinement, - on_close: Option) + Send + Sync>>, - on_save: Option) + Send + Sync>>, - on_cancel: Option) + Send + Sync>>, + panel: gpui::AnyElement, + backdrop: gpui::Rgba, + on_close: + Option>, } -impl Modal { - pub fn new(id: impl Into, cx: &mut gpui::Context) -> Self { +impl ModalFrame { + pub fn new( + id: impl Into, + focus: gpui::FocusHandle, + backdrop: gpui::Rgba, + ) -> Self { Self { id: id.into(), - focus: cx.focus_handle(), - title: None, - content: Vec::new(), - footer: Vec::new(), - width: Some(gpui::px(520.0)), - open: false, - close_on_backdrop: true, - last_action: None, - style: gpui::StyleRefinement::default(), + focus, + panel: gpui::div().into_any_element(), + backdrop, on_close: None, - on_save: None, - on_cancel: None, } } - pub fn title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } - - pub fn width(mut self, width: gpui::Pixels) -> Self { - self.width = Some(width); - self - } - - pub fn close_on_backdrop(mut self, close: bool) -> Self { - self.close_on_backdrop = close; - self - } - - pub fn child(mut self, child: E) -> Self - where - E: gpui::IntoElement + Clone + Send + Sync + 'static, - { - let element = child.clone(); - self.content - .push(Arc::new(move || element.clone().into_any_element())); - self - } - - pub fn children(mut self, children: impl IntoIterator) -> Self - where - E: gpui::IntoElement + Clone + Send + Sync + 'static, - { - self.content.extend( - children - .into_iter() - .map(|child| { - let element = child.clone(); - Arc::new(move || element.clone().into_any_element()) - as Arc gpui::AnyElement + Send + Sync> - }) - .collect::>(), - ); - self - } - - pub fn footer_child(mut self, child: E) -> Self - where - E: gpui::IntoElement + Clone + Send + Sync + 'static, - { - let element = child.clone(); - self.footer - .push(Arc::new(move || element.clone().into_any_element())); - self - } - - pub fn footer_children(mut self, children: impl IntoIterator) -> Self - where - E: gpui::IntoElement + Clone + Send + Sync + 'static, - { - self.footer.extend( - children - .into_iter() - .map(|child| { - let element = child.clone(); - Arc::new(move || element.clone().into_any_element()) - as Arc gpui::AnyElement + Send + Sync> - }) - .collect::>(), - ); + pub fn panel(mut self, panel: impl gpui::IntoElement) -> Self { + self.panel = panel.into_any_element(); self } pub fn on_close( mut self, - handler: Arc) + Send + Sync>, + handler: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, ) -> Self { - self.on_close = Some(handler); + self.on_close = Some(Arc::new(handler)); self } - - pub fn on_save(mut self, handler: Arc) + Send + Sync>) -> Self { - self.on_save = Some(handler); - self - } - - pub fn on_cancel( - mut self, - handler: Arc) + Send + Sync>, - ) -> Self { - self.on_cancel = Some(handler); - self - } - - pub fn set_open(&mut self, open: bool, cx: &mut gpui::Context) { - if self.open == open { - return; - } - self.open = open; - cx.notify(); - } - - pub fn open(&mut self, cx: &mut gpui::Context) { - self.set_open(true, cx); - } - - pub fn close(&mut self, cx: &mut gpui::Context) { - self.close_with_action(ModalAction::Close, cx); - } - - pub fn toggle(&mut self, cx: &mut gpui::Context) { - let next = !self.open; - self.set_open(next, cx); - } - - pub fn is_open(&self) -> bool { - self.open - } - - pub fn focus_handle(&self) -> &gpui::FocusHandle { - &self.focus - } - - pub fn state(&self) -> ModalState { - ModalState { - open: self.open, - last_action: self.last_action, - } - } - - pub fn last_action(&self) -> Option { - self.last_action - } - - pub fn take_last_action(&mut self) -> Option { - self.last_action.take() - } - - fn close_with_action(&mut self, action: ModalAction, cx: &mut gpui::Context) { - self.last_action = Some(action); - - if let Some(on_close) = self.on_close.clone() { - on_close(action, cx); - } - - self.open = false; - cx.notify(); - } - - fn handle_backdrop_mouse_down( - &mut self, - _event: &gpui::MouseDownEvent, - _window: &mut gpui::Window, - cx: &mut gpui::Context, - ) { - if self.close_on_backdrop { - self.close_with_action(ModalAction::Close, cx); - } - } - - fn handle_cancel(&mut self, cx: &mut gpui::Context) { - if let Some(on_cancel) = self.on_cancel.clone() { - on_cancel(cx); - } - self.close_with_action(ModalAction::Cancel, cx); - } - - fn handle_save(&mut self, cx: &mut gpui::Context) { - if let Some(on_save) = self.on_save.clone() { - on_save(cx); - } - self.close_with_action(ModalAction::Save, cx); - } } -impl gpui::Styled for Modal { - fn style(&mut self) -> &mut gpui::StyleRefinement { - &mut self.style - } -} +impl gpui::RenderOnce for ModalFrame { + fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl gpui::IntoElement { + let mut panel_wrap = gpui::div().child(self.panel); -impl gpui::Render for Modal { - fn render( - &mut self, - _window: &mut gpui::Window, - cx: &mut gpui::Context, - ) -> impl IntoElement { - if !self.open { - return gpui::Empty.into_any_element(); + if let Some(handler) = self.on_close { + panel_wrap = panel_wrap.on_mouse_down_out(move |event, window, app| { + (handler)(event, window, app); + }); } - let theme = cx.theme(); - - let title = self.title.clone().map(|title| { - gpui::div() - .px_3() - .py_2() - .text_color(theme.foreground) - .child(title) - .into_any_element() - }); - - let body: Vec = self - .content - .iter() - .enumerate() - .map(|(ix, build)| gpui::div().id(ix).child(build()).into_any_element()) - .collect(); - - let mut footer: Vec = self.footer.iter().map(|build| build()).collect(); - - if footer.is_empty() { - let mut actions = Vec::new(); - if self.on_cancel.is_some() { - actions.push( - Button::label((self.id.clone(), "cancel"), "Cancel") - .text() - .on_click(cx.listener(|this, _e, _w, cx| { - this.handle_cancel(cx); - })) - .into_any_element(), - ); - } - if self.on_save.is_some() { - actions.push( - Button::label((self.id.clone(), "save"), "Save") - .primary() - .on_click(cx.listener(|this, _e, _w, cx| { - this.handle_save(cx); - })) - .into_any_element(), - ); - } - - if !actions.is_empty() { - footer = actions; - } - } - - let footer = if footer.is_empty() { - gpui::div().into_any_element() - } else { - gpui::div() - .px_3() - .py_2() - .flex() - .flex_row() - .justify_end() - .gap_2() - .children(footer) - .into_any_element() - }; - - let mut panel = gpui::div() - .id(gpui::ElementId::Name(format!("{}-panel", self.id).into())) - .flex() - .flex_col() - .bg(theme.panel) - .border_1() - .border_color(theme.border) - .rounded_md() - .children(title) - .child( - gpui::div() - .px_3() - .py_2() - .flex() - .flex_col() - .gap_2() - .children(body), - ) - .child(footer) - .block_mouse_except_scroll() - .track_focus(&self.focus) - .on_key_down( - cx.listener(|this, event: &gpui::KeyDownEvent, _window, cx| { - if event.keystroke.key.as_str() == "escape" { - this.close_with_action(ModalAction::Cancel, cx); - } - }), - ); - - if let Some(width) = self.width { - panel = panel.w(width); - } - - let panel_wrap = if self.close_on_backdrop { - gpui::div() - .child(panel) - .on_mouse_down_out(cx.listener(|this, event, window, cx| { - this.handle_backdrop_mouse_down(event, window, cx); - })) - } else { - gpui::div().child(panel) - }; - - gpui::div() - .id(self.id.clone()) + let root = gpui::div() + .id(self.id) .size_full() .absolute() .top_0() .left_0() .occlude() - .child( - gpui::div() - .size_full() - .bg(gpui::rgba(0x00000080)) - .absolute() - .top_0() - .left_0(), - ) - .child( - gpui::div() - .size_full() - .absolute() - .top_0() - .left_0() - .flex() - .flex_col() - .items_center() - .justify_center() - .child(panel_wrap), - ) - .into_any_element() + .track_focus(&self.focus); + + root.child( + gpui::div() + .size_full() + .bg(self.backdrop) + .absolute() + .top_0() + .left_0(), + ) + .child( + gpui::div() + .size_full() + .absolute() + .top_0() + .left_0() + .flex() + .flex_col() + .items_center() + .justify_center() + .child(panel_wrap), + ) } } diff --git a/src/dispatcher.rs b/src/dispatcher.rs new file mode 100644 index 0000000..99e4742 --- /dev/null +++ b/src/dispatcher.rs @@ -0,0 +1,191 @@ +use crate::{ + app::App, + keymap::{Command, CommandDispatcher, FocusTarget}, +}; + +impl CommandDispatcher for App { + fn dispatch(&mut self, command: Command, cx: &mut gpui::Context) -> bool { + match command { + Command::Sync => { + self.handle_sync(cx); + true + } + Command::FocusSearch => false, + Command::FocusTable => { + self.focus_target = match self.focus_target { + FocusTarget::Table => { + self.sidebar.update(cx, |sidebar, cx| { + sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx); + }); + FocusTarget::SidebarProjects + } + FocusTarget::SidebarProjects => { + self.sidebar.update(cx, |sidebar, cx| { + sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx); + }); + FocusTarget::SidebarTags + } + _ => FocusTarget::Table, + }; + cx.notify(); + true + } + Command::FocusSidebar => { + self.focus_target = match self.focus_target { + FocusTarget::Table => { + self.sidebar.update(cx, |sidebar, cx| { + sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx); + }); + FocusTarget::SidebarTags + } + FocusTarget::SidebarTags => { + self.sidebar.update(cx, |sidebar, cx| { + sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx); + }); + FocusTarget::SidebarProjects + } + _ => FocusTarget::Table, + }; + cx.notify(); + true + } + Command::FocusSidebarProjects => { + self.focus_target = FocusTarget::SidebarProjects; + self.sidebar.update(cx, |sidebar, cx| { + sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx); + }); + cx.notify(); + true + } + Command::FocusSidebarTags => { + self.focus_target = FocusTarget::SidebarTags; + self.sidebar.update(cx, |sidebar, cx| { + sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx); + }); + cx.notify(); + true + } + Command::SelectNextRow + | Command::SelectPrevRow + | Command::SelectFirstRow + | Command::SelectLastRow => { + match self.focus_target { + FocusTarget::SidebarProjects | FocusTarget::SidebarTags => { + self.sidebar + .update(cx, |sidebar, cx| sidebar.dispatch(command, cx)); + } + _ => { + self.task_table + .update(cx, |table, cx| table.dispatch(command, cx)); + } + } + true + } + Command::OpenSelectedTask => { + match self.focus_target { + FocusTarget::SidebarProjects | FocusTarget::SidebarTags => { + self.sidebar + .update(cx, |sidebar, cx| sidebar.dispatch(command, cx)); + } + _ => { + self.open_selected_task(None, cx); + } + } + true + } + Command::CloseModal => { + self.close_task_detail(None, cx); + true + } + Command::SaveModal => { + self.close_task_detail(None, cx); + true + } + Command::ModalScrollUp => { + self.scroll_task_detail(-1, cx); + true + } + Command::ModalScrollDown => { + self.scroll_task_detail(2, cx); + true + } + Command::ExpandProject | Command::CollapseProject => { + match self.focus_target { + FocusTarget::SidebarProjects => { + self.sidebar + .update(cx, |sidebar, cx| sidebar.dispatch(command, cx)); + } + _ => { + self.task_table + .update(cx, |table, cx| table.dispatch(command, cx)); + } + } + true + } + Command::NextPage | Command::PrevPage | Command::ClearSelection => { + self.task_table + .update(cx, |table, cx| table.dispatch(command, cx)); + true + } + Command::ToggleDropdown + | Command::SelectNextOption + | Command::SelectPrevOption + | Command::BlurInput => { + self.task_table + .update(cx, |table, cx| table.dispatch(command, cx)); + true + } + Command::ClearAllFilters => { + self.filter_state.update(cx, |state, cx| { + state.clear(); + cx.notify(); + }); + true + } + Command::ClearProjectFilter => { + self.filter_state.update(cx, |state, cx| { + state.clear_project(); + cx.notify(); + }); + true + } + Command::ClearTagFilter => { + self.filter_state.update(cx, |state, cx| { + state.clear_tags(); + cx.notify(); + }); + true + } + Command::ClearSearchAndDropdowns => { + self.filter_state.update(cx, |state, cx| { + state.clear_search_and_dropdowns(); + cx.notify(); + }); + self.task_table.update(cx, |table, cx| { + table.clear_search_input(cx); + table.reset_dropdowns(cx); + }); + true + } + Command::HeaderMoveNext => { + self.task_table.update(cx, |table, cx| { + table.header_move_next(cx); + }); + true + } + Command::HeaderMovePrev => { + self.task_table.update(cx, |table, cx| { + table.header_move_prev(cx); + }); + true + } + Command::HeaderCycleSortOrder => { + self.task_table.update(cx, |table, cx| { + table.header_cycle_sort_order(cx); + }); + true + } + _ => false, + } + } +} diff --git a/src/keymap/command.rs b/src/keymap/command.rs index fac1ae4..a93b633 100644 --- a/src/keymap/command.rs +++ b/src/keymap/command.rs @@ -25,6 +25,8 @@ pub enum Command { // Modal CloseModal, SaveModal, + ModalScrollUp, + ModalScrollDown, // Filter ApplySearch, @@ -70,6 +72,8 @@ impl Command { "BlurInput" => Some(Self::BlurInput), "CloseModal" => Some(Self::CloseModal), "SaveModal" => Some(Self::SaveModal), + "ModalScrollUp" => Some(Self::ModalScrollUp), + "ModalScrollDown" => Some(Self::ModalScrollDown), "ApplySearch" => Some(Self::ApplySearch), "ClearFilters" => Some(Self::ClearFilters), "ClearAllFilters" => Some(Self::ClearAllFilters), @@ -110,6 +114,8 @@ impl Command { Self::BlurInput => "BlurInput", Self::CloseModal => "CloseModal", Self::SaveModal => "SaveModal", + Self::ModalScrollUp => "ModalScrollUp", + Self::ModalScrollDown => "ModalScrollDown", Self::ApplySearch => "ApplySearch", Self::ClearFilters => "ClearFilters", Self::ClearAllFilters => "ClearAllFilters", diff --git a/src/keymap/defaults.rs b/src/keymap/defaults.rs index d57b8dd..3a6877d 100644 --- a/src/keymap/defaults.rs +++ b/src/keymap/defaults.rs @@ -403,6 +403,26 @@ pub fn build_default_keymap() -> KeymapLayer { KeyChord::new(Key::Escape, Mods::none()), Command::CloseModal, ); + layer.bind( + ContextId::Modal, + KeyChord::new(Key::Char('j'), Mods::none()), + Command::ModalScrollDown, + ); + layer.bind( + ContextId::Modal, + KeyChord::new(Key::Char('k'), Mods::none()), + Command::ModalScrollUp, + ); + layer.bind( + ContextId::Modal, + KeyChord::new(Key::ArrowDown, Mods::none()), + Command::ModalScrollDown, + ); + layer.bind( + ContextId::Modal, + KeyChord::new(Key::ArrowUp, Mods::none()), + Command::ModalScrollUp, + ); layer.bind( ContextId::Modal, KeyChord::new(Key::Enter, Mods::ctrl()), diff --git a/src/main.rs b/src/main.rs index 2ff7167..124d3f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use crate::app::App; mod app; mod components; +mod dispatcher; mod keymap; mod models; mod task; diff --git a/src/task/filter.rs b/src/task/filter.rs index fbd048e..ca52f72 100644 --- a/src/task/filter.rs +++ b/src/task/filter.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use chrono::{DateTime, NaiveDate, Utc}; -use super::model::{Task, TaskPriority, TaskStatus}; +use super::model::{TaskPriority, TaskStatus, TaskSummary}; use crate::models::{DueFilter, FilterState, PriorityFilter, StatusFilter}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -131,7 +131,7 @@ impl From for TaskFilter { } impl TaskFilter { - pub fn matches(&self, task: &Task) -> bool { + pub fn matches(&self, task: &TaskSummary) -> bool { if let Some(status) = &self.status { match status { TaskStatus::Pending => { @@ -267,7 +267,7 @@ impl TaskFilter { true } - pub fn apply(&self, tasks: &[Task]) -> Vec { + pub fn apply(&self, tasks: &[TaskSummary]) -> Vec { tasks.iter().filter(|t| self.matches(t)).cloned().collect() } } diff --git a/src/task/mod.rs b/src/task/mod.rs index e0187cc..87d00db 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -5,5 +5,8 @@ pub mod service; pub use error::{TaskError, TaskResult}; pub use filter::{DueDateFilter, TagsFilterMode, TaskFilter}; -pub use model::{Task, TaskAnnotation, TaskOverview, TaskPriority, TaskStatus, TaskUpdate}; +pub use model::{ + Task, TaskAnnotation, TaskDetailState, TaskDetailVm, TaskOverview, TaskPriority, TaskStatus, + TaskSummary, TaskUpdate, +}; pub use service::{SyncResult, TaskService}; diff --git a/src/task/model.rs b/src/task/model.rs index a32887a..49c4537 100644 --- a/src/task/model.rs +++ b/src/task/model.rs @@ -1,6 +1,6 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TaskPriority { @@ -219,6 +219,232 @@ impl Task { } } +#[derive(Debug, Clone)] +pub struct TaskSummary { + pub uuid: uuid::Uuid, + pub id: Option, + pub working_id: Option, + pub status: TaskStatus, + pub description: String, + pub project: Option, + pub priority: TaskPriority, + pub tags: HashSet, + pub due: Option>, + pub wait: Option>, + pub dependencies: HashSet, + pub is_active: bool, + pub is_blocked: bool, +} + +impl TaskSummary { + pub fn is_overdue(&self) -> bool { + self.due.map_or(false, |due| due < Utc::now()) + } + + pub fn is_due_today(&self) -> bool { + self.due + .map_or(false, |due| due.date_naive() == Utc::now().date_naive()) + } +} + +impl From<&Task> for TaskSummary { + fn from(task: &Task) -> Self { + Self { + uuid: task.uuid, + id: task.id, + working_id: task.working_id, + status: task.status.clone(), + description: task.description.clone(), + project: task.project.clone(), + priority: task.priority, + tags: task.tags.clone(), + due: task.due, + wait: task.wait, + dependencies: task.dependencies.clone(), + is_active: task.is_active, + is_blocked: task.is_blocked, + } + } +} + +#[derive(Debug, Clone)] +pub struct TaskIdentityVm { + pub uuid: uuid::Uuid, + pub id: Option, + pub working_id: Option, +} + +#[derive(Debug, Clone)] +pub struct TaskOverviewVm { + pub description: String, + pub status: TaskStatus, + pub project: Option, + pub priority: TaskPriority, + pub is_active: bool, +} + +#[derive(Debug, Clone)] +pub struct TaskDatesVm { + pub entry: Option>, + pub modified: Option>, + pub start: Option>, + pub end: Option>, + pub due: Option>, + pub scheduled: Option>, + pub wait: Option>, + pub until: Option>, +} + +#[derive(Debug, Clone)] +pub struct TaskTagsVm { + pub tags: Vec, + pub virtual_tags: Vec, +} + +#[derive(Debug, Clone)] +pub struct TaskLinkVm { + pub uuid: uuid::Uuid, + pub id: Option, + pub description: String, + pub status: TaskStatus, +} + +#[derive(Debug, Clone)] +pub struct TaskDependenciesVm { + pub depends_on: Vec, + pub blocked_by: Vec, + pub blocking: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct TaskMetricsVm { + pub urgency: Option, +} + +#[derive(Debug, Clone)] +pub struct TaskDetailVm { + pub identity: TaskIdentityVm, + pub overview: TaskOverviewVm, + pub dates: TaskDatesVm, + pub tags: TaskTagsVm, + pub dependencies: TaskDependenciesVm, + pub annotations: Vec, + pub udas: Vec<(String, String)>, + pub metrics: TaskMetricsVm, +} + +impl TaskDetailVm { + pub fn from_task(task: &Task, all_tasks: &[TaskSummary]) -> Self { + let mut tags: Vec = task.tags.iter().cloned().collect(); + tags.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + + let task_map: HashMap = + all_tasks.iter().map(|t| (t.uuid, t)).collect(); + + let to_link = |uuid: &uuid::Uuid| -> TaskLinkVm { + match task_map.get(uuid) { + Some(summary) => TaskLinkVm { + uuid: *uuid, + id: summary.working_id.or(summary.id), + description: summary.description.clone(), + status: summary.status.clone(), + }, + None => TaskLinkVm { + uuid: *uuid, + id: None, + description: "Unknown task".to_string(), + status: TaskStatus::Unknown("Missing".to_string()), + }, + } + }; + + let mut depends_on: Vec = task.dependencies.iter().map(to_link).collect(); + depends_on.sort_by(|a, b| a.uuid.cmp(&b.uuid)); + + let mut blocked_by: Vec = task + .dependencies + .iter() + .filter(|uuid| { + task_map + .get(uuid) + .map(|summary| !matches!(summary.status, TaskStatus::Completed)) + .unwrap_or(true) + }) + .map(to_link) + .collect(); + blocked_by.sort_by(|a, b| a.uuid.cmp(&b.uuid)); + + let mut blocking: Vec = all_tasks + .iter() + .filter(|summary| summary.dependencies.contains(&task.uuid)) + .map(|summary| TaskLinkVm { + uuid: summary.uuid, + id: summary.working_id.or(summary.id), + description: summary.description.clone(), + status: summary.status.clone(), + }) + .collect(); + blocking.sort_by(|a, b| a.uuid.cmp(&b.uuid)); + + let mut virtual_tags = Vec::new(); + if !blocked_by.is_empty() || task.is_blocked { + virtual_tags.push("BLOCKED".to_string()); + } + if !blocking.is_empty() { + virtual_tags.push("BLOCKING".to_string()); + } + if let Some(due) = task.due { + let today = Utc::now().date_naive(); + let tomorrow = (Utc::now() + Duration::days(1)).date_naive(); + let due_date = due.date_naive(); + if due_date == today { + virtual_tags.push("TODAY".to_string()); + } else if due_date == tomorrow { + virtual_tags.push("TOMORROW".to_string()); + } else { + virtual_tags.push("DUE".to_string()); + } + } + + let mut annotations = task.annotations.clone(); + annotations.sort_by(|a, b| a.entry.cmp(&b.entry)); + + TaskDetailVm { + identity: TaskIdentityVm { + uuid: task.uuid, + id: task.id, + working_id: task.working_id, + }, + overview: TaskOverviewVm { + description: task.description.clone(), + status: task.status.clone(), + project: task.project.clone(), + priority: task.priority, + is_active: task.is_active, + }, + dates: TaskDatesVm { + entry: task.entry, + modified: task.modified, + start: None, + end: None, + due: task.due, + scheduled: None, + wait: task.wait, + until: None, + }, + tags: TaskTagsVm { tags, virtual_tags }, + dependencies: TaskDependenciesVm { + depends_on, + blocked_by, + blocking, + }, + annotations, + udas: Vec::new(), + metrics: TaskMetricsVm::default(), + } + } +} + impl From for Task { fn from(task: taskchampion::Task) -> Self { Self { @@ -262,3 +488,17 @@ pub struct TaskOverview { pub pending_tasks: usize, pub completed_tasks: usize, } + +#[derive(Debug, Clone)] +pub enum TaskDetailState { + Idle, + Loading(uuid::Uuid), + Ready(TaskDetailVm), + Error(uuid::Uuid, String), +} + +impl Default for TaskDetailState { + fn default() -> Self { + TaskDetailState::Idle + } +} diff --git a/src/task/service.rs b/src/task/service.rs index 967785c..3f2fd4c 100644 --- a/src/task/service.rs +++ b/src/task/service.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use super::error::{TaskError, TaskResult}; use super::filter::TaskFilter; -use super::model::{Task, TaskOverview, TaskStatus}; +use super::model::{Task, TaskDetailVm, TaskOverview, TaskStatus, TaskSummary}; pub struct TaskService { replica: Replica, @@ -142,6 +142,15 @@ impl TaskService { } } + pub fn get_task_detail( + &mut self, + uuid: Uuid, + all_tasks: &[TaskSummary], + ) -> TaskResult { + let task = self.get_task(uuid)?.ok_or(TaskError::NotFound(uuid))?; + Ok(TaskDetailVm::from_task(&task, all_tasks)) + } + pub fn get_all_tasks(&mut self) -> TaskResult> { log::debug!("TaskService::get_all_tasks: Fetching all tasks from replica"); let all = self.replica.all_tasks().map_err(|e| { @@ -232,9 +241,10 @@ impl TaskService { }) } - pub fn get_filtered_tasks(&mut self, filter: &TaskFilter) -> TaskResult> { + pub fn get_filtered_tasks(&mut self, filter: &TaskFilter) -> TaskResult> { let all = self.get_all_tasks()?; - Ok(filter.apply(&all)) + let summaries: Vec = all.iter().map(TaskSummary::from).collect(); + Ok(filter.apply(&summaries)) } pub fn update_task( diff --git a/src/ui.rs b/src/ui.rs index 46fd7a8..67d9366 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -47,6 +47,7 @@ pub fn table_col_status_width() -> gpui::Rems { } pub const DATE_FORMAT: &str = "%Y-%m-%d"; +pub const DATE_TIME_FORMAT: &str = "%Y-%m-%d %H:%M"; pub fn card_style(div: gpui::Div, theme: &Theme) -> gpui::Div { div.bg(theme.card) diff --git a/src/view/app_layout.rs b/src/view/app_layout.rs new file mode 100644 index 0000000..0acb999 --- /dev/null +++ b/src/view/app_layout.rs @@ -0,0 +1,91 @@ +use gpui::prelude::*; + +use crate::keymap::FocusTarget; +use crate::theme::Theme; +use crate::ui::{CARD_PADDING, CARD_RADIUS, ROOT_PADDING, SECTION_GAP, SIDEBAR_WIDTH}; +use crate::view::sidebar::Sidebar; +use crate::view::status_bar::StatusBar; +use crate::view::task_table::TaskTable; + +pub fn render_app_layout( + theme: &Theme, + focus_handle: &gpui::FocusHandle, + focus_target: FocusTarget, + sidebar: gpui::Entity, + task_table: gpui::Entity, + status_bar: gpui::Entity, + on_root_key_down: impl Fn(&gpui::KeyDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, + on_sidebar_mouse_down: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, + on_table_mouse_down: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, + modal: Option, +) -> gpui::AnyElement { + let sidebar_focused = focus_target.is_sidebar(); + + let sidebar_border_color = if sidebar_focused { + theme.focus_ring + } else { + theme.divider + }; + + let sidebar = gpui::div() + .bg(theme.card) + .border_2() + .border_color(sidebar_border_color) + .rounded(CARD_RADIUS) + .p(CARD_PADDING) + .w(SIDEBAR_WIDTH) + .h_full() + .flex_shrink_0() + .overflow_hidden() + .on_mouse_down(gpui::MouseButton::Left, on_sidebar_mouse_down) + .child(sidebar); + + let table_focused = matches!(focus_target, FocusTarget::Table | FocusTarget::TableHeaders); + + let table_border_color = if table_focused { + theme.focus_ring + } else { + theme.divider + }; + + let main = gpui::div() + .bg(theme.card) + .border_2() + .border_color(table_border_color) + .rounded(CARD_RADIUS) + .p(CARD_PADDING) + .flex_1() + .h_full() + .min_w_0() + .overflow_hidden() + .p_0() + .on_mouse_down(gpui::MouseButton::Left, on_table_mouse_down) + .child(task_table); + + let content = gpui::div() + .flex() + .flex_1() + .min_h_0() + .gap(SECTION_GAP) + .child(sidebar) + .child(main); + + let mut root = gpui::div() + .flex() + .flex_col() + .size_full() + .relative() + .bg(theme.background) + .p(ROOT_PADDING) + .gap(SECTION_GAP) + .track_focus(focus_handle) + .on_key_down(on_root_key_down) + .child(content) + .child(status_bar); + + if let Some(modal) = modal { + root = root.child(modal); + } + + root.into_any_element() +} diff --git a/src/view/mod.rs b/src/view/mod.rs index b55fc43..86ff42d 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -1,3 +1,5 @@ +pub mod app_layout; pub mod sidebar; pub mod status_bar; +pub mod task_detail_modal; pub mod task_table; diff --git a/src/view/sidebar.rs b/src/view/sidebar.rs index 89070d7..3774d89 100644 --- a/src/view/sidebar.rs +++ b/src/view/sidebar.rs @@ -18,6 +18,10 @@ pub enum SidebarSection { Tags, } +pub enum SidebarEvent { + Focused(SidebarSection), +} + pub struct Sidebar { project_tree: ProjectTree, tags: Vec, @@ -77,6 +81,7 @@ impl Sidebar { _window: &mut Window, cx: &mut Context, ) { + cx.emit(SidebarEvent::Focused(SidebarSection::Projects)); self.filter_state.update(cx, |filter, cx| { filter.select_project(full_path); cx.notify(); @@ -91,6 +96,7 @@ impl Sidebar { } fn handle_tag_click(&mut self, tag_name: String, _window: &mut Window, cx: &mut Context) { + cx.emit(SidebarEvent::Focused(SidebarSection::Tags)); self.filter_state.update(cx, |filter, cx| { filter.toggle_tag(tag_name); cx.notify(); @@ -99,6 +105,7 @@ impl Sidebar { } fn handle_clear_tags(&mut self, _window: &mut Window, cx: &mut Context) { + cx.emit(SidebarEvent::Focused(SidebarSection::Tags)); self.filter_state.update(cx, |filter, cx| { filter.active_tags.clear(); cx.notify(); @@ -107,6 +114,7 @@ impl Sidebar { } fn handle_clear_project(&mut self, _window: &mut Window, cx: &mut Context) { + cx.emit(SidebarEvent::Focused(SidebarSection::Projects)); self.filter_state.update(cx, |filter, cx| { filter.selected_project = None; cx.notify(); @@ -543,6 +551,8 @@ impl CommandDispatcher for Sidebar { } } +impl gpui::EventEmitter for Sidebar {} + impl Render for Sidebar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); diff --git a/src/view/task_detail_modal.rs b/src/view/task_detail_modal.rs new file mode 100644 index 0000000..0b7c6b2 --- /dev/null +++ b/src/view/task_detail_modal.rs @@ -0,0 +1,563 @@ +use gpui::prelude::*; +use std::sync::Arc; + +use crate::components::label::Label; +use crate::components::modal::ModalFrame; +use crate::task::model::TaskLinkVm; +use crate::task::{self, TaskDetailState}; +use crate::theme::Theme; +use crate::ui::{DATE_FORMAT, DATE_TIME_FORMAT}; + +pub fn render_task_detail_modal( + detail_state: &TaskDetailState, + focus_handle: &gpui::FocusHandle, + scroll_handle: &gpui::ScrollHandle, + theme: &Theme, + on_close_out: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, + on_close_click: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, +) -> gpui::AnyElement { + let panel = match detail_state { + TaskDetailState::Ready(detail) => { + render_task_detail_panel(detail, scroll_handle, theme, on_close_click) + } + TaskDetailState::Error(_, message) => { + render_task_detail_placeholder_panel("Task Details", message, theme, on_close_click) + } + TaskDetailState::Loading(_) | TaskDetailState::Idle => { + render_task_detail_placeholder_panel( + "Task Details", + "Loading task...", + theme, + on_close_click, + ) + } + }; + + 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("Cancel (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, + scroll_handle: &gpui::ScrollHandle, + theme: &Theme, + on_close_click: OnCloseClick, +) -> gpui::AnyElement +where + OnCloseClick: Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, +{ + let status_label = if detail.overview.is_active { + "Active".to_string() + } else { + detail.overview.status.clone().into() + }; + + let priority_label: String = 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_color = match detail.overview.priority { + 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 { + badges.push(chip( + project, + Theme::alpha(theme.accent, 0.15), + theme.accent, + )); + } + + let id_label = detail + .identity + .working_id + .or(detail.identity.id) + .map(|id| format!("#{}", id)) + .unwrap_or_else(|| format!("#{}", detail.identity.uuid)); + + let title = format!("{} {}", id_label, detail.overview.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 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(close_button); + + 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 value_label = |value: String| Label::new(value).text_color(value_color).into_any_element(); + + let kv_row = |label: &str, value: gpui::AnyElement| { + gpui::div() + .flex() + .items_start() + .gap_3() + .child( + Label::new(label.to_string()) + .text_color(label_color) + .text_sm() + .w(gpui::rems(10.0)), + ) + .child(gpui::div().flex_1().min_w_0().child(value)) + }; + + 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 overview_grid = gpui::div() + .flex() + .flex_col() + .gap_2() + .child(kv_row("Status", value_label(status_label.clone()))) + .child(kv_row( + "Description", + value_label(detail.overview.description.clone()), + )) + .child(kv_row( + "Project", + value_label( + detail + .overview + .project + .clone() + .unwrap_or_else(|| "-".to_string()), + ), + )) + .child(kv_row("Priority", value_label(priority_label.clone()))) + .child(kv_row("Due", value_label(due_text))); + + 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 tags_content = 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]; + + if !detail.annotations.is_empty() { + let items = detail.annotations.iter().map(|annotation| { + gpui::div() + .flex() + .flex_col() + .gap_1() + .min_w_0() + .child( + Label::new(annotation.entry.format(DATE_TIME_FORMAT).to_string()) + .text_xs() + .text_color(theme.muted), + ) + .child( + Label::new(annotation.content.clone()) + .text_sm() + .text_color(value_color), + ) + .into_any_element() + }); + + let annotations_section = section( + "Annotations", + gpui::div().flex().flex_col().gap_2().children(items), + ); + 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 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( + gpui::div() + .id("task-detail-cancel") + .px(gpui::rems(0.75)) + .py(gpui::rems(0.35)) + .rounded_md() + .border_1() + .border_color(theme.divider) + .bg(theme.raised) + .text_color(theme.foreground) + .cursor_pointer() + .hover(|s| s.bg(theme.hover)) + .on_mouse_down(gpui::MouseButton::Left, move |event, window, app| { + (on_close_footer)(event, window, app); + }) + .child(Label::new("Cancel (Esc)")), + ); + + 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_table.rs b/src/view/task_table.rs index 6901c09..15b3d4f 100644 --- a/src/view/task_table.rs +++ b/src/view/task_table.rs @@ -12,7 +12,7 @@ use crate::{ }, keymap::{Command, CommandDispatcher}, models::{DueFilter, FilterState, PriorityFilter, StatusFilter}, - task::{self, TaskFilter, TaskService}, + task::{self, TaskFilter, TaskService, TaskSummary}, theme::{self, ActiveTheme}, ui::{ DATE_FORMAT, TABLE_FILTER_BAR_INITIAL_HEIGHT, TABLE_MAX_DESCRIPTION_LENGTH, priority_badge, @@ -220,8 +220,8 @@ impl TaskRow { } } -impl From<&task::Task> for TaskRow { - fn from(value: &task::Task) -> Self { +impl From<&task::TaskSummary> for TaskRow { + fn from(value: &task::TaskSummary) -> Self { let status = if value.is_active { "Active".to_string() } else { @@ -261,7 +261,7 @@ impl Default for FilterBarFocus { pub struct TaskTable { id: gpui::ElementId, filter_state: gpui::Entity, - cached_tasks: Vec, + cached_tasks: Vec, cached_rows: Vec, sort_state: SortState, pagination: PaginationState, @@ -438,18 +438,9 @@ impl TaskTable { &self.cached_rows[start..end] } - pub fn reload_tasks(&mut self, task_service: &mut TaskService, cx: &mut gpui::Context) { - let all_tasks = task_service.get_all_tasks().unwrap_or_else(|e| { - log::error!("[TaskTable] Failed to load tasks: {}", e); - vec![] - }); - - self.reload_tasks_from_all(all_tasks, cx); - } - pub fn reload_tasks_from_all( &mut self, - all_tasks: Vec, + all_tasks: Vec, cx: &mut gpui::Context, ) { let filter_state = self.filter_state.read(cx).clone(); @@ -488,7 +479,7 @@ impl TaskTable { fn sync_filter_dropdowns( &mut self, - due_tasks: &[task::Task], + due_tasks: &[task::TaskSummary], filter_state: &FilterState, cx: &mut gpui::Context, ) { @@ -523,7 +514,7 @@ impl TaskTable { }); } - fn build_due_items(tasks: &[task::Task]) -> Vec { + fn build_due_items(tasks: &[task::TaskSummary]) -> Vec { let now = chrono::Utc::now(); let today = now.date_naive(); let week_end = now + chrono::Duration::days(7); @@ -769,6 +760,12 @@ impl TaskTable { cx.notify(); } + pub fn selected_task_uuid(&self) -> Option { + self.selected_global_idx + .and_then(|idx| self.cached_tasks.get(idx)) + .map(|task| task.uuid) + } + pub fn focus_search_input(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context) { self.filter_bar_focus = FilterBarFocus::SearchInput; self.search_input.update(cx, |input, cx| { @@ -1198,6 +1195,7 @@ impl TaskTable { fn render_row(&self, idx: usize, row: &TaskRow, cx: &gpui::Context) -> gpui::Div { let theme = cx.theme(); let selected = self.selected_page_idx == Some(idx); + let row_uuid = row.uuid; gpui::div() .flex() @@ -1215,7 +1213,12 @@ impl TaskTable { .cursor_pointer() .on_mouse_down( gpui::MouseButton::Left, - cx.listener(move |table, _, _, cx| table.select_row(idx, cx)), + cx.listener(move |table, event: &gpui::MouseDownEvent, _window, cx| { + table.select_row(idx, cx); + if event.click_count >= 2 { + cx.emit(TaskTableEvent::OpenTask(row_uuid)); + } + }), ) .child( gpui::div() @@ -1413,6 +1416,12 @@ impl CommandDispatcher for TaskTable { } } +pub enum TaskTableEvent { + OpenTask(uuid::Uuid), +} + +impl gpui::EventEmitter for TaskTable {} + impl gpui::Render for TaskTable { fn render( &mut self,