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:
+128
-8
@@ -1,9 +1,13 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::*;
|
||||
|
||||
use crate::{
|
||||
components::toast::{ToastGlobal, ToastHost},
|
||||
components::{
|
||||
confirm_dialog::ConfirmDialog,
|
||||
toast::{ToastGlobal, ToastHost},
|
||||
},
|
||||
keymap::{FocusTarget, KeymapStack},
|
||||
models::{FilterState, ProjectTree},
|
||||
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) focus_handle: gpui::FocusHandle,
|
||||
pub(super) focus_target: FocusTarget,
|
||||
@@ -30,16 +42,24 @@ pub(super) struct App {
|
||||
pub(super) task_service: TaskService,
|
||||
pub(super) tasks: Vec<TaskSummary>,
|
||||
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 {
|
||||
fn render(
|
||||
&mut self,
|
||||
_window: &mut gpui::Window,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
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| {
|
||||
app.handle_key_down(event, window, cx);
|
||||
});
|
||||
@@ -66,6 +86,52 @@ impl gpui::Render for App {
|
||||
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(
|
||||
theme,
|
||||
&self.focus_handle,
|
||||
@@ -77,7 +143,7 @@ impl gpui::Render for App {
|
||||
on_root_key_down,
|
||||
on_sidebar_mouse_down,
|
||||
on_table_mouse_down,
|
||||
modal,
|
||||
overlay,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -120,6 +186,16 @@ impl App {
|
||||
all_tasks: Vec<task::TaskSummary>,
|
||||
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;
|
||||
let (projects, tags) = Self::build_sidebar_data(&self.tasks);
|
||||
|
||||
@@ -132,10 +208,15 @@ impl App {
|
||||
});
|
||||
|
||||
let tasks = self.tasks.clone();
|
||||
self.task_table
|
||||
.update(cx, |table, cx| table.reload_tasks_from_all(tasks, cx));
|
||||
self.task_table.update(cx, |table, 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_tag_suggestions(cx);
|
||||
}
|
||||
|
||||
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>) {
|
||||
match self.task_service.get_all_tasks() {
|
||||
Ok(all_tasks) => {
|
||||
@@ -241,6 +337,16 @@ impl App {
|
||||
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();
|
||||
keymap.push_layer(crate::keymap::defaults::build_default_keymap());
|
||||
|
||||
@@ -257,6 +363,9 @@ impl App {
|
||||
task_service,
|
||||
tasks: task_summaries,
|
||||
focus_before_modal: FocusTarget::Table,
|
||||
delete_confirm: None,
|
||||
created_task_uuid: None,
|
||||
needs_focus_restore: false,
|
||||
};
|
||||
|
||||
window.focus(&app_instance.focus_handle);
|
||||
@@ -290,17 +399,28 @@ impl App {
|
||||
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();
|
||||
|
||||
cx.subscribe(&modal_events, |app, _modal, event, cx| match event {
|
||||
TaskDetailModalEvent::Closed => {
|
||||
app.focus_target = app.focus_before_modal;
|
||||
cx.notify();
|
||||
TaskDetailModalEvent::Closed {
|
||||
task_id,
|
||||
was_creating,
|
||||
} => {
|
||||
app.handle_modal_closed(*task_id, *was_creating, cx);
|
||||
}
|
||||
TaskDetailModalEvent::SaveEdits { task_id, update } => {
|
||||
app.handle_save_task_edits(*task_id, update.clone(), cx);
|
||||
}
|
||||
TaskDetailModalEvent::CreateTask { draft, annotations } => {
|
||||
app.handle_create_task(draft.clone(), annotations.clone(), cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use gpui::prelude::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::components::dialog::{Dialog, DialogButton, DialogButtonVariant};
|
||||
@@ -7,6 +6,7 @@ use crate::theme::Theme;
|
||||
#[derive(Clone)]
|
||||
pub struct ConfirmDialog {
|
||||
title: String,
|
||||
message: Option<String>,
|
||||
hint: Option<String>,
|
||||
cancel: Option<(
|
||||
String,
|
||||
@@ -25,6 +25,7 @@ impl ConfirmDialog {
|
||||
pub fn new(title: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
message: None,
|
||||
hint: None,
|
||||
cancel: 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 {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
@@ -74,6 +80,9 @@ impl ConfirmDialog {
|
||||
|
||||
pub fn render(self, theme: &Theme) -> gpui::Div {
|
||||
let mut dialog = Dialog::new(self.title);
|
||||
if let Some(message) = self.message {
|
||||
dialog = dialog.message(message);
|
||||
}
|
||||
if let Some(hint) = self.hint {
|
||||
dialog = dialog.hint(hint);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct Input {
|
||||
|
||||
suggestions: Vec<Suggestion>,
|
||||
suggestions_open: bool,
|
||||
accepted_suggestion: bool,
|
||||
active_suggestion: usize,
|
||||
suggest: Option<Arc<dyn Fn(&str) -> Vec<Suggestion> + Send + Sync>>,
|
||||
on_change: Option<Arc<dyn Fn(&str, &mut gpui::Context<Self>) + Send + Sync>>,
|
||||
@@ -42,6 +43,7 @@ impl Input {
|
||||
|
||||
suggestions: vec![],
|
||||
suggestions_open: false,
|
||||
accepted_suggestion: false,
|
||||
active_suggestion: 0,
|
||||
suggest: None,
|
||||
on_change: None,
|
||||
@@ -89,6 +91,12 @@ impl Input {
|
||||
&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 {
|
||||
self.suggestions_open
|
||||
}
|
||||
@@ -224,6 +232,20 @@ impl Input {
|
||||
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>) {
|
||||
self.suggestions_open = false;
|
||||
if let Some(on_submit) = self.on_submit.clone() {
|
||||
@@ -369,6 +391,7 @@ impl Input {
|
||||
|
||||
// For single-line, check suggestions then submit
|
||||
if self.suggestions_open {
|
||||
self.mark_suggestion_accept(cx);
|
||||
self.accept_suggestion(cx);
|
||||
} else {
|
||||
self.submit(cx);
|
||||
@@ -396,6 +419,17 @@ impl Input {
|
||||
|
||||
"up" => 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" => {
|
||||
if ctrl {
|
||||
|
||||
@@ -163,6 +163,11 @@ impl CommandDispatcher for App {
|
||||
.update(cx, |table, cx| table.dispatch(command, cx));
|
||||
true
|
||||
}
|
||||
Command::CreateTask | Command::DeleteSelectedTask => {
|
||||
self.task_table
|
||||
.update(cx, |table, cx| table.dispatch(command, cx));
|
||||
true
|
||||
}
|
||||
Command::ToggleDropdown
|
||||
| Command::SelectNextOption
|
||||
| Command::SelectPrevOption
|
||||
|
||||
+235
@@ -1,4 +1,5 @@
|
||||
use crate::{
|
||||
app::DeleteConfirmState,
|
||||
components::toast::ToastKind,
|
||||
keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord},
|
||||
task::{self, TaskSummary},
|
||||
@@ -40,6 +41,28 @@ impl App {
|
||||
window: &mut gpui::Window,
|
||||
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) {
|
||||
let context = self.active_context(cx);
|
||||
|
||||
@@ -197,6 +220,125 @@ impl App {
|
||||
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>) {
|
||||
let summary = TaskSummary::from(&task);
|
||||
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>) {
|
||||
let summary = TaskSummary::from(&task);
|
||||
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 {
|
||||
if self.task_detail_modal.read(cx).is_open() {
|
||||
return self.task_detail_modal.read(cx).active_context();
|
||||
|
||||
@@ -12,6 +12,8 @@ pub enum Command {
|
||||
// Actions
|
||||
OpenSelectedTask,
|
||||
OpenTaskEdit,
|
||||
CreateTask,
|
||||
DeleteSelectedTask,
|
||||
Sync,
|
||||
|
||||
// Focus
|
||||
@@ -79,6 +81,8 @@ impl Command {
|
||||
"ClearSelection" => Some(Self::ClearSelection),
|
||||
"OpenSelectedTask" => Some(Self::OpenSelectedTask),
|
||||
"OpenTaskEdit" => Some(Self::OpenTaskEdit),
|
||||
"CreateTask" => Some(Self::CreateTask),
|
||||
"DeleteSelectedTask" => Some(Self::DeleteSelectedTask),
|
||||
"Sync" => Some(Self::Sync),
|
||||
"FocusSearch" => Some(Self::FocusSearch),
|
||||
"FocusTable" => Some(Self::FocusTable),
|
||||
@@ -137,6 +141,8 @@ impl Command {
|
||||
Self::ClearSelection => "ClearSelection",
|
||||
Self::OpenSelectedTask => "OpenSelectedTask",
|
||||
Self::OpenTaskEdit => "OpenTaskEdit",
|
||||
Self::CreateTask => "CreateTask",
|
||||
Self::DeleteSelectedTask => "DeleteSelectedTask",
|
||||
Self::Sync => "Sync",
|
||||
Self::FocusSearch => "FocusSearch",
|
||||
Self::FocusTable => "FocusTable",
|
||||
|
||||
@@ -136,6 +136,16 @@ pub fn build_default_keymap() -> KeymapLayer {
|
||||
KeyChord::new(Key::Char('e'), Mods::none()),
|
||||
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(
|
||||
ContextId::Table,
|
||||
KeyChord::new(Key::ArrowLeft, Mods::none()),
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ pub mod service;
|
||||
pub use error::{TaskError, TaskResult};
|
||||
pub use filter::{DueDateFilter, TagsFilterMode, TaskFilter};
|
||||
pub use model::{
|
||||
Task, TaskAnnotation, TaskDetailState, TaskDetailVm, TaskOverview, TaskPriority, TaskStatus,
|
||||
TaskSummary, TaskUpdate,
|
||||
Task, TaskAnnotation, TaskDetailState, TaskDetailVm, TaskDraft, TaskOverview, TaskPriority,
|
||||
TaskStatus, TaskSummary, TaskUpdate,
|
||||
};
|
||||
pub use service::{SyncResult, TaskService};
|
||||
|
||||
+20
-1
@@ -447,6 +447,15 @@ impl TaskDetailVm {
|
||||
|
||||
impl From<taskchampion::Task> for Task {
|
||||
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 {
|
||||
uuid: task.get_uuid(),
|
||||
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(),
|
||||
project: task.get_value("project").map(|v| v.to_string()),
|
||||
priority: task.get_priority().into(),
|
||||
tags: task.get_tags().map(|t| t.to_string()).collect(),
|
||||
tags,
|
||||
due: task.get_due().map(Into::into),
|
||||
wait: task.get_wait().map(Into::into),
|
||||
entry: task.get_entry().map(Into::into),
|
||||
@@ -479,6 +488,16 @@ pub struct TaskUpdate {
|
||||
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)]
|
||||
pub struct TaskOverview {
|
||||
pub tasks: Vec<Task>,
|
||||
|
||||
+81
-23
@@ -2,14 +2,12 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use taskchampion::{
|
||||
Operations, Replica, ServerConfig, Status, StorageConfig, Tag, storage::AccessMode,
|
||||
};
|
||||
use taskchampion::{Operations, Replica, ServerConfig, Status, StorageConfig, storage::AccessMode};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::error::{TaskError, TaskResult};
|
||||
use super::filter::TaskFilter;
|
||||
use super::model::{Task, TaskDetailVm, TaskOverview, TaskStatus, TaskSummary};
|
||||
use super::model::{Task, TaskDetailVm, TaskDraft, TaskOverview, TaskStatus, TaskSummary};
|
||||
|
||||
pub struct TaskService {
|
||||
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 mut ops = Operations::new();
|
||||
|
||||
@@ -104,14 +102,74 @@ impl TaskService {
|
||||
.create_task(uuid, &mut ops)
|
||||
.map_err(|e| TaskError::Storage(e.to_string()))?;
|
||||
|
||||
let entry_time = Utc::now();
|
||||
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()))?;
|
||||
|
||||
self.replica
|
||||
.commit_operations(ops)
|
||||
.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
|
||||
.replica
|
||||
.working_set()
|
||||
@@ -296,22 +354,24 @@ impl TaskService {
|
||||
}
|
||||
|
||||
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) {
|
||||
if let Ok(tag) = Tag::try_from(tag_str.as_str()) {
|
||||
tc_task
|
||||
.remove_tag(&tag, &mut ops)
|
||||
.map_err(|e| TaskError::Storage(e.to_string()))?;
|
||||
}
|
||||
let key = format!("tag_{}", tag_str);
|
||||
tc_task
|
||||
.set_value(key, None, &mut ops)
|
||||
.map_err(|e| TaskError::Storage(e.to_string()))?;
|
||||
}
|
||||
|
||||
for tag_str in new_tags.difference(¤t_tags) {
|
||||
if let Ok(tag) = Tag::try_from(tag_str.as_str()) {
|
||||
tc_task
|
||||
.add_tag(&tag, &mut ops)
|
||||
.map_err(|e| TaskError::Storage(e.to_string()))?;
|
||||
}
|
||||
let key = format!("tag_{}", tag_str);
|
||||
tc_task
|
||||
.set_value(key, Some(String::new()), &mut ops)
|
||||
.map_err(|e| TaskError::Storage(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,10 +491,9 @@ impl TaskService {
|
||||
.map_err(|e| TaskError::Storage(e.to_string()))?
|
||||
.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
|
||||
.add_tag(&tag, &mut ops)
|
||||
.set_value(key, Some(String::new()), &mut ops)
|
||||
.map_err(|e| TaskError::Storage(e.to_string()))?;
|
||||
|
||||
self.replica
|
||||
@@ -453,10 +512,9 @@ impl TaskService {
|
||||
.map_err(|e| TaskError::Storage(e.to_string()))?
|
||||
.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
|
||||
.remove_tag(&tag, &mut ops)
|
||||
.set_value(key, None, &mut ops)
|
||||
.map_err(|e| TaskError::Storage(e.to_string()))?;
|
||||
|
||||
self.replica
|
||||
|
||||
@@ -49,6 +49,12 @@ impl StatusBar {
|
||||
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>) {
|
||||
self.error_message = Some(message);
|
||||
cx.notify();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use chrono::Utc;
|
||||
use chrono::{NaiveDate, TimeZone, Utc};
|
||||
use gpui::prelude::*;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::components::button::{Dropdown, DropdownItem};
|
||||
@@ -7,6 +8,7 @@ use crate::components::input::Input;
|
||||
use crate::components::toast::{ToastGlobal, ToastKind};
|
||||
use crate::keymap::{Command, CommandDispatcher, ContextId};
|
||||
use crate::task::{self, TaskDetailVm};
|
||||
use crate::ui::DATE_FORMAT;
|
||||
|
||||
mod annotations;
|
||||
mod bindings;
|
||||
@@ -25,11 +27,18 @@ use self::history::FormHistory;
|
||||
use self::state::{ConfirmAction, InlineEditTarget, ModalMode, TaskModalState};
|
||||
|
||||
pub enum TaskDetailModalEvent {
|
||||
Closed,
|
||||
Closed {
|
||||
task_id: Option<uuid::Uuid>,
|
||||
was_creating: bool,
|
||||
},
|
||||
SaveEdits {
|
||||
task_id: uuid::Uuid,
|
||||
update: TaskEditUpdate,
|
||||
},
|
||||
CreateTask {
|
||||
draft: task::TaskDraft,
|
||||
annotations: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
struct ModalEntities {
|
||||
@@ -54,6 +63,7 @@ pub struct TaskDetailModal {
|
||||
scroll_handle: gpui::ScrollHandle,
|
||||
entities: ModalEntities,
|
||||
project_suggestions: Arc<Mutex<Vec<String>>>,
|
||||
tag_suggestions: Arc<Mutex<Vec<String>>>,
|
||||
form_history: FormHistory,
|
||||
}
|
||||
|
||||
@@ -61,6 +71,7 @@ impl TaskDetailModal {
|
||||
pub fn new(cx: &mut gpui::Context<Self>) -> Self {
|
||||
let modal_entity = cx.entity().clone();
|
||||
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 on_change_entity = modal_entity.clone();
|
||||
@@ -100,23 +111,58 @@ impl TaskDetailModal {
|
||||
};
|
||||
|
||||
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 prefix_matches = Vec::new();
|
||||
let mut contains_matches = Vec::new();
|
||||
|
||||
for project in list.iter() {
|
||||
let hay = project.to_lowercase();
|
||||
for candidate in candidates {
|
||||
let hay = candidate.to_lowercase();
|
||||
if hay.starts_with(&needle) {
|
||||
let boundary = hay.len() == needle.len()
|
||||
|| hay.as_bytes().get(needle.len()) == Some(&b'.')
|
||||
|| needle.ends_with('.');
|
||||
if boundary {
|
||||
level_matches.push(project.clone());
|
||||
level_matches.push(candidate);
|
||||
} else {
|
||||
prefix_matches.push(project.clone());
|
||||
prefix_matches.push(candidate);
|
||||
}
|
||||
} 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 tags_input = cx.new(|cx| {
|
||||
let tags_input = {
|
||||
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(
|
||||
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();
|
||||
});
|
||||
cx.new(|cx| {
|
||||
Input::new("task-edit-tags", cx, "Add tag")
|
||||
.with_suggest(Arc::new(move |query| {
|
||||
let (prefix, needle) = match query
|
||||
.char_indices()
|
||||
.rev()
|
||||
.find(|(_, ch)| ch.is_whitespace() || *ch == ',')
|
||||
{
|
||||
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![
|
||||
DropdownItem::new("Pending"),
|
||||
@@ -190,6 +284,7 @@ impl TaskDetailModal {
|
||||
scroll_handle: gpui::ScrollHandle::new(),
|
||||
entities,
|
||||
project_suggestions,
|
||||
tag_suggestions,
|
||||
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(
|
||||
&mut self,
|
||||
detail: TaskDetailVm,
|
||||
@@ -230,6 +354,31 @@ impl TaskDetailModal {
|
||||
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) {
|
||||
self.scroll_handle = gpui::ScrollHandle::new();
|
||||
self.scroll_handle.scroll_to_item(0);
|
||||
@@ -253,6 +402,7 @@ impl TaskDetailModal {
|
||||
self.state.form = TaskForm::default();
|
||||
self.state.errors.clear();
|
||||
self.state.mode = ModalMode::View;
|
||||
self.state.is_create = false;
|
||||
self.state.edit_state = EditState::Navigating;
|
||||
self.state.annotations = AnnotationState::default();
|
||||
self.state.error = error;
|
||||
@@ -272,6 +422,7 @@ impl TaskDetailModal {
|
||||
self.state.error = None;
|
||||
self.state.original = Some(detail.clone());
|
||||
self.state.errors.clear();
|
||||
self.state.is_create = false;
|
||||
self.reset_pending_state();
|
||||
self.sync_from_detail(&detail, cx, true);
|
||||
|
||||
@@ -333,6 +484,7 @@ impl TaskDetailModal {
|
||||
self.state.loading = false;
|
||||
self.state.error = None;
|
||||
self.state.original = Some(detail.clone());
|
||||
self.state.is_create = false;
|
||||
self.sync_from_detail(&detail, cx, reset_form);
|
||||
cx.notify();
|
||||
}
|
||||
@@ -368,11 +520,16 @@ impl TaskDetailModal {
|
||||
self.state.loading = false;
|
||||
self.state.original = Some(detail);
|
||||
self.state.error = None;
|
||||
self.state.is_create = false;
|
||||
self.reset_pending_state();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
@@ -393,6 +550,11 @@ impl TaskDetailModal {
|
||||
}
|
||||
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
@@ -412,7 +574,15 @@ impl TaskDetailModal {
|
||||
}
|
||||
|
||||
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>) {
|
||||
@@ -526,6 +696,11 @@ impl TaskDetailModal {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.state.is_create {
|
||||
self.submit_create(cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.state.original.is_none() {
|
||||
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) {
|
||||
match self.state.form.validate_field(field) {
|
||||
Some(message) => {
|
||||
@@ -631,8 +869,13 @@ impl TaskDetailModal {
|
||||
.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();
|
||||
cx.emit(TaskDetailModalEvent::Closed);
|
||||
cx.emit(TaskDetailModalEvent::Closed {
|
||||
task_id,
|
||||
was_creating,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -1093,7 +1336,13 @@ impl TaskDetailModal {
|
||||
CommandResult::Handled
|
||||
}
|
||||
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
|
||||
} else {
|
||||
self.exit_edit_field(window, cx);
|
||||
|
||||
@@ -45,6 +45,7 @@ pub(super) fn render_task_detail_modal(
|
||||
panel::render_task_detail_panel(
|
||||
detail,
|
||||
state.mode,
|
||||
state.is_create,
|
||||
state.edit_state,
|
||||
&state.form,
|
||||
&state.errors,
|
||||
|
||||
@@ -104,6 +104,7 @@ where
|
||||
pub(super) fn render_task_detail_panel<OnCloseClick>(
|
||||
detail: &task::TaskDetailVm,
|
||||
mode: ModalMode,
|
||||
is_create: bool,
|
||||
edit_state: EditState,
|
||||
form: &TaskForm,
|
||||
errors: &HashMap<FieldId, gpui::SharedString>,
|
||||
@@ -129,6 +130,7 @@ where
|
||||
OnCloseClick: Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
|
||||
{
|
||||
let is_editing = mode == ModalMode::Edit;
|
||||
let is_creating = is_create;
|
||||
let is_navigating = edit_state == EditState::Navigating;
|
||||
let status_label = if is_editing {
|
||||
form.status.clone().into()
|
||||
@@ -204,7 +206,7 @@ where
|
||||
);
|
||||
}
|
||||
|
||||
if is_editing {
|
||||
if is_editing && !is_creating {
|
||||
badges.push(
|
||||
Chip::new("Editing")
|
||||
.variant(ChipVariant::Info)
|
||||
@@ -224,7 +226,11 @@ where
|
||||
} else {
|
||||
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 header = render_header(title, badges, is_editing, theme, cx, on_close_click.clone());
|
||||
@@ -303,6 +309,7 @@ where
|
||||
let footer = render_footer(
|
||||
detail,
|
||||
is_editing,
|
||||
is_creating,
|
||||
is_navigating,
|
||||
errors,
|
||||
form,
|
||||
@@ -1015,6 +1022,7 @@ fn render_extras_section(detail: &TaskDetailVm, value_color: gpui::Rgba) -> Opti
|
||||
fn render_footer(
|
||||
detail: &TaskDetailVm,
|
||||
is_editing: bool,
|
||||
is_creating: bool,
|
||||
is_navigating: bool,
|
||||
errors: &HashMap<FieldId, gpui::SharedString>,
|
||||
form: &TaskForm,
|
||||
@@ -1027,7 +1035,27 @@ fn render_footer(
|
||||
) -> gpui::Div {
|
||||
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| {
|
||||
modal.cancel_edit(Some(window), cx);
|
||||
}));
|
||||
|
||||
@@ -73,6 +73,7 @@ pub(super) struct TaskModalState {
|
||||
pub(super) form: TaskForm,
|
||||
pub(super) errors: HashMap<FieldId, SharedString>,
|
||||
pub(super) mode: ModalMode,
|
||||
pub(super) is_create: bool,
|
||||
pub(super) edit_state: EditState,
|
||||
pub(super) annotations: AnnotationState,
|
||||
pub(super) error: Option<SharedString>,
|
||||
@@ -98,6 +99,7 @@ impl Default for TaskModalState {
|
||||
form: TaskForm::default(),
|
||||
errors: HashMap::new(),
|
||||
mode: ModalMode::default(),
|
||||
is_create: false,
|
||||
edit_state: EditState::default(),
|
||||
annotations: AnnotationState::default(),
|
||||
error: None,
|
||||
|
||||
+104
-11
@@ -7,6 +7,7 @@ use gpui::prelude::*;
|
||||
use crate::{
|
||||
components::{
|
||||
self,
|
||||
action_button::ActionButton,
|
||||
button::{Dropdown, DropdownItem},
|
||||
input::Input,
|
||||
},
|
||||
@@ -751,6 +752,45 @@ impl TaskTable {
|
||||
.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>) {
|
||||
self.filter_bar_focus = FilterBarFocus::SearchInput;
|
||||
self.search_input.update(cx, |input, cx| {
|
||||
@@ -1059,6 +1099,13 @@ impl TaskTable {
|
||||
.child(status_wrapper)
|
||||
.child(priority_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);
|
||||
|
||||
gpui::div()
|
||||
@@ -1175,6 +1222,11 @@ impl TaskTable {
|
||||
.w(gpui::rems(6.0))
|
||||
.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 {
|
||||
@@ -1182,19 +1234,32 @@ impl TaskTable {
|
||||
let selected = self.selected_page_idx == Some(idx);
|
||||
let row_uuid = row.uuid;
|
||||
|
||||
gpui::div()
|
||||
let delete_button = gpui::div()
|
||||
.id(("delete-btn", idx))
|
||||
.flex()
|
||||
.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()
|
||||
.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()
|
||||
.on_mouse_down(
|
||||
gpui::MouseButton::Left,
|
||||
@@ -1249,7 +1314,23 @@ impl TaskTable {
|
||||
components::label::Label::new(row.status.clone())
|
||||
.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 {
|
||||
@@ -1395,6 +1476,16 @@ impl CommandDispatcher for TaskTable {
|
||||
self.blur_filter_bar(cx);
|
||||
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,
|
||||
_ => false,
|
||||
}
|
||||
@@ -1403,6 +1494,8 @@ impl CommandDispatcher for TaskTable {
|
||||
|
||||
pub enum TaskTableEvent {
|
||||
OpenTask(uuid::Uuid),
|
||||
CreateRequested,
|
||||
DeleteRequested(uuid::Uuid),
|
||||
}
|
||||
|
||||
impl gpui::EventEmitter<TaskTableEvent> for TaskTable {}
|
||||
|
||||
Reference in New Issue
Block a user