feat: add task creation, deletion, and modal focus restoration

Implemented task creation flow with keyboard shortcut (c) and UI button. Added task deletion with confirmation dialog,
accessible via Delete key or per-row delete button. Fixed modal close behavior to restore
focus and reselect the appropriate task (created task for new tasks, original task for view/edit).
Enhanced tag and project autocomplete with hierarchical project suggestions and tag filtering
This commit is contained in:
Ignacio Perez
2026-01-04 11:29:26 -03:00
parent eabe07d489
commit 1c4110caa9
17 changed files with 995 additions and 77 deletions
+46 -3
View File
@@ -69,6 +69,9 @@ These shortcuts work when the task table has focus:
| Shortcut | Action | | Shortcut | Action |
|----------|--------| |----------|--------|
| `Enter` | Open selected task details | | `Enter` | Open selected task details |
| `e` | Edit selected task |
| `c` | Create new task |
| `Delete` | Delete selected task (with confirmation) |
| `←` | Collapse current project | | `←` | Collapse current project |
| `→` | Expand current project | | `→` | Expand current project |
@@ -203,16 +206,56 @@ These shortcuts work when a filter dropdown (Status/Priority/Due) has focus:
| `Ctrl+H` | Focus previous filter element | | `Ctrl+H` | Focus previous filter element |
| `Ctrl+J` | Focus table headers | | `Ctrl+J` | Focus table headers |
## Modal (Task Details) ## Modal (Task Details - View Mode)
These shortcuts work when viewing task details: These shortcuts work when viewing task details:
| Shortcut | Action | | Shortcut | Action |
|----------|--------| |----------|--------|
| `Escape` | Cancel edit (when editing) or close modal | | `Escape` | Close modal |
| `e` | Enter edit mode |
| `j` / `↓` | Scroll down | | `j` / `↓` | Scroll down |
| `k` / `↑` | Scroll up | | `k` / `↑` | Scroll up |
| `Ctrl+Enter` | Save edits (when editing) or close modal |
## Modal (Task Details - Edit Mode - Navigating)
These shortcuts work when editing a task but not actively typing in a field:
| Shortcut | Action |
|----------|--------|
| `Escape` | Close modal (prompts if unsaved changes) |
| `Ctrl+S` | Save changes |
| `j` / `↓` / `Tab` | Focus next field |
| `k` / `↑` / `Shift+Tab` | Focus previous field |
| `Enter` / `Space` | Edit focused field or item |
| `Delete` / `Backspace` | Delete selected item (tag/annotation) |
| `h` / `l` | Navigate between items (tags/annotations) |
| `u` | Undo last change |
| `r` | Redo last undone change |
| `y` | Confirm action (in delete dialogs) |
| `n` | Cancel action (in delete dialogs) |
## Modal (Task Details - Edit Mode - Typing)
These shortcuts work when actively typing in an input field:
| Shortcut | Action |
|----------|--------|
| `Escape` | Exit field (return to navigation) |
| `Ctrl+S` | Save all changes |
| `Enter` | Submit field or exit (depends on field type) |
## Modal (Task Details - Edit Mode - Dropdown Open)
These shortcuts work when a dropdown (Priority/Status) is open:
| Shortcut | Action |
|----------|--------|
| `Escape` | Close dropdown |
| `Ctrl+S` | Save all changes |
| `Enter` / `Space` | Select option and close dropdown |
| `j` / `↓` | Select next option |
| `k` / `↑` | Select previous option |
## Search Input Editing ## Search Input Editing
+128 -8
View File
@@ -1,9 +1,13 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc;
use gpui::prelude::*; use gpui::prelude::*;
use crate::{ use crate::{
components::toast::{ToastGlobal, ToastHost}, components::{
confirm_dialog::ConfirmDialog,
toast::{ToastGlobal, ToastHost},
},
keymap::{FocusTarget, KeymapStack}, keymap::{FocusTarget, KeymapStack},
models::{FilterState, ProjectTree}, models::{FilterState, ProjectTree},
task::{self, TaskOverview, TaskService, TaskSummary}, task::{self, TaskOverview, TaskService, TaskSummary},
@@ -17,6 +21,14 @@ use crate::{
}, },
}; };
pub(super) struct DeleteConfirmState {
pub(super) task_id: uuid::Uuid,
pub(super) id_display: String,
pub(super) description: String,
pub(super) project: Option<String>,
pub(super) tags: Vec<String>,
}
pub(super) struct App { pub(super) struct App {
pub(super) focus_handle: gpui::FocusHandle, pub(super) focus_handle: gpui::FocusHandle,
pub(super) focus_target: FocusTarget, pub(super) focus_target: FocusTarget,
@@ -30,16 +42,24 @@ pub(super) struct App {
pub(super) task_service: TaskService, pub(super) task_service: TaskService,
pub(super) tasks: Vec<TaskSummary>, pub(super) tasks: Vec<TaskSummary>,
pub(super) focus_before_modal: FocusTarget, pub(super) focus_before_modal: FocusTarget,
pub(super) delete_confirm: Option<DeleteConfirmState>,
pub(super) created_task_uuid: Option<uuid::Uuid>,
pub(super) needs_focus_restore: bool,
} }
impl gpui::Render for App { impl gpui::Render for App {
fn render( fn render(
&mut self, &mut self,
_window: &mut gpui::Window, window: &mut gpui::Window,
cx: &mut gpui::Context<Self>, cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement { ) -> impl gpui::IntoElement {
let theme = cx.theme(); let theme = cx.theme();
if self.needs_focus_restore {
self.needs_focus_restore = false;
window.focus(&self.focus_handle);
}
let on_root_key_down = cx.listener(|app, event: &gpui::KeyDownEvent, window, cx| { let on_root_key_down = cx.listener(|app, event: &gpui::KeyDownEvent, window, cx| {
app.handle_key_down(event, window, cx); app.handle_key_down(event, window, cx);
}); });
@@ -66,6 +86,52 @@ impl gpui::Render for App {
None None
}; };
let delete_dialog = self.delete_confirm.as_ref().map(|state| {
let cancel_handler = Arc::new(cx.listener(|app, _event, _window, cx| {
app.cancel_delete_confirm(cx);
}));
let confirm_handler = Arc::new(cx.listener(|app, _event, _window, cx| {
app.confirm_delete_task(cx);
}));
let mut message = format!(
"This will permanently delete: {} \"{}\"",
state.id_display,
state.description.trim()
);
if let Some(project) = state
.project
.as_ref()
.map(|p| p.trim())
.filter(|p| !p.is_empty())
{
message.push_str(&format!(" | Project: {}", project));
}
if !state.tags.is_empty() {
message.push_str(&format!(" | Tags: {}", state.tags.join(", ")));
}
ConfirmDialog::new("Delete task?")
.message(message)
.hint("Enter/y = delete | Esc/n = cancel")
.cancel("Cancel (Esc)", cancel_handler.clone())
.danger("Delete (Enter)", confirm_handler.clone())
.on_backdrop_click(cancel_handler)
.render(theme)
.into_any_element()
});
let overlay = match (modal, delete_dialog) {
(None, None) => None,
(Some(modal), None) => Some(modal),
(None, Some(dialog)) => Some(dialog),
(Some(modal), Some(dialog)) => {
Some(gpui::div().child(modal).child(dialog).into_any_element())
}
};
app_layout::render_app_layout( app_layout::render_app_layout(
theme, theme,
&self.focus_handle, &self.focus_handle,
@@ -77,7 +143,7 @@ impl gpui::Render for App {
on_root_key_down, on_root_key_down,
on_sidebar_mouse_down, on_sidebar_mouse_down,
on_table_mouse_down, on_table_mouse_down,
modal, overlay,
) )
} }
} }
@@ -120,6 +186,16 @@ impl App {
all_tasks: Vec<task::TaskSummary>, all_tasks: Vec<task::TaskSummary>,
cx: &mut gpui::Context<Self>, cx: &mut gpui::Context<Self>,
) { ) {
let previous_selection = self.task_table.read(cx).selected_task_uuid();
let filter_state = self.filter_state.read(cx).clone();
let filtered = task::TaskFilter::from(&filter_state).apply(&all_tasks);
let reselect_uuid = previous_selection.and_then(|uuid| {
filtered
.iter()
.any(|task| task.uuid == uuid)
.then_some(uuid)
});
self.tasks = all_tasks; self.tasks = all_tasks;
let (projects, tags) = Self::build_sidebar_data(&self.tasks); let (projects, tags) = Self::build_sidebar_data(&self.tasks);
@@ -132,10 +208,15 @@ impl App {
}); });
let tasks = self.tasks.clone(); let tasks = self.tasks.clone();
self.task_table self.task_table.update(cx, |table, cx| {
.update(cx, |table, cx| table.reload_tasks_from_all(tasks, cx)); table.reload_tasks_from_all(tasks, cx);
if let Some(uuid) = reselect_uuid {
let _ = table.select_task_by_uuid(uuid, cx);
}
});
self.update_modal_project_suggestions(cx); self.update_modal_project_suggestions(cx);
self.update_modal_tag_suggestions(cx);
} }
fn update_modal_project_suggestions(&self, cx: &mut gpui::Context<Self>) { fn update_modal_project_suggestions(&self, cx: &mut gpui::Context<Self>) {
@@ -153,6 +234,21 @@ impl App {
}); });
} }
fn update_modal_tag_suggestions(&self, cx: &mut gpui::Context<Self>) {
let mut tags: Vec<String> = self
.tasks
.iter()
.flat_map(|task| task.tags.iter().cloned())
.collect();
tags.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
tags.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
self.task_detail_modal.update(cx, |modal, cx| {
modal.set_tag_suggestions(tags, cx);
});
}
fn reload_tasks(&mut self, cx: &mut gpui::Context<Self>) { fn reload_tasks(&mut self, cx: &mut gpui::Context<Self>) {
match self.task_service.get_all_tasks() { match self.task_service.get_all_tasks() {
Ok(all_tasks) => { Ok(all_tasks) => {
@@ -241,6 +337,16 @@ impl App {
modal.set_project_suggestions(project_suggestions, cx); modal.set_project_suggestions(project_suggestions, cx);
}); });
let mut tag_suggestions: Vec<String> = task_summaries
.iter()
.flat_map(|task| task.tags.iter().cloned())
.collect();
tag_suggestions.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
tag_suggestions.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
task_detail_modal.update(cx, |modal, cx| {
modal.set_tag_suggestions(tag_suggestions, cx);
});
let mut keymap = KeymapStack::new(); let mut keymap = KeymapStack::new();
keymap.push_layer(crate::keymap::defaults::build_default_keymap()); keymap.push_layer(crate::keymap::defaults::build_default_keymap());
@@ -257,6 +363,9 @@ impl App {
task_service, task_service,
tasks: task_summaries, tasks: task_summaries,
focus_before_modal: FocusTarget::Table, focus_before_modal: FocusTarget::Table,
delete_confirm: None,
created_task_uuid: None,
needs_focus_restore: false,
}; };
window.focus(&app_instance.focus_handle); window.focus(&app_instance.focus_handle);
@@ -290,17 +399,28 @@ impl App {
app.open_task_detail(*task_id, false, None, cx); app.open_task_detail(*task_id, false, None, cx);
} }
} }
TaskTableEvent::CreateRequested => {
app.open_task_create(cx);
}
TaskTableEvent::DeleteRequested(task_id) => {
app.request_delete_task(*task_id, cx);
}
}) })
.detach(); .detach();
cx.subscribe(&modal_events, |app, _modal, event, cx| match event { cx.subscribe(&modal_events, |app, _modal, event, cx| match event {
TaskDetailModalEvent::Closed => { TaskDetailModalEvent::Closed {
app.focus_target = app.focus_before_modal; task_id,
cx.notify(); was_creating,
} => {
app.handle_modal_closed(*task_id, *was_creating, cx);
} }
TaskDetailModalEvent::SaveEdits { task_id, update } => { TaskDetailModalEvent::SaveEdits { task_id, update } => {
app.handle_save_task_edits(*task_id, update.clone(), cx); app.handle_save_task_edits(*task_id, update.clone(), cx);
} }
TaskDetailModalEvent::CreateTask { draft, annotations } => {
app.handle_create_task(draft.clone(), annotations.clone(), cx);
}
}) })
.detach(); .detach();
+10 -1
View File
@@ -1,4 +1,3 @@
use gpui::prelude::*;
use std::sync::Arc; use std::sync::Arc;
use crate::components::dialog::{Dialog, DialogButton, DialogButtonVariant}; use crate::components::dialog::{Dialog, DialogButton, DialogButtonVariant};
@@ -7,6 +6,7 @@ use crate::theme::Theme;
#[derive(Clone)] #[derive(Clone)]
pub struct ConfirmDialog { pub struct ConfirmDialog {
title: String, title: String,
message: Option<String>,
hint: Option<String>, hint: Option<String>,
cancel: Option<( cancel: Option<(
String, String,
@@ -25,6 +25,7 @@ impl ConfirmDialog {
pub fn new(title: impl Into<String>) -> Self { pub fn new(title: impl Into<String>) -> Self {
Self { Self {
title: title.into(), title: title.into(),
message: None,
hint: None, hint: None,
cancel: None, cancel: None,
confirm: None, confirm: None,
@@ -32,6 +33,11 @@ impl ConfirmDialog {
} }
} }
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
pub fn hint(mut self, hint: impl Into<String>) -> Self { pub fn hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into()); self.hint = Some(hint.into());
self self
@@ -74,6 +80,9 @@ impl ConfirmDialog {
pub fn render(self, theme: &Theme) -> gpui::Div { pub fn render(self, theme: &Theme) -> gpui::Div {
let mut dialog = Dialog::new(self.title); let mut dialog = Dialog::new(self.title);
if let Some(message) = self.message {
dialog = dialog.message(message);
}
if let Some(hint) = self.hint { if let Some(hint) = self.hint {
dialog = dialog.hint(hint); dialog = dialog.hint(hint);
} }
+34
View File
@@ -18,6 +18,7 @@ pub struct Input {
suggestions: Vec<Suggestion>, suggestions: Vec<Suggestion>,
suggestions_open: bool, suggestions_open: bool,
accepted_suggestion: bool,
active_suggestion: usize, active_suggestion: usize,
suggest: Option<Arc<dyn Fn(&str) -> Vec<Suggestion> + Send + Sync>>, suggest: Option<Arc<dyn Fn(&str) -> Vec<Suggestion> + Send + Sync>>,
on_change: Option<Arc<dyn Fn(&str, &mut gpui::Context<Self>) + Send + Sync>>, on_change: Option<Arc<dyn Fn(&str, &mut gpui::Context<Self>) + Send + Sync>>,
@@ -42,6 +43,7 @@ impl Input {
suggestions: vec![], suggestions: vec![],
suggestions_open: false, suggestions_open: false,
accepted_suggestion: false,
active_suggestion: 0, active_suggestion: 0,
suggest: None, suggest: None,
on_change: None, on_change: None,
@@ -89,6 +91,12 @@ impl Input {
&self.id &self.id
} }
pub fn consume_suggestion_accept(&mut self) -> bool {
let accepted = self.accepted_suggestion;
self.accepted_suggestion = false;
accepted
}
pub fn has_suggestions_open(&self) -> bool { pub fn has_suggestions_open(&self) -> bool {
self.suggestions_open self.suggestions_open
} }
@@ -224,6 +232,20 @@ impl Input {
self.accept_suggestion(cx); self.accept_suggestion(cx);
} }
fn mark_suggestion_accept(&mut self, cx: &mut gpui::Context<Self>) {
if self.accepted_suggestion {
return;
}
self.accepted_suggestion = true;
let entity = cx.entity().clone();
cx.defer(move |cx| {
let _ = entity.update(cx, |input, _cx| {
input.accepted_suggestion = false;
});
});
}
fn submit(&mut self, cx: &mut gpui::Context<Self>) { fn submit(&mut self, cx: &mut gpui::Context<Self>) {
self.suggestions_open = false; self.suggestions_open = false;
if let Some(on_submit) = self.on_submit.clone() { if let Some(on_submit) = self.on_submit.clone() {
@@ -369,6 +391,7 @@ impl Input {
// For single-line, check suggestions then submit // For single-line, check suggestions then submit
if self.suggestions_open { if self.suggestions_open {
self.mark_suggestion_accept(cx);
self.accept_suggestion(cx); self.accept_suggestion(cx);
} else { } else {
self.submit(cx); self.submit(cx);
@@ -396,6 +419,17 @@ impl Input {
"up" => self.move_suggestion(-1, cx), "up" => self.move_suggestion(-1, cx),
"down" => self.move_suggestion(1, cx), "down" => self.move_suggestion(1, cx),
"space" => {
if ctrl {
return;
}
if self.suggestions_open {
self.mark_suggestion_accept(cx);
self.accept_suggestion(cx);
} else {
self.insert_text(" ", cx);
}
}
"left" => { "left" => {
if ctrl { if ctrl {
+5
View File
@@ -163,6 +163,11 @@ impl CommandDispatcher for App {
.update(cx, |table, cx| table.dispatch(command, cx)); .update(cx, |table, cx| table.dispatch(command, cx));
true true
} }
Command::CreateTask | Command::DeleteSelectedTask => {
self.task_table
.update(cx, |table, cx| table.dispatch(command, cx));
true
}
Command::ToggleDropdown Command::ToggleDropdown
| Command::SelectNextOption | Command::SelectNextOption
| Command::SelectPrevOption | Command::SelectPrevOption
+235
View File
@@ -1,4 +1,5 @@
use crate::{ use crate::{
app::DeleteConfirmState,
components::toast::ToastKind, components::toast::ToastKind,
keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord}, keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord},
task::{self, TaskSummary}, task::{self, TaskSummary},
@@ -40,6 +41,28 @@ impl App {
window: &mut gpui::Window, window: &mut gpui::Window,
cx: &mut gpui::Context<Self>, cx: &mut gpui::Context<Self>,
) { ) {
if self.delete_confirm.is_some() {
let key = event.keystroke.key.as_str().to_lowercase();
let mods = &event.keystroke.modifiers;
let has_mods = mods.control || mods.alt || mods.shift || mods.platform;
if !has_mods {
match key.as_str() {
"enter" | "y" => {
self.confirm_delete_task(cx);
return;
}
"escape" | "n" => {
self.cancel_delete_confirm(cx);
return;
}
_ => {}
}
}
return;
}
if let Some(chord) = KeyChord::from_gpui(event) { if let Some(chord) = KeyChord::from_gpui(event) {
let context = self.active_context(cx); let context = self.active_context(cx);
@@ -197,6 +220,125 @@ impl App {
cx.notify(); cx.notify();
} }
pub(super) fn open_task_create(&mut self, cx: &mut gpui::Context<Self>) {
if self.task_detail_modal.read(cx).is_open() {
return;
}
self.focus_before_modal = self.focus_target;
self.task_detail_modal.update(cx, |modal, cx| {
modal.open_create(None, cx);
});
cx.notify();
}
pub(super) fn request_delete_task(
&mut self,
task_id: uuid::Uuid,
cx: &mut gpui::Context<Self>,
) {
if self.delete_confirm.is_some() {
return;
}
if self.task_detail_modal.read(cx).is_editing() {
self.task_detail_modal.update(cx, |modal, cx| {
modal.cancel_edit(None, cx);
});
}
let Some(task) = self.tasks.iter().find(|task| task.uuid == task_id) else {
return;
};
let id_display = task
.working_id
.or(task.id)
.map(|id| format!("#{}", id))
.unwrap_or_else(|| task_id.to_string());
let mut tags: Vec<String> = task.tags.iter().cloned().collect();
tags.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
self.delete_confirm = Some(DeleteConfirmState {
task_id,
id_display,
description: task.description.clone(),
project: task.project.clone(),
tags,
});
cx.notify();
}
pub(super) fn cancel_delete_confirm(&mut self, cx: &mut gpui::Context<Self>) {
if self.delete_confirm.take().is_some() {
cx.notify();
}
}
pub(super) fn confirm_delete_task(&mut self, cx: &mut gpui::Context<Self>) {
let Some(confirm) = self.delete_confirm.take() else {
return;
};
let next_selection = self
.task_table
.read(cx)
.next_selection_after_delete(confirm.task_id);
if let Err(e) = self.task_service.delete_task(confirm.task_id) {
log::error!("[App] Failed to delete task: {}", e);
self.toast_host.update(cx, |host, cx| {
host.push(
ToastKind::Error,
format!("Failed to delete task: {}", e),
cx,
);
});
return;
}
let updated_task = match self.task_service.get_task(confirm.task_id) {
Ok(task) => task,
Err(e) => {
log::error!("[App] Failed to reload deleted task: {}", e);
None
}
};
if let Some(task) = updated_task {
self.upsert_task_summary(task, cx);
} else {
self.tasks.retain(|task| task.uuid != confirm.task_id);
let summaries = self.tasks.clone();
self.update_ui_from_tasks(summaries, cx);
}
self.task_table.update(cx, |table, cx| {
if let Some(next_uuid) = next_selection {
if !table.select_task_by_uuid(next_uuid, cx) {
table.clear_selection(cx);
}
} else {
table.clear_selection(cx);
}
});
let modal_task_id = self.task_detail_modal.read(cx).task_id();
if modal_task_id == Some(confirm.task_id) {
self.task_detail_modal.update(cx, |modal, cx| {
modal.close(None, cx);
});
self.toast_host.update(cx, |host, cx| {
host.push(ToastKind::Info, "Task deleted", cx);
});
}
self.status_bar.update(cx, |bar, cx| {
bar.set_dirty(cx);
});
}
fn apply_task_update(&mut self, task: task::Task, cx: &mut gpui::Context<Self>) { fn apply_task_update(&mut self, task: task::Task, cx: &mut gpui::Context<Self>) {
let summary = TaskSummary::from(&task); let summary = TaskSummary::from(&task);
if let Some(existing) = self.tasks.iter_mut().find(|t| t.uuid == task.uuid) { if let Some(existing) = self.tasks.iter_mut().find(|t| t.uuid == task.uuid) {
@@ -214,6 +356,18 @@ impl App {
}); });
} }
fn upsert_task_summary(&mut self, task: task::Task, cx: &mut gpui::Context<Self>) {
let summary = TaskSummary::from(&task);
if let Some(existing) = self.tasks.iter_mut().find(|t| t.uuid == task.uuid) {
*existing = summary;
} else {
self.tasks.push(summary);
}
let summaries = self.tasks.clone();
self.update_ui_from_tasks(summaries, cx);
}
fn sync_task_detail(&mut self, task: task::Task, cx: &mut gpui::Context<Self>) { fn sync_task_detail(&mut self, task: task::Task, cx: &mut gpui::Context<Self>) {
let summary = TaskSummary::from(&task); let summary = TaskSummary::from(&task);
if let Some(existing) = self.tasks.iter_mut().find(|t| t.uuid == task.uuid) { if let Some(existing) = self.tasks.iter_mut().find(|t| t.uuid == task.uuid) {
@@ -364,6 +518,87 @@ impl App {
} }
} }
pub(super) fn handle_create_task(
&mut self,
draft: task::TaskDraft,
annotations: Vec<String>,
cx: &mut gpui::Context<Self>,
) {
let created = match self.task_service.create_task(draft) {
Ok(task) => task,
Err(e) => {
log::error!("[App] Failed to create task: {}", e);
self.toast_host.update(cx, |host, cx| {
host.push(
ToastKind::Error,
format!("Failed to create task: {}", e),
cx,
);
});
return;
}
};
let mut latest_task = created;
for text in annotations {
match self.task_service.add_annotation(latest_task.uuid, text) {
Ok(task) => {
latest_task = task;
}
Err(e) => {
log::error!("[App] Failed to add annotation: {}", e);
self.toast_host.update(cx, |host, cx| {
host.push(
ToastKind::Error,
format!("Failed to add annotation: {}", e),
cx,
);
});
break;
}
}
}
let created_uuid = latest_task.uuid;
self.created_task_uuid = Some(created_uuid);
self.upsert_task_summary(latest_task, cx);
self.task_detail_modal.update(cx, |modal, cx| {
modal.close(None, cx);
});
self.status_bar.update(cx, |bar, cx| {
bar.set_dirty(cx);
});
}
pub(super) fn handle_modal_closed(
&mut self,
task_id: Option<uuid::Uuid>,
was_creating: bool,
cx: &mut gpui::Context<Self>,
) {
self.focus_target = self.focus_before_modal;
self.task_table.update(cx, |table, cx| {
table.blur_filter_bar(cx);
table.blur_table_headers(cx);
let uuid_to_select = if was_creating {
self.created_task_uuid.take()
} else {
task_id
};
if let Some(uuid) = uuid_to_select {
let _ = table.select_task_by_uuid(uuid, cx);
}
});
self.needs_focus_restore = true;
cx.notify();
}
fn active_context(&self, cx: &gpui::Context<Self>) -> ContextId { fn active_context(&self, cx: &gpui::Context<Self>) -> ContextId {
if self.task_detail_modal.read(cx).is_open() { if self.task_detail_modal.read(cx).is_open() {
return self.task_detail_modal.read(cx).active_context(); return self.task_detail_modal.read(cx).active_context();
+6
View File
@@ -12,6 +12,8 @@ pub enum Command {
// Actions // Actions
OpenSelectedTask, OpenSelectedTask,
OpenTaskEdit, OpenTaskEdit,
CreateTask,
DeleteSelectedTask,
Sync, Sync,
// Focus // Focus
@@ -79,6 +81,8 @@ impl Command {
"ClearSelection" => Some(Self::ClearSelection), "ClearSelection" => Some(Self::ClearSelection),
"OpenSelectedTask" => Some(Self::OpenSelectedTask), "OpenSelectedTask" => Some(Self::OpenSelectedTask),
"OpenTaskEdit" => Some(Self::OpenTaskEdit), "OpenTaskEdit" => Some(Self::OpenTaskEdit),
"CreateTask" => Some(Self::CreateTask),
"DeleteSelectedTask" => Some(Self::DeleteSelectedTask),
"Sync" => Some(Self::Sync), "Sync" => Some(Self::Sync),
"FocusSearch" => Some(Self::FocusSearch), "FocusSearch" => Some(Self::FocusSearch),
"FocusTable" => Some(Self::FocusTable), "FocusTable" => Some(Self::FocusTable),
@@ -137,6 +141,8 @@ impl Command {
Self::ClearSelection => "ClearSelection", Self::ClearSelection => "ClearSelection",
Self::OpenSelectedTask => "OpenSelectedTask", Self::OpenSelectedTask => "OpenSelectedTask",
Self::OpenTaskEdit => "OpenTaskEdit", Self::OpenTaskEdit => "OpenTaskEdit",
Self::CreateTask => "CreateTask",
Self::DeleteSelectedTask => "DeleteSelectedTask",
Self::Sync => "Sync", Self::Sync => "Sync",
Self::FocusSearch => "FocusSearch", Self::FocusSearch => "FocusSearch",
Self::FocusTable => "FocusTable", Self::FocusTable => "FocusTable",
+10
View File
@@ -136,6 +136,16 @@ pub fn build_default_keymap() -> KeymapLayer {
KeyChord::new(Key::Char('e'), Mods::none()), KeyChord::new(Key::Char('e'), Mods::none()),
Command::OpenTaskEdit, Command::OpenTaskEdit,
); );
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('c'), Mods::none()),
Command::CreateTask,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Delete, Mods::none()),
Command::DeleteSelectedTask,
);
layer.bind( layer.bind(
ContextId::Table, ContextId::Table,
KeyChord::new(Key::ArrowLeft, Mods::none()), KeyChord::new(Key::ArrowLeft, Mods::none()),
+2 -2
View File
@@ -6,7 +6,7 @@ pub mod service;
pub use error::{TaskError, TaskResult}; pub use error::{TaskError, TaskResult};
pub use filter::{DueDateFilter, TagsFilterMode, TaskFilter}; pub use filter::{DueDateFilter, TagsFilterMode, TaskFilter};
pub use model::{ pub use model::{
Task, TaskAnnotation, TaskDetailState, TaskDetailVm, TaskOverview, TaskPriority, TaskStatus, Task, TaskAnnotation, TaskDetailState, TaskDetailVm, TaskDraft, TaskOverview, TaskPriority,
TaskSummary, TaskUpdate, TaskStatus, TaskSummary, TaskUpdate,
}; };
pub use service::{SyncResult, TaskService}; pub use service::{SyncResult, TaskService};
+20 -1
View File
@@ -447,6 +447,15 @@ impl TaskDetailVm {
impl From<taskchampion::Task> for Task { impl From<taskchampion::Task> for Task {
fn from(task: taskchampion::Task) -> Self { fn from(task: taskchampion::Task) -> Self {
let mut tags: HashSet<String> = task
.get_taskmap()
.keys()
.filter_map(|key| key.strip_prefix("tag_").map(|tag| tag.to_string()))
.collect();
for tag in task.get_tags().filter(|tag| tag.is_synthetic()) {
tags.insert(tag.to_string());
}
Self { Self {
uuid: task.get_uuid(), uuid: task.get_uuid(),
id: task.get_value("ID").map(|value| value.parse().unwrap()), id: task.get_value("ID").map(|value| value.parse().unwrap()),
@@ -454,7 +463,7 @@ impl From<taskchampion::Task> for Task {
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()),
priority: task.get_priority().into(), priority: task.get_priority().into(),
tags: task.get_tags().map(|t| t.to_string()).collect(), tags,
due: task.get_due().map(Into::into), due: task.get_due().map(Into::into),
wait: task.get_wait().map(Into::into), wait: task.get_wait().map(Into::into),
entry: task.get_entry().map(Into::into), entry: task.get_entry().map(Into::into),
@@ -479,6 +488,16 @@ pub struct TaskUpdate {
pub dependencies: Option<Vec<String>>, pub dependencies: Option<Vec<String>>,
} }
#[derive(Debug, Clone)]
pub struct TaskDraft {
pub description: String,
pub project: Option<String>,
pub priority: Option<TaskPriority>,
pub status: Option<TaskStatus>,
pub tags: HashSet<String>,
pub due: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TaskOverview { pub struct TaskOverview {
pub tasks: Vec<Task>, pub tasks: Vec<Task>,
+81 -23
View File
@@ -2,14 +2,12 @@ use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use taskchampion::{ use taskchampion::{Operations, Replica, ServerConfig, Status, StorageConfig, storage::AccessMode};
Operations, Replica, ServerConfig, Status, StorageConfig, Tag, storage::AccessMode,
};
use uuid::Uuid; use uuid::Uuid;
use super::error::{TaskError, TaskResult}; use super::error::{TaskError, TaskResult};
use super::filter::TaskFilter; use super::filter::TaskFilter;
use super::model::{Task, TaskDetailVm, TaskOverview, TaskStatus, TaskSummary}; use super::model::{Task, TaskDetailVm, TaskDraft, TaskOverview, TaskStatus, TaskSummary};
pub struct TaskService { pub struct TaskService {
replica: Replica, replica: Replica,
@@ -95,7 +93,7 @@ impl TaskService {
}) })
} }
pub fn create_task(&mut self, description: String) -> TaskResult<Task> { pub fn create_task(&mut self, draft: TaskDraft) -> TaskResult<Task> {
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
let mut ops = Operations::new(); let mut ops = Operations::new();
@@ -104,14 +102,74 @@ impl TaskService {
.create_task(uuid, &mut ops) .create_task(uuid, &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?; .map_err(|e| TaskError::Storage(e.to_string()))?;
let entry_time = Utc::now();
tc_task tc_task
.set_description(description, &mut ops) .set_status(Status::Pending, &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?;
tc_task
.set_entry(Some(entry_time), &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?;
tc_task
.set_description(draft.description, &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?;
if let Some(project) = draft.project {
tc_task
.set_value("project", Some(project), &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?;
}
if let Some(priority) = draft.priority {
let pri: String = priority.into();
tc_task
.set_priority(pri, &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?;
}
if let Some(due) = draft.due {
tc_task
.set_due(Some(due), &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?;
}
for tag_str in draft.tags {
let key = format!("tag_{}", tag_str);
tc_task
.set_value(key, Some(String::new()), &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?;
}
if let Some(status) = draft.status {
match status {
TaskStatus::Completed => {
tc_task
.done(&mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?;
}
TaskStatus::Deleted => {
tc_task
.set_status(Status::Deleted, &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?;
}
TaskStatus::Pending => {}
TaskStatus::Recurring | TaskStatus::Unknown(_) => {}
}
}
let modified_time = Utc::now();
tc_task
.set_modified(modified_time, &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?; .map_err(|e| TaskError::Storage(e.to_string()))?;
self.replica self.replica
.commit_operations(ops) .commit_operations(ops)
.map_err(|e| TaskError::Storage(e.to_string()))?; .map_err(|e| TaskError::Storage(e.to_string()))?;
self.replica
.rebuild_working_set(false)
.map_err(|e| TaskError::Storage(e.to_string()))?;
let working_set = self let working_set = self
.replica .replica
.working_set() .working_set()
@@ -296,22 +354,24 @@ impl TaskService {
} }
if let Some(new_tags) = tags { if let Some(new_tags) = tags {
let current_tags: HashSet<String> = tc_task.get_tags().map(|t| t.to_string()).collect(); let current_tags: HashSet<String> = tc_task
.get_taskmap()
.keys()
.filter_map(|key| key.strip_prefix("tag_").map(|tag| tag.to_string()))
.collect();
for tag_str in current_tags.difference(&new_tags) { for tag_str in current_tags.difference(&new_tags) {
if let Ok(tag) = Tag::try_from(tag_str.as_str()) { let key = format!("tag_{}", tag_str);
tc_task tc_task
.remove_tag(&tag, &mut ops) .set_value(key, None, &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?; .map_err(|e| TaskError::Storage(e.to_string()))?;
}
} }
for tag_str in new_tags.difference(&current_tags) { for tag_str in new_tags.difference(&current_tags) {
if let Ok(tag) = Tag::try_from(tag_str.as_str()) { let key = format!("tag_{}", tag_str);
tc_task tc_task
.add_tag(&tag, &mut ops) .set_value(key, Some(String::new()), &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?; .map_err(|e| TaskError::Storage(e.to_string()))?;
}
} }
} }
@@ -431,10 +491,9 @@ impl TaskService {
.map_err(|e| TaskError::Storage(e.to_string()))? .map_err(|e| TaskError::Storage(e.to_string()))?
.ok_or(TaskError::NotFound(uuid))?; .ok_or(TaskError::NotFound(uuid))?;
let tag = Tag::try_from(tag_str).map_err(|_| TaskError::InvalidTag(tag_str.to_string()))?; let key = format!("tag_{}", tag_str);
tc_task tc_task
.add_tag(&tag, &mut ops) .set_value(key, Some(String::new()), &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?; .map_err(|e| TaskError::Storage(e.to_string()))?;
self.replica self.replica
@@ -453,10 +512,9 @@ impl TaskService {
.map_err(|e| TaskError::Storage(e.to_string()))? .map_err(|e| TaskError::Storage(e.to_string()))?
.ok_or(TaskError::NotFound(uuid))?; .ok_or(TaskError::NotFound(uuid))?;
let tag = Tag::try_from(tag_str).map_err(|_| TaskError::InvalidTag(tag_str.to_string()))?; let key = format!("tag_{}", tag_str);
tc_task tc_task
.remove_tag(&tag, &mut ops) .set_value(key, None, &mut ops)
.map_err(|e| TaskError::Storage(e.to_string()))?; .map_err(|e| TaskError::Storage(e.to_string()))?;
self.replica self.replica
+6
View File
@@ -49,6 +49,12 @@ impl StatusBar {
cx.notify(); cx.notify();
} }
pub fn set_dirty(&mut self, cx: &mut Context<Self>) {
self.last_sync_message = "Needs sync".to_string();
self.sync_state = SyncState::Idle;
cx.notify();
}
pub fn set_error(&mut self, message: String, cx: &mut Context<Self>) { pub fn set_error(&mut self, message: String, cx: &mut Context<Self>) {
self.error_message = Some(message); self.error_message = Some(message);
cx.notify(); cx.notify();
+274 -25
View File
@@ -1,5 +1,6 @@
use chrono::Utc; use chrono::{NaiveDate, TimeZone, Utc};
use gpui::prelude::*; use gpui::prelude::*;
use std::collections::HashSet;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use crate::components::button::{Dropdown, DropdownItem}; use crate::components::button::{Dropdown, DropdownItem};
@@ -7,6 +8,7 @@ use crate::components::input::Input;
use crate::components::toast::{ToastGlobal, ToastKind}; use crate::components::toast::{ToastGlobal, ToastKind};
use crate::keymap::{Command, CommandDispatcher, ContextId}; use crate::keymap::{Command, CommandDispatcher, ContextId};
use crate::task::{self, TaskDetailVm}; use crate::task::{self, TaskDetailVm};
use crate::ui::DATE_FORMAT;
mod annotations; mod annotations;
mod bindings; mod bindings;
@@ -25,11 +27,18 @@ use self::history::FormHistory;
use self::state::{ConfirmAction, InlineEditTarget, ModalMode, TaskModalState}; use self::state::{ConfirmAction, InlineEditTarget, ModalMode, TaskModalState};
pub enum TaskDetailModalEvent { pub enum TaskDetailModalEvent {
Closed, Closed {
task_id: Option<uuid::Uuid>,
was_creating: bool,
},
SaveEdits { SaveEdits {
task_id: uuid::Uuid, task_id: uuid::Uuid,
update: TaskEditUpdate, update: TaskEditUpdate,
}, },
CreateTask {
draft: task::TaskDraft,
annotations: Vec<String>,
},
} }
struct ModalEntities { struct ModalEntities {
@@ -54,6 +63,7 @@ pub struct TaskDetailModal {
scroll_handle: gpui::ScrollHandle, scroll_handle: gpui::ScrollHandle,
entities: ModalEntities, entities: ModalEntities,
project_suggestions: Arc<Mutex<Vec<String>>>, project_suggestions: Arc<Mutex<Vec<String>>>,
tag_suggestions: Arc<Mutex<Vec<String>>>,
form_history: FormHistory, form_history: FormHistory,
} }
@@ -61,6 +71,7 @@ impl TaskDetailModal {
pub fn new(cx: &mut gpui::Context<Self>) -> Self { pub fn new(cx: &mut gpui::Context<Self>) -> Self {
let modal_entity = cx.entity().clone(); let modal_entity = cx.entity().clone();
let project_suggestions: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new())); let project_suggestions: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let tag_suggestions: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let annotation_input = cx.new(|cx| { let annotation_input = cx.new(|cx| {
let on_change_entity = modal_entity.clone(); let on_change_entity = modal_entity.clone();
@@ -100,23 +111,58 @@ impl TaskDetailModal {
}; };
let needle = query.to_lowercase(); let needle = query.to_lowercase();
let mut candidates = Vec::new();
let mut seen = HashSet::new();
for project in list.iter() {
let parts: Vec<&str> = project
.split('.')
.filter(|part| !part.trim().is_empty())
.collect();
if parts.is_empty() {
continue;
}
let mut prefix = String::new();
for (idx, part) in parts.iter().enumerate() {
if !prefix.is_empty() {
prefix.push('.');
}
prefix.push_str(part);
let key = prefix.to_lowercase();
if seen.insert(key) {
candidates.push(prefix.clone());
}
if idx + 1 < parts.len() {
let mut with_dot = prefix.clone();
with_dot.push('.');
let key = with_dot.to_lowercase();
if seen.insert(key) {
candidates.push(with_dot);
}
}
}
}
let mut level_matches = Vec::new(); let mut level_matches = Vec::new();
let mut prefix_matches = Vec::new(); let mut prefix_matches = Vec::new();
let mut contains_matches = Vec::new(); let mut contains_matches = Vec::new();
for project in list.iter() { for candidate in candidates {
let hay = project.to_lowercase(); let hay = candidate.to_lowercase();
if hay.starts_with(&needle) { if hay.starts_with(&needle) {
let boundary = hay.len() == needle.len() let boundary = hay.len() == needle.len()
|| hay.as_bytes().get(needle.len()) == Some(&b'.') || hay.as_bytes().get(needle.len()) == Some(&b'.')
|| needle.ends_with('.'); || needle.ends_with('.');
if boundary { if boundary {
level_matches.push(project.clone()); level_matches.push(candidate);
} else { } else {
prefix_matches.push(project.clone()); prefix_matches.push(candidate);
} }
} else if hay.contains(&needle) { } else if hay.contains(&needle) {
contains_matches.push(project.clone()); contains_matches.push(candidate);
} }
} }
@@ -138,24 +184,72 @@ impl TaskDetailModal {
let due_input = cx.new(|cx| Input::new("task-edit-due", cx, "Due (YYYY-MM-DD)")); let due_input = cx.new(|cx| Input::new("task-edit-due", cx, "Due (YYYY-MM-DD)"));
let tags_input = cx.new(|cx| { let tags_input = {
let on_change_entity = modal_entity.clone(); let on_change_entity = modal_entity.clone();
let suggestions = tag_suggestions.clone();
Input::new("task-edit-tags", cx, "Add tag").with_on_change(Arc::new( cx.new(|cx| {
move |value, cx| { Input::new("task-edit-tags", cx, "Add tag")
cx.defer({ .with_suggest(Arc::new(move |query| {
let entity = on_change_entity.clone(); let (prefix, needle) = match query
let v = value.to_string(); .char_indices()
move |cx| { .rev()
let _ = entity.update(cx, |modal, cx| { .find(|(_, ch)| ch.is_whitespace() || *ch == ',')
modal.state.form.tag_draft = v; {
cx.notify(); Some((idx, _)) => (&query[..=idx], &query[idx + 1..]),
}); None => ("", query),
};
let needle = needle.trim();
if needle.is_empty() {
return Vec::new();
} }
});
}, let Ok(list) = suggestions.lock() else {
)) return Vec::new();
}); };
let needle_lower = needle.to_lowercase();
let mut prefix_matches = Vec::new();
let mut contains_matches = Vec::new();
for tag in list.iter() {
let hay = tag.to_lowercase();
if hay.starts_with(&needle_lower) {
prefix_matches.push(tag.clone());
} else if hay.contains(&needle_lower) {
contains_matches.push(tag.clone());
}
}
prefix_matches.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
contains_matches.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
prefix_matches
.into_iter()
.chain(contains_matches)
.take(8)
.map(|tag| {
let mut insert = String::new();
insert.push_str(prefix);
insert.push_str(&tag);
crate::components::input::Suggestion::new(tag, insert)
})
.collect()
}))
.with_on_change(Arc::new(move |value, cx| {
cx.defer({
let entity = on_change_entity.clone();
let v = value.to_string();
move |cx| {
let _ = entity.update(cx, |modal, cx| {
modal.state.form.tag_draft = v;
cx.notify();
});
}
});
}))
})
};
let status_items = vec![ let status_items = vec![
DropdownItem::new("Pending"), DropdownItem::new("Pending"),
@@ -190,6 +284,7 @@ impl TaskDetailModal {
scroll_handle: gpui::ScrollHandle::new(), scroll_handle: gpui::ScrollHandle::new(),
entities, entities,
project_suggestions, project_suggestions,
tag_suggestions,
form_history: FormHistory::default(), form_history: FormHistory::default(),
} }
} }
@@ -212,6 +307,35 @@ impl TaskDetailModal {
} }
} }
pub fn set_tag_suggestions(&mut self, tags: Vec<String>, _cx: &mut gpui::Context<Self>) {
if let Ok(mut list) = self.tag_suggestions.lock() {
*list = tags;
}
}
fn placeholder_detail() -> TaskDetailVm {
let task = task::Task::new(
uuid::Uuid::nil(),
None,
task::TaskStatus::Pending,
String::new(),
None,
task::TaskPriority::None,
HashSet::new(),
None,
None,
None,
None,
vec![],
HashSet::new(),
false,
false,
None,
);
TaskDetailVm::from_task(&task, &[])
}
pub fn open_with_detail( pub fn open_with_detail(
&mut self, &mut self,
detail: TaskDetailVm, detail: TaskDetailVm,
@@ -230,6 +354,31 @@ impl TaskDetailModal {
self.open_with_detail_mode(detail, true, window, cx); self.open_with_detail_mode(detail, true, window, cx);
} }
pub fn open_create(&mut self, window: Option<&mut gpui::Window>, cx: &mut gpui::Context<Self>) {
self.reset_open_state();
self.state.task_id = None;
self.state.loading = false;
self.state.error = None;
self.state.original = Some(Self::placeholder_detail());
self.state.form = TaskForm::default();
self.state.errors.clear();
self.state.is_create = true;
self.state.mode = ModalMode::Edit;
self.state.edit_state = EditState::Navigating;
self.state.modal_focus = ModalFocus::Description;
self.state.annotations = AnnotationState::default();
self.reset_pending_state();
self.apply_form_inputs(cx);
self.form_history.clear();
self.form_history.push(self.state.form.clone());
if let Some(window) = window {
window.focus(&self.form_focus_handle);
}
cx.notify();
}
fn reset_open_state(&mut self) { fn reset_open_state(&mut self) {
self.scroll_handle = gpui::ScrollHandle::new(); self.scroll_handle = gpui::ScrollHandle::new();
self.scroll_handle.scroll_to_item(0); self.scroll_handle.scroll_to_item(0);
@@ -253,6 +402,7 @@ impl TaskDetailModal {
self.state.form = TaskForm::default(); self.state.form = TaskForm::default();
self.state.errors.clear(); self.state.errors.clear();
self.state.mode = ModalMode::View; self.state.mode = ModalMode::View;
self.state.is_create = false;
self.state.edit_state = EditState::Navigating; self.state.edit_state = EditState::Navigating;
self.state.annotations = AnnotationState::default(); self.state.annotations = AnnotationState::default();
self.state.error = error; self.state.error = error;
@@ -272,6 +422,7 @@ impl TaskDetailModal {
self.state.error = None; self.state.error = None;
self.state.original = Some(detail.clone()); self.state.original = Some(detail.clone());
self.state.errors.clear(); self.state.errors.clear();
self.state.is_create = false;
self.reset_pending_state(); self.reset_pending_state();
self.sync_from_detail(&detail, cx, true); self.sync_from_detail(&detail, cx, true);
@@ -333,6 +484,7 @@ impl TaskDetailModal {
self.state.loading = false; self.state.loading = false;
self.state.error = None; self.state.error = None;
self.state.original = Some(detail.clone()); self.state.original = Some(detail.clone());
self.state.is_create = false;
self.sync_from_detail(&detail, cx, reset_form); self.sync_from_detail(&detail, cx, reset_form);
cx.notify(); cx.notify();
} }
@@ -368,11 +520,16 @@ impl TaskDetailModal {
self.state.loading = false; self.state.loading = false;
self.state.original = Some(detail); self.state.original = Some(detail);
self.state.error = None; self.state.error = None;
self.state.is_create = false;
self.reset_pending_state(); self.reset_pending_state();
cx.notify(); cx.notify();
} }
fn enter_edit_mode(&mut self, window: Option<&mut gpui::Window>, cx: &mut gpui::Context<Self>) { fn enter_edit_mode(&mut self, window: Option<&mut gpui::Window>, cx: &mut gpui::Context<Self>) {
if self.state.is_create {
return;
}
let Some(detail) = self.state.original.clone() else { let Some(detail) = self.state.original.clone() else {
return; return;
}; };
@@ -393,6 +550,11 @@ impl TaskDetailModal {
} }
pub fn cancel_edit(&mut self, window: Option<&mut gpui::Window>, cx: &mut gpui::Context<Self>) { pub fn cancel_edit(&mut self, window: Option<&mut gpui::Window>, cx: &mut gpui::Context<Self>) {
if self.state.is_create {
self.close(window, cx);
return;
}
let Some(detail) = self.state.original.clone() else { let Some(detail) = self.state.original.clone() else {
return; return;
}; };
@@ -412,7 +574,15 @@ impl TaskDetailModal {
} }
pub fn is_editing(&self) -> bool { pub fn is_editing(&self) -> bool {
self.state.mode == ModalMode::Edit self.state.mode == ModalMode::Edit && !self.state.is_create
}
pub fn is_creating(&self) -> bool {
self.state.mode == ModalMode::Edit && self.state.is_create
}
pub fn task_id(&self) -> Option<uuid::Uuid> {
self.state.task_id
} }
fn apply_form_inputs(&mut self, cx: &mut gpui::Context<Self>) { fn apply_form_inputs(&mut self, cx: &mut gpui::Context<Self>) {
@@ -526,6 +696,11 @@ impl TaskDetailModal {
return; return;
} }
if self.state.is_create {
self.submit_create(cx);
return;
}
if self.state.original.is_none() { if self.state.original.is_none() {
return; return;
} }
@@ -553,6 +728,69 @@ impl TaskDetailModal {
}); });
} }
fn submit_create(&mut self, cx: &mut gpui::Context<Self>) {
if self.state.mode != ModalMode::Edit || !self.state.is_create {
return;
}
self.sync_form_from_inputs(cx);
self.state.errors = self.state.form.validate();
if !self.state.errors.is_empty() {
cx.notify();
return;
}
let description = self.state.form.description.trim().to_string();
let project = match self.state.form.project.trim() {
"" => None,
value => Some(value.to_string()),
};
let due = if self.state.form.due.trim().is_empty() {
None
} else {
NaiveDate::parse_from_str(self.state.form.due.trim(), DATE_FORMAT)
.ok()
.and_then(|date| date.and_hms_opt(0, 0, 0))
.map(|date| Utc.from_utc_datetime(&date))
};
let priority = match self.state.form.priority {
task::TaskPriority::None => None,
value => Some(value),
};
let status = match self.state.form.status.clone() {
task::TaskStatus::Pending => None,
value => Some(value),
};
let tags = self.state.form.tags.iter().cloned().collect();
let annotations = self
.state
.annotations
.items
.iter()
.filter_map(|item| match item.origin {
AnnotationOrigin::Added => Some(item.text.to_string()),
_ => None,
})
.collect();
cx.emit(TaskDetailModalEvent::CreateTask {
draft: task::TaskDraft {
description,
project,
priority,
status,
tags,
due,
},
annotations,
});
}
fn update_field_error(&mut self, field: FieldId) { fn update_field_error(&mut self, field: FieldId) {
match self.state.form.validate_field(field) { match self.state.form.validate_field(field) {
Some(message) => { Some(message) => {
@@ -631,8 +869,13 @@ impl TaskDetailModal {
.update(cx, |input, cx| input.blur(window, cx)); .update(cx, |input, cx| input.blur(window, cx));
} }
let task_id = self.state.task_id;
let was_creating = self.state.is_create;
self.state = TaskModalState::default(); self.state = TaskModalState::default();
cx.emit(TaskDetailModalEvent::Closed); cx.emit(TaskDetailModalEvent::Closed {
task_id,
was_creating,
});
cx.notify(); cx.notify();
} }
@@ -1093,7 +1336,13 @@ impl TaskDetailModal {
CommandResult::Handled CommandResult::Handled
} }
ModalFocus::Project => { ModalFocus::Project => {
if self.project_suggestions_open(cx) { let accepted = self
.entities
.project_input
.update(cx, |input, _cx| input.consume_suggestion_accept());
if accepted {
CommandResult::Handled
} else if self.project_suggestions_open(cx) {
CommandResult::NotHandled CommandResult::NotHandled
} else { } else {
self.exit_edit_field(window, cx); self.exit_edit_field(window, cx);
+1
View File
@@ -45,6 +45,7 @@ pub(super) fn render_task_detail_modal(
panel::render_task_detail_panel( panel::render_task_detail_panel(
detail, detail,
state.mode, state.mode,
state.is_create,
state.edit_state, state.edit_state,
&state.form, &state.form,
&state.errors, &state.errors,
+31 -3
View File
@@ -104,6 +104,7 @@ where
pub(super) fn render_task_detail_panel<OnCloseClick>( pub(super) fn render_task_detail_panel<OnCloseClick>(
detail: &task::TaskDetailVm, detail: &task::TaskDetailVm,
mode: ModalMode, mode: ModalMode,
is_create: bool,
edit_state: EditState, edit_state: EditState,
form: &TaskForm, form: &TaskForm,
errors: &HashMap<FieldId, gpui::SharedString>, errors: &HashMap<FieldId, gpui::SharedString>,
@@ -129,6 +130,7 @@ where
OnCloseClick: Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, OnCloseClick: Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
{ {
let is_editing = mode == ModalMode::Edit; let is_editing = mode == ModalMode::Edit;
let is_creating = is_create;
let is_navigating = edit_state == EditState::Navigating; let is_navigating = edit_state == EditState::Navigating;
let status_label = if is_editing { let status_label = if is_editing {
form.status.clone().into() form.status.clone().into()
@@ -204,7 +206,7 @@ where
); );
} }
if is_editing { if is_editing && !is_creating {
badges.push( badges.push(
Chip::new("Editing") Chip::new("Editing")
.variant(ChipVariant::Info) .variant(ChipVariant::Info)
@@ -224,7 +226,11 @@ where
} else { } else {
detail.overview.description.clone() detail.overview.description.clone()
}; };
let title = format!("{} {}", id_label, title_description); let title = if is_creating {
"New task".to_string()
} else {
format!("{} {}", id_label, title_description)
};
let on_close_click = Arc::new(on_close_click); let on_close_click = Arc::new(on_close_click);
let header = render_header(title, badges, is_editing, theme, cx, on_close_click.clone()); let header = render_header(title, badges, is_editing, theme, cx, on_close_click.clone());
@@ -303,6 +309,7 @@ where
let footer = render_footer( let footer = render_footer(
detail, detail,
is_editing, is_editing,
is_creating,
is_navigating, is_navigating,
errors, errors,
form, form,
@@ -1015,6 +1022,7 @@ fn render_extras_section(detail: &TaskDetailVm, value_color: gpui::Rgba) -> Opti
fn render_footer( fn render_footer(
detail: &TaskDetailVm, detail: &TaskDetailVm,
is_editing: bool, is_editing: bool,
is_creating: bool,
is_navigating: bool, is_navigating: bool,
errors: &HashMap<FieldId, gpui::SharedString>, errors: &HashMap<FieldId, gpui::SharedString>,
form: &TaskForm, form: &TaskForm,
@@ -1027,7 +1035,27 @@ fn render_footer(
) -> gpui::Div { ) -> gpui::Div {
let mut action_row = gpui::div().flex().items_center().gap_2(); let mut action_row = gpui::div().flex().items_center().gap_2();
if is_editing { if is_creating {
let cancel_create_handler = Arc::new(cx.listener(|modal, _event, window, cx| {
modal.close(Some(window), cx);
}));
let create_handler = Arc::new(cx.listener(|modal, _event, _window, cx| {
modal.submit_create(cx);
}));
let cancel_button =
ActionButton::new("Cancel (Esc)").on_click(move |event, window, app| {
(cancel_create_handler)(event, window, app);
});
let create_button =
ActionButton::new("Create (Ctrl+S)").on_click(move |event, window, app| {
(create_handler)(event, window, app);
});
action_row = action_row.child(cancel_button).child(create_button);
} else if is_editing {
let cancel_edit_handler = Arc::new(cx.listener(|modal, _event, window, cx| { let cancel_edit_handler = Arc::new(cx.listener(|modal, _event, window, cx| {
modal.cancel_edit(Some(window), cx); modal.cancel_edit(Some(window), cx);
})); }));
+2
View File
@@ -73,6 +73,7 @@ pub(super) struct TaskModalState {
pub(super) form: TaskForm, pub(super) form: TaskForm,
pub(super) errors: HashMap<FieldId, SharedString>, pub(super) errors: HashMap<FieldId, SharedString>,
pub(super) mode: ModalMode, pub(super) mode: ModalMode,
pub(super) is_create: bool,
pub(super) edit_state: EditState, pub(super) edit_state: EditState,
pub(super) annotations: AnnotationState, pub(super) annotations: AnnotationState,
pub(super) error: Option<SharedString>, pub(super) error: Option<SharedString>,
@@ -98,6 +99,7 @@ impl Default for TaskModalState {
form: TaskForm::default(), form: TaskForm::default(),
errors: HashMap::new(), errors: HashMap::new(),
mode: ModalMode::default(), mode: ModalMode::default(),
is_create: false,
edit_state: EditState::default(), edit_state: EditState::default(),
annotations: AnnotationState::default(), annotations: AnnotationState::default(),
error: None, error: None,
+104 -11
View File
@@ -7,6 +7,7 @@ use gpui::prelude::*;
use crate::{ use crate::{
components::{ components::{
self, self,
action_button::ActionButton,
button::{Dropdown, DropdownItem}, button::{Dropdown, DropdownItem},
input::Input, input::Input,
}, },
@@ -751,6 +752,45 @@ impl TaskTable {
.map(|task| task.uuid) .map(|task| task.uuid)
} }
pub fn select_task_by_uuid(&mut self, uuid: uuid::Uuid, cx: &mut gpui::Context<Self>) -> bool {
let Some(idx) = self.cached_tasks.iter().position(|task| task.uuid == uuid) else {
self.selected_page_idx = None;
self.selected_global_idx = None;
cx.notify();
return false;
};
let page = idx / self.pagination.page_size + 1;
self.pagination.current_page(page);
let page_first_idx = self.pagination.first_item_index();
self.selected_global_idx = Some(idx);
self.selected_page_idx = Some(idx - page_first_idx);
cx.notify();
true
}
pub fn next_selection_after_delete(&self, deleted_uuid: uuid::Uuid) -> Option<uuid::Uuid> {
let selected_uuid = self.selected_task_uuid();
if selected_uuid != Some(deleted_uuid) {
return selected_uuid;
}
let idx = self
.cached_tasks
.iter()
.position(|task| task.uuid == deleted_uuid)?;
if idx + 1 < self.cached_tasks.len() {
return Some(self.cached_tasks[idx + 1].uuid);
}
if idx > 0 {
return Some(self.cached_tasks[idx - 1].uuid);
}
None
}
pub fn focus_search_input(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) { pub fn focus_search_input(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) {
self.filter_bar_focus = FilterBarFocus::SearchInput; self.filter_bar_focus = FilterBarFocus::SearchInput;
self.search_input.update(cx, |input, cx| { self.search_input.update(cx, |input, cx| {
@@ -1059,6 +1099,13 @@ impl TaskTable {
.child(status_wrapper) .child(status_wrapper)
.child(priority_wrapper) .child(priority_wrapper)
.child(due_wrapper) .child(due_wrapper)
.child(
ActionButton::new("+ New")
.id("task-new")
.on_click(cx.listener(|_table, _event, _window, cx| {
cx.emit(TaskTableEvent::CreateRequested);
})),
)
.child(clear_button); .child(clear_button);
gpui::div() gpui::div()
@@ -1175,6 +1222,11 @@ impl TaskTable {
.w(gpui::rems(6.0)) .w(gpui::rems(6.0))
.child(self.render_header_column(SortColumn::Status, "header-status", cx)), .child(self.render_header_column(SortColumn::Status, "header-status", cx)),
) )
.child(
gpui::div()
.w(gpui::rems(2.0))
.child(components::label::Label::new("").text_color(theme.muted)),
)
} }
fn render_row(&self, idx: usize, row: &TaskRow, cx: &gpui::Context<Self>) -> gpui::Div { fn render_row(&self, idx: usize, row: &TaskRow, cx: &gpui::Context<Self>) -> gpui::Div {
@@ -1182,19 +1234,32 @@ impl TaskTable {
let selected = self.selected_page_idx == Some(idx); let selected = self.selected_page_idx == Some(idx);
let row_uuid = row.uuid; let row_uuid = row.uuid;
gpui::div() let delete_button = gpui::div()
.id(("delete-btn", idx))
.flex() .flex()
.items_center() .items_center()
.justify_center()
.w(gpui::rems(2.0))
.h(gpui::rems(2.0))
.rounded_md()
.text_color(theme.error)
.cursor_pointer()
.hover(|s| s.bg(theme.hover).text_color(theme.error))
.active(|s| s.bg(theme.selection))
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(move |table, _event: &gpui::MouseDownEvent, _window, cx| {
table.select_row(idx, cx);
cx.emit(TaskTableEvent::DeleteRequested(row_uuid));
}),
)
.child(components::label::Label::new("").text_sm());
let row_content = gpui::div()
.flex()
.flex_1()
.items_center()
.gap_2() .gap_2()
.px_4()
.py_1()
.border_b_1()
.border_color(theme.divider)
.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.hover)))
.cursor_pointer() .cursor_pointer()
.on_mouse_down( .on_mouse_down(
gpui::MouseButton::Left, gpui::MouseButton::Left,
@@ -1249,7 +1314,23 @@ impl TaskTable {
components::label::Label::new(row.status.clone()) components::label::Label::new(row.status.clone())
.text_color(self.status_color(row, cx)), .text_color(self.status_color(row, cx)),
), ),
) );
gpui::div()
.flex()
.items_center()
.gap_2()
.px_4()
.py_1()
.border_b_1()
.border_color(theme.divider)
.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.hover)))
.child(row_content)
.child(delete_button)
} }
fn render_footer(&self, cx: &gpui::Context<Self>) -> gpui::Div { fn render_footer(&self, cx: &gpui::Context<Self>) -> gpui::Div {
@@ -1395,6 +1476,16 @@ impl CommandDispatcher for TaskTable {
self.blur_filter_bar(cx); self.blur_filter_bar(cx);
true true
} }
Command::CreateTask => {
cx.emit(TaskTableEvent::CreateRequested);
true
}
Command::DeleteSelectedTask => {
if let Some(task_id) = self.selected_task_uuid() {
cx.emit(TaskTableEvent::DeleteRequested(task_id));
}
true
}
Command::ExpandProject | Command::CollapseProject => false, Command::ExpandProject | Command::CollapseProject => false,
_ => false, _ => false,
} }
@@ -1403,6 +1494,8 @@ impl CommandDispatcher for TaskTable {
pub enum TaskTableEvent { pub enum TaskTableEvent {
OpenTask(uuid::Uuid), OpenTask(uuid::Uuid),
CreateRequested,
DeleteRequested(uuid::Uuid),
} }
impl gpui::EventEmitter<TaskTableEvent> for TaskTable {} impl gpui::EventEmitter<TaskTableEvent> for TaskTable {}