feat: Add reusable modal and task detail view

Split the task detail modal into its own view and add a generic modal
frame, plus new models and service mapping for task details. Update app layout,
keymaps, and sidebar focus events to support the modal and keyboard flow, with small
UI tweaks like wrapping and scroll handling.
This commit is contained in:
Ignacio Perez
2025-12-30 11:57:28 -03:00
parent 87ecec7f67
commit 1693655e47
16 changed files with 1453 additions and 645 deletions
+223 -274
View File
@@ -2,25 +2,37 @@ use std::collections::HashMap;
use gpui::prelude::*;
use crate::keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord, KeymapStack};
use crate::models::{FilterState, ProjectTree};
use crate::task::{self, TaskOverview, TaskService};
use crate::theme::ActiveTheme;
use crate::ui::{ROOT_PADDING, SECTION_GAP, SIDEBAR_WIDTH};
use crate::view::sidebar::{Sidebar, TagItem};
use crate::view::status_bar::{StatusBar, StatusBarEvent, SyncState};
use crate::view::task_table::TaskTable;
use gpui::div;
use crate::{
components::modal::ModalState,
keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord, KeymapStack},
models::{FilterState, ProjectTree},
task::{self, TaskDetailState, TaskOverview, TaskService, TaskSummary},
theme::ActiveTheme,
view::{
app_layout,
sidebar::{Sidebar, SidebarEvent, SidebarSection, TagItem},
status_bar::{StatusBar, StatusBarEvent, SyncState},
task_detail_modal,
task_table::{TaskTable, TaskTableEvent},
},
};
pub(crate) struct App {
focus_handle: gpui::FocusHandle,
focus_target: FocusTarget,
keymap: KeymapStack,
sidebar: gpui::Entity<Sidebar>,
filter_state: gpui::Entity<FilterState>,
status_bar: gpui::Entity<StatusBar>,
task_table: gpui::Entity<TaskTable>,
task_service: TaskService,
pub(super) struct App {
pub(super) focus_handle: gpui::FocusHandle,
pub(super) focus_target: FocusTarget,
pub(super) keymap: KeymapStack,
pub(super) sidebar: gpui::Entity<Sidebar>,
pub(super) filter_state: gpui::Entity<FilterState>,
pub(super) status_bar: gpui::Entity<StatusBar>,
pub(super) task_table: gpui::Entity<TaskTable>,
pub(super) task_service: TaskService,
pub(super) tasks: Vec<TaskSummary>,
pub(super) selected_task_id: Option<uuid::Uuid>,
pub(super) task_detail_state: TaskDetailState,
pub(super) modal_state: ModalState,
pub(super) modal_focus_handle: gpui::FocusHandle,
pub(super) focus_before_modal: FocusTarget,
pub(super) modal_scroll_handle: gpui::ScrollHandle,
}
impl gpui::Render for App {
@@ -31,27 +43,11 @@ impl gpui::Render for App {
) -> impl gpui::IntoElement {
let theme = cx.theme();
let sidebar_focused = self.focus_target.is_sidebar();
let sidebar_border_color = if sidebar_focused {
theme.focus_ring
} else {
theme.divider
};
let sidebar = div()
.bg(theme.card)
.border_2()
.border_color(sidebar_border_color)
.rounded(crate::ui::CARD_RADIUS)
.p(crate::ui::CARD_PADDING)
.w(SIDEBAR_WIDTH)
.h_full()
.flex_shrink_0()
.overflow_hidden()
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|app, _event, _window, cx| {
let on_root_key_down = cx.listener(|app, event: &gpui::KeyDownEvent, window, cx| {
app.handle_key_down(event, window, cx);
});
let on_sidebar_mouse_down =
cx.listener(|app, _event: &gpui::MouseDownEvent, _window, cx| {
if !app.focus_target.is_sidebar() {
app.focus_target = FocusTarget::SidebarProjects;
app.sidebar.update(cx, |sidebar, cx| {
@@ -59,241 +55,51 @@ impl gpui::Render for App {
});
cx.notify();
}
}),
)
.child(self.sidebar.clone());
let table_focused = matches!(
self.focus_target,
FocusTarget::Table | FocusTarget::TableHeaders
);
let table_border_color = if table_focused {
theme.focus_ring
} else {
theme.divider
};
let main = div()
.bg(theme.card)
.border_2()
.border_color(table_border_color)
.rounded(crate::ui::CARD_RADIUS)
.p(crate::ui::CARD_PADDING)
.flex_1()
.h_full()
.min_w_0()
.overflow_hidden()
.p_0()
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|app, _event, _window, cx| {
});
let on_table_mouse_down = cx.listener(|app, _event: &gpui::MouseDownEvent, _window, cx| {
if !matches!(app.focus_target, FocusTarget::Table) {
app.focus_target = FocusTarget::Table;
cx.notify();
}
}),
});
let modal = if self.modal_state.open {
let on_close_backdrop =
cx.listener(|app, _event: &gpui::MouseDownEvent, window, cx| {
app.close_task_detail(Some(window), cx);
});
let on_close_click = cx.listener(|app, _event: &gpui::MouseDownEvent, window, cx| {
app.close_task_detail(Some(window), cx);
});
Some(task_detail_modal::render_task_detail_modal(
&self.task_detail_state,
&self.modal_focus_handle,
&self.modal_scroll_handle,
theme,
on_close_backdrop,
on_close_click,
))
} else {
None
};
app_layout::render_app_layout(
theme,
&self.focus_handle,
self.focus_target,
self.sidebar.clone(),
self.task_table.clone(),
self.status_bar.clone(),
on_root_key_down,
on_sidebar_mouse_down,
on_table_mouse_down,
modal,
)
.child(self.task_table.clone());
let content = div()
.flex()
.flex_1()
.min_h_0()
.gap(SECTION_GAP)
.child(sidebar)
.child(main);
div()
.flex()
.flex_col()
.size_full()
.bg(theme.background)
.p(ROOT_PADDING)
.gap(SECTION_GAP)
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(|app, event, window, cx| {
app.handle_key_down(event, window, cx);
}))
.child(content)
.child(self.status_bar.clone())
}
}
impl CommandDispatcher for App {
fn dispatch(&mut self, command: Command, cx: &mut gpui::Context<Self>) -> bool {
match command {
Command::Sync => {
self.handle_sync(cx);
true
}
Command::FocusSearch => false,
Command::FocusTable => {
self.focus_target = match self.focus_target {
FocusTarget::Table => {
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx);
});
FocusTarget::SidebarProjects
}
FocusTarget::SidebarProjects => {
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx);
});
FocusTarget::SidebarTags
}
_ => FocusTarget::Table,
};
cx.notify();
true
}
Command::FocusSidebar => {
self.focus_target = match self.focus_target {
FocusTarget::Table => {
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx);
});
FocusTarget::SidebarTags
}
FocusTarget::SidebarTags => {
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx);
});
FocusTarget::SidebarProjects
}
_ => FocusTarget::Table,
};
cx.notify();
true
}
Command::FocusSidebarProjects => {
self.focus_target = FocusTarget::SidebarProjects;
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx);
});
cx.notify();
true
}
Command::FocusSidebarTags => {
self.focus_target = FocusTarget::SidebarTags;
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx);
});
cx.notify();
true
}
Command::SelectNextRow
| Command::SelectPrevRow
| Command::SelectFirstRow
| Command::SelectLastRow => {
match self.focus_target {
FocusTarget::SidebarProjects | FocusTarget::SidebarTags => {
self.sidebar
.update(cx, |sidebar, cx| sidebar.dispatch(command, cx));
}
_ => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
}
}
true
}
Command::OpenSelectedTask => {
match self.focus_target {
FocusTarget::SidebarProjects | FocusTarget::SidebarTags => {
self.sidebar
.update(cx, |sidebar, cx| sidebar.dispatch(command, cx));
}
_ => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
}
}
true
}
Command::ExpandProject | Command::CollapseProject => {
match self.focus_target {
FocusTarget::SidebarProjects => {
self.sidebar
.update(cx, |sidebar, cx| sidebar.dispatch(command, cx));
}
_ => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
}
}
true
}
Command::NextPage | Command::PrevPage | Command::ClearSelection => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
true
}
Command::ToggleDropdown
| Command::SelectNextOption
| Command::SelectPrevOption
| Command::BlurInput => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
true
}
Command::ClearAllFilters => {
self.filter_state.update(cx, |state, cx| {
state.clear();
cx.notify();
});
true
}
Command::ClearProjectFilter => {
self.filter_state.update(cx, |state, cx| {
state.clear_project();
cx.notify();
});
true
}
Command::ClearTagFilter => {
self.filter_state.update(cx, |state, cx| {
state.clear_tags();
cx.notify();
});
true
}
Command::ClearSearchAndDropdowns => {
self.filter_state.update(cx, |state, cx| {
state.clear_search_and_dropdowns();
cx.notify();
});
self.task_table.update(cx, |table, cx| {
table.clear_search_input(cx);
table.reset_dropdowns(cx);
});
true
}
Command::HeaderMoveNext => {
self.task_table.update(cx, |table, cx| {
table.header_move_next(cx);
});
true
}
Command::HeaderMovePrev => {
self.task_table.update(cx, |table, cx| {
table.header_move_prev(cx);
});
true
}
Command::HeaderCycleSortOrder => {
self.task_table.update(cx, |table, cx| {
table.header_cycle_sort_order(cx);
});
true
}
_ => false,
}
}
}
impl App {
fn build_sidebar_data(tasks: &[task::Task]) -> (Vec<(String, usize)>, Vec<TagItem>) {
fn build_sidebar_data(tasks: &[task::TaskSummary]) -> (Vec<(String, usize)>, Vec<TagItem>) {
let mut project_counts: HashMap<String, usize> = HashMap::new();
let mut tag_counts: HashMap<String, usize> = HashMap::new();
@@ -325,8 +131,13 @@ impl App {
(projects, tag_items)
}
fn update_ui_from_tasks(&mut self, all_tasks: Vec<task::Task>, cx: &mut gpui::Context<Self>) {
let (projects, tags) = Self::build_sidebar_data(&all_tasks);
fn update_ui_from_tasks(
&mut self,
all_tasks: Vec<task::TaskSummary>,
cx: &mut gpui::Context<Self>,
) {
self.tasks = all_tasks;
let (projects, tags) = Self::build_sidebar_data(&self.tasks);
let mut project_tree = ProjectTree::new();
project_tree.build_from_projects(&projects);
@@ -336,9 +147,9 @@ impl App {
sidebar.update_tags(tags, cx);
});
self.task_table.update(cx, |table, cx| {
table.reload_tasks_from_all(all_tasks, cx);
});
let tasks = self.tasks.clone();
self.task_table
.update(cx, |table, cx| table.reload_tasks_from_all(tasks, cx));
}
fn reload_tasks(&mut self, cx: &mut gpui::Context<Self>) {
@@ -347,7 +158,8 @@ impl App {
self.status_bar.update(cx, |bar, cx| {
bar.clear_error(cx);
});
self.update_ui_from_tasks(all_tasks, cx);
let summaries: Vec<TaskSummary> = all_tasks.iter().map(TaskSummary::from).collect();
self.update_ui_from_tasks(summaries, cx);
}
Err(e) => {
log::error!("[App] Failed to load tasks: {}", e);
@@ -359,7 +171,7 @@ impl App {
}
}
fn handle_sync(&mut self, cx: &mut gpui::Context<Self>) {
pub(super) fn handle_sync(&mut self, cx: &mut gpui::Context<Self>) {
self.status_bar.update(cx, |bar, cx| {
bar.set_sync_state(SyncState::Syncing, cx);
bar.set_last_sync_message("Syncing...".to_string(), cx);
@@ -367,7 +179,8 @@ impl App {
match self.task_service.get_all_tasks() {
Ok(all_tasks) => {
self.update_ui_from_tasks(all_tasks, cx);
let summaries: Vec<TaskSummary> = all_tasks.iter().map(TaskSummary::from).collect();
self.update_ui_from_tasks(summaries, cx);
self.status_bar.update(cx, |bar, cx| {
bar.set_sync_state(SyncState::Success, cx);
@@ -394,6 +207,17 @@ impl App {
let context = self.active_context(cx);
if let Some(command) = self.keymap.resolve(context, &chord) {
if self.modal_state.open {
match command {
Command::CloseModal
| Command::SaveModal
| Command::Sync
| Command::ModalScrollUp
| Command::ModalScrollDown => {}
_ => return,
}
}
match command {
Command::FocusSearch => {
let from_headers = matches!(self.focus_target, FocusTarget::TableHeaders);
@@ -466,7 +290,102 @@ impl App {
}
}
pub(super) fn open_selected_task(
&mut self,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
if self.modal_state.open {
return;
}
let task_id = self.task_table.read(cx).selected_task_uuid();
let Some(task_id) = task_id else {
return;
};
self.open_task_detail(task_id, window, cx);
}
fn open_task_detail(
&mut self,
task_id: uuid::Uuid,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
self.focus_before_modal = self.focus_target;
self.modal_state.open = true;
if let Some(window) = window {
window.focus(&self.modal_focus_handle);
}
self.modal_scroll_handle = gpui::ScrollHandle::new();
self.modal_scroll_handle.scroll_to_item(0);
if self.selected_task_id == Some(task_id) {
if matches!(self.task_detail_state, TaskDetailState::Ready(_)) {
cx.notify();
return;
}
}
self.selected_task_id = Some(task_id);
self.task_detail_state = TaskDetailState::Loading(task_id);
cx.notify();
let tasks = self.tasks.clone();
match self.task_service.get_task_detail(task_id, &tasks) {
Ok(detail) => {
self.task_detail_state = TaskDetailState::Ready(detail);
}
Err(e) => {
self.task_detail_state = TaskDetailState::Error(task_id, e.to_string());
}
}
cx.notify();
}
pub(super) fn close_task_detail(
&mut self,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
if !self.modal_state.open {
return;
}
self.modal_state.open = false;
self.selected_task_id = None;
self.focus_target = self.focus_before_modal;
if let Some(window) = window {
window.focus(&self.focus_handle);
}
cx.notify();
}
pub(super) fn scroll_task_detail(&self, delta: i32, cx: &mut gpui::Context<Self>) {
let handle = &self.modal_scroll_handle;
let current = if delta > 0 {
handle.bottom_item()
} else {
handle.top_item()
};
let next = if delta > 0 {
current.saturating_add(1)
} else {
current.saturating_sub(1)
};
handle.scroll_to_item(next);
cx.notify();
}
fn active_context(&self, cx: &gpui::Context<Self>) -> ContextId {
if self.modal_state.open {
return ContextId::Modal;
}
if matches!(self.focus_target, FocusTarget::Table) {
let filter_context = self.task_table.read(cx).get_active_filter_context();
if let Some(context) = filter_context {
@@ -502,6 +421,9 @@ impl App {
}
});
let task_summaries: Vec<TaskSummary> =
overview.tasks.iter().map(TaskSummary::from).collect();
let mut project_tree = ProjectTree::new();
project_tree.build_from_projects(&overview.projects);
@@ -518,9 +440,11 @@ impl App {
let task_table = cx
.new(|cx| TaskTable::new("main-task-table", filter_state.clone(), cx));
let task_table_events = task_table.clone();
let sidebar_events = sidebar.clone();
task_table.update(cx, |table, cx| {
table.reload_tasks(&mut task_service, cx);
table.reload_tasks_from_all(task_summaries.clone(), cx);
});
let mut keymap = KeymapStack::new();
@@ -535,6 +459,13 @@ impl App {
status_bar: status_bar.clone(),
task_table,
task_service,
tasks: task_summaries,
selected_task_id: None,
task_detail_state: TaskDetailState::default(),
modal_state: ModalState::default(),
modal_focus_handle: cx.focus_handle(),
focus_before_modal: FocusTarget::Table,
modal_scroll_handle: gpui::ScrollHandle::new(),
};
window.focus(&app.focus_handle);
@@ -550,6 +481,24 @@ impl App {
}
})
.detach();
cx.subscribe(&sidebar_events, |app, _sidebar, event, cx| match event {
SidebarEvent::Focused(section) => {
app.focus_target = match section {
SidebarSection::Projects => FocusTarget::SidebarProjects,
SidebarSection::Tags => FocusTarget::SidebarTags,
};
cx.notify();
}
})
.detach();
cx.subscribe(&task_table_events, |app, _table, event, cx| match event {
TaskTableEvent::OpenTask(task_id) => {
if !app.modal_state.open {
app.open_task_detail(*task_id, None, cx);
}
}
})
.detach();
app
})
+31 -319
View File
@@ -2,360 +2,73 @@ use std::sync::Arc;
use gpui::prelude::*;
use crate::components::button::{Button, ButtonVariants};
use crate::theme::ActiveTheme;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ModalAction {
Close,
Cancel,
Save,
}
#[derive(Clone, Debug, Default)]
pub struct ModalState {
pub open: bool,
pub last_action: Option<ModalAction>,
}
pub struct Modal {
#[derive(gpui::IntoElement)]
pub struct ModalFrame {
id: gpui::ElementId,
focus: gpui::FocusHandle,
title: Option<gpui::SharedString>,
content: Vec<Arc<dyn Fn() -> gpui::AnyElement + Send + Sync>>,
footer: Vec<Arc<dyn Fn() -> gpui::AnyElement + Send + Sync>>,
width: Option<gpui::Pixels>,
open: bool,
close_on_backdrop: bool,
last_action: Option<ModalAction>,
style: gpui::StyleRefinement,
on_close: Option<Arc<dyn Fn(ModalAction, &mut gpui::Context<Self>) + Send + Sync>>,
on_save: Option<Arc<dyn Fn(&mut gpui::Context<Self>) + Send + Sync>>,
on_cancel: Option<Arc<dyn Fn(&mut gpui::Context<Self>) + Send + Sync>>,
panel: gpui::AnyElement,
backdrop: gpui::Rgba,
on_close:
Option<Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>>,
}
impl Modal {
pub fn new(id: impl Into<gpui::ElementId>, cx: &mut gpui::Context<Self>) -> Self {
impl ModalFrame {
pub fn new(
id: impl Into<gpui::ElementId>,
focus: gpui::FocusHandle,
backdrop: gpui::Rgba,
) -> Self {
Self {
id: id.into(),
focus: cx.focus_handle(),
title: None,
content: Vec::new(),
footer: Vec::new(),
width: Some(gpui::px(520.0)),
open: false,
close_on_backdrop: true,
last_action: None,
style: gpui::StyleRefinement::default(),
focus,
panel: gpui::div().into_any_element(),
backdrop,
on_close: None,
on_save: None,
on_cancel: None,
}
}
pub fn title(mut self, title: impl Into<gpui::SharedString>) -> Self {
self.title = Some(title.into());
self
}
pub fn width(mut self, width: gpui::Pixels) -> Self {
self.width = Some(width);
self
}
pub fn close_on_backdrop(mut self, close: bool) -> Self {
self.close_on_backdrop = close;
self
}
pub fn child<E>(mut self, child: E) -> Self
where
E: gpui::IntoElement + Clone + Send + Sync + 'static,
{
let element = child.clone();
self.content
.push(Arc::new(move || element.clone().into_any_element()));
self
}
pub fn children<E>(mut self, children: impl IntoIterator<Item = E>) -> Self
where
E: gpui::IntoElement + Clone + Send + Sync + 'static,
{
self.content.extend(
children
.into_iter()
.map(|child| {
let element = child.clone();
Arc::new(move || element.clone().into_any_element())
as Arc<dyn Fn() -> gpui::AnyElement + Send + Sync>
})
.collect::<Vec<_>>(),
);
self
}
pub fn footer_child<E>(mut self, child: E) -> Self
where
E: gpui::IntoElement + Clone + Send + Sync + 'static,
{
let element = child.clone();
self.footer
.push(Arc::new(move || element.clone().into_any_element()));
self
}
pub fn footer_children<E>(mut self, children: impl IntoIterator<Item = E>) -> Self
where
E: gpui::IntoElement + Clone + Send + Sync + 'static,
{
self.footer.extend(
children
.into_iter()
.map(|child| {
let element = child.clone();
Arc::new(move || element.clone().into_any_element())
as Arc<dyn Fn() -> gpui::AnyElement + Send + Sync>
})
.collect::<Vec<_>>(),
);
pub fn panel(mut self, panel: impl gpui::IntoElement) -> Self {
self.panel = panel.into_any_element();
self
}
pub fn on_close(
mut self,
handler: Arc<dyn Fn(ModalAction, &mut gpui::Context<Self>) + Send + Sync>,
handler: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
) -> Self {
self.on_close = Some(handler);
self.on_close = Some(Arc::new(handler));
self
}
pub fn on_save(mut self, handler: Arc<dyn Fn(&mut gpui::Context<Self>) + Send + Sync>) -> Self {
self.on_save = Some(handler);
self
}
pub fn on_cancel(
mut self,
handler: Arc<dyn Fn(&mut gpui::Context<Self>) + Send + Sync>,
) -> Self {
self.on_cancel = Some(handler);
self
}
impl gpui::RenderOnce for ModalFrame {
fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl gpui::IntoElement {
let mut panel_wrap = gpui::div().child(self.panel);
pub fn set_open(&mut self, open: bool, cx: &mut gpui::Context<Self>) {
if self.open == open {
return;
}
self.open = open;
cx.notify();
}
pub fn open(&mut self, cx: &mut gpui::Context<Self>) {
self.set_open(true, cx);
}
pub fn close(&mut self, cx: &mut gpui::Context<Self>) {
self.close_with_action(ModalAction::Close, cx);
}
pub fn toggle(&mut self, cx: &mut gpui::Context<Self>) {
let next = !self.open;
self.set_open(next, cx);
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn focus_handle(&self) -> &gpui::FocusHandle {
&self.focus
}
pub fn state(&self) -> ModalState {
ModalState {
open: self.open,
last_action: self.last_action,
}
}
pub fn last_action(&self) -> Option<ModalAction> {
self.last_action
}
pub fn take_last_action(&mut self) -> Option<ModalAction> {
self.last_action.take()
}
fn close_with_action(&mut self, action: ModalAction, cx: &mut gpui::Context<Self>) {
self.last_action = Some(action);
if let Some(on_close) = self.on_close.clone() {
on_close(action, cx);
}
self.open = false;
cx.notify();
}
fn handle_backdrop_mouse_down(
&mut self,
_event: &gpui::MouseDownEvent,
_window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) {
if self.close_on_backdrop {
self.close_with_action(ModalAction::Close, cx);
}
}
fn handle_cancel(&mut self, cx: &mut gpui::Context<Self>) {
if let Some(on_cancel) = self.on_cancel.clone() {
on_cancel(cx);
}
self.close_with_action(ModalAction::Cancel, cx);
}
fn handle_save(&mut self, cx: &mut gpui::Context<Self>) {
if let Some(on_save) = self.on_save.clone() {
on_save(cx);
}
self.close_with_action(ModalAction::Save, cx);
}
}
impl gpui::Styled for Modal {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl gpui::Render for Modal {
fn render(
&mut self,
_window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl IntoElement {
if !self.open {
return gpui::Empty.into_any_element();
}
let theme = cx.theme();
let title = self.title.clone().map(|title| {
gpui::div()
.px_3()
.py_2()
.text_color(theme.foreground)
.child(title)
.into_any_element()
if let Some(handler) = self.on_close {
panel_wrap = panel_wrap.on_mouse_down_out(move |event, window, app| {
(handler)(event, window, app);
});
let body: Vec<gpui::AnyElement> = self
.content
.iter()
.enumerate()
.map(|(ix, build)| gpui::div().id(ix).child(build()).into_any_element())
.collect();
let mut footer: Vec<gpui::AnyElement> = self.footer.iter().map(|build| build()).collect();
if footer.is_empty() {
let mut actions = Vec::new();
if self.on_cancel.is_some() {
actions.push(
Button::label((self.id.clone(), "cancel"), "Cancel")
.text()
.on_click(cx.listener(|this, _e, _w, cx| {
this.handle_cancel(cx);
}))
.into_any_element(),
);
}
if self.on_save.is_some() {
actions.push(
Button::label((self.id.clone(), "save"), "Save")
.primary()
.on_click(cx.listener(|this, _e, _w, cx| {
this.handle_save(cx);
}))
.into_any_element(),
);
}
if !actions.is_empty() {
footer = actions;
}
}
let footer = if footer.is_empty() {
gpui::div().into_any_element()
} else {
gpui::div()
.px_3()
.py_2()
.flex()
.flex_row()
.justify_end()
.gap_2()
.children(footer)
.into_any_element()
};
let mut panel = gpui::div()
.id(gpui::ElementId::Name(format!("{}-panel", self.id).into()))
.flex()
.flex_col()
.bg(theme.panel)
.border_1()
.border_color(theme.border)
.rounded_md()
.children(title)
.child(
gpui::div()
.px_3()
.py_2()
.flex()
.flex_col()
.gap_2()
.children(body),
)
.child(footer)
.block_mouse_except_scroll()
.track_focus(&self.focus)
.on_key_down(
cx.listener(|this, event: &gpui::KeyDownEvent, _window, cx| {
if event.keystroke.key.as_str() == "escape" {
this.close_with_action(ModalAction::Cancel, cx);
}
}),
);
if let Some(width) = self.width {
panel = panel.w(width);
}
let panel_wrap = if self.close_on_backdrop {
gpui::div()
.child(panel)
.on_mouse_down_out(cx.listener(|this, event, window, cx| {
this.handle_backdrop_mouse_down(event, window, cx);
}))
} else {
gpui::div().child(panel)
};
gpui::div()
.id(self.id.clone())
let root = gpui::div()
.id(self.id)
.size_full()
.absolute()
.top_0()
.left_0()
.occlude()
.child(
.track_focus(&self.focus);
root.child(
gpui::div()
.size_full()
.bg(gpui::rgba(0x00000080))
.bg(self.backdrop)
.absolute()
.top_0()
.left_0(),
@@ -372,6 +85,5 @@ impl gpui::Render for Modal {
.justify_center()
.child(panel_wrap),
)
.into_any_element()
}
}
+191
View File
@@ -0,0 +1,191 @@
use crate::{
app::App,
keymap::{Command, CommandDispatcher, FocusTarget},
};
impl CommandDispatcher for App {
fn dispatch(&mut self, command: Command, cx: &mut gpui::Context<Self>) -> bool {
match command {
Command::Sync => {
self.handle_sync(cx);
true
}
Command::FocusSearch => false,
Command::FocusTable => {
self.focus_target = match self.focus_target {
FocusTarget::Table => {
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx);
});
FocusTarget::SidebarProjects
}
FocusTarget::SidebarProjects => {
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx);
});
FocusTarget::SidebarTags
}
_ => FocusTarget::Table,
};
cx.notify();
true
}
Command::FocusSidebar => {
self.focus_target = match self.focus_target {
FocusTarget::Table => {
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx);
});
FocusTarget::SidebarTags
}
FocusTarget::SidebarTags => {
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx);
});
FocusTarget::SidebarProjects
}
_ => FocusTarget::Table,
};
cx.notify();
true
}
Command::FocusSidebarProjects => {
self.focus_target = FocusTarget::SidebarProjects;
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Projects, cx);
});
cx.notify();
true
}
Command::FocusSidebarTags => {
self.focus_target = FocusTarget::SidebarTags;
self.sidebar.update(cx, |sidebar, cx| {
sidebar.set_section(crate::view::sidebar::SidebarSection::Tags, cx);
});
cx.notify();
true
}
Command::SelectNextRow
| Command::SelectPrevRow
| Command::SelectFirstRow
| Command::SelectLastRow => {
match self.focus_target {
FocusTarget::SidebarProjects | FocusTarget::SidebarTags => {
self.sidebar
.update(cx, |sidebar, cx| sidebar.dispatch(command, cx));
}
_ => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
}
}
true
}
Command::OpenSelectedTask => {
match self.focus_target {
FocusTarget::SidebarProjects | FocusTarget::SidebarTags => {
self.sidebar
.update(cx, |sidebar, cx| sidebar.dispatch(command, cx));
}
_ => {
self.open_selected_task(None, cx);
}
}
true
}
Command::CloseModal => {
self.close_task_detail(None, cx);
true
}
Command::SaveModal => {
self.close_task_detail(None, cx);
true
}
Command::ModalScrollUp => {
self.scroll_task_detail(-1, cx);
true
}
Command::ModalScrollDown => {
self.scroll_task_detail(2, cx);
true
}
Command::ExpandProject | Command::CollapseProject => {
match self.focus_target {
FocusTarget::SidebarProjects => {
self.sidebar
.update(cx, |sidebar, cx| sidebar.dispatch(command, cx));
}
_ => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
}
}
true
}
Command::NextPage | Command::PrevPage | Command::ClearSelection => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
true
}
Command::ToggleDropdown
| Command::SelectNextOption
| Command::SelectPrevOption
| Command::BlurInput => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
true
}
Command::ClearAllFilters => {
self.filter_state.update(cx, |state, cx| {
state.clear();
cx.notify();
});
true
}
Command::ClearProjectFilter => {
self.filter_state.update(cx, |state, cx| {
state.clear_project();
cx.notify();
});
true
}
Command::ClearTagFilter => {
self.filter_state.update(cx, |state, cx| {
state.clear_tags();
cx.notify();
});
true
}
Command::ClearSearchAndDropdowns => {
self.filter_state.update(cx, |state, cx| {
state.clear_search_and_dropdowns();
cx.notify();
});
self.task_table.update(cx, |table, cx| {
table.clear_search_input(cx);
table.reset_dropdowns(cx);
});
true
}
Command::HeaderMoveNext => {
self.task_table.update(cx, |table, cx| {
table.header_move_next(cx);
});
true
}
Command::HeaderMovePrev => {
self.task_table.update(cx, |table, cx| {
table.header_move_prev(cx);
});
true
}
Command::HeaderCycleSortOrder => {
self.task_table.update(cx, |table, cx| {
table.header_cycle_sort_order(cx);
});
true
}
_ => false,
}
}
}
+6
View File
@@ -25,6 +25,8 @@ pub enum Command {
// Modal
CloseModal,
SaveModal,
ModalScrollUp,
ModalScrollDown,
// Filter
ApplySearch,
@@ -70,6 +72,8 @@ impl Command {
"BlurInput" => Some(Self::BlurInput),
"CloseModal" => Some(Self::CloseModal),
"SaveModal" => Some(Self::SaveModal),
"ModalScrollUp" => Some(Self::ModalScrollUp),
"ModalScrollDown" => Some(Self::ModalScrollDown),
"ApplySearch" => Some(Self::ApplySearch),
"ClearFilters" => Some(Self::ClearFilters),
"ClearAllFilters" => Some(Self::ClearAllFilters),
@@ -110,6 +114,8 @@ impl Command {
Self::BlurInput => "BlurInput",
Self::CloseModal => "CloseModal",
Self::SaveModal => "SaveModal",
Self::ModalScrollUp => "ModalScrollUp",
Self::ModalScrollDown => "ModalScrollDown",
Self::ApplySearch => "ApplySearch",
Self::ClearFilters => "ClearFilters",
Self::ClearAllFilters => "ClearAllFilters",
+20
View File
@@ -403,6 +403,26 @@ pub fn build_default_keymap() -> KeymapLayer {
KeyChord::new(Key::Escape, Mods::none()),
Command::CloseModal,
);
layer.bind(
ContextId::Modal,
KeyChord::new(Key::Char('j'), Mods::none()),
Command::ModalScrollDown,
);
layer.bind(
ContextId::Modal,
KeyChord::new(Key::Char('k'), Mods::none()),
Command::ModalScrollUp,
);
layer.bind(
ContextId::Modal,
KeyChord::new(Key::ArrowDown, Mods::none()),
Command::ModalScrollDown,
);
layer.bind(
ContextId::Modal,
KeyChord::new(Key::ArrowUp, Mods::none()),
Command::ModalScrollUp,
);
layer.bind(
ContextId::Modal,
KeyChord::new(Key::Enter, Mods::ctrl()),
+1
View File
@@ -2,6 +2,7 @@ use crate::app::App;
mod app;
mod components;
mod dispatcher;
mod keymap;
mod models;
mod task;
+3 -3
View File
@@ -2,7 +2,7 @@ use std::collections::HashSet;
use chrono::{DateTime, NaiveDate, Utc};
use super::model::{Task, TaskPriority, TaskStatus};
use super::model::{TaskPriority, TaskStatus, TaskSummary};
use crate::models::{DueFilter, FilterState, PriorityFilter, StatusFilter};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -131,7 +131,7 @@ impl From<FilterState> for TaskFilter {
}
impl TaskFilter {
pub fn matches(&self, task: &Task) -> bool {
pub fn matches(&self, task: &TaskSummary) -> bool {
if let Some(status) = &self.status {
match status {
TaskStatus::Pending => {
@@ -267,7 +267,7 @@ impl TaskFilter {
true
}
pub fn apply(&self, tasks: &[Task]) -> Vec<Task> {
pub fn apply(&self, tasks: &[TaskSummary]) -> Vec<TaskSummary> {
tasks.iter().filter(|t| self.matches(t)).cloned().collect()
}
}
+4 -1
View File
@@ -5,5 +5,8 @@ pub mod service;
pub use error::{TaskError, TaskResult};
pub use filter::{DueDateFilter, TagsFilterMode, TaskFilter};
pub use model::{Task, TaskAnnotation, TaskOverview, TaskPriority, TaskStatus, TaskUpdate};
pub use model::{
Task, TaskAnnotation, TaskDetailState, TaskDetailVm, TaskOverview, TaskPriority, TaskStatus,
TaskSummary, TaskUpdate,
};
pub use service::{SyncResult, TaskService};
+242 -2
View File
@@ -1,6 +1,6 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use chrono::{DateTime, Duration, Utc};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskPriority {
@@ -219,6 +219,232 @@ impl Task {
}
}
#[derive(Debug, Clone)]
pub struct TaskSummary {
pub uuid: uuid::Uuid,
pub id: Option<usize>,
pub working_id: Option<usize>,
pub status: TaskStatus,
pub description: String,
pub project: Option<String>,
pub priority: TaskPriority,
pub tags: HashSet<String>,
pub due: Option<DateTime<Utc>>,
pub wait: Option<DateTime<Utc>>,
pub dependencies: HashSet<uuid::Uuid>,
pub is_active: bool,
pub is_blocked: bool,
}
impl TaskSummary {
pub fn is_overdue(&self) -> bool {
self.due.map_or(false, |due| due < Utc::now())
}
pub fn is_due_today(&self) -> bool {
self.due
.map_or(false, |due| due.date_naive() == Utc::now().date_naive())
}
}
impl From<&Task> for TaskSummary {
fn from(task: &Task) -> Self {
Self {
uuid: task.uuid,
id: task.id,
working_id: task.working_id,
status: task.status.clone(),
description: task.description.clone(),
project: task.project.clone(),
priority: task.priority,
tags: task.tags.clone(),
due: task.due,
wait: task.wait,
dependencies: task.dependencies.clone(),
is_active: task.is_active,
is_blocked: task.is_blocked,
}
}
}
#[derive(Debug, Clone)]
pub struct TaskIdentityVm {
pub uuid: uuid::Uuid,
pub id: Option<usize>,
pub working_id: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct TaskOverviewVm {
pub description: String,
pub status: TaskStatus,
pub project: Option<String>,
pub priority: TaskPriority,
pub is_active: bool,
}
#[derive(Debug, Clone)]
pub struct TaskDatesVm {
pub entry: Option<DateTime<Utc>>,
pub modified: Option<DateTime<Utc>>,
pub start: Option<DateTime<Utc>>,
pub end: Option<DateTime<Utc>>,
pub due: Option<DateTime<Utc>>,
pub scheduled: Option<DateTime<Utc>>,
pub wait: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct TaskTagsVm {
pub tags: Vec<String>,
pub virtual_tags: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct TaskLinkVm {
pub uuid: uuid::Uuid,
pub id: Option<usize>,
pub description: String,
pub status: TaskStatus,
}
#[derive(Debug, Clone)]
pub struct TaskDependenciesVm {
pub depends_on: Vec<TaskLinkVm>,
pub blocked_by: Vec<TaskLinkVm>,
pub blocking: Vec<TaskLinkVm>,
}
#[derive(Debug, Clone, Default)]
pub struct TaskMetricsVm {
pub urgency: Option<f32>,
}
#[derive(Debug, Clone)]
pub struct TaskDetailVm {
pub identity: TaskIdentityVm,
pub overview: TaskOverviewVm,
pub dates: TaskDatesVm,
pub tags: TaskTagsVm,
pub dependencies: TaskDependenciesVm,
pub annotations: Vec<TaskAnnotation>,
pub udas: Vec<(String, String)>,
pub metrics: TaskMetricsVm,
}
impl TaskDetailVm {
pub fn from_task(task: &Task, all_tasks: &[TaskSummary]) -> Self {
let mut tags: Vec<String> = task.tags.iter().cloned().collect();
tags.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
let task_map: HashMap<uuid::Uuid, &TaskSummary> =
all_tasks.iter().map(|t| (t.uuid, t)).collect();
let to_link = |uuid: &uuid::Uuid| -> TaskLinkVm {
match task_map.get(uuid) {
Some(summary) => TaskLinkVm {
uuid: *uuid,
id: summary.working_id.or(summary.id),
description: summary.description.clone(),
status: summary.status.clone(),
},
None => TaskLinkVm {
uuid: *uuid,
id: None,
description: "Unknown task".to_string(),
status: TaskStatus::Unknown("Missing".to_string()),
},
}
};
let mut depends_on: Vec<TaskLinkVm> = task.dependencies.iter().map(to_link).collect();
depends_on.sort_by(|a, b| a.uuid.cmp(&b.uuid));
let mut blocked_by: Vec<TaskLinkVm> = task
.dependencies
.iter()
.filter(|uuid| {
task_map
.get(uuid)
.map(|summary| !matches!(summary.status, TaskStatus::Completed))
.unwrap_or(true)
})
.map(to_link)
.collect();
blocked_by.sort_by(|a, b| a.uuid.cmp(&b.uuid));
let mut blocking: Vec<TaskLinkVm> = all_tasks
.iter()
.filter(|summary| summary.dependencies.contains(&task.uuid))
.map(|summary| TaskLinkVm {
uuid: summary.uuid,
id: summary.working_id.or(summary.id),
description: summary.description.clone(),
status: summary.status.clone(),
})
.collect();
blocking.sort_by(|a, b| a.uuid.cmp(&b.uuid));
let mut virtual_tags = Vec::new();
if !blocked_by.is_empty() || task.is_blocked {
virtual_tags.push("BLOCKED".to_string());
}
if !blocking.is_empty() {
virtual_tags.push("BLOCKING".to_string());
}
if let Some(due) = task.due {
let today = Utc::now().date_naive();
let tomorrow = (Utc::now() + Duration::days(1)).date_naive();
let due_date = due.date_naive();
if due_date == today {
virtual_tags.push("TODAY".to_string());
} else if due_date == tomorrow {
virtual_tags.push("TOMORROW".to_string());
} else {
virtual_tags.push("DUE".to_string());
}
}
let mut annotations = task.annotations.clone();
annotations.sort_by(|a, b| a.entry.cmp(&b.entry));
TaskDetailVm {
identity: TaskIdentityVm {
uuid: task.uuid,
id: task.id,
working_id: task.working_id,
},
overview: TaskOverviewVm {
description: task.description.clone(),
status: task.status.clone(),
project: task.project.clone(),
priority: task.priority,
is_active: task.is_active,
},
dates: TaskDatesVm {
entry: task.entry,
modified: task.modified,
start: None,
end: None,
due: task.due,
scheduled: None,
wait: task.wait,
until: None,
},
tags: TaskTagsVm { tags, virtual_tags },
dependencies: TaskDependenciesVm {
depends_on,
blocked_by,
blocking,
},
annotations,
udas: Vec::new(),
metrics: TaskMetricsVm::default(),
}
}
}
impl From<taskchampion::Task> for Task {
fn from(task: taskchampion::Task) -> Self {
Self {
@@ -262,3 +488,17 @@ pub struct TaskOverview {
pub pending_tasks: usize,
pub completed_tasks: usize,
}
#[derive(Debug, Clone)]
pub enum TaskDetailState {
Idle,
Loading(uuid::Uuid),
Ready(TaskDetailVm),
Error(uuid::Uuid, String),
}
impl Default for TaskDetailState {
fn default() -> Self {
TaskDetailState::Idle
}
}
+13 -3
View File
@@ -9,7 +9,7 @@ use uuid::Uuid;
use super::error::{TaskError, TaskResult};
use super::filter::TaskFilter;
use super::model::{Task, TaskOverview, TaskStatus};
use super::model::{Task, TaskDetailVm, TaskOverview, TaskStatus, TaskSummary};
pub struct TaskService {
replica: Replica,
@@ -142,6 +142,15 @@ impl TaskService {
}
}
pub fn get_task_detail(
&mut self,
uuid: Uuid,
all_tasks: &[TaskSummary],
) -> TaskResult<TaskDetailVm> {
let task = self.get_task(uuid)?.ok_or(TaskError::NotFound(uuid))?;
Ok(TaskDetailVm::from_task(&task, all_tasks))
}
pub fn get_all_tasks(&mut self) -> TaskResult<Vec<Task>> {
log::debug!("TaskService::get_all_tasks: Fetching all tasks from replica");
let all = self.replica.all_tasks().map_err(|e| {
@@ -232,9 +241,10 @@ impl TaskService {
})
}
pub fn get_filtered_tasks(&mut self, filter: &TaskFilter) -> TaskResult<Vec<Task>> {
pub fn get_filtered_tasks(&mut self, filter: &TaskFilter) -> TaskResult<Vec<TaskSummary>> {
let all = self.get_all_tasks()?;
Ok(filter.apply(&all))
let summaries: Vec<TaskSummary> = all.iter().map(TaskSummary::from).collect();
Ok(filter.apply(&summaries))
}
pub fn update_task(
+1
View File
@@ -47,6 +47,7 @@ pub fn table_col_status_width() -> gpui::Rems {
}
pub const DATE_FORMAT: &str = "%Y-%m-%d";
pub const DATE_TIME_FORMAT: &str = "%Y-%m-%d %H:%M";
pub fn card_style(div: gpui::Div, theme: &Theme) -> gpui::Div {
div.bg(theme.card)
+91
View File
@@ -0,0 +1,91 @@
use gpui::prelude::*;
use crate::keymap::FocusTarget;
use crate::theme::Theme;
use crate::ui::{CARD_PADDING, CARD_RADIUS, ROOT_PADDING, SECTION_GAP, SIDEBAR_WIDTH};
use crate::view::sidebar::Sidebar;
use crate::view::status_bar::StatusBar;
use crate::view::task_table::TaskTable;
pub fn render_app_layout(
theme: &Theme,
focus_handle: &gpui::FocusHandle,
focus_target: FocusTarget,
sidebar: gpui::Entity<Sidebar>,
task_table: gpui::Entity<TaskTable>,
status_bar: gpui::Entity<StatusBar>,
on_root_key_down: impl Fn(&gpui::KeyDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
on_sidebar_mouse_down: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
on_table_mouse_down: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
modal: Option<gpui::AnyElement>,
) -> gpui::AnyElement {
let sidebar_focused = focus_target.is_sidebar();
let sidebar_border_color = if sidebar_focused {
theme.focus_ring
} else {
theme.divider
};
let sidebar = gpui::div()
.bg(theme.card)
.border_2()
.border_color(sidebar_border_color)
.rounded(CARD_RADIUS)
.p(CARD_PADDING)
.w(SIDEBAR_WIDTH)
.h_full()
.flex_shrink_0()
.overflow_hidden()
.on_mouse_down(gpui::MouseButton::Left, on_sidebar_mouse_down)
.child(sidebar);
let table_focused = matches!(focus_target, FocusTarget::Table | FocusTarget::TableHeaders);
let table_border_color = if table_focused {
theme.focus_ring
} else {
theme.divider
};
let main = gpui::div()
.bg(theme.card)
.border_2()
.border_color(table_border_color)
.rounded(CARD_RADIUS)
.p(CARD_PADDING)
.flex_1()
.h_full()
.min_w_0()
.overflow_hidden()
.p_0()
.on_mouse_down(gpui::MouseButton::Left, on_table_mouse_down)
.child(task_table);
let content = gpui::div()
.flex()
.flex_1()
.min_h_0()
.gap(SECTION_GAP)
.child(sidebar)
.child(main);
let mut root = gpui::div()
.flex()
.flex_col()
.size_full()
.relative()
.bg(theme.background)
.p(ROOT_PADDING)
.gap(SECTION_GAP)
.track_focus(focus_handle)
.on_key_down(on_root_key_down)
.child(content)
.child(status_bar);
if let Some(modal) = modal {
root = root.child(modal);
}
root.into_any_element()
}
+2
View File
@@ -1,3 +1,5 @@
pub mod app_layout;
pub mod sidebar;
pub mod status_bar;
pub mod task_detail_modal;
pub mod task_table;
+10
View File
@@ -18,6 +18,10 @@ pub enum SidebarSection {
Tags,
}
pub enum SidebarEvent {
Focused(SidebarSection),
}
pub struct Sidebar {
project_tree: ProjectTree,
tags: Vec<TagItem>,
@@ -77,6 +81,7 @@ impl Sidebar {
_window: &mut Window,
cx: &mut Context<Self>,
) {
cx.emit(SidebarEvent::Focused(SidebarSection::Projects));
self.filter_state.update(cx, |filter, cx| {
filter.select_project(full_path);
cx.notify();
@@ -91,6 +96,7 @@ impl Sidebar {
}
fn handle_tag_click(&mut self, tag_name: String, _window: &mut Window, cx: &mut Context<Self>) {
cx.emit(SidebarEvent::Focused(SidebarSection::Tags));
self.filter_state.update(cx, |filter, cx| {
filter.toggle_tag(tag_name);
cx.notify();
@@ -99,6 +105,7 @@ impl Sidebar {
}
fn handle_clear_tags(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
cx.emit(SidebarEvent::Focused(SidebarSection::Tags));
self.filter_state.update(cx, |filter, cx| {
filter.active_tags.clear();
cx.notify();
@@ -107,6 +114,7 @@ impl Sidebar {
}
fn handle_clear_project(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
cx.emit(SidebarEvent::Focused(SidebarSection::Projects));
self.filter_state.update(cx, |filter, cx| {
filter.selected_project = None;
cx.notify();
@@ -543,6 +551,8 @@ impl CommandDispatcher for Sidebar {
}
}
impl gpui::EventEmitter<SidebarEvent> for Sidebar {}
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme().clone();
+563
View File
@@ -0,0 +1,563 @@
use gpui::prelude::*;
use std::sync::Arc;
use crate::components::label::Label;
use crate::components::modal::ModalFrame;
use crate::task::model::TaskLinkVm;
use crate::task::{self, TaskDetailState};
use crate::theme::Theme;
use crate::ui::{DATE_FORMAT, DATE_TIME_FORMAT};
pub fn render_task_detail_modal(
detail_state: &TaskDetailState,
focus_handle: &gpui::FocusHandle,
scroll_handle: &gpui::ScrollHandle,
theme: &Theme,
on_close_out: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
on_close_click: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
) -> gpui::AnyElement {
let panel = match detail_state {
TaskDetailState::Ready(detail) => {
render_task_detail_panel(detail, scroll_handle, theme, on_close_click)
}
TaskDetailState::Error(_, message) => {
render_task_detail_placeholder_panel("Task Details", message, theme, on_close_click)
}
TaskDetailState::Loading(_) | TaskDetailState::Idle => {
render_task_detail_placeholder_panel(
"Task Details",
"Loading task...",
theme,
on_close_click,
)
}
};
ModalFrame::new("task-detail-modal", focus_handle.clone(), theme.backdrop)
.panel(panel)
.on_close(on_close_out)
.into_any_element()
}
fn render_task_detail_placeholder_panel<OnCloseClick>(
title: &str,
message: &str,
theme: &Theme,
on_close_click: OnCloseClick,
) -> gpui::AnyElement
where
OnCloseClick: Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
{
let on_close_click = Arc::new(on_close_click);
let on_close_header = on_close_click.clone();
let close_button = gpui::div()
.id("task-detail-close")
.px(gpui::rems(0.5))
.py(gpui::rems(0.25))
.rounded_md()
.text_color(theme.muted)
.cursor_pointer()
.hover(|s| s.bg(theme.hover).text_color(theme.foreground))
.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
(on_close_header)(event, window, app);
})
.child("X");
let header = gpui::div()
.flex()
.items_center()
.justify_between()
.px(gpui::rems(1.0))
.py(gpui::rems(0.75))
.border_b_1()
.border_color(theme.divider)
.child(Label::new(title.to_string()).text_color(theme.foreground))
.child(close_button);
let body = gpui::div()
.flex()
.flex_col()
.flex_1()
.min_h_0()
.items_center()
.justify_center()
.text_color(theme.muted)
.child(message.to_string());
let on_close_footer = on_close_click.clone();
let footer_button = gpui::div()
.id("task-detail-cancel")
.px(gpui::rems(0.75))
.py(gpui::rems(0.35))
.rounded_md()
.border_1()
.border_color(theme.divider)
.bg(theme.raised)
.text_color(theme.foreground)
.cursor_pointer()
.hover(|s| s.bg(theme.hover))
.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
(on_close_footer)(event, window, app);
})
.child(Label::new("Cancel (Esc)"));
gpui::div()
.id("task-detail-panel")
.flex()
.flex_col()
.w(gpui::rems(48.0))
.h(gpui::rems(40.0))
.bg(theme.panel)
.border_1()
.border_color(theme.border)
.rounded_md()
.block_mouse_except_scroll()
.child(header)
.child(body)
.child(
gpui::div()
.flex()
.items_center()
.justify_end()
.px(gpui::rems(1.0))
.py(gpui::rems(0.5))
.border_t_1()
.border_color(theme.divider)
.child(footer_button),
)
.into_any_element()
}
fn render_task_detail_panel<OnCloseClick>(
detail: &task::TaskDetailVm,
scroll_handle: &gpui::ScrollHandle,
theme: &Theme,
on_close_click: OnCloseClick,
) -> gpui::AnyElement
where
OnCloseClick: Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
{
let status_label = if detail.overview.is_active {
"Active".to_string()
} else {
detail.overview.status.clone().into()
};
let priority_label: String = detail.overview.priority.into();
let chip = |label: &str, bg: gpui::Rgba, fg: gpui::Rgba| {
gpui::div()
.px(gpui::rems(0.5))
.py(gpui::rems(0.125))
.rounded(gpui::rems(0.25))
.bg(bg)
.text_color(fg)
.text_xs()
.font_weight(gpui::FontWeight::MEDIUM)
.child(label.to_string())
};
let status_color = match status_label.as_str() {
"Active" => theme.success,
"Pending" => theme.warning,
"Completed" => theme.muted,
"Deleted" => theme.error,
"Recurring" => theme.info,
_ => theme.muted,
};
let priority_color = match detail.overview.priority {
task::TaskPriority::High => theme.high,
task::TaskPriority::Medium => theme.medium,
task::TaskPriority::Low => theme.low,
task::TaskPriority::None => theme.muted,
};
let mut badges = vec![chip(
&status_label,
Theme::alpha(status_color, 0.18),
status_color,
)];
if priority_label != "None" {
badges.push(chip(
&priority_label,
Theme::alpha(priority_color, 0.18),
priority_color,
));
}
if let Some(project) = &detail.overview.project {
badges.push(chip(
project,
Theme::alpha(theme.accent, 0.15),
theme.accent,
));
}
let id_label = detail
.identity
.working_id
.or(detail.identity.id)
.map(|id| format!("#{}", id))
.unwrap_or_else(|| format!("#{}", detail.identity.uuid));
let title = format!("{} {}", id_label, detail.overview.description);
let on_close_click = Arc::new(on_close_click);
let on_close_header = on_close_click.clone();
let close_button = gpui::div()
.id("task-detail-close")
.px(gpui::rems(0.5))
.py(gpui::rems(0.25))
.rounded_md()
.text_color(theme.muted)
.cursor_pointer()
.hover(|s| s.bg(theme.hover).text_color(theme.foreground))
.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
(on_close_header)(event, window, app);
})
.child("X");
let header = gpui::div()
.flex()
.items_start()
.justify_between()
.gap_4()
.px(gpui::rems(1.0))
.py(gpui::rems(0.75))
.border_b_1()
.border_color(theme.divider)
.child(
gpui::div()
.flex()
.flex_col()
.gap_2()
.child(
Label::new(title)
.text_color(theme.foreground)
.font_weight(gpui::FontWeight::BOLD),
)
.child(gpui::div().flex().gap_2().children(badges)),
)
.child(close_button);
let label_color = Theme::alpha(theme.foreground, 0.72);
let value_color = theme.foreground;
let section_title_color = Theme::alpha(theme.foreground, 0.88);
let value_label = |value: String| Label::new(value).text_color(value_color).into_any_element();
let kv_row = |label: &str, value: gpui::AnyElement| {
gpui::div()
.flex()
.items_start()
.gap_3()
.child(
Label::new(label.to_string())
.text_color(label_color)
.text_sm()
.w(gpui::rems(10.0)),
)
.child(gpui::div().flex_1().min_w_0().child(value))
};
let section_header = |title: &str| {
Label::new(title.to_uppercase())
.text_sm()
.text_color(section_title_color)
.font_weight(gpui::FontWeight::BOLD)
};
let section = |title: &str, content: gpui::Div| {
gpui::div()
.flex()
.flex_col()
.gap_2()
.bg(theme.raised)
.border_1()
.border_color(theme.divider)
.rounded_md()
.px(gpui::rems(0.75))
.py(gpui::rems(0.5))
.child(section_header(title))
.child(content)
};
let due_text = detail
.dates
.due
.map(|d| d.format(DATE_FORMAT).to_string())
.unwrap_or_else(|| "-".to_string());
let overview_grid = gpui::div()
.flex()
.flex_col()
.gap_2()
.child(kv_row("Status", value_label(status_label.clone())))
.child(kv_row(
"Description",
value_label(detail.overview.description.clone()),
))
.child(kv_row(
"Project",
value_label(
detail
.overview
.project
.clone()
.unwrap_or_else(|| "-".to_string()),
),
))
.child(kv_row("Priority", value_label(priority_label.clone())))
.child(kv_row("Due", value_label(due_text)));
let mut overview_section = section("Overview", overview_grid);
if !detail.dependencies.blocked_by.is_empty() || !detail.dependencies.blocking.is_empty() {
let mut info = Vec::new();
if !detail.dependencies.blocked_by.is_empty() {
info.push(format!(
"Blocked by {} task(s)",
detail.dependencies.blocked_by.len()
));
}
if !detail.dependencies.blocking.is_empty() {
info.push(format!(
"Blocking {} task(s)",
detail.dependencies.blocking.len()
));
}
overview_section = overview_section.child(
gpui::div()
.text_sm()
.text_color(label_color)
.child(info.join(" / ")),
);
}
let tags_content = if detail.tags.tags.is_empty() {
value_label("-".to_string())
} else {
let chips =
detail.tags.tags.iter().map(|tag| {
chip(tag, Theme::alpha(theme.info, 0.18), theme.info).into_any_element()
});
gpui::div()
.flex()
.gap_2()
.children(chips)
.into_any_element()
};
let tags_section = section(
"Tags",
gpui::div()
.flex()
.flex_col()
.gap_2()
.child(tags_content)
.when(!detail.tags.virtual_tags.is_empty(), |div| {
let vchips = detail.tags.virtual_tags.iter().map(|tag| {
chip(tag, Theme::alpha(theme.muted, 0.2), theme.muted).into_any_element()
});
div.child(gpui::div().flex().gap_2().children(vchips).text_sm())
}),
);
let format_dt = |value: Option<chrono::DateTime<chrono::Utc>>| {
value
.map(|d| d.format(DATE_TIME_FORMAT).to_string())
.unwrap_or_else(|| "-".to_string())
};
let dates_grid = gpui::div()
.flex()
.flex_col()
.gap_2()
.child(kv_row("Entry", value_label(format_dt(detail.dates.entry))))
.child(kv_row(
"Modified",
value_label(format_dt(detail.dates.modified)),
))
.child(kv_row("Start", value_label(format_dt(detail.dates.start))))
.child(kv_row("End", value_label(format_dt(detail.dates.end))))
.child(kv_row(
"Scheduled",
value_label(format_dt(detail.dates.scheduled)),
))
.child(kv_row("Wait", value_label(format_dt(detail.dates.wait))))
.child(kv_row("Until", value_label(format_dt(detail.dates.until))));
let dates_section = section("Dates", dates_grid);
let uuid_value = detail.identity.uuid.to_string();
let id_value = detail
.identity
.working_id
.or(detail.identity.id)
.map(|id| id.to_string())
.unwrap_or_else(|| "-".to_string());
let mut meta_grid = gpui::div()
.flex()
.flex_col()
.gap_2()
.child(kv_row("UUID", value_label(uuid_value)))
.child(kv_row("ID", value_label(id_value)));
if let Some(urgency) = detail.metrics.urgency {
meta_grid = meta_grid.child(kv_row("Urgency", value_label(format!("{:.2}", urgency))));
}
let meta_section = section("Metadata", meta_grid);
let format_link = |link: &TaskLinkVm| {
let id = link
.id
.map(|id| format!("#{}", id))
.unwrap_or_else(|| link.uuid.to_string());
let status: String = link.status.clone().into();
format!("{} {} ({})", id, link.description, status)
};
let render_links = |links: &[TaskLinkVm]| {
if links.is_empty() {
value_label("-".to_string())
} else {
let items = links.iter().map(|link| {
Label::new(format_link(link))
.text_sm()
.text_color(value_color)
.into_any_element()
});
gpui::div()
.flex()
.flex_col()
.gap_1()
.min_w_0()
.children(items)
.into_any_element()
}
};
let deps_grid = gpui::div()
.flex()
.flex_col()
.gap_2()
.child(kv_row(
"Depends On",
render_links(&detail.dependencies.depends_on),
))
.child(kv_row(
"Blocked By",
render_links(&detail.dependencies.blocked_by),
))
.child(kv_row(
"Blocking",
render_links(&detail.dependencies.blocking),
));
let deps_section = section("Dependencies", deps_grid);
let mut sections = vec![overview_section, tags_section, deps_section];
if !detail.annotations.is_empty() {
let items = detail.annotations.iter().map(|annotation| {
gpui::div()
.flex()
.flex_col()
.gap_1()
.min_w_0()
.child(
Label::new(annotation.entry.format(DATE_TIME_FORMAT).to_string())
.text_xs()
.text_color(theme.muted),
)
.child(
Label::new(annotation.content.clone())
.text_sm()
.text_color(value_color),
)
.into_any_element()
});
let annotations_section = section(
"Annotations",
gpui::div().flex().flex_col().gap_2().children(items),
);
sections.push(annotations_section);
}
sections.push(dates_section);
sections.push(meta_section);
if !detail.udas.is_empty() {
let rows = detail
.udas
.iter()
.map(|(key, value)| kv_row(key, value_label(value.clone())).into_any_element());
let udas_section = section(
"Extras",
gpui::div().flex().flex_col().gap_2().children(rows),
);
sections.push(udas_section);
}
let body = gpui::div()
.id("task-detail-body")
.flex()
.flex_col()
.flex_1()
.min_h_0()
.overflow_y_scroll()
.track_scroll(scroll_handle)
.px(gpui::rems(1.0))
.py(gpui::rems(0.75))
.gap_4()
.children(sections);
let on_close_footer = on_close_click.clone();
let footer = gpui::div()
.flex()
.items_center()
.justify_end()
.px(gpui::rems(1.0))
.py(gpui::rems(0.5))
.border_t_1()
.border_color(theme.divider)
.child(
gpui::div()
.id("task-detail-cancel")
.px(gpui::rems(0.75))
.py(gpui::rems(0.35))
.rounded_md()
.border_1()
.border_color(theme.divider)
.bg(theme.raised)
.text_color(theme.foreground)
.cursor_pointer()
.hover(|s| s.bg(theme.hover))
.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
(on_close_footer)(event, window, app);
})
.child(Label::new("Cancel (Esc)")),
);
gpui::div()
.id("task-detail-panel")
.flex()
.flex_col()
.w(gpui::rems(48.0))
.h(gpui::rems(40.0))
.bg(theme.panel)
.border_1()
.border_color(theme.border)
.rounded_md()
.block_mouse_except_scroll()
.child(header)
.child(body)
.child(footer)
.into_any_element()
}
+26 -17
View File
@@ -12,7 +12,7 @@ use crate::{
},
keymap::{Command, CommandDispatcher},
models::{DueFilter, FilterState, PriorityFilter, StatusFilter},
task::{self, TaskFilter, TaskService},
task::{self, TaskFilter, TaskService, TaskSummary},
theme::{self, ActiveTheme},
ui::{
DATE_FORMAT, TABLE_FILTER_BAR_INITIAL_HEIGHT, TABLE_MAX_DESCRIPTION_LENGTH, priority_badge,
@@ -220,8 +220,8 @@ impl TaskRow {
}
}
impl From<&task::Task> for TaskRow {
fn from(value: &task::Task) -> Self {
impl From<&task::TaskSummary> for TaskRow {
fn from(value: &task::TaskSummary) -> Self {
let status = if value.is_active {
"Active".to_string()
} else {
@@ -261,7 +261,7 @@ impl Default for FilterBarFocus {
pub struct TaskTable {
id: gpui::ElementId,
filter_state: gpui::Entity<FilterState>,
cached_tasks: Vec<task::Task>,
cached_tasks: Vec<task::TaskSummary>,
cached_rows: Vec<TaskRow>,
sort_state: SortState,
pagination: PaginationState,
@@ -438,18 +438,9 @@ impl TaskTable {
&self.cached_rows[start..end]
}
pub fn reload_tasks(&mut self, task_service: &mut TaskService, cx: &mut gpui::Context<Self>) {
let all_tasks = task_service.get_all_tasks().unwrap_or_else(|e| {
log::error!("[TaskTable] Failed to load tasks: {}", e);
vec![]
});
self.reload_tasks_from_all(all_tasks, cx);
}
pub fn reload_tasks_from_all(
&mut self,
all_tasks: Vec<task::Task>,
all_tasks: Vec<task::TaskSummary>,
cx: &mut gpui::Context<Self>,
) {
let filter_state = self.filter_state.read(cx).clone();
@@ -488,7 +479,7 @@ impl TaskTable {
fn sync_filter_dropdowns(
&mut self,
due_tasks: &[task::Task],
due_tasks: &[task::TaskSummary],
filter_state: &FilterState,
cx: &mut gpui::Context<Self>,
) {
@@ -523,7 +514,7 @@ impl TaskTable {
});
}
fn build_due_items(tasks: &[task::Task]) -> Vec<DropdownItem> {
fn build_due_items(tasks: &[task::TaskSummary]) -> Vec<DropdownItem> {
let now = chrono::Utc::now();
let today = now.date_naive();
let week_end = now + chrono::Duration::days(7);
@@ -769,6 +760,12 @@ impl TaskTable {
cx.notify();
}
pub fn selected_task_uuid(&self) -> Option<uuid::Uuid> {
self.selected_global_idx
.and_then(|idx| self.cached_tasks.get(idx))
.map(|task| task.uuid)
}
pub fn focus_search_input(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) {
self.filter_bar_focus = FilterBarFocus::SearchInput;
self.search_input.update(cx, |input, cx| {
@@ -1198,6 +1195,7 @@ impl TaskTable {
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);
let row_uuid = row.uuid;
gpui::div()
.flex()
@@ -1215,7 +1213,12 @@ impl TaskTable {
.cursor_pointer()
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(move |table, _, _, cx| table.select_row(idx, cx)),
cx.listener(move |table, event: &gpui::MouseDownEvent, _window, cx| {
table.select_row(idx, cx);
if event.click_count >= 2 {
cx.emit(TaskTableEvent::OpenTask(row_uuid));
}
}),
)
.child(
gpui::div()
@@ -1413,6 +1416,12 @@ impl CommandDispatcher for TaskTable {
}
}
pub enum TaskTableEvent {
OpenTask(uuid::Uuid),
}
impl gpui::EventEmitter<TaskTableEvent> for TaskTable {}
impl gpui::Render for TaskTable {
fn render(
&mut self,