diff --git a/src/app.rs b/src/app.rs index cfc036a..c56f3e9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,13 @@ +use std::collections::HashMap; + use gpui::prelude::*; use crate::models::{FilterState, ProjectTree}; -use crate::task::{TaskOverview, TaskService}; +use crate::task::{self, TaskFilter, TaskOverview, TaskService}; use crate::theme::ActiveTheme; -use crate::ui::{card_style, SECTION_GAP, ROOT_PADDING}; +use crate::ui::{ROOT_PADDING, SECTION_GAP, card_style}; use crate::view::sidebar::{Sidebar, TagItem}; -use crate::view::status_bar::StatusBar; +use crate::view::status_bar::{StatusBar, StatusBarEvent, SyncState}; use crate::view::task_table::TaskTable; use gpui::div; @@ -61,11 +63,96 @@ impl gpui::Render for App { } impl App { + fn build_sidebar_data(tasks: &[task::Task]) -> (Vec<(String, usize)>, Vec) { + let mut project_counts: HashMap = HashMap::new(); + let mut tag_counts: HashMap = HashMap::new(); + + for task in tasks { + if let Some(project) = &task.project { + *project_counts.entry(project.clone()).or_insert(0) += 1; + } + + for tag in &task.tags { + *tag_counts.entry(tag.clone()).or_insert(0) += 1; + } + } + + let mut projects: Vec<(String, usize)> = project_counts.into_iter().collect(); + projects.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + + let mut tags: Vec<(String, usize)> = tag_counts.into_iter().collect(); + tags.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + + let tag_items = tags + .into_iter() + .map(|(name, task_count)| TagItem { name, task_count }) + .collect(); + + (projects, tag_items) + } + fn reload_tasks(&mut self, cx: &mut gpui::Context) { - let task_service = &mut self.task_service; - self.task_table.update(cx, |table, cx| { - table.reload_tasks(task_service, cx); + let all_tasks = self.task_service.get_all_tasks().unwrap_or_else(|e| { + log::error!("[App] Failed to load tasks: {}", e); + vec![] }); + + let filter_state = self.filter_state.read(cx).clone(); + let task_filter = TaskFilter::from(&filter_state); + let filtered_tasks = task_filter.apply(&all_tasks); + let (projects, tags) = Self::build_sidebar_data(&filtered_tasks); + + let mut project_tree = ProjectTree::new(); + project_tree.build_from_projects(&projects); + + self.sidebar.update(cx, |sidebar, cx| { + sidebar.update_projects(project_tree, cx); + sidebar.update_tags(tags, cx); + }); + + self.task_table.update(cx, |table, cx| { + table.reload_tasks_from_all(all_tasks, cx); + }); + } + + fn handle_sync(&mut self, cx: &mut gpui::Context) { + self.status_bar.update(cx, |bar, cx| { + bar.set_sync_state(SyncState::Syncing, cx); + bar.set_last_sync_message("Syncing...".to_string(), cx); + }); + + match self.task_service.get_all_tasks() { + Ok(all_tasks) => { + let filter_state = self.filter_state.read(cx).clone(); + let task_filter = TaskFilter::from(&filter_state); + let filtered_tasks = task_filter.apply(&all_tasks); + let (projects, tags) = Self::build_sidebar_data(&filtered_tasks); + + let mut project_tree = ProjectTree::new(); + project_tree.build_from_projects(&projects); + + self.sidebar.update(cx, |sidebar, cx| { + sidebar.update_projects(project_tree, cx); + sidebar.update_tags(tags, cx); + }); + + self.task_table.update(cx, |table, cx| { + table.reload_tasks_from_all(all_tasks, cx); + }); + + self.status_bar.update(cx, |bar, cx| { + bar.set_sync_state(SyncState::Success, cx); + bar.set_last_sync_message("Synced".to_string(), cx); + }); + } + Err(e) => { + log::error!("[App] Sync failed: {}", e); + self.status_bar.update(cx, |bar, cx| { + bar.set_sync_state(SyncState::Error, cx); + bar.set_last_sync_message(format!("Error: {}", e), cx); + }); + } + } } pub fn run() -> () { @@ -118,7 +205,7 @@ impl App { let app = App { sidebar, filter_state: filter_state.clone(), - status_bar, + status_bar: status_bar.clone(), task_table, task_service, }; @@ -128,6 +215,13 @@ impl App { }) .detach(); + cx.subscribe(&status_bar, |app, _bar, event, cx| match event { + StatusBarEvent::SyncRequested => { + app.handle_sync(cx); + } + }) + .detach(); + app }) }, diff --git a/src/components/button/dropdown.rs b/src/components/button/dropdown.rs index c4f5bcb..dd5c6b7 100644 --- a/src/components/button/dropdown.rs +++ b/src/components/button/dropdown.rs @@ -5,6 +5,7 @@ use gpui::prelude::*; use crate::components::button::Button; use crate::components::label::Label; use crate::theme::ActiveTheme; +use crate::ui::{clickable_control_style, disabled_control_style}; #[derive(Clone, Debug)] pub struct DropdownItem { @@ -288,36 +289,20 @@ impl gpui::Render for Dropdown { .text_color(theme.foreground) .into_any_element() } else { - let mut trigger = gpui::div() - .flex() - .items_center() - .gap_2() - .px_3() - .py_2() - .rounded_md() - .border_1() - .border_color(theme.border) - .bg(theme.background) - .text_sm() - .text_color(theme.foreground) - .whitespace_nowrap() + let base = gpui::div() .child(Label::new(label.clone())) .child(Label::new(arrow).text_color(theme.muted)); - if disabled { - trigger = trigger.text_color(theme.muted).cursor_not_allowed(); + let trigger = if disabled { + disabled_control_style(base, theme) } else { - trigger = trigger - .cursor_pointer() - .hover(|s: gpui::StyleRefinement| s.bg(theme.hover)); - } + clickable_control_style(base, theme) + }; trigger.into_any_element() }; - let mut trigger_wrap = gpui::div() - .flex_shrink_0() - .child(trigger); + let mut trigger_wrap = gpui::div().flex_shrink_0().child(trigger); if !disabled { trigger_wrap = trigger_wrap.on_mouse_down( gpui::MouseButton::Left, diff --git a/src/components/label.rs b/src/components/label.rs index 781cf62..11fe04f 100644 --- a/src/components/label.rs +++ b/src/components/label.rs @@ -1,7 +1,5 @@ use gpui::prelude::*; -use crate::theme::ActiveTheme; - #[derive(gpui::IntoElement)] pub struct Label { text: gpui::SharedString, diff --git a/src/theme.rs b/src/theme.rs index a806e63..a4aea05 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,5 +1,3 @@ -use gpui::prelude::*; - pub type Color = gpui::Rgba; #[derive(Debug, Clone)] @@ -43,7 +41,12 @@ pub struct Theme { impl Theme { pub fn alpha(c: Color, a: f32) -> Color { - gpui::Rgba { r: c.r, g: c.g, b: c.b, a: a.clamp(0.0, 1.0) } + gpui::Rgba { + r: c.r, + g: c.g, + b: c.b, + a: a.clamp(0.0, 1.0), + } } pub fn dark() -> Self { diff --git a/src/ui.rs b/src/ui.rs index d48dfba..32daae4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,5 +1,5 @@ use gpui::prelude::*; -use gpui::{px, Pixels}; +use gpui::{Pixels, px}; use crate::theme::Theme; @@ -8,6 +8,8 @@ pub const CARD_PADDING: Pixels = px(8.0); pub const SECTION_GAP: Pixels = px(12.0); pub const INSET_GAP: Pixels = px(8.0); pub const ROOT_PADDING: Pixels = px(12.0); +pub const CONTROL_RADIUS: Pixels = px(6.0); +pub const CONTROL_BORDER: Pixels = px(1.0); pub fn card_style(div: gpui::Div, theme: &Theme) -> gpui::Div { div.bg(theme.card) @@ -80,3 +82,53 @@ pub fn priority_badge(priority: &str, theme: &Theme) -> gpui::Div { .font_weight(gpui::FontWeight::MEDIUM) .child(priority.to_string()) } + +pub fn control_style(div: gpui::Div, theme: &Theme) -> gpui::Div { + div.flex() + .items_center() + .gap_2() + .px_3() + .py_2() + .rounded(CONTROL_RADIUS) + .border(CONTROL_BORDER) + .border_color(theme.border) + .bg(theme.background) + .text_sm() + .text_color(theme.foreground) + .whitespace_nowrap() +} + +pub fn clickable_control_style(div: gpui::Div, theme: &Theme) -> gpui::Div { + control_style(div, theme) + .cursor_pointer() + .hover(|s| s.bg(theme.hover)) +} + +pub fn disabled_control_style(div: gpui::Div, theme: &Theme) -> gpui::Div { + control_style(div, theme) + .text_color(theme.muted) + .cursor_not_allowed() +} + +pub fn ghost_button_style(div: gpui::Div, theme: &Theme) -> gpui::Div { + div.flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded(CONTROL_RADIUS) + .text_sm() + .text_color(theme.foreground) + .cursor_pointer() + .hover(|s| s.bg(theme.hover)) +} + +pub fn text_button_style(div: gpui::Div, theme: &Theme) -> gpui::Div { + div.flex() + .items_center() + .gap_1() + .text_sm() + .text_color(theme.muted) + .cursor_pointer() + .hover(|s| s.text_color(theme.accent)) +} diff --git a/src/view/sidebar.rs b/src/view/sidebar.rs index f46c8bd..925fc60 100644 --- a/src/view/sidebar.rs +++ b/src/view/sidebar.rs @@ -103,7 +103,7 @@ impl Sidebar { .rounded_sm() .cursor_pointer() .when(is_all_selected, |this| this.bg(theme.selection)) - .when(!is_all_selected, |this| this.hover(|style| style.bg(theme.hover))) + .when(!is_all_selected, |this| this.hover(|s| s.bg(theme.hover))) .on_mouse_down( gpui::MouseButton::Left, cx.listener(|view, _event, window, cx| { @@ -153,7 +153,7 @@ impl Sidebar { .rounded_sm() .cursor_pointer() .when(is_selected, |this| this.bg(theme.selection)) - .when(!is_selected, |this| this.hover(|style| style.bg(theme.hover))) + .when(!is_selected, |this| this.hover(|s| s.bg(theme.hover))) .child(div().w(px(indent as f32))) .child( div() @@ -235,7 +235,7 @@ impl Sidebar { .rounded_sm() .cursor_pointer() .when(is_active, |this| this.bg(theme.selection)) - .when(!is_active, |this| this.hover(|style| style.bg(theme.hover))) + .when(!is_active, |this| this.hover(|s| s.bg(theme.hover))) .on_mouse_down( gpui::MouseButton::Left, cx.listener(move |view, _event, window, cx| { diff --git a/src/view/status_bar.rs b/src/view/status_bar.rs index 068d0ab..d677418 100644 --- a/src/view/status_bar.rs +++ b/src/view/status_bar.rs @@ -1,30 +1,135 @@ -use gpui::{Context, IntoElement, Render, Window, div, prelude::*, px}; +use gpui::{Context, IntoElement, MouseButton, Render, Window, div, prelude::*, rems}; +use crate::components::label::Label; use crate::theme::ActiveTheme; -use crate::ui::{card_style, CARD_RADIUS}; +use crate::ui::divider_v; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncState { + Idle, + Syncing, + Success, + Error, +} + +impl Default for SyncState { + fn default() -> Self { + Self::Idle + } +} pub struct StatusBar { - // TODO: Add vim_mode, sync_state, last_sync, etc. + sync_state: SyncState, + last_sync_message: String, } impl StatusBar { pub fn new(_cx: &mut Context) -> Self { - Self {} + Self { + sync_state: SyncState::default(), + last_sync_message: String::new(), + } + } + + pub fn set_sync_state(&mut self, state: SyncState, cx: &mut Context) { + self.sync_state = state; + cx.notify(); + } + + pub fn set_last_sync_message(&mut self, message: String, cx: &mut Context) { + self.last_sync_message = message; + cx.notify(); + } + + pub fn clear_message(&mut self, cx: &mut Context) { + self.last_sync_message.clear(); + self.sync_state = SyncState::Idle; + cx.notify(); + } + + fn sync_icon(&self) -> &'static str { + match self.sync_state { + SyncState::Idle => "↻", + SyncState::Syncing => "◌", + SyncState::Success => "✓", + SyncState::Error => "✕", + } } } impl Render for StatusBar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); + let is_syncing = self.sync_state == SyncState::Syncing; - card_style(div(), &theme) + let sync_color = match self.sync_state { + SyncState::Success => theme.success, + SyncState::Error => theme.error, + SyncState::Syncing => theme.info, + SyncState::Idle => theme.muted, + }; + + let sync_button = div() + .flex() + .items_center() + .gap_1() + .px_2() + .py_1() + .rounded_md() + .text_sm() + .text_color(sync_color) + .when(!is_syncing, |d| { + d.cursor_pointer() + .hover(|s| s.bg(theme.hover)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|_this, _event, _window, cx| { + cx.emit(StatusBarEvent::SyncRequested); + }), + ) + }) + .when(is_syncing, |d| d.cursor_not_allowed()) + .child(Label::new(self.sync_icon()).text_color(sync_color)) + .child(Label::new("Sync").text_color(sync_color)); + + let status_text = if !self.last_sync_message.is_empty() { + Label::new(self.last_sync_message.clone()).text_color(theme.muted) + } else { + Label::new("") + }; + + div() .flex() .items_center() .justify_between() .w_full() - .h(px(28.0)) + .h(rems(2.0)) .px_3() .py_1() - .rounded(CARD_RADIUS) + .bg(theme.panel) + .border_t_1() + .border_color(theme.divider) + .child( + div() + .flex() + .items_center() + .gap_3() + .text_xs() + .child(status_text), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child(divider_v(&theme).h(rems(1.0))) + .child(sync_button), + ) } } + +pub enum StatusBarEvent { + SyncRequested, +} + +impl gpui::EventEmitter for StatusBar {} diff --git a/src/view/task_table.rs b/src/view/task_table.rs index fdd8195..f513035 100644 --- a/src/view/task_table.rs +++ b/src/view/task_table.rs @@ -386,13 +386,21 @@ impl TaskTable { } pub fn reload_tasks(&mut self, task_service: &mut TaskService, cx: &mut gpui::Context) { - let filter_state = self.filter_state.read(cx).clone(); - 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, + cx: &mut gpui::Context, + ) { + let filter_state = self.filter_state.read(cx).clone(); + let task_filter = TaskFilter::from(&filter_state); let mut due_filter = task_filter.clone(); due_filter.due_filter = None; @@ -602,6 +610,35 @@ impl TaskTable { let has_filters = filter.has_active_filters(); let view = cx.entity().clone(); + let clear_button = { + let mut btn = gpui::div() + .id("clear-all-filters") + .flex_shrink_0() + .min_w(gpui::rems(5.5)) + .px_2() + .py_1() + .rounded_md() + .text_sm(); + + if has_filters { + btn = btn + .text_color(theme.error) + .cursor_pointer() + .hover(|s| s.bg(theme.hover)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|table, _, _, cx| { + table.handle_clear_filters(cx); + }), + ) + .child("✕ Clear"); + } else { + btn = btn.child(gpui::div().opacity(0.0).child("✕ Clear")); + } + + btn + }; + let bar = gpui::div() .id("filter-bar") .flex() @@ -618,40 +655,22 @@ impl TaskTable { .child(self.status_dropdown.clone()) .child(self.priority_dropdown.clone()) .child(self.due_dropdown.clone()) - .when(has_filters, |this| { - this.child( - gpui::div() - .id("clear-all-filters") - .flex_shrink_0() - .px_2() - .py_1() - .rounded_md() - .text_sm() - .text_color(theme.error) - .cursor_pointer() - .hover(|s| s.bg(theme.hover)) - .on_mouse_down( - gpui::MouseButton::Left, - cx.listener(|table, _, _, cx| { - table.handle_clear_filters(cx); - }), - ) - .child("✕ Clear"), - ) - }); + .child(clear_button); - gpui::div().child(bar).on_children_prepainted(move |bounds, _, cx| { - let Some(bounds) = bounds.first() else { - return; - }; - let height = bounds.size.height; - cx.update_entity(&view, |table, cx| { - if table.filter_bar_height != height { - table.filter_bar_height = height; - cx.notify(); - } - }); - }) + gpui::div() + .child(bar) + .on_children_prepainted(move |bounds, _, cx| { + let Some(bounds) = bounds.first() else { + return; + }; + let height = bounds.size.height; + cx.update_entity(&view, |table, cx| { + if table.filter_bar_height != height { + table.filter_bar_height = height; + cx.notify(); + } + }); + }) } fn render_header_column( @@ -917,7 +936,9 @@ impl gpui::Render for TaskTable { ) -> impl gpui::IntoElement { let theme = cx.theme(); - let panel = components::panel::Panel::new(self.id.clone()).flex().flex_col(); + let panel = components::panel::Panel::new(self.id.clone()) + .flex() + .flex_col(); if self.need_reload { return panel @@ -959,15 +980,13 @@ impl gpui::Render for TaskTable { ) .child(footer); - panel - .child(body) - .child( - gpui::div() - .absolute() - .top_0() - .left_0() - .right_0() - .child(filter_bar), - ) + panel.child(body).child( + gpui::div() + .absolute() + .top_0() + .left_0() + .right_0() + .child(filter_bar), + ) } }