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
This commit is contained in:
+78
-45
@@ -1,14 +1,13 @@
|
|||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::components::{
|
use crate::models::{FilterState, ProjectTree};
|
||||||
self,
|
use crate::theme::ActiveTheme;
|
||||||
input::{Input, Suggestion},
|
use crate::view::sidebar::{Sidebar, TagItem};
|
||||||
};
|
use gpui::div;
|
||||||
|
|
||||||
pub(crate) struct App {
|
pub(crate) struct App {
|
||||||
input: gpui::Entity<Input>,
|
sidebar: gpui::Entity<Sidebar>,
|
||||||
current_text: gpui::SharedString,
|
filter_state: gpui::Entity<FilterState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl gpui::Render for App {
|
impl gpui::Render for App {
|
||||||
@@ -17,12 +16,26 @@ impl gpui::Render for App {
|
|||||||
_window: &mut gpui::Window,
|
_window: &mut gpui::Window,
|
||||||
_cx: &mut gpui::Context<Self>,
|
_cx: &mut gpui::Context<Self>,
|
||||||
) -> impl gpui::IntoElement {
|
) -> impl gpui::IntoElement {
|
||||||
components::panel::Panel::new()
|
let theme = _cx.theme();
|
||||||
.child(self.input.clone())
|
|
||||||
.child(components::label::Label::new(format!(
|
div()
|
||||||
"Text: {}",
|
.flex()
|
||||||
&self.current_text
|
.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(),
|
gpui::WindowOptions::default(),
|
||||||
|_window: &mut gpui::Window, app: &mut gpui::App| {
|
|_window: &mut gpui::Window, app: &mut gpui::App| {
|
||||||
app.new(|cx: &mut gpui::Context<'_, App>| {
|
app.new(|cx: &mut gpui::Context<'_, App>| {
|
||||||
let input = cx.new(|cx| {
|
let filter_state = cx.new(|_cx| FilterState::new());
|
||||||
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"),
|
|
||||||
];
|
|
||||||
|
|
||||||
suggestions
|
let mut project_tree = ProjectTree::new();
|
||||||
.into_iter()
|
project_tree.build_from_projects(&[
|
||||||
.filter(|(insert, _)| {
|
("Work.Backend.API".to_string(), 5),
|
||||||
text.is_empty()
|
("Work.Backend.DB".to_string(), 7),
|
||||||
|| insert.to_lowercase().contains(&text.to_lowercase())
|
("Work.Frontend.React".to_string(), 3),
|
||||||
})
|
("Work.Frontend.Styling".to_string(), 2),
|
||||||
.map(|(insert, label)| {
|
("Home.Kitchen".to_string(), 4),
|
||||||
Suggestion::new(format!("{} - {}", insert, label), insert)
|
("Home.Garden".to_string(), 2),
|
||||||
})
|
("ignis.v0.1.phase0".to_string(), 15),
|
||||||
.collect()
|
("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 {
|
App {
|
||||||
input,
|
sidebar,
|
||||||
current_text: "".into(),
|
filter_state,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
+10
-1
@@ -70,7 +70,15 @@ impl gpui::RenderOnce for Panel {
|
|||||||
.content
|
.content
|
||||||
.drain(..)
|
.drain(..)
|
||||||
.enumerate()
|
.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();
|
.collect();
|
||||||
|
|
||||||
gpui::div()
|
gpui::div()
|
||||||
@@ -82,6 +90,7 @@ impl gpui::RenderOnce for Panel {
|
|||||||
.p(gpui::px(self.padding))
|
.p(gpui::px(self.padding))
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
|
.h_full()
|
||||||
.children(header)
|
.children(header)
|
||||||
.children(children)
|
.children(children)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ use crate::app::App;
|
|||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod components;
|
mod components;
|
||||||
|
mod models;
|
||||||
mod theme;
|
mod theme;
|
||||||
|
mod view;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
App::run();
|
App::run();
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FilterState {
|
||||||
|
pub selected_project: Option<String>,
|
||||||
|
pub active_tags: HashSet<String>,
|
||||||
|
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<String>) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod filter_state;
|
||||||
|
pub mod project_tree;
|
||||||
|
|
||||||
|
pub use filter_state::*;
|
||||||
|
pub use project_tree::*;
|
||||||
@@ -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<usize>,
|
||||||
|
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<ProjectNode>,
|
||||||
|
root_indices: Vec<usize>,
|
||||||
|
path_to_index: HashMap<String, usize>,
|
||||||
|
expanded_paths: HashMap<String, bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<usize> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod sidebar;
|
||||||
@@ -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<TagItem>,
|
||||||
|
filter_state: Entity<FilterState>,
|
||||||
|
on_filter_change: Option<Arc<dyn Fn(FilterState, &mut Window, &mut Context<Self>) + 'static>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sidebar {
|
||||||
|
pub fn new(
|
||||||
|
project_tree: ProjectTree,
|
||||||
|
tags: Vec<TagItem>,
|
||||||
|
filter_state: Entity<FilterState>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> 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<F>(mut self, callback: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(FilterState, &mut Window, &mut Context<Self>) + 'static,
|
||||||
|
{
|
||||||
|
self.on_filter_change = Some(Arc::new(callback));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_projects(&mut self, project_tree: ProjectTree, cx: &mut Context<Self>) {
|
||||||
|
self.project_tree = project_tree;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_tags(&mut self, tags: Vec<TagItem>, cx: &mut Context<Self>) {
|
||||||
|
self.tags = tags;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_project_click(
|
||||||
|
&mut self,
|
||||||
|
full_path: Option<String>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
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>) {
|
||||||
|
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>) {
|
||||||
|
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<Self>) -> Vec<Div> {
|
||||||
|
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<Self>) -> Vec<Div> {
|
||||||
|
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<Self>) -> 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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user