From d132e51da22d8495312026a3a82016d08f35fba9 Mon Sep 17 00:00:00 2001 From: Ignacio Perez Date: Sat, 27 Dec 2025 12:40:49 -0300 Subject: [PATCH] feat: Add TaskTable with sorting and pagination Add a new TaskTable component that displays tasks in a sortable, paginated table. Columns include ID, Description, Project, Due, Priority, and Status. Clicking column headers sorts the data (toggles between ascending and descending). Priority and status are color-coded based on theme colors. Update Panel component to accept an ID and simplify child rendering. Add warning, info, and priority colors (high/medium/low) to Theme. Add Into and Into implementations for TaskPriority and TaskStatus to support sorting and display. --- src/app.rs | 33 +- src/components/label.rs | 5 +- src/components/panel.rs | 23 +- src/task/model.rs | 38 +++ src/theme.rs | 17 + src/view/mod.rs | 1 + src/view/sidebar.rs | 14 +- src/view/task_table.rs | 686 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 774 insertions(+), 43 deletions(-) create mode 100644 src/view/task_table.rs diff --git a/src/app.rs b/src/app.rs index a54e7d9..6a1a0e6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,12 +5,14 @@ use crate::task::{TaskOverview, TaskService}; use crate::theme::ActiveTheme; use crate::view::sidebar::{Sidebar, TagItem}; use crate::view::status_bar::StatusBar; +use crate::view::task_table::TaskTable; use gpui::div; pub(crate) struct App { sidebar: gpui::Entity, filter_state: gpui::Entity, status_bar: gpui::Entity, + task_table: gpui::Entity, task_service: TaskService, } @@ -25,19 +27,21 @@ impl gpui::Render for App { let content = div() .flex() .flex_1() - .child(div().w(gpui::px(250.)).h_full().child(self.sidebar.clone())) + .min_h_0() + .overflow_hidden() + .child( + div() + .w(gpui::px(250.)) + .h_full() + .flex_shrink_0() + .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"), - ), + .min_w_0() + .child(self.task_table.clone()), ); div() @@ -88,14 +92,21 @@ impl App { let status_bar = cx.new(|cx| StatusBar::new(cx)); - let sidebar = cx.new(|cx| { - Sidebar::new(project_tree, tags, filter_state.clone(), cx) + let sidebar = + cx.new(|cx| Sidebar::new(project_tree, tags, filter_state.clone(), cx)); + + let task_table = cx + .new(|cx| TaskTable::new("main-task-table", filter_state.clone(), cx)); + + task_table.update(cx, |table, cx| { + table.reload_tasks(&mut task_service, cx); }); App { sidebar, filter_state, status_bar, + task_table, task_service, } }) diff --git a/src/components/label.rs b/src/components/label.rs index 4cc9fd3..781cf62 100644 --- a/src/components/label.rs +++ b/src/components/label.rs @@ -24,12 +24,9 @@ impl gpui::Styled for Label { } impl RenderOnce for Label { - fn render(mut self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement { - let theme = cx.theme(); - + fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl gpui::IntoElement { let mut div = gpui::div() .line_height(gpui::rems(1.25)) - .text_color(theme.foreground) .child(gpui::StyledText::new(&self.text)); *div.style() = self.style; diff --git a/src/components/panel.rs b/src/components/panel.rs index a7f2a71..eb909de 100644 --- a/src/components/panel.rs +++ b/src/components/panel.rs @@ -4,6 +4,7 @@ use crate::theme::ActiveTheme; #[derive(gpui::IntoElement)] pub struct Panel { + id: gpui::ElementId, content: Vec, title: Option, border: f32, @@ -12,12 +13,13 @@ pub struct Panel { } impl Panel { - pub fn new() -> Self { + pub fn new(id: impl Into) -> Self { Self { + id: id.into(), content: Vec::new(), title: None, - border: 1.0, - padding: 8.0, + border: 0.0, + padding: 0.0, style: gpui::StyleRefinement::default(), } } @@ -74,20 +76,7 @@ impl gpui::RenderOnce for Panel { .into_any_element() }); - let children: Vec = self - .content - .drain(..) - .enumerate() - .map(|(ix, c)| { - gpui::div() - .id(ix) - .flex() - .flex_col() - .flex_1() - .child(c) - .into_any_element() - }) - .collect(); + let children: Vec = self.content.drain(..).collect(); let mut div = gpui::div() .size_full() diff --git a/src/task/model.rs b/src/task/model.rs index 177b507..a32887a 100644 --- a/src/task/model.rs +++ b/src/task/model.rs @@ -10,6 +10,17 @@ pub enum TaskPriority { None, } +impl Into for TaskPriority { + fn into(self) -> usize { + match self { + TaskPriority::High => 0, + TaskPriority::Medium => 1, + TaskPriority::Low => 2, + TaskPriority::None => 3, + } + } +} + impl Default for TaskPriority { fn default() -> Self { TaskPriority::None @@ -27,6 +38,17 @@ impl std::fmt::Display for TaskPriority { } } +impl Into for TaskPriority { + fn into(self) -> String { + match self { + TaskPriority::High => "High".to_string(), + TaskPriority::Medium => "Medium".to_string(), + TaskPriority::Low => "Low".to_string(), + TaskPriority::None => "None".to_string(), + } + } +} + impl From<&str> for TaskPriority { fn from(s: &str) -> Self { match s { @@ -59,6 +81,18 @@ impl Default for TaskStatus { } } +impl Into for TaskStatus { + fn into(self) -> String { + match self { + TaskStatus::Pending => "Pending".to_string(), + TaskStatus::Completed => "Completed".to_string(), + TaskStatus::Deleted => "Deleted".to_string(), + TaskStatus::Unknown(reason) => format!("Unknown({})", reason), + TaskStatus::Recurring => "Recurring".to_string(), + } + } +} + impl From<&str> for TaskStatus { fn from(s: &str) -> Self { match s { @@ -119,6 +153,7 @@ impl From for TaskAnnotation { #[derive(Debug, Clone, Default)] pub struct Task { pub uuid: uuid::Uuid, + pub id: Option, pub status: TaskStatus, pub description: String, pub project: Option, @@ -138,6 +173,7 @@ pub struct Task { impl Task { pub fn new( uuid: uuid::Uuid, + id: Option, status: TaskStatus, description: String, project: Option, @@ -168,6 +204,7 @@ impl Task { dependencies, is_active, is_blocked, + id, working_id, } } @@ -186,6 +223,7 @@ impl From for Task { fn from(task: taskchampion::Task) -> Self { Self { uuid: task.get_uuid(), + id: task.get_value("ID").map(|value| value.parse().unwrap()), status: task.get_status().into(), description: task.get_description().to_string(), project: task.get_value("project").map(|v| v.to_string()), diff --git a/src/theme.rs b/src/theme.rs index 2c77744..7ddba4f 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -10,10 +10,16 @@ pub struct Theme { pub border: Color, pub error: Color, pub success: Color, + pub warning: Color, + pub info: Color, pub selection: Color, pub selection_foreground: Color, pub text: Color, pub text_size: Option>, + + pub high: Color, + pub medium: Color, + pub low: Color, } impl Theme { @@ -27,10 +33,16 @@ impl Theme { border: gpui::rgb(0x313244), error: gpui::rgb(0xF38BA8), success: gpui::rgb(0xA6E3A1), + warning: gpui::rgb(0xF9E2AF), + info: gpui::rgb(0x74C7EC), selection: gpui::rgb(0x45475A), selection_foreground: gpui::rgb(0xCDD6F4), text: gpui::rgb(0xCDD6F4), text_size: Some(gpui::Size::new(14, 14)), + + high: gpui::rgb(0xF38BA8), + medium: gpui::rgb(0xF9E2AF), + low: gpui::rgb(0xA6E3A1), } } @@ -44,10 +56,15 @@ impl Theme { border: gpui::rgb(0xE0E0E0), error: gpui::rgb(0xFF4444), success: gpui::rgb(0x4CAF50), + warning: gpui::rgb(0xFFA726), + info: gpui::rgb(0x29B6F6), selection: gpui::rgb(0xD0D0D0), selection_foreground: gpui::rgb(0x333333), text: gpui::rgb(0x333333), text_size: Some(gpui::Size::new(14, 14)), + high: gpui::rgb(0xF38BA8), + medium: gpui::rgb(0xF9E2AF), + low: gpui::rgb(0xA6E3A1), } } diff --git a/src/view/mod.rs b/src/view/mod.rs index 4946380..b55fc43 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -1,2 +1,3 @@ pub mod sidebar; pub mod status_bar; +pub mod task_table; diff --git a/src/view/sidebar.rs b/src/view/sidebar.rs index 63865a2..18a8975 100644 --- a/src/view/sidebar.rs +++ b/src/view/sidebar.rs @@ -1,8 +1,4 @@ -use crate::components::{ - icon::{Icon, IconName, IconSize}, - label::Label, - panel::Panel, -}; +use crate::components::{label::Label, panel::Panel}; use crate::models::{FilterState, ProjectTree}; use crate::theme::ActiveTheme; use gpui::{Context, Div, Entity, IntoElement, Window, div, prelude::*, px}; @@ -284,7 +280,7 @@ impl Render for Sidebar { let projects = self.render_projects(cx); let tags = self.render_tags(cx); - Panel::new().border(1.0).padding(0.0).child( + Panel::new("Sidebar").border(1.0).padding(0.0).child( div() .flex() .flex_col() @@ -322,11 +318,7 @@ impl Render for Sidebar { .children(projects), ), ) - .child( - div() - .h_px() - .bg(theme.border), - ) + .child(div().h_px().bg(theme.border)) .child( div() .flex() diff --git a/src/view/task_table.rs b/src/view/task_table.rs new file mode 100644 index 0000000..8522aeb --- /dev/null +++ b/src/view/task_table.rs @@ -0,0 +1,686 @@ +use std::cmp::Ordering; + +use gpui::prelude::*; + +use crate::{ + components, + models::FilterState, + task::{self, TaskFilter, TaskService}, + theme::{self, ActiveTheme}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortColumn { + Id, + Description, + Project, + Due, + Priority, + Status, +} + +impl SortColumn { + pub fn label(&self) -> &'static str { + match self { + SortColumn::Id => "ID", + SortColumn::Description => "Description", + SortColumn::Project => "Project", + SortColumn::Due => "Due", + SortColumn::Priority => "Priority", + SortColumn::Status => "Status", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortDirection { + Asc, + Desc, +} + +impl SortDirection { + pub fn toggle(&self) -> Self { + match self { + SortDirection::Asc => SortDirection::Desc, + SortDirection::Desc => SortDirection::Asc, + } + } + + pub fn arrow(&self) -> &'static str { + match self { + SortDirection::Asc => "↑", + SortDirection::Desc => "↓", + } + } +} + +impl Default for SortDirection { + fn default() -> Self { + Self::Desc + } +} + +#[derive(Debug, Clone, Copy)] +pub struct SortState { + pub column: SortColumn, + pub direction: SortDirection, +} + +impl Default for SortState { + fn default() -> Self { + Self { + column: SortColumn::Priority, + direction: SortDirection::Desc, + } + } +} + +pub struct PaginationState { + current_page: usize, + page_size: usize, + total_items: usize, +} + +impl Default for PaginationState { + fn default() -> Self { + Self { + current_page: 1, + page_size: 20, + total_items: 0, + } + } +} + +impl PaginationState { + pub fn new(current_page: usize, page_size: usize, total_items: usize) -> Self { + Self { + current_page, + page_size, + total_items, + } + } + + pub fn total_items(&mut self, total_items: usize) { + self.total_items = total_items; + } + + pub fn page_size(&mut self, page_size: usize) { + self.page_size = page_size; + } + + pub fn current_page(&mut self, current_page: usize) { + self.current_page = current_page; + } + + #[inline] + pub fn can_next(&self) -> bool { + self.current_page < self.total_items / self.page_size + 1 + } + + #[inline] + pub fn can_previous(&self) -> bool { + self.current_page > 1 + } + + pub fn next_page(&mut self) { + if self.current_page < self.total_items / self.page_size + 1 { + self.current_page += 1; + } + } + + pub fn previous_page(&mut self) { + if self.current_page > 1 { + self.current_page -= 1; + } + } + + pub fn first_item_index(&self) -> usize { + (self.current_page - 1) * self.page_size + } + + pub fn last_item_index(&self) -> usize { + (self.first_item_index() + self.page_size).min(self.total_items) + } + + pub fn last_item_display(&self) -> usize { + self.last_item_index() + } +} + +pub struct TaskRow { + pub uuid: uuid::Uuid, + pub id_display: String, + pub description: String, + pub project: String, + pub due: String, + pub priority: String, + pub status: String, + pub is_due_today: bool, + pub is_overdue: bool, + pub is_active: bool, +} + +impl TaskRow { + fn truncate(desc: &str, max_len: usize) -> String { + if desc.len() <= max_len { + desc.to_string() + } else { + format!("{}...", &desc[..max_len - 3]) + } + } + + fn format_date(due: &Option>, is_today: bool) -> String { + match due { + None => "-".to_string(), + Some(dt) => { + if is_today { + "Today".to_string() + } else { + dt.format("%d-%m-%Y").to_string() + } + } + } + } +} + +impl From<&task::Task> for TaskRow { + fn from(value: &task::Task) -> Self { + let status = if value.is_active { + "Active".to_string() + } else { + value.status.clone().into() + }; + + Self { + uuid: value.uuid, + id_display: value.working_id.unwrap_or(0).to_string(), + description: Self::truncate(&value.description, 50), + project: value.project.clone().unwrap_or(String::new()), + due: Self::format_date(&value.due, value.is_due_today()), + priority: value.priority.into(), + status, + is_due_today: value.is_due_today(), + is_overdue: value.is_overdue(), + is_active: value.is_active, + } + } +} + +pub struct TaskTable { + id: gpui::ElementId, + filter_state: gpui::Entity, + cached_tasks: Vec, + cached_rows: Vec, + sort_state: SortState, + pagination: PaginationState, + selected_page_idx: Option, + selected_global_idx: Option, + need_reload: bool, +} + +impl TaskTable { + pub fn new( + id: impl Into, + filter_state: gpui::Entity, + cx: &mut gpui::Context, + ) -> Self { + cx.observe(&filter_state, |table, _filter, cx| { + table.need_reload = true; + cx.notify(); + }) + .detach(); + + Self { + id: id.into(), + filter_state, + cached_tasks: vec![], + cached_rows: vec![], + sort_state: SortState::default(), + pagination: PaginationState::default(), + selected_page_idx: None, + selected_global_idx: None, + need_reload: true, + } + } + + pub fn set_sort(&mut self, column: SortColumn, cx: &mut gpui::Context) { + if self.sort_state.column == column { + self.sort_state.direction = self.sort_state.direction.toggle(); + } else { + self.sort_state.column = column; + self.sort_state.direction = SortDirection::Desc; + } + self.apply_sort(); + self.recalculate_rows(); + cx.notify(); + } + + fn apply_sort(&mut self) { + let direction = self.sort_state.direction; + let column = self.sort_state.column; + + self.cached_tasks.sort_by(|a, b| { + let ordering = match column { + SortColumn::Id => a.working_id.unwrap_or(0).cmp(&b.working_id.unwrap_or(0)), + SortColumn::Description => a.description.cmp(&b.description), + SortColumn::Project => { + let a_proj = a.project.as_deref().unwrap_or(""); + let b_proj = b.project.as_deref().unwrap_or(""); + a_proj.cmp(b_proj) + } + SortColumn::Due => match (&a.due, &b.due) { + (Some(a_due), Some(b_due)) => a_due.cmp(b_due), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + }, + SortColumn::Priority => { + let a_order: usize = a.priority.into(); + let b_order: usize = b.priority.into(); + a_order.cmp(&b_order) + } + SortColumn::Status => { + let a_status: String = a.status.clone().into(); + let b_status: String = b.status.clone().into(); + a_status.cmp(&b_status) + } + }; + + match direction { + SortDirection::Asc => ordering, + SortDirection::Desc => ordering.reverse(), + } + }); + } + + fn get_current_page_rows(&self) -> &[TaskRow] { + let start = self.pagination.first_item_index(); + let end = self.pagination.last_item_index(); + + &self.cached_rows[start..end] + } + + pub fn reload_tasks(&mut self, task_service: &mut TaskService, cx: &mut gpui::Context) { + let filter_state = self.filter_state.read(cx).clone(); + + let task_filter = TaskFilter::from(&filter_state); + + let filtered_tasks = task_service + .get_filtered_tasks(&task_filter) + .unwrap_or_else(|e| { + log::error!("[TaskTable] Failed to load filtered tasks: {}", e); + vec![] + }); + + self.cached_tasks = filtered_tasks; + self.apply_sort(); + self.pagination.total_items(self.cached_tasks.len()); + + if let Some(idx) = self.selected_global_idx { + if idx >= self.cached_tasks.len() { + self.selected_global_idx = None; + self.selected_page_idx = None; + } + } + + self.recalculate_rows(); + + self.need_reload = false; + + cx.notify(); + } + + fn recalculate_rows(&mut self) { + self.cached_rows = self.cached_tasks.iter().map(TaskRow::from).collect(); + } + + fn priority_color(&self, row: &TaskRow, cx: &gpui::Context) -> theme::Color { + let theme = cx.theme(); + + match row.priority.as_str() { + "High" => theme.high, + "Medium" => theme.medium, + "Low" => theme.low, + _ => theme.foreground, + } + } + + fn due_color(&self, row: &TaskRow, cx: &gpui::Context) -> theme::Color { + let theme = cx.theme(); + + if row.is_due_today { + theme.accent + } else if row.is_overdue { + theme.error + } else { + theme.foreground + } + } + + fn status_color(&self, row: &TaskRow, cx: &gpui::Context) -> theme::Color { + let theme = cx.theme(); + + match row.status.as_str() { + "Active" => theme.success, + "Pending" => theme.warning, + "Completed" => theme.muted, + "Deleted" => theme.error, + "Recurring" => theme.info, + _ => theme.muted, + } + } + + pub fn select_row(&mut self, idx: usize, cx: &mut gpui::Context) { + self.selected_page_idx = Some(idx); + self.selected_global_idx = Some(self.pagination.first_item_index() + idx); + cx.notify(); + } + + pub fn go_previous_page(&mut self, cx: &mut gpui::Context) { + self.pagination.previous_page(); + cx.notify(); + } + + pub fn go_next_page(&mut self, cx: &mut gpui::Context) { + self.pagination.next_page(); + cx.notify(); + } + + fn render_header_column( + &self, + column: SortColumn, + id: &'static str, + cx: &gpui::Context, + ) -> impl gpui::IntoElement { + let theme = cx.theme(); + let is_sorted = self.sort_state.column == column; + let arrow = if is_sorted { + self.sort_state.direction.arrow() + } else { + "" + }; + + gpui::div() + .id(id) + .flex() + .items_center() + .gap_1() + .cursor_pointer() + .hover(|s| s.text_color(theme.foreground)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |table, _, _, cx| { + table.set_sort(column, cx); + }), + ) + .child( + components::label::Label::new(column.label()).text_color(if is_sorted { + theme.accent + } else { + theme.muted + }), + ) + .when(!arrow.is_empty(), |div| { + div.child(components::label::Label::new(arrow).text_color(theme.accent)) + }) + } + + fn render_header(&self, cx: &gpui::Context) -> gpui::Div { + let theme = cx.theme(); + + gpui::div() + .flex() + .flex_shrink_0() + .items_center() + .gap_2() + .px_4() + .py_1() + .border_b_1() + .border_color(theme.border) + .bg(theme.panel) + .text_sm() + .font_weight(gpui::FontWeight::MEDIUM) + .child( + gpui::div() + .min_w(gpui::rems(3.0)) + .flex() + .items_center() + .gap_1() + .child(components::label::Label::new(" ").text_color(theme.muted)) + .child(self.render_header_column(SortColumn::Id, "header-id", cx)), + ) + .child( + gpui::div() + .flex_1() + .min_w(gpui::rems(10.0)) + .child(self.render_header_column(SortColumn::Description, "header-desc", cx)), + ) + .child( + gpui::div() + .w(gpui::rems(10.0)) + .child(self.render_header_column(SortColumn::Project, "header-project", cx)), + ) + .child( + gpui::div() + .w(gpui::rems(7.0)) + .child(self.render_header_column(SortColumn::Due, "header-due", cx)), + ) + .child( + gpui::div() + .w(gpui::rems(5.0)) + .child(self.render_header_column(SortColumn::Priority, "header-priority", cx)), + ) + .child( + gpui::div() + .w(gpui::rems(6.0)) + .child(self.render_header_column(SortColumn::Status, "header-status", cx)), + ) + } + + fn render_row(&self, idx: usize, row: &TaskRow, cx: &gpui::Context) -> gpui::Div { + let theme = cx.theme(); + let selected = self.selected_page_idx == Some(idx); + + gpui::div() + .flex() + .items_center() + .gap_2() + .px_4() + .py_1() + .border_b_1() + .border_color(theme.border) + .text_color(theme.foreground) + .when(selected, |d| { + d.bg(theme.selection).text_color(theme.selection_foreground) + }) + .when(!selected, |d| d.hover(|s| s.bg(theme.panel))) + .cursor_pointer() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |table, _, _, cx| table.select_row(idx, cx)), + ) + .child( + gpui::div() + .min_w(gpui::rems(3.0)) + .flex() + .items_center() + .gap_1() + .child( + components::label::Label::new(if selected { ">" } else { " " }) + .text_color(theme.accent), + ) + .child(components::label::Label::new(row.id_display.clone())), + ) + .child( + gpui::div() + .flex_1() + .min_w(gpui::rems(10.0)) + .overflow_x_hidden() + .child( + components::label::Label::new(row.description.clone()) + .text_ellipsis() + .whitespace_nowrap(), + ), + ) + .child( + gpui::div().w(gpui::rems(10.0)).overflow_x_hidden().child( + components::label::Label::new(row.project.clone()) + .text_color(theme.muted) + .text_ellipsis() + .whitespace_nowrap(), + ), + ) + .child(gpui::div().w(gpui::rems(7.0)).child( + components::label::Label::new(row.due.clone()).text_color(self.due_color(row, cx)), + )) + .child( + gpui::div().w(gpui::rems(5.0)).child( + components::label::Label::new(row.priority.clone()) + .text_color(self.priority_color(row, cx)) + .font_weight(gpui::FontWeight::BOLD), + ), + ) + .child( + gpui::div().w(gpui::rems(6.0)).child( + components::label::Label::new(row.status.clone()) + .text_color(self.status_color(row, cx)), + ), + ) + } + + fn render_footer(&self, cx: &gpui::Context) -> gpui::Div { + let theme = cx.theme(); + let can_prev = self.pagination.can_previous(); + let can_next = self.pagination.can_next(); + let pages = (self.pagination.total_items + self.pagination.page_size - 1) + / self.pagination.page_size.max(1); + + gpui::div() + .flex() + .flex_shrink_0() + .justify_between() + .items_center() + .px_4() + .py_2() + .border_t_1() + .border_color(theme.border) + .bg(theme.panel) + .text_sm() + .child( + components::label::Label::new(format!( + "Showing {}-{} of {}", + self.pagination.first_item_index() + 1, + self.pagination.last_item_display(), + self.pagination.total_items + )) + .text_color(theme.muted), + ) + .child( + gpui::div() + .flex() + .items_center() + .gap_3() + .child( + components::label::Label::new(format!( + "Page {} of {}", + self.pagination.current_page, + pages.max(1) + )) + .text_color(theme.muted), + ) + .child( + gpui::div() + .flex() + .gap_1() + .child( + gpui::div() + .id("prev-btn") + .px_2() + .py_1() + .rounded_md() + .text_color(if can_prev { + theme.foreground + } else { + theme.muted + }) + .when(can_prev, |d| { + d.cursor_pointer().hover(|s| s.bg(theme.selection)) + }) + .when(!can_prev, |d| d.cursor_not_allowed()) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|table, _, _, cx| table.go_previous_page(cx)), + ) + .child(components::label::Label::new("← Prev")), + ) + .child( + gpui::div() + .id("next-btn") + .px_2() + .py_1() + .rounded_md() + .text_color(if can_next { + theme.foreground + } else { + theme.muted + }) + .when(can_next, |d| { + d.cursor_pointer().hover(|s| s.bg(theme.selection)) + }) + .when(!can_next, |d| d.cursor_not_allowed()) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|table, _, _, cx| table.go_next_page(cx)), + ) + .child(components::label::Label::new("Next →")), + ), + ), + ) + } +} + +impl gpui::Render for TaskTable { + fn render( + &mut self, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + let theme = cx.theme(); + + let panel = components::panel::Panel::new(self.id.clone()); + + if self.need_reload { + return panel + .flex() + .flex_col() + .size_full() + .items_center() + .justify_center() + .child(components::label::Label::new("Loading...").text_color(theme.text)); + } + + let current_page = self.get_current_page_rows(); + let rows: Vec = current_page + .iter() + .enumerate() + .map(|(index, row)| self.render_row(index, row, cx)) + .collect(); + + panel + .flex() + .flex_col() + .size_full() + .overflow_hidden() + .bg(theme.background) + .child(self.render_header(cx)) + .child( + gpui::div() + .id("task-table-content") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .child(gpui::div().flex().flex_col().children(rows)), + ) + .child(self.render_footer(cx)) + } +}