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<usize> and Into<String> implementations for TaskPriority and TaskStatus to support sorting and display.
This commit is contained in:
+22
-11
@@ -5,12 +5,14 @@ use crate::task::{TaskOverview, TaskService};
|
|||||||
use crate::theme::ActiveTheme;
|
use crate::theme::ActiveTheme;
|
||||||
use crate::view::sidebar::{Sidebar, TagItem};
|
use crate::view::sidebar::{Sidebar, TagItem};
|
||||||
use crate::view::status_bar::StatusBar;
|
use crate::view::status_bar::StatusBar;
|
||||||
|
use crate::view::task_table::TaskTable;
|
||||||
use gpui::div;
|
use gpui::div;
|
||||||
|
|
||||||
pub(crate) struct App {
|
pub(crate) struct App {
|
||||||
sidebar: gpui::Entity<Sidebar>,
|
sidebar: gpui::Entity<Sidebar>,
|
||||||
filter_state: gpui::Entity<FilterState>,
|
filter_state: gpui::Entity<FilterState>,
|
||||||
status_bar: gpui::Entity<StatusBar>,
|
status_bar: gpui::Entity<StatusBar>,
|
||||||
|
task_table: gpui::Entity<TaskTable>,
|
||||||
task_service: TaskService,
|
task_service: TaskService,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,19 +27,21 @@ impl gpui::Render for App {
|
|||||||
let content = div()
|
let content = div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_1()
|
.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(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.h_full()
|
.h_full()
|
||||||
.flex()
|
.min_w_0()
|
||||||
.items_center()
|
.child(self.task_table.clone()),
|
||||||
.justify_center()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(theme.muted)
|
|
||||||
.child("Main panel - TaskTable will go here"),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
div()
|
div()
|
||||||
@@ -88,14 +92,21 @@ impl App {
|
|||||||
|
|
||||||
let status_bar = cx.new(|cx| StatusBar::new(cx));
|
let status_bar = cx.new(|cx| StatusBar::new(cx));
|
||||||
|
|
||||||
let sidebar = cx.new(|cx| {
|
let sidebar =
|
||||||
Sidebar::new(project_tree, tags, filter_state.clone(), cx)
|
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 {
|
App {
|
||||||
sidebar,
|
sidebar,
|
||||||
filter_state,
|
filter_state,
|
||||||
status_bar,
|
status_bar,
|
||||||
|
task_table,
|
||||||
task_service,
|
task_service,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,12 +24,9 @@ impl gpui::Styled for Label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for Label {
|
impl RenderOnce for Label {
|
||||||
fn render(mut self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
|
fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl gpui::IntoElement {
|
||||||
let theme = cx.theme();
|
|
||||||
|
|
||||||
let mut div = gpui::div()
|
let mut div = gpui::div()
|
||||||
.line_height(gpui::rems(1.25))
|
.line_height(gpui::rems(1.25))
|
||||||
.text_color(theme.foreground)
|
|
||||||
.child(gpui::StyledText::new(&self.text));
|
.child(gpui::StyledText::new(&self.text));
|
||||||
|
|
||||||
*div.style() = self.style;
|
*div.style() = self.style;
|
||||||
|
|||||||
+6
-17
@@ -4,6 +4,7 @@ use crate::theme::ActiveTheme;
|
|||||||
|
|
||||||
#[derive(gpui::IntoElement)]
|
#[derive(gpui::IntoElement)]
|
||||||
pub struct Panel {
|
pub struct Panel {
|
||||||
|
id: gpui::ElementId,
|
||||||
content: Vec<gpui::AnyElement>,
|
content: Vec<gpui::AnyElement>,
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
border: f32,
|
border: f32,
|
||||||
@@ -12,12 +13,13 @@ pub struct Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Panel {
|
impl Panel {
|
||||||
pub fn new() -> Self {
|
pub fn new(id: impl Into<gpui::ElementId>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
id: id.into(),
|
||||||
content: Vec::new(),
|
content: Vec::new(),
|
||||||
title: None,
|
title: None,
|
||||||
border: 1.0,
|
border: 0.0,
|
||||||
padding: 8.0,
|
padding: 0.0,
|
||||||
style: gpui::StyleRefinement::default(),
|
style: gpui::StyleRefinement::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,20 +76,7 @@ impl gpui::RenderOnce for Panel {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
});
|
});
|
||||||
|
|
||||||
let children: Vec<gpui::AnyElement> = self
|
let children: Vec<gpui::AnyElement> = self.content.drain(..).collect();
|
||||||
.content
|
|
||||||
.drain(..)
|
|
||||||
.enumerate()
|
|
||||||
.map(|(ix, c)| {
|
|
||||||
gpui::div()
|
|
||||||
.id(ix)
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.flex_1()
|
|
||||||
.child(c)
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut div = gpui::div()
|
let mut div = gpui::div()
|
||||||
.size_full()
|
.size_full()
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ pub enum TaskPriority {
|
|||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Into<usize> for TaskPriority {
|
||||||
|
fn into(self) -> usize {
|
||||||
|
match self {
|
||||||
|
TaskPriority::High => 0,
|
||||||
|
TaskPriority::Medium => 1,
|
||||||
|
TaskPriority::Low => 2,
|
||||||
|
TaskPriority::None => 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for TaskPriority {
|
impl Default for TaskPriority {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
TaskPriority::None
|
TaskPriority::None
|
||||||
@@ -27,6 +38,17 @@ impl std::fmt::Display for TaskPriority {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Into<String> 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 {
|
impl From<&str> for TaskPriority {
|
||||||
fn from(s: &str) -> Self {
|
fn from(s: &str) -> Self {
|
||||||
match s {
|
match s {
|
||||||
@@ -59,6 +81,18 @@ impl Default for TaskStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Into<String> 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 {
|
impl From<&str> for TaskStatus {
|
||||||
fn from(s: &str) -> Self {
|
fn from(s: &str) -> Self {
|
||||||
match s {
|
match s {
|
||||||
@@ -119,6 +153,7 @@ impl From<taskchampion::Annotation> for TaskAnnotation {
|
|||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct Task {
|
pub struct Task {
|
||||||
pub uuid: uuid::Uuid,
|
pub uuid: uuid::Uuid,
|
||||||
|
pub id: Option<usize>,
|
||||||
pub status: TaskStatus,
|
pub status: TaskStatus,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub project: Option<String>,
|
pub project: Option<String>,
|
||||||
@@ -138,6 +173,7 @@ pub struct Task {
|
|||||||
impl Task {
|
impl Task {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
uuid: uuid::Uuid,
|
uuid: uuid::Uuid,
|
||||||
|
id: Option<usize>,
|
||||||
status: TaskStatus,
|
status: TaskStatus,
|
||||||
description: String,
|
description: String,
|
||||||
project: Option<String>,
|
project: Option<String>,
|
||||||
@@ -168,6 +204,7 @@ impl Task {
|
|||||||
dependencies,
|
dependencies,
|
||||||
is_active,
|
is_active,
|
||||||
is_blocked,
|
is_blocked,
|
||||||
|
id,
|
||||||
working_id,
|
working_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +223,7 @@ impl From<taskchampion::Task> for Task {
|
|||||||
fn from(task: taskchampion::Task) -> Self {
|
fn from(task: taskchampion::Task) -> Self {
|
||||||
Self {
|
Self {
|
||||||
uuid: task.get_uuid(),
|
uuid: task.get_uuid(),
|
||||||
|
id: task.get_value("ID").map(|value| value.parse().unwrap()),
|
||||||
status: task.get_status().into(),
|
status: task.get_status().into(),
|
||||||
description: task.get_description().to_string(),
|
description: task.get_description().to_string(),
|
||||||
project: task.get_value("project").map(|v| v.to_string()),
|
project: task.get_value("project").map(|v| v.to_string()),
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ pub struct Theme {
|
|||||||
pub border: Color,
|
pub border: Color,
|
||||||
pub error: Color,
|
pub error: Color,
|
||||||
pub success: Color,
|
pub success: Color,
|
||||||
|
pub warning: Color,
|
||||||
|
pub info: Color,
|
||||||
pub selection: Color,
|
pub selection: Color,
|
||||||
pub selection_foreground: Color,
|
pub selection_foreground: Color,
|
||||||
pub text: Color,
|
pub text: Color,
|
||||||
pub text_size: Option<gpui::Size<u32>>,
|
pub text_size: Option<gpui::Size<u32>>,
|
||||||
|
|
||||||
|
pub high: Color,
|
||||||
|
pub medium: Color,
|
||||||
|
pub low: Color,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Theme {
|
impl Theme {
|
||||||
@@ -27,10 +33,16 @@ impl Theme {
|
|||||||
border: gpui::rgb(0x313244),
|
border: gpui::rgb(0x313244),
|
||||||
error: gpui::rgb(0xF38BA8),
|
error: gpui::rgb(0xF38BA8),
|
||||||
success: gpui::rgb(0xA6E3A1),
|
success: gpui::rgb(0xA6E3A1),
|
||||||
|
warning: gpui::rgb(0xF9E2AF),
|
||||||
|
info: gpui::rgb(0x74C7EC),
|
||||||
selection: gpui::rgb(0x45475A),
|
selection: gpui::rgb(0x45475A),
|
||||||
selection_foreground: gpui::rgb(0xCDD6F4),
|
selection_foreground: gpui::rgb(0xCDD6F4),
|
||||||
text: gpui::rgb(0xCDD6F4),
|
text: gpui::rgb(0xCDD6F4),
|
||||||
text_size: Some(gpui::Size::new(14, 14)),
|
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),
|
border: gpui::rgb(0xE0E0E0),
|
||||||
error: gpui::rgb(0xFF4444),
|
error: gpui::rgb(0xFF4444),
|
||||||
success: gpui::rgb(0x4CAF50),
|
success: gpui::rgb(0x4CAF50),
|
||||||
|
warning: gpui::rgb(0xFFA726),
|
||||||
|
info: gpui::rgb(0x29B6F6),
|
||||||
selection: gpui::rgb(0xD0D0D0),
|
selection: gpui::rgb(0xD0D0D0),
|
||||||
selection_foreground: gpui::rgb(0x333333),
|
selection_foreground: gpui::rgb(0x333333),
|
||||||
text: gpui::rgb(0x333333),
|
text: gpui::rgb(0x333333),
|
||||||
text_size: Some(gpui::Size::new(14, 14)),
|
text_size: Some(gpui::Size::new(14, 14)),
|
||||||
|
high: gpui::rgb(0xF38BA8),
|
||||||
|
medium: gpui::rgb(0xF9E2AF),
|
||||||
|
low: gpui::rgb(0xA6E3A1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod status_bar;
|
pub mod status_bar;
|
||||||
|
pub mod task_table;
|
||||||
|
|||||||
+3
-11
@@ -1,8 +1,4 @@
|
|||||||
use crate::components::{
|
use crate::components::{label::Label, panel::Panel};
|
||||||
icon::{Icon, IconName, IconSize},
|
|
||||||
label::Label,
|
|
||||||
panel::Panel,
|
|
||||||
};
|
|
||||||
use crate::models::{FilterState, ProjectTree};
|
use crate::models::{FilterState, ProjectTree};
|
||||||
use crate::theme::ActiveTheme;
|
use crate::theme::ActiveTheme;
|
||||||
use gpui::{Context, Div, Entity, IntoElement, Window, div, prelude::*, px};
|
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 projects = self.render_projects(cx);
|
||||||
let tags = self.render_tags(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()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
@@ -322,11 +318,7 @@ impl Render for Sidebar {
|
|||||||
.children(projects),
|
.children(projects),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(div().h_px().bg(theme.border))
|
||||||
div()
|
|
||||||
.h_px()
|
|
||||||
.bg(theme.border),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
|
|||||||
@@ -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<chrono::DateTime<chrono::Utc>>, 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<FilterState>,
|
||||||
|
cached_tasks: Vec<task::Task>,
|
||||||
|
cached_rows: Vec<TaskRow>,
|
||||||
|
sort_state: SortState,
|
||||||
|
pagination: PaginationState,
|
||||||
|
selected_page_idx: Option<usize>,
|
||||||
|
selected_global_idx: Option<usize>,
|
||||||
|
need_reload: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskTable {
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<gpui::ElementId>,
|
||||||
|
filter_state: gpui::Entity<FilterState>,
|
||||||
|
cx: &mut gpui::Context<Self>,
|
||||||
|
) -> 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<Self>) {
|
||||||
|
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<Self>) {
|
||||||
|
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<Self>) -> 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<Self>) -> 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<Self>) -> 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>) {
|
||||||
|
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>) {
|
||||||
|
self.pagination.previous_page();
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn go_next_page(&mut self, cx: &mut gpui::Context<Self>) {
|
||||||
|
self.pagination.next_page();
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_header_column(
|
||||||
|
&self,
|
||||||
|
column: SortColumn,
|
||||||
|
id: &'static str,
|
||||||
|
cx: &gpui::Context<Self>,
|
||||||
|
) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>,
|
||||||
|
) -> 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<gpui::Div> = 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user