From 4cd01954ef3f4db94a26c1ad5f66807973b3776a Mon Sep 17 00:00:00 2001 From: Ignacio Perez Date: Sat, 27 Dec 2025 20:58:22 -0300 Subject: [PATCH] feat & fix: Add status bar with sync button and fix filter bugs Added a status bar at the bottom with a sync button that updates the task list from the local database. The button shows different states (idle, syncing, success, error) with colors and icons, and displays status messages. Fixed the layout shift bug when clearing filters by giving dropdowns a minimum width. Also fixed sidebar counts not updating when filters change - now the sidebar shows correct project and tag counts based on filtered tasks. Extracted reusable button and dropdown styles to keep the UI consistent across components. --- src/app.rs | 108 +++++++++++++++++++++++++-- src/components/button/dropdown.rs | 29 ++------ src/components/label.rs | 2 - src/theme.rs | 9 ++- src/ui.rs | 54 +++++++++++++- src/view/sidebar.rs | 6 +- src/view/status_bar.rs | 119 ++++++++++++++++++++++++++++-- src/view/task_table.rs | 111 ++++++++++++++++------------ 8 files changed, 347 insertions(+), 91 deletions(-) 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), + ) } }