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.
This commit is contained in:
Ignacio Perez
2025-12-27 20:58:22 -03:00
parent 95b1d62b1c
commit 4cd01954ef
8 changed files with 347 additions and 91 deletions
+101 -7
View File
@@ -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<TagItem>) {
let mut project_counts: HashMap<String, usize> = HashMap::new();
let mut tag_counts: HashMap<String, usize> = 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<Self>) {
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>) {
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
})
},
+7 -22
View File
@@ -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,
-2
View File
@@ -1,7 +1,5 @@
use gpui::prelude::*;
use crate::theme::ActiveTheme;
#[derive(gpui::IntoElement)]
pub struct Label {
text: gpui::SharedString,
+6 -3
View File
@@ -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 {
+53 -1
View File
@@ -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))
}
+3 -3
View File
@@ -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| {
+112 -7
View File
@@ -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 {}
Self {
sync_state: SyncState::default(),
last_sync_message: String::new(),
}
}
pub fn set_sync_state(&mut self, state: SyncState, cx: &mut Context<Self>) {
self.sync_state = state;
cx.notify();
}
pub fn set_last_sync_message(&mut self, message: String, cx: &mut Context<Self>) {
self.last_sync_message = message;
cx.notify();
}
pub fn clear_message(&mut self, cx: &mut Context<Self>) {
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<Self>) -> 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<StatusBarEvent> for StatusBar {}
+65 -46
View File
@@ -386,13 +386,21 @@ impl TaskTable {
}
pub fn reload_tasks(&mut self, task_service: &mut TaskService, cx: &mut gpui::Context<Self>) {
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<task::Task>,
cx: &mut gpui::Context<Self>,
) {
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),
)
}
}