From 7842b16ccf6ed5d948c031146a16b473b250175c Mon Sep 17 00:00:00 2001 From: Ignacio Perez Date: Thu, 25 Dec 2025 10:55:58 -0300 Subject: [PATCH] feat: Implement hierarchical sidebar with projects and tags Add complete sidebar implementation with: - ProjectTree model using arena allocator for hierarchical projects - FilterState model for global filter state management - Sidebar view with independent scrollable sections - Project tree with expand/collapse functionality - Tag selection with toggle support - Observable filter state shared across components --- src/app.rs | 123 ++++++++----- src/components/panel.rs | 11 +- src/main.rs | 2 + src/models/filter_state.rs | 152 ++++++++++++++++ src/models/mod.rs | 5 + src/models/project_tree.rs | 266 ++++++++++++++++++++++++++++ src/view/mod.rs | 1 + src/view/sidebar.rs | 345 +++++++++++++++++++++++++++++++++++++ 8 files changed, 859 insertions(+), 46 deletions(-) create mode 100644 src/models/filter_state.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/project_tree.rs create mode 100644 src/view/mod.rs create mode 100644 src/view/sidebar.rs diff --git a/src/app.rs b/src/app.rs index cbed26b..bbc7045 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,14 +1,13 @@ use gpui::prelude::*; -use std::sync::Arc; -use crate::components::{ - self, - input::{Input, Suggestion}, -}; +use crate::models::{FilterState, ProjectTree}; +use crate::theme::ActiveTheme; +use crate::view::sidebar::{Sidebar, TagItem}; +use gpui::div; pub(crate) struct App { - input: gpui::Entity, - current_text: gpui::SharedString, + sidebar: gpui::Entity, + filter_state: gpui::Entity, } impl gpui::Render for App { @@ -17,12 +16,26 @@ impl gpui::Render for App { _window: &mut gpui::Window, _cx: &mut gpui::Context, ) -> impl gpui::IntoElement { - components::panel::Panel::new() - .child(self.input.clone()) - .child(components::label::Label::new(format!( - "Text: {}", - &self.current_text - ))) + let theme = _cx.theme(); + + div() + .flex() + .size_full() + .bg(theme.background) + .child(div().w(gpui::px(250.)).h_full().child(self.sidebar.clone())) + .child( + div() + .flex_1() + .h_full() + .flex() + .items_center() + .justify_center() + .child( + div() + .text_color(theme.muted) + .child("Main panel - TaskTable will go here"), + ), + ) } } @@ -36,42 +49,62 @@ impl App { gpui::WindowOptions::default(), |_window: &mut gpui::Window, app: &mut gpui::App| { app.new(|cx: &mut gpui::Context<'_, App>| { - let input = cx.new(|cx| { - Input::new("input", cx, "Type here...").with_suggest(Arc::new(|text| { - let suggestions = vec![ - ("project:", "Filter by project"), - ("+work", "Tag: work"), - ("+personal", "Tag: personal"), - ("due:today", "Tasks for today"), - ("due:tomorrow", "Tasks for tomorrow"), - ("priority:H", "Priority high"), - ("priority:M", "Priority medium"), - ("priority:L", "Priority low"), - ]; + let filter_state = cx.new(|_cx| FilterState::new()); - suggestions - .into_iter() - .filter(|(insert, _)| { - text.is_empty() - || insert.to_lowercase().contains(&text.to_lowercase()) - }) - .map(|(insert, label)| { - Suggestion::new(format!("{} - {}", insert, label), insert) - }) - .collect() - })) + let mut project_tree = ProjectTree::new(); + project_tree.build_from_projects(&[ + ("Work.Backend.API".to_string(), 5), + ("Work.Backend.DB".to_string(), 7), + ("Work.Frontend.React".to_string(), 3), + ("Work.Frontend.Styling".to_string(), 2), + ("Home.Kitchen".to_string(), 4), + ("Home.Garden".to_string(), 2), + ("ignis.v0.1.phase0".to_string(), 15), + ("free-ai".to_string(), 2), + ]); + + project_tree.expand_path("Work"); + project_tree.expand_path("Work.Backend"); + + let tags = vec![ + TagItem { + name: "parser".to_string(), + task_count: 8, + }, + TagItem { + name: "cli".to_string(), + task_count: 5, + }, + TagItem { + name: "testing".to_string(), + task_count: 6, + }, + TagItem { + name: "diagnostics".to_string(), + task_count: 7, + }, + TagItem { + name: "analyzer".to_string(), + task_count: 3, + }, + ]; + + let sidebar = cx.new(|cx| { + Sidebar::new(project_tree, tags, filter_state.clone(), cx) + .on_filter_change(|filter, _window, _cx| { + println!("Filter changed:"); + if let Some(ref project) = filter.selected_project { + println!(" Project: {}", project); + } else { + println!(" Project: All"); + } + println!(" Active tags: {:?}", filter.active_tags); + }) }); - cx.observe(&input, |this, input, cx| { - let value = input.read(cx).value().to_string(); - this.current_text = value.into(); - cx.notify(); - }) - .detach(); - App { - input, - current_text: "".into(), + sidebar, + filter_state, } }) }, diff --git a/src/components/panel.rs b/src/components/panel.rs index 4744811..224e69c 100644 --- a/src/components/panel.rs +++ b/src/components/panel.rs @@ -70,7 +70,15 @@ impl gpui::RenderOnce for Panel { .content .drain(..) .enumerate() - .map(|(ix, c)| gpui::div().id(ix).child(c).into_any_element()) + .map(|(ix, c)| { + gpui::div() + .id(ix) + .flex() + .flex_col() + .flex_1() + .child(c) + .into_any_element() + }) .collect(); gpui::div() @@ -82,6 +90,7 @@ impl gpui::RenderOnce for Panel { .p(gpui::px(self.padding)) .flex() .flex_col() + .h_full() .children(header) .children(children) } diff --git a/src/main.rs b/src/main.rs index 0a5e73c..c0422b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,9 @@ use crate::app::App; mod app; mod components; +mod models; mod theme; +mod view; fn main() { App::run(); diff --git a/src/models/filter_state.rs b/src/models/filter_state.rs new file mode 100644 index 0000000..4963226 --- /dev/null +++ b/src/models/filter_state.rs @@ -0,0 +1,152 @@ +use std::collections::HashSet; + +#[derive(Debug, Clone, Default)] +pub struct FilterState { + pub selected_project: Option, + pub active_tags: HashSet, + pub search_text: String, + pub status_filter: StatusFilter, + pub priority_filter: PriorityFilter, + pub due_filter: DueFilter, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusFilter { + All, + Pending, + Completed, + Waiting, + Deleted, +} + +impl Default for StatusFilter { + fn default() -> Self { + Self::Pending + } +} + +impl StatusFilter { + pub fn as_str(&self) -> &'static str { + match self { + Self::All => "All", + Self::Pending => "Pending", + Self::Completed => "Completed", + Self::Waiting => "Waiting", + Self::Deleted => "Deleted", + } + } + + pub fn all_variants() -> &'static [Self] { + &[ + Self::All, + Self::Pending, + Self::Completed, + Self::Waiting, + Self::Deleted, + ] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PriorityFilter { + All, + High, + Medium, + Low, + None, +} + +impl Default for PriorityFilter { + fn default() -> Self { + Self::All + } +} + +impl PriorityFilter { + pub fn as_str(&self) -> &'static str { + match self { + Self::All => "All", + Self::High => "High (H)", + Self::Medium => "Medium (M)", + Self::Low => "Low (L)", + Self::None => "None", + } + } + + pub fn all_variants() -> &'static [Self] { + &[Self::All, Self::High, Self::Medium, Self::Low, Self::None] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DueFilter { + All, + Overdue, + Today, + ThisWeek, + NoDate, +} + +impl Default for DueFilter { + fn default() -> Self { + Self::All + } +} + +impl DueFilter { + pub fn as_str(&self) -> &'static str { + match self { + Self::All => "All", + Self::Overdue => "Overdue", + Self::Today => "Today", + Self::ThisWeek => "This Week", + Self::NoDate => "No Date", + } + } + + pub fn all_variants() -> &'static [Self] { + &[ + Self::All, + Self::Overdue, + Self::Today, + Self::ThisWeek, + Self::NoDate, + ] + } +} + +impl FilterState { + pub fn new() -> Self { + Self::default() + } + + pub fn select_project(&mut self, project: Option) { + self.selected_project = project; + } + + pub fn toggle_tag(&mut self, tag: String) { + if self.active_tags.contains(&tag) { + self.active_tags.remove(&tag); + } else { + self.active_tags.insert(tag); + } + } + + pub fn clear(&mut self) { + self.selected_project = None; + self.active_tags.clear(); + self.search_text.clear(); + self.status_filter = StatusFilter::default(); + self.priority_filter = PriorityFilter::default(); + self.due_filter = DueFilter::default(); + } + + pub fn has_active_filters(&self) -> bool { + self.selected_project.is_some() + || !self.active_tags.is_empty() + || !self.search_text.is_empty() + || self.status_filter != StatusFilter::default() + || self.priority_filter != PriorityFilter::default() + || self.due_filter != DueFilter::default() + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..cf29840 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,5 @@ +pub mod filter_state; +pub mod project_tree; + +pub use filter_state::*; +pub use project_tree::*; diff --git a/src/models/project_tree.rs b/src/models/project_tree.rs new file mode 100644 index 0000000..c1685f9 --- /dev/null +++ b/src/models/project_tree.rs @@ -0,0 +1,266 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct ProjectNode { + pub name: String, + pub full_path: String, + pub task_count: usize, + pub direct_task_count: usize, + pub level: usize, + pub children_indices: Vec, + pub is_expanded: bool, +} + +impl ProjectNode { + pub fn new(name: String, full_path: String, level: usize) -> Self { + Self { + name, + full_path, + task_count: 0, + direct_task_count: 0, + level, + children_indices: Vec::new(), + is_expanded: false, + } + } + + #[inline] + pub fn has_children(&self) -> bool { + !self.children_indices.is_empty() + } +} + +#[derive(Debug, Clone)] +pub struct ProjectTree { + nodes: Vec, + root_indices: Vec, + path_to_index: HashMap, + expanded_paths: HashMap, +} + +impl ProjectTree { + pub fn new() -> Self { + Self { + nodes: Vec::new(), + root_indices: Vec::new(), + path_to_index: HashMap::new(), + expanded_paths: HashMap::new(), + } + } + + pub fn build_from_projects(&mut self, projects: &[(String, usize)]) { + self.nodes.clear(); + self.root_indices.clear(); + self.path_to_index.clear(); + + for (project_path, task_count) in projects { + if project_path.is_empty() { + continue; + } + + let segments: Vec<&str> = project_path.split('.').collect(); + self.insert_project(&segments, task_count); + } + + self.root_indices.sort_by(|a, b| { + self.nodes[*a] + .name + .to_lowercase() + .cmp(&self.nodes[*b].name.to_lowercase()) + }); + + let nodes_len = self.nodes.len(); + for i in 0..nodes_len { + let mut indices_with_names: Vec<_> = self.nodes[i] + .children_indices + .iter() + .map(|&idx| (idx, self.nodes[idx].name.to_lowercase())) + .collect(); + indices_with_names.sort_by(|a, b| a.1.cmp(&b.1)); + self.nodes[i].children_indices = + indices_with_names.into_iter().map(|(idx, _)| idx).collect(); + } + + for (path, is_expanded) in &self.expanded_paths { + if let Some(&idx) = self.path_to_index.get(path) { + self.nodes[idx].is_expanded = *is_expanded; + } + } + } + + fn insert_project(&mut self, segments: &[&str], task_count: &usize) { + if segments.is_empty() { + return; + } + + let mut current_path = String::new(); + let mut parent_idx: Option = None; + + for (level, &segment) in segments.iter().enumerate() { + if !current_path.is_empty() { + current_path.push('.'); + } + current_path.push_str(segment); + + let node_idx = if let Some(&idx) = self.path_to_index.get(¤t_path) { + idx + } else { + let new_idx = self.nodes.len(); + let is_expanded = self + .expanded_paths + .get(¤t_path) + .copied() + .unwrap_or(false); + + let node = ProjectNode { + name: segment.to_string(), + full_path: current_path.clone(), + task_count: 0, + direct_task_count: 0, + level, + children_indices: Vec::new(), + is_expanded, + }; + + self.nodes.push(node); + self.path_to_index.insert(current_path.clone(), new_idx); + + if let Some(parent) = parent_idx { + self.nodes[parent].children_indices.push(new_idx); + } else { + self.root_indices.push(new_idx); + } + + new_idx + }; + + self.nodes[node_idx].task_count += task_count; + + if level == segments.len() - 1 { + self.nodes[node_idx].direct_task_count += task_count; + } + + parent_idx = Some(node_idx); + } + } + + pub fn toggle_expansion(&mut self, full_path: &str) { + if let Some(&idx) = self.path_to_index.get(full_path) { + let new_state = !self.nodes[idx].is_expanded; + self.nodes[idx].is_expanded = new_state; + self.expanded_paths.insert(full_path.to_string(), new_state); + } + } + + pub fn expand_path(&mut self, full_path: &str) { + let segments: Vec<&str> = full_path.split('.').collect(); + let mut current_path = String::new(); + + for segment in segments { + if !current_path.is_empty() { + current_path.push('.'); + } + current_path.push_str(segment); + + if let Some(&idx) = self.path_to_index.get(¤t_path) { + self.nodes[idx].is_expanded = true; + self.expanded_paths.insert(current_path.clone(), true); + } + } + } + + pub fn collapse_all(&mut self) { + for node in &mut self.nodes { + node.is_expanded = false; + } + self.expanded_paths.clear(); + } + + pub fn root_indices(&self) -> &[usize] { + &self.root_indices + } + + pub fn get_node(&self, idx: usize) -> Option<&ProjectNode> { + self.nodes.get(idx) + } + + pub fn get_node_mut(&mut self, idx: usize) -> Option<&mut ProjectNode> { + self.nodes.get_mut(idx) + } + + pub fn find_by_path(&self, path: &str) -> Option<&ProjectNode> { + self.path_to_index + .get(path) + .and_then(|&idx| self.nodes.get(idx)) + } + + pub fn iter_visible(&self) -> Vec<(usize, &ProjectNode)> { + let mut result = Vec::new(); + for &root_idx in &self.root_indices { + self.collect_visible(root_idx, &mut result); + } + result + } + + fn collect_visible<'a>(&'a self, idx: usize, result: &mut Vec<(usize, &'a ProjectNode)>) { + if let Some(node) = self.nodes.get(idx) { + result.push((idx, node)); + + if node.is_expanded { + for &child_idx in &node.children_indices { + self.collect_visible(child_idx, result); + } + } + } + } +} + +impl Default for ProjectTree { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_simple_tree() { + let mut tree = ProjectTree::new(); + tree.build_from_projects(&[("Work".to_string(), 5), ("Home".to_string(), 3)]); + + assert_eq!(tree.root_indices().len(), 2); + } + + #[test] + fn test_build_nested_tree() { + let mut tree = ProjectTree::new(); + tree.build_from_projects(&[ + ("Work.Backend.API".to_string(), 2), + ("Work.Backend.DB".to_string(), 3), + ("Work.Frontend".to_string(), 5), + ]); + + let work_node = tree.find_by_path("Work").unwrap(); + assert_eq!(work_node.task_count, 10); + assert_eq!(work_node.direct_task_count, 0); + + let backend_node = tree.find_by_path("Work.Backend").unwrap(); + assert_eq!(backend_node.task_count, 5); + } + + #[test] + fn test_toggle_expansion() { + let mut tree = ProjectTree::new(); + tree.build_from_projects(&[("Work.Backend".to_string(), 5)]); + + assert!(!tree.find_by_path("Work").unwrap().is_expanded); + + tree.toggle_expansion("Work"); + assert!(tree.find_by_path("Work").unwrap().is_expanded); + + tree.toggle_expansion("Work"); + assert!(!tree.find_by_path("Work").unwrap().is_expanded); + } +} diff --git a/src/view/mod.rs b/src/view/mod.rs new file mode 100644 index 0000000..e16f8da --- /dev/null +++ b/src/view/mod.rs @@ -0,0 +1 @@ +pub mod sidebar; diff --git a/src/view/sidebar.rs b/src/view/sidebar.rs new file mode 100644 index 0000000..dffa736 --- /dev/null +++ b/src/view/sidebar.rs @@ -0,0 +1,345 @@ +use crate::components::{divider::Divider, panel::Panel}; +use crate::models::{FilterState, ProjectTree}; +use crate::theme::ActiveTheme; +use gpui::{Context, Div, Entity, IntoElement, Window, div, prelude::*, px}; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct TagItem { + pub name: String, + pub task_count: usize, +} + +pub struct Sidebar { + project_tree: ProjectTree, + tags: Vec, + filter_state: Entity, + on_filter_change: Option) + 'static>>, +} + +impl Sidebar { + pub fn new( + project_tree: ProjectTree, + tags: Vec, + filter_state: Entity, + cx: &mut Context, + ) -> Self { + cx.observe(&filter_state, |_sidebar, _filter, cx| { + cx.notify(); + }) + .detach(); + + Self { + project_tree, + tags, + filter_state, + on_filter_change: None, + } + } + + pub fn on_filter_change(mut self, callback: F) -> Self + where + F: Fn(FilterState, &mut Window, &mut Context) + 'static, + { + self.on_filter_change = Some(Arc::new(callback)); + self + } + + pub fn update_projects(&mut self, project_tree: ProjectTree, cx: &mut Context) { + self.project_tree = project_tree; + cx.notify(); + } + + pub fn update_tags(&mut self, tags: Vec, cx: &mut Context) { + self.tags = tags; + cx.notify(); + } + + fn handle_project_click( + &mut self, + full_path: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.filter_state.update(cx, |filter, _cx| { + filter.select_project(full_path); + }); + + if let Some(callback) = &self.on_filter_change { + let filter = self.filter_state.read(cx).clone(); + callback(filter, window, cx); + } + + cx.notify(); + } + + fn handle_expand_toggle(&mut self, full_path: String, cx: &mut Context) { + self.project_tree.toggle_expansion(&full_path); + cx.notify(); + } + + fn handle_tag_click(&mut self, tag_name: String, window: &mut Window, cx: &mut Context) { + self.filter_state.update(cx, |filter, _cx| { + filter.toggle_tag(tag_name); + }); + + if let Some(callback) = &self.on_filter_change { + let filter = self.filter_state.read(cx).clone(); + callback(filter, window, cx); + } + + cx.notify(); + } + + fn render_projects(&self, cx: &mut Context) -> Vec
{ + let theme = cx.theme(); + let filter = self.filter_state.read(cx); + let mut elements = Vec::new(); + + let is_all_selected = filter.selected_project.is_none(); + elements.push( + div() + .flex() + .items_center() + .gap_2() + .px_3() + .py_1() + .cursor_pointer() + .when(is_all_selected, |this| this.bg(theme.selection)) + .hover(|style| style.bg(theme.panel)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|view, _event, window, cx| { + view.handle_project_click(None, window, cx); + }), + ) + .child( + div() + .w_3() + .h_3() + .rounded_full() + .border_1() + .border_color(theme.accent) + .when(is_all_selected, |this| this.bg(theme.accent)), + ) + .child( + div() + .text_color(if is_all_selected { + theme.foreground + } else { + theme.muted + }) + .child("All"), + ), + ); + + for (_idx, node) in self.project_tree.iter_visible() { + let is_selected = filter + .selected_project + .as_ref() + .map(|p| p == &node.full_path) + .unwrap_or(false); + + let indent = node.level * 16; + let full_path = node.full_path.clone(); + let full_path_for_expand = node.full_path.clone(); + let has_children = node.has_children(); + let is_expanded = node.is_expanded; + + elements.push( + div() + .flex() + .items_center() + .gap_1() + .px_3() + .py_1() + .cursor_pointer() + .when(is_selected, |this| this.bg(theme.selection)) + .hover(|style| style.bg(theme.panel)) + .child(div().w(px(indent as f32))) + .child( + div() + .w_4() + .h_4() + .flex() + .items_center() + .justify_center() + .when(has_children, |this| { + this.on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |view, _event, _window, cx| { + view.handle_expand_toggle(full_path_for_expand.clone(), cx); + }), + ) + }) + .child(if has_children { + div() + .text_color(theme.muted) + .text_xs() + .child(if is_expanded { "▼" } else { "▶" }) + } else { + div() + }), + ) + .child( + div() + .flex() + .flex_1() + .items_center() + .gap_2() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |view, _event, window, cx| { + view.handle_project_click(Some(full_path.clone()), window, cx); + }), + ) + .child( + div() + .w_3() + .h_3() + .rounded_full() + .border_1() + .border_color(theme.accent) + .when(is_selected, |this| this.bg(theme.accent)), + ) + .child( + div() + .text_color(if is_selected { + theme.foreground + } else { + theme.muted + }) + .child(format!("{} ({})", node.name, node.task_count)), + ), + ), + ); + } + + elements + } + + fn render_tags(&self, cx: &mut Context) -> Vec
{ + let theme = cx.theme(); + let filter = self.filter_state.read(cx); + let mut elements = Vec::new(); + + for tag in &self.tags { + let is_active = filter.active_tags.contains(&tag.name); + let tag_name = tag.name.clone(); + + elements.push( + div() + .flex() + .items_center() + .gap_2() + .px_3() + .py_1() + .cursor_pointer() + .when(is_active, |this| this.bg(theme.selection)) + .hover(|style| style.bg(theme.panel)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |view, _event, window, cx| { + view.handle_tag_click(tag_name.clone(), window, cx); + }), + ) + .child( + div() + .w_3() + .h_3() + .rounded_sm() + .border_1() + .border_color(theme.accent) + .when(is_active, |this| { + this.bg(theme.accent) + .flex() + .items_center() + .justify_center() + .child(div().text_color(theme.background).text_xs().child("✓")) + }), + ) + .child( + div() + .text_color(if is_active { + theme.foreground + } else { + theme.muted + }) + .child(format!("{} ({})", tag.name, tag.task_count)), + ), + ); + } + + elements + } +} + +impl Render for Sidebar { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let projects = self.render_projects(cx); + let tags = self.render_tags(cx); + + Panel::new().border(1.0).padding(0.0).child( + div() + .flex() + .flex_col() + .h_full() + .bg(theme.background) + .child( + div() + .px_3() + .py_2() + .border_b_1() + .border_color(theme.border) + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::BOLD) + .text_color(theme.foreground) + .child("PROJECTS"), + ), + ) + .child( + div() + .id("sidebar-projects") + .flex() + .flex_col() + .flex_1() + .py_2() + .overflow_y_scroll() + .scrollbar_width(gpui::px(6.0)) + .children(projects), + ) + .child(div().px_3().py_2().child(Divider::new( + theme.border, + crate::components::divider::DividerDirection::Horizontal, + ))) + .child( + div() + .px_3() + .py_2() + .border_b_1() + .border_color(theme.border) + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::BOLD) + .text_color(theme.foreground) + .child("TAGS"), + ), + ) + .child( + div() + .id("sidebar-tags") + .flex() + .flex_col() + .flex_1() + .py_2() + .overflow_y_scroll() + .scrollbar_width(gpui::px(6.0)) + .children(tags), + ), + ) + } +}