feat(modal): add edit mode with anchored dropdowns and keyboard workflow
- Add TaskDetailModal edit mode with form validation, tags + annotations editing, and SaveEdits event - Anchor dropdown/suggestion menus using deferred/anchored overlay to avoid clipping - Add modal-specific keymap contexts (ModalInput/ModalDropdown) for Esc/Ctrl+Enter/Tab navigation - Refresh modal project suggestions from tasks; improve project child filtering boundary match - Small UI polish (toast styling, layout spacing) and update shortcuts docs
This commit is contained in:
@@ -209,10 +209,10 @@ These shortcuts work when viewing task details:
|
|||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| `Escape` | Close modal |
|
| `Escape` | Cancel edit (when editing) or close modal |
|
||||||
| `j` / `↓` | Scroll down |
|
| `j` / `↓` | Scroll down |
|
||||||
| `k` / `↑` | Scroll up |
|
| `k` / `↑` | Scroll up |
|
||||||
| `Ctrl+Enter` | Close modal (same as Esc) |
|
| `Ctrl+Enter` | Save edits (when editing) or close modal |
|
||||||
|
|
||||||
## Search Input Editing
|
## Search Input Editing
|
||||||
|
|
||||||
|
|||||||
+32
-179
@@ -4,14 +4,14 @@ use gpui::prelude::*;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
components::toast::{ToastGlobal, ToastHost},
|
components::toast::{ToastGlobal, ToastHost},
|
||||||
keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord, KeymapStack},
|
keymap::{FocusTarget, KeymapStack},
|
||||||
models::{FilterState, ProjectTree},
|
models::{FilterState, ProjectTree},
|
||||||
task::{self, TaskOverview, TaskService, TaskSummary},
|
task::{self, TaskOverview, TaskService, TaskSummary},
|
||||||
theme::ActiveTheme,
|
theme::ActiveTheme,
|
||||||
view::{
|
view::{
|
||||||
app_layout,
|
app_layout,
|
||||||
sidebar::{Sidebar, SidebarEvent, SidebarSection, TagItem},
|
sidebar::{Sidebar, SidebarEvent, SidebarSection, TagItem},
|
||||||
status_bar::{StatusBar, StatusBarEvent, SyncState},
|
status_bar::{StatusBar, StatusBarEvent},
|
||||||
task_detail_modal::{TaskDetailModal, TaskDetailModalEvent},
|
task_detail_modal::{TaskDetailModal, TaskDetailModalEvent},
|
||||||
task_table::{TaskTable, TaskTableEvent},
|
task_table::{TaskTable, TaskTableEvent},
|
||||||
},
|
},
|
||||||
@@ -115,7 +115,7 @@ impl App {
|
|||||||
(projects, tag_items)
|
(projects, tag_items)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_ui_from_tasks(
|
pub(super) fn update_ui_from_tasks(
|
||||||
&mut self,
|
&mut self,
|
||||||
all_tasks: Vec<task::TaskSummary>,
|
all_tasks: Vec<task::TaskSummary>,
|
||||||
cx: &mut gpui::Context<Self>,
|
cx: &mut gpui::Context<Self>,
|
||||||
@@ -134,6 +134,23 @@ impl App {
|
|||||||
let tasks = self.tasks.clone();
|
let tasks = self.tasks.clone();
|
||||||
self.task_table
|
self.task_table
|
||||||
.update(cx, |table, cx| table.reload_tasks_from_all(tasks, cx));
|
.update(cx, |table, cx| table.reload_tasks_from_all(tasks, cx));
|
||||||
|
|
||||||
|
self.update_modal_project_suggestions(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_modal_project_suggestions(&self, cx: &mut gpui::Context<Self>) {
|
||||||
|
let mut projects: Vec<String> = self
|
||||||
|
.tasks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|task| task.project.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
projects.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
|
||||||
|
projects.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
||||||
|
|
||||||
|
self.task_detail_modal.update(cx, |modal, cx| {
|
||||||
|
modal.set_project_suggestions(projects, cx);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reload_tasks(&mut self, cx: &mut gpui::Context<Self>) {
|
fn reload_tasks(&mut self, cx: &mut gpui::Context<Self>) {
|
||||||
@@ -155,182 +172,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn handle_sync(&mut self, cx: &mut gpui::Context<Self>) {
|
|
||||||
self.status_bar.update(cx, |bar, cx| {
|
|
||||||
bar.set_sync_state(SyncState::Syncing, cx);
|
|
||||||
bar.set_last_sync_message("Syncing...".to_string(), cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
match self.task_service.get_all_tasks() {
|
|
||||||
Ok(all_tasks) => {
|
|
||||||
let summaries: Vec<TaskSummary> = all_tasks.iter().map(TaskSummary::from).collect();
|
|
||||||
self.update_ui_from_tasks(summaries, cx);
|
|
||||||
|
|
||||||
self.status_bar.update(cx, |bar, cx| {
|
|
||||||
bar.set_sync_state(SyncState::Success, cx);
|
|
||||||
bar.set_last_sync_message("Synced".to_string(), cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("[App] Sync failed: {}", e);
|
|
||||||
self.status_bar.update(cx, |bar, cx| {
|
|
||||||
bar.set_sync_state(SyncState::Error, cx);
|
|
||||||
bar.set_last_sync_message(format!("Error: {}", e), cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_key_down(
|
|
||||||
&mut self,
|
|
||||||
event: &gpui::KeyDownEvent,
|
|
||||||
window: &mut gpui::Window,
|
|
||||||
cx: &mut gpui::Context<Self>,
|
|
||||||
) {
|
|
||||||
if let Some(chord) = KeyChord::from_gpui(event) {
|
|
||||||
let context = self.active_context(cx);
|
|
||||||
|
|
||||||
if let Some(command) = self.keymap.resolve(context, &chord) {
|
|
||||||
let modal_is_open = self.task_detail_modal.read(cx).is_open();
|
|
||||||
|
|
||||||
if modal_is_open {
|
|
||||||
match command {
|
|
||||||
Command::CloseModal
|
|
||||||
| Command::SaveModal
|
|
||||||
| Command::Sync
|
|
||||||
| Command::ModalScrollUp
|
|
||||||
| Command::ModalScrollDown => {}
|
|
||||||
_ => return,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match command {
|
|
||||||
Command::FocusSearch => {
|
|
||||||
let from_headers = matches!(self.focus_target, FocusTarget::TableHeaders);
|
|
||||||
self.focus_target = FocusTarget::Table;
|
|
||||||
self.task_table.update(cx, |table, cx| {
|
|
||||||
if from_headers {
|
|
||||||
table.blur_table_headers(cx);
|
|
||||||
}
|
|
||||||
table.focus_search_input(window, cx);
|
|
||||||
});
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
Command::FocusTableHeaders => {
|
|
||||||
self.focus_target = FocusTarget::TableHeaders;
|
|
||||||
self.task_table.update(cx, |table, cx| {
|
|
||||||
table.blur_search_input(window, cx);
|
|
||||||
table.set_filter_bar_focus(
|
|
||||||
crate::view::task_table::FilterBarFocus::None,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
table.focus_table_headers(window, cx);
|
|
||||||
});
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
Command::FocusTable => {
|
|
||||||
self.focus_target = FocusTarget::Table;
|
|
||||||
self.task_table.update(cx, |table, cx| match context {
|
|
||||||
ContextId::TextInput | ContextId::FilterBar => {
|
|
||||||
table.blur_search_input(window, cx);
|
|
||||||
table.set_filter_bar_focus(
|
|
||||||
crate::view::task_table::FilterBarFocus::None,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ContextId::TableHeaders => {
|
|
||||||
table.blur_table_headers(cx);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
});
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
Command::FocusFilterNext | Command::FocusFilterPrev => {
|
|
||||||
self.task_table.update(cx, |table, cx| {
|
|
||||||
use crate::view::task_table::FilterBarFocus;
|
|
||||||
let was_on_input =
|
|
||||||
matches!(table.get_filter_bar_focus(), FilterBarFocus::SearchInput);
|
|
||||||
|
|
||||||
if command == Command::FocusFilterNext {
|
|
||||||
table.focus_filter_next(cx);
|
|
||||||
} else {
|
|
||||||
table.focus_filter_prev(cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
if was_on_input {
|
|
||||||
table.blur_search_input(window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
let now_on_input =
|
|
||||||
matches!(table.get_filter_bar_focus(), FilterBarFocus::SearchInput);
|
|
||||||
if now_on_input && !was_on_input {
|
|
||||||
table.focus_search_input(window, cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
self.dispatch(command, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn open_selected_task(
|
|
||||||
&mut self,
|
|
||||||
window: Option<&mut gpui::Window>,
|
|
||||||
cx: &mut gpui::Context<Self>,
|
|
||||||
) {
|
|
||||||
if self.task_detail_modal.read(cx).is_open() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let task_id = self.task_table.read(cx).selected_task_uuid();
|
|
||||||
let Some(task_id) = task_id else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
self.open_task_detail(task_id, window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_task_detail(
|
|
||||||
&mut self,
|
|
||||||
task_id: uuid::Uuid,
|
|
||||||
window: Option<&mut gpui::Window>,
|
|
||||||
cx: &mut gpui::Context<Self>,
|
|
||||||
) {
|
|
||||||
self.focus_before_modal = self.focus_target;
|
|
||||||
|
|
||||||
let tasks = self.tasks.clone();
|
|
||||||
match self.task_service.get_task_detail(task_id, &tasks) {
|
|
||||||
Ok(detail) => {
|
|
||||||
self.task_detail_modal.update(cx, |modal, cx| {
|
|
||||||
modal.open_with_detail(detail, window, cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
self.task_detail_modal.update(cx, |modal, cx| {
|
|
||||||
modal.open_with_error(task_id, e.to_string(), window, cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn active_context(&self, cx: &gpui::Context<Self>) -> ContextId {
|
|
||||||
if self.task_detail_modal.read(cx).is_open() {
|
|
||||||
return ContextId::Modal;
|
|
||||||
}
|
|
||||||
if matches!(self.focus_target, FocusTarget::Table) {
|
|
||||||
let filter_context = self.task_table.read(cx).get_active_filter_context();
|
|
||||||
if let Some(context) = filter_context {
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.focus_target.to_context()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run() -> () {
|
pub fn run() -> () {
|
||||||
let app = gpui::Application::new();
|
let app = gpui::Application::new();
|
||||||
|
|
||||||
@@ -390,6 +231,15 @@ impl App {
|
|||||||
task_table.update(cx, |table, cx| {
|
task_table.update(cx, |table, cx| {
|
||||||
table.reload_tasks_from_all(task_summaries.clone(), cx);
|
table.reload_tasks_from_all(task_summaries.clone(), cx);
|
||||||
});
|
});
|
||||||
|
let mut project_suggestions: Vec<String> = task_summaries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|task| task.project.clone())
|
||||||
|
.collect();
|
||||||
|
project_suggestions.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
|
||||||
|
project_suggestions.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
||||||
|
task_detail_modal.update(cx, |modal, cx| {
|
||||||
|
modal.set_project_suggestions(project_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());
|
||||||
@@ -448,6 +298,9 @@ impl App {
|
|||||||
app.focus_target = app.focus_before_modal;
|
app.focus_target = app.focus_before_modal;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
TaskDetailModalEvent::SaveEdits { task_id, update } => {
|
||||||
|
app.handle_save_task_edits(*task_id, update.clone(), cx);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
|
use gpui::{Corner, anchored, deferred, px, point};
|
||||||
|
|
||||||
use crate::components::button::Button;
|
use crate::components::button::Button;
|
||||||
use crate::components::label::Label;
|
use crate::components::label::Label;
|
||||||
@@ -41,6 +42,7 @@ pub struct Dropdown {
|
|||||||
selected_index: Option<usize>,
|
selected_index: Option<usize>,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
loading: bool,
|
loading: bool,
|
||||||
|
inline_menu: bool,
|
||||||
placeholder: gpui::SharedString,
|
placeholder: gpui::SharedString,
|
||||||
label_prefix: Option<gpui::SharedString>,
|
label_prefix: Option<gpui::SharedString>,
|
||||||
on_select: Option<Arc<dyn Fn(usize, &DropdownItem, &mut gpui::Context<Self>) + Send + Sync>>,
|
on_select: Option<Arc<dyn Fn(usize, &DropdownItem, &mut gpui::Context<Self>) + Send + Sync>>,
|
||||||
@@ -56,7 +58,8 @@ impl Dropdown {
|
|||||||
selected_index: None,
|
selected_index: None,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
placeholder: "Seleccionar".into(),
|
inline_menu: false,
|
||||||
|
placeholder: "Select".into(),
|
||||||
label_prefix: None,
|
label_prefix: None,
|
||||||
on_select: None,
|
on_select: None,
|
||||||
}
|
}
|
||||||
@@ -117,6 +120,11 @@ impl Dropdown {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn inline_menu(mut self) -> Self {
|
||||||
|
self.inline_menu = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on_select(
|
pub fn on_select(
|
||||||
mut self,
|
mut self,
|
||||||
handler: Arc<dyn Fn(usize, &DropdownItem, &mut gpui::Context<Self>) + Send + Sync>,
|
handler: Arc<dyn Fn(usize, &DropdownItem, &mut gpui::Context<Self>) + Send + Sync>,
|
||||||
@@ -279,14 +287,8 @@ impl Dropdown {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
gpui::div()
|
let menu = gpui::div()
|
||||||
.absolute()
|
.w_full() // Same width as trigger
|
||||||
.top_full()
|
|
||||||
.left_0()
|
|
||||||
.min_w(gpui::rems(12.0))
|
|
||||||
.min_w_full()
|
|
||||||
.mt_1()
|
|
||||||
.occlude()
|
|
||||||
.p_1()
|
.p_1()
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(theme.border)
|
.border_color(theme.border)
|
||||||
@@ -294,10 +296,24 @@ impl Dropdown {
|
|||||||
.rounded_md()
|
.rounded_md()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.shadow_lg()
|
.shadow_lg()
|
||||||
.children(items)
|
.occlude()
|
||||||
|
.children(items);
|
||||||
|
|
||||||
|
if self.inline_menu {
|
||||||
|
menu.into_any_element()
|
||||||
|
} else {
|
||||||
|
deferred(
|
||||||
|
anchored()
|
||||||
|
.anchor(Corner::TopLeft)
|
||||||
|
.offset(point(px(0.0), px(4.0)))
|
||||||
|
.snap_to_window()
|
||||||
|
.child(menu),
|
||||||
|
)
|
||||||
|
.with_priority(1)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl gpui::Render for Dropdown {
|
impl gpui::Render for Dropdown {
|
||||||
fn render(
|
fn render(
|
||||||
@@ -352,7 +368,6 @@ impl gpui::Render for Dropdown {
|
|||||||
|
|
||||||
let mut container = gpui::div()
|
let mut container = gpui::div()
|
||||||
.id(self.id.clone())
|
.id(self.id.clone())
|
||||||
.relative()
|
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.child(trigger_wrap)
|
.child(trigger_wrap)
|
||||||
|
|||||||
+179
-16
@@ -1,7 +1,8 @@
|
|||||||
mod suggestion;
|
mod suggestion;
|
||||||
|
|
||||||
use crate::theme::ActiveTheme;
|
use crate::theme::{ActiveTheme, Theme};
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
|
use gpui::{Corner, anchored, deferred, px, point};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub use suggestion::Suggestion;
|
pub use suggestion::Suggestion;
|
||||||
@@ -13,14 +14,15 @@ pub struct Input {
|
|||||||
placeholder: gpui::SharedString,
|
placeholder: gpui::SharedString,
|
||||||
|
|
||||||
cursor_pos: usize,
|
cursor_pos: usize,
|
||||||
|
multiline: bool,
|
||||||
|
|
||||||
suggestions: Vec<Suggestion>,
|
suggestions: Vec<Suggestion>,
|
||||||
suggestions_open: bool,
|
suggestions_open: 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>>,
|
||||||
on_submit: Option<Arc<dyn Fn(&str, &mut gpui::Context<Self>) + Send + Sync>>,
|
on_submit: Option<Arc<dyn Fn(&str, &mut gpui::Context<Self>) + Send + Sync>>,
|
||||||
|
external_suggestions: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Input {
|
impl Input {
|
||||||
@@ -36,17 +38,23 @@ impl Input {
|
|||||||
placeholder: placeholder.into(),
|
placeholder: placeholder.into(),
|
||||||
|
|
||||||
cursor_pos: 0,
|
cursor_pos: 0,
|
||||||
|
multiline: false,
|
||||||
|
|
||||||
suggestions: vec![],
|
suggestions: vec![],
|
||||||
suggestions_open: false,
|
suggestions_open: false,
|
||||||
active_suggestion: 0,
|
active_suggestion: 0,
|
||||||
|
|
||||||
suggest: None,
|
suggest: None,
|
||||||
on_change: None,
|
on_change: None,
|
||||||
on_submit: None,
|
on_submit: None,
|
||||||
|
external_suggestions: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn external_suggestions(mut self) -> Self {
|
||||||
|
self.external_suggestions = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_suggest(mut self, f: Arc<dyn Fn(&str) -> Vec<Suggestion> + Send + Sync>) -> Self {
|
pub fn with_suggest(mut self, f: Arc<dyn Fn(&str) -> Vec<Suggestion> + Send + Sync>) -> Self {
|
||||||
self.suggest = Some(f);
|
self.suggest = Some(f);
|
||||||
self
|
self
|
||||||
@@ -60,6 +68,11 @@ impl Input {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn multiline(mut self) -> Self {
|
||||||
|
self.multiline = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_on_submit(
|
pub fn with_on_submit(
|
||||||
mut self,
|
mut self,
|
||||||
f: Arc<dyn Fn(&str, &mut gpui::Context<Self>) + Send + Sync>,
|
f: Arc<dyn Fn(&str, &mut gpui::Context<Self>) + Send + Sync>,
|
||||||
@@ -72,6 +85,14 @@ impl Input {
|
|||||||
&self.value
|
&self.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn element_id(&self) -> &gpui::ElementId {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_suggestions_open(&self) -> bool {
|
||||||
|
self.suggestions_open
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_value(&mut self, value: impl Into<String>, cx: &mut gpui::Context<Self>) {
|
pub fn set_value(&mut self, value: impl Into<String>, cx: &mut gpui::Context<Self>) {
|
||||||
self.value = value.into();
|
self.value = value.into();
|
||||||
self.cursor_pos = self.value.len();
|
self.cursor_pos = self.value.len();
|
||||||
@@ -79,8 +100,16 @@ impl Input {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_value_silent(&mut self, value: impl Into<String>, cx: &mut gpui::Context<Self>) {
|
||||||
|
self.value = value.into();
|
||||||
|
self.cursor_pos = self.value.len();
|
||||||
|
self.suggestions_open = false;
|
||||||
|
self.suggestions.clear();
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clear(&mut self, cx: &mut gpui::Context<Self>) {
|
pub fn clear(&mut self, cx: &mut gpui::Context<Self>) {
|
||||||
self.set_value("", cx);
|
self.set_value_silent("", cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn focus(&self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) {
|
pub fn focus(&self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) {
|
||||||
@@ -163,7 +192,7 @@ impl Input {
|
|||||||
if let Some(suggest) = &self.suggest {
|
if let Some(suggest) = &self.suggest {
|
||||||
self.suggestions = suggest(&self.value);
|
self.suggestions = suggest(&self.value);
|
||||||
self.active_suggestion = 0;
|
self.active_suggestion = 0;
|
||||||
self.suggestions_open = !self.suggestions.is_empty();
|
self.suggestions_open = !self.suggestions.is_empty() && !self.value.is_empty();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,6 +324,24 @@ impl Input {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cursor_line_info(&self) -> (usize, usize) {
|
||||||
|
let mut line = 0;
|
||||||
|
let mut line_start = 0;
|
||||||
|
|
||||||
|
for (idx, ch) in self.value.char_indices() {
|
||||||
|
if idx >= self.cursor_pos {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '\n' {
|
||||||
|
line += 1;
|
||||||
|
line_start = idx + ch.len_utf8();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(line, self.cursor_pos.saturating_sub(line_start))
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_key_down(
|
fn handle_key_down(
|
||||||
&mut self,
|
&mut self,
|
||||||
event: &gpui::KeyDownEvent,
|
event: &gpui::KeyDownEvent,
|
||||||
@@ -311,6 +358,11 @@ impl Input {
|
|||||||
|
|
||||||
match key {
|
match key {
|
||||||
"enter" => {
|
"enter" => {
|
||||||
|
if self.multiline && shift {
|
||||||
|
self.insert_text("\n", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if self.suggestions_open {
|
if self.suggestions_open {
|
||||||
self.accept_suggestion(cx);
|
self.accept_suggestion(cx);
|
||||||
} else {
|
} else {
|
||||||
@@ -429,8 +481,52 @@ impl Input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_suggestions(&self, cx: &gpui::Context<Self>) -> impl IntoElement {
|
pub fn render_suggestions_external(&self, cx: &gpui::Context<Self>) -> Option<gpui::AnyElement> {
|
||||||
if !self.suggestions_open {
|
if !self.suggestions_open {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let theme = cx.theme();
|
||||||
|
let items: Vec<gpui::AnyElement> = self
|
||||||
|
.suggestions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, s)| {
|
||||||
|
let is_active = i == self.active_suggestion;
|
||||||
|
gpui::div()
|
||||||
|
.on_mouse_down(
|
||||||
|
gpui::MouseButton::Left,
|
||||||
|
cx.listener(move |this, _e, _w, cx| {
|
||||||
|
this.click_suggestion(i, cx);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.cursor_pointer()
|
||||||
|
.px_2()
|
||||||
|
.py_1()
|
||||||
|
.when(is_active, |el| el.bg(theme.selection))
|
||||||
|
.text_color(if is_active {
|
||||||
|
theme.selection_foreground
|
||||||
|
} else {
|
||||||
|
theme.foreground
|
||||||
|
})
|
||||||
|
.child(s.label.clone())
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Some(gpui::div()
|
||||||
|
.mt_1()
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.border)
|
||||||
|
.bg(theme.panel)
|
||||||
|
.rounded_md()
|
||||||
|
.overflow_hidden()
|
||||||
|
.children(items)
|
||||||
|
.into_any_element())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_suggestions(&self, cx: &gpui::Context<Self>) -> impl IntoElement {
|
||||||
|
if !self.suggestions_open || self.external_suggestions {
|
||||||
return gpui::div().into_any_element();
|
return gpui::div().into_any_element();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,18 +558,84 @@ impl Input {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
gpui::div()
|
let menu = gpui::div()
|
||||||
.absolute()
|
.w_full()
|
||||||
.top_full()
|
|
||||||
.left_0()
|
|
||||||
.right_0()
|
|
||||||
.mt_1()
|
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(theme.border)
|
.border_color(theme.border)
|
||||||
.bg(theme.panel)
|
.bg(theme.panel)
|
||||||
.rounded_md()
|
.rounded_md()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.children(items)
|
.shadow_lg()
|
||||||
|
.occlude()
|
||||||
|
.children(items);
|
||||||
|
|
||||||
|
deferred(
|
||||||
|
anchored()
|
||||||
|
.anchor(Corner::TopLeft)
|
||||||
|
.offset(point(px(0.0), px(4.0)))
|
||||||
|
.snap_to_window()
|
||||||
|
.child(menu),
|
||||||
|
)
|
||||||
|
.with_priority(1)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_multiline_content(&self, is_focused: bool, theme: &Theme) -> gpui::AnyElement {
|
||||||
|
if self.value.is_empty() {
|
||||||
|
let cursor = if is_focused {
|
||||||
|
gpui::div().w_px().h_4().bg(theme.accent).into_any_element()
|
||||||
|
} else {
|
||||||
|
gpui::div().into_any_element()
|
||||||
|
};
|
||||||
|
|
||||||
|
return gpui::div()
|
||||||
|
.id(self.id.clone())
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.items_start()
|
||||||
|
.child(
|
||||||
|
gpui::div().flex().items_center().child(cursor).child(
|
||||||
|
gpui::div()
|
||||||
|
.text_color(theme.muted)
|
||||||
|
.child(self.placeholder.clone()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (cursor_line, cursor_col) = self.cursor_line_info();
|
||||||
|
let lines: Vec<&str> = self.value.split('\n').collect();
|
||||||
|
let mut rows = Vec::with_capacity(lines.len());
|
||||||
|
|
||||||
|
for (idx, line) in lines.iter().enumerate() {
|
||||||
|
if is_focused && idx == cursor_line {
|
||||||
|
let col = cursor_col.min(line.len());
|
||||||
|
let (before, after) = line.split_at(col);
|
||||||
|
let after_text = if after.is_empty() { " " } else { after };
|
||||||
|
|
||||||
|
rows.push(
|
||||||
|
gpui::div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.child(before.to_string())
|
||||||
|
.child(gpui::div().w_px().h_4().bg(theme.accent))
|
||||||
|
.child(after_text.to_string())
|
||||||
|
.into_any_element(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let text = if line.is_empty() { " " } else { line };
|
||||||
|
rows.push(gpui::div().child(text.to_string()).into_any_element());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gpui::div()
|
||||||
|
.id(self.id.clone())
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.items_start()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(theme.foreground)
|
||||||
|
.children(rows)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,9 +649,9 @@ impl gpui::Render for Input {
|
|||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let is_focused = self.focus.is_focused(window);
|
let is_focused = self.focus.is_focused(window);
|
||||||
|
|
||||||
let show_placeholder = self.value.is_empty();
|
let content = if self.multiline {
|
||||||
|
self.render_multiline_content(is_focused, theme)
|
||||||
let content = if show_placeholder {
|
} else if self.value.is_empty() {
|
||||||
let cursor = if is_focused {
|
let cursor = if is_focused {
|
||||||
gpui::div().w_px().h_4().bg(theme.accent).into_any_element()
|
gpui::div().w_px().h_4().bg(theme.accent).into_any_element()
|
||||||
} else {
|
} else {
|
||||||
@@ -554,6 +716,7 @@ impl gpui::Render for Input {
|
|||||||
})
|
})
|
||||||
.relative()
|
.relative()
|
||||||
.min_w(gpui::rems(12.))
|
.min_w(gpui::rems(12.))
|
||||||
|
.when(self.multiline, |el| el.min_h(gpui::rems(4.0)))
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(if is_focused {
|
.border_color(if is_focused {
|
||||||
theme.accent
|
theme.accent
|
||||||
|
|||||||
@@ -67,13 +67,13 @@ impl gpui::RenderOnce for ModalFrame {
|
|||||||
.left_0();
|
.left_0();
|
||||||
|
|
||||||
if let Some(handler) = self.on_close {
|
if let Some(handler) = self.on_close {
|
||||||
backdrop = backdrop.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
|
backdrop =
|
||||||
|
backdrop.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
|
||||||
(handler)(event, window, app);
|
(handler)(event, window, app);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
root.child(backdrop)
|
root.child(backdrop).child(
|
||||||
.child(
|
|
||||||
gpui::div()
|
gpui::div()
|
||||||
.size_full()
|
.size_full()
|
||||||
.absolute()
|
.absolute()
|
||||||
|
|||||||
+12
-11
@@ -82,9 +82,9 @@ impl gpui::Render for ToastHost {
|
|||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.w(gpui::rems(1.5))
|
.w(gpui::rems(1.75))
|
||||||
.h(gpui::rems(1.5))
|
.h(gpui::rems(1.75))
|
||||||
.text_sm()
|
.text_xs()
|
||||||
.text_color(theme.muted)
|
.text_color(theme.muted)
|
||||||
.cursor_pointer()
|
.cursor_pointer()
|
||||||
.hover(|s| s.text_color(theme.accent))
|
.hover(|s| s.text_color(theme.accent))
|
||||||
@@ -94,17 +94,17 @@ impl gpui::Render for ToastHost {
|
|||||||
host.dismiss(toast_id, cx);
|
host.dismiss(toast_id, cx);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(Label::new("X"));
|
.child(Label::new("×"));
|
||||||
|
|
||||||
let background = mix_color(theme.background, accent, 0.2);
|
let background = mix_color(theme.raised, accent, 0.28);
|
||||||
let border = Theme::alpha(accent, 0.45);
|
let border = Theme::alpha(accent, 0.5);
|
||||||
|
|
||||||
gpui::div()
|
gpui::div()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.px_5()
|
.px(gpui::rems(1.25))
|
||||||
.py_3()
|
.py(gpui::rems(0.75))
|
||||||
.occlude()
|
.occlude()
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(border)
|
.border_color(border)
|
||||||
@@ -113,13 +113,13 @@ impl gpui::Render for ToastHost {
|
|||||||
.shadow_lg()
|
.shadow_lg()
|
||||||
.child(
|
.child(
|
||||||
gpui::div()
|
gpui::div()
|
||||||
.w(gpui::px(6.0))
|
.w(gpui::px(8.0))
|
||||||
.h_full()
|
.h_full()
|
||||||
.bg(Theme::alpha(accent, 0.9))
|
.bg(Theme::alpha(accent, 0.85))
|
||||||
.rounded_md(),
|
.rounded_md(),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
gpui::div().flex_1().min_w(gpui::rems(18.0)).child(
|
gpui::div().flex_1().min_w(gpui::rems(22.0)).child(
|
||||||
Label::new(toast.message.clone())
|
Label::new(toast.message.clone())
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.text_color(theme.foreground)
|
.text_color(theme.foreground)
|
||||||
@@ -135,6 +135,7 @@ impl gpui::Render for ToastHost {
|
|||||||
.absolute()
|
.absolute()
|
||||||
.top(gpui::rems(1.0))
|
.top(gpui::rems(1.0))
|
||||||
.right(gpui::rems(1.0))
|
.right(gpui::rems(1.0))
|
||||||
|
.occlude()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ use crate::{
|
|||||||
impl App {
|
impl App {
|
||||||
fn close_task_detail(&mut self, cx: &mut gpui::Context<Self>) {
|
fn close_task_detail(&mut self, cx: &mut gpui::Context<Self>) {
|
||||||
self.task_detail_modal.update(cx, |modal, cx| {
|
self.task_detail_modal.update(cx, |modal, cx| {
|
||||||
|
if modal.is_editing() {
|
||||||
|
modal.cancel_edit(cx);
|
||||||
|
} else {
|
||||||
modal.close(cx);
|
modal.close(cx);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+357
@@ -0,0 +1,357 @@
|
|||||||
|
use crate::{
|
||||||
|
components::toast::ToastKind,
|
||||||
|
keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord},
|
||||||
|
task::{self, TaskSummary},
|
||||||
|
view::{status_bar::SyncState, task_detail_modal::TaskEditUpdate},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::App;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub(super) fn handle_sync(&mut self, cx: &mut gpui::Context<Self>) {
|
||||||
|
self.status_bar.update(cx, |bar, cx| {
|
||||||
|
bar.set_sync_state(SyncState::Syncing, cx);
|
||||||
|
bar.set_last_sync_message("Syncing...".to_string(), cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
match self.task_service.get_all_tasks() {
|
||||||
|
Ok(all_tasks) => {
|
||||||
|
let summaries: Vec<TaskSummary> = all_tasks.iter().map(TaskSummary::from).collect();
|
||||||
|
self.update_ui_from_tasks(summaries, cx);
|
||||||
|
|
||||||
|
self.status_bar.update(cx, |bar, cx| {
|
||||||
|
bar.set_sync_state(SyncState::Success, cx);
|
||||||
|
bar.set_last_sync_message("Synced".to_string(), cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[App] Sync failed: {}", e);
|
||||||
|
self.status_bar.update(cx, |bar, cx| {
|
||||||
|
bar.set_sync_state(SyncState::Error, cx);
|
||||||
|
bar.set_last_sync_message(format!("Error: {}", e), cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_key_down(
|
||||||
|
&mut self,
|
||||||
|
event: &gpui::KeyDownEvent,
|
||||||
|
window: &mut gpui::Window,
|
||||||
|
cx: &mut gpui::Context<Self>,
|
||||||
|
) {
|
||||||
|
if let Some(chord) = KeyChord::from_gpui(event) {
|
||||||
|
let context = self.active_context(cx);
|
||||||
|
|
||||||
|
if let Some(command) = self.keymap.resolve(context, &chord) {
|
||||||
|
let modal_is_open = self.task_detail_modal.read(cx).is_open();
|
||||||
|
|
||||||
|
if modal_is_open {
|
||||||
|
let mut handled = false;
|
||||||
|
self.task_detail_modal.update(cx, |modal, cx| {
|
||||||
|
handled = modal.dispatch_command(command, Some(window), cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
if handled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if command != Command::Sync {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match command {
|
||||||
|
Command::FocusSearch => {
|
||||||
|
let from_headers = matches!(self.focus_target, FocusTarget::TableHeaders);
|
||||||
|
self.focus_target = FocusTarget::Table;
|
||||||
|
self.task_table.update(cx, |table, cx| {
|
||||||
|
if from_headers {
|
||||||
|
table.blur_table_headers(cx);
|
||||||
|
}
|
||||||
|
table.focus_search_input(window, cx);
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
Command::FocusTableHeaders => {
|
||||||
|
self.focus_target = FocusTarget::TableHeaders;
|
||||||
|
self.task_table.update(cx, |table, cx| {
|
||||||
|
table.blur_search_input(window, cx);
|
||||||
|
table.set_filter_bar_focus(
|
||||||
|
crate::view::task_table::FilterBarFocus::None,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
table.focus_table_headers(window, cx);
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
Command::FocusTable => {
|
||||||
|
self.focus_target = FocusTarget::Table;
|
||||||
|
self.task_table.update(cx, |table, cx| match context {
|
||||||
|
ContextId::TextInput | ContextId::FilterBar => {
|
||||||
|
table.blur_search_input(window, cx);
|
||||||
|
table.set_filter_bar_focus(
|
||||||
|
crate::view::task_table::FilterBarFocus::None,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ContextId::TableHeaders => {
|
||||||
|
table.blur_table_headers(cx);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
Command::FocusFilterNext | Command::FocusFilterPrev => {
|
||||||
|
self.task_table.update(cx, |table, cx| {
|
||||||
|
use crate::view::task_table::FilterBarFocus;
|
||||||
|
let was_on_input =
|
||||||
|
matches!(table.get_filter_bar_focus(), FilterBarFocus::SearchInput);
|
||||||
|
|
||||||
|
if command == Command::FocusFilterNext {
|
||||||
|
table.focus_filter_next(cx);
|
||||||
|
} else {
|
||||||
|
table.focus_filter_prev(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if was_on_input {
|
||||||
|
table.blur_search_input(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now_on_input =
|
||||||
|
matches!(table.get_filter_bar_focus(), FilterBarFocus::SearchInput);
|
||||||
|
if now_on_input && !was_on_input {
|
||||||
|
table.focus_search_input(window, cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.dispatch(command, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn open_selected_task(
|
||||||
|
&mut self,
|
||||||
|
window: Option<&mut gpui::Window>,
|
||||||
|
cx: &mut gpui::Context<Self>,
|
||||||
|
) {
|
||||||
|
if self.task_detail_modal.read(cx).is_open() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let task_id = self.task_table.read(cx).selected_task_uuid();
|
||||||
|
let Some(task_id) = task_id else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.open_task_detail(task_id, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn open_task_detail(
|
||||||
|
&mut self,
|
||||||
|
task_id: uuid::Uuid,
|
||||||
|
window: Option<&mut gpui::Window>,
|
||||||
|
cx: &mut gpui::Context<Self>,
|
||||||
|
) {
|
||||||
|
self.focus_before_modal = self.focus_target;
|
||||||
|
|
||||||
|
let tasks = self.tasks.clone();
|
||||||
|
match self.task_service.get_task_detail(task_id, &tasks) {
|
||||||
|
Ok(detail) => {
|
||||||
|
self.task_detail_modal.update(cx, |modal, cx| {
|
||||||
|
modal.open_with_detail(detail, window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.task_detail_modal.update(cx, |modal, cx| {
|
||||||
|
modal.open_with_error(task_id, e.to_string(), window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
*existing = summary;
|
||||||
|
} else {
|
||||||
|
self.tasks.push(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaries = self.tasks.clone();
|
||||||
|
self.update_ui_from_tasks(summaries, cx);
|
||||||
|
|
||||||
|
let detail = task::TaskDetailVm::from_task(&task, &self.tasks);
|
||||||
|
self.task_detail_modal.update(cx, |modal, cx| {
|
||||||
|
modal.apply_saved_detail(detail, 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) {
|
||||||
|
*existing = summary;
|
||||||
|
} else {
|
||||||
|
self.tasks.push(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaries = self.tasks.clone();
|
||||||
|
self.update_ui_from_tasks(summaries, cx);
|
||||||
|
|
||||||
|
let detail = task::TaskDetailVm::from_task(&task, &self.tasks);
|
||||||
|
self.task_detail_modal.update(cx, |modal, cx| {
|
||||||
|
modal.set_detail(detail, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_save_task_edits(
|
||||||
|
&mut self,
|
||||||
|
task_id: uuid::Uuid,
|
||||||
|
update: TaskEditUpdate,
|
||||||
|
cx: &mut gpui::Context<Self>,
|
||||||
|
) {
|
||||||
|
let TaskEditUpdate {
|
||||||
|
description,
|
||||||
|
project,
|
||||||
|
priority,
|
||||||
|
status,
|
||||||
|
due,
|
||||||
|
tags,
|
||||||
|
annotations_add,
|
||||||
|
annotations_delete,
|
||||||
|
} = update;
|
||||||
|
let mut latest_task: Option<task::Task> = None;
|
||||||
|
|
||||||
|
if description.is_some()
|
||||||
|
|| project.is_some()
|
||||||
|
|| priority.is_some()
|
||||||
|
|| tags.is_some()
|
||||||
|
|| due.is_some()
|
||||||
|
{
|
||||||
|
match self.task_service.update_task(
|
||||||
|
task_id,
|
||||||
|
description,
|
||||||
|
project,
|
||||||
|
priority,
|
||||||
|
tags,
|
||||||
|
due,
|
||||||
|
None,
|
||||||
|
) {
|
||||||
|
Ok(task) => latest_task = Some(task),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[App] Failed to update task: {}", e);
|
||||||
|
self.toast_host.update(cx, |host, cx| {
|
||||||
|
host.push(
|
||||||
|
ToastKind::Error,
|
||||||
|
format!("Failed to update task: {}", e),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(status) = status {
|
||||||
|
let status_result = match status {
|
||||||
|
task::TaskStatus::Completed => self.task_service.complete_task(task_id),
|
||||||
|
task::TaskStatus::Pending => self.task_service.reopen_task(task_id),
|
||||||
|
task::TaskStatus::Deleted => {
|
||||||
|
self.task_service.delete_task(task_id).and_then(|_| {
|
||||||
|
self.task_service
|
||||||
|
.get_task(task_id)
|
||||||
|
.and_then(|task| task.ok_or(task::TaskError::NotFound(task_id)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => self
|
||||||
|
.task_service
|
||||||
|
.get_task(task_id)
|
||||||
|
.and_then(|task| task.ok_or(task::TaskError::NotFound(task_id))),
|
||||||
|
};
|
||||||
|
|
||||||
|
match status_result {
|
||||||
|
Ok(task) => latest_task = Some(task),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[App] Failed to update status: {}", e);
|
||||||
|
self.toast_host.update(cx, |host, cx| {
|
||||||
|
host.push(
|
||||||
|
ToastKind::Error,
|
||||||
|
format!("Failed to update status: {}", e),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if let Some(task) = latest_task {
|
||||||
|
self.apply_task_update(task, cx);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for text in annotations_add {
|
||||||
|
match self.task_service.add_annotation(task_id, text) {
|
||||||
|
Ok(task) => latest_task = Some(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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if let Some(task) = latest_task {
|
||||||
|
self.sync_task_detail(task, cx);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in annotations_delete {
|
||||||
|
match self.task_service.remove_annotation(task_id, entry) {
|
||||||
|
Ok(task) => latest_task = Some(task),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[App] Failed to delete annotation: {}", e);
|
||||||
|
self.toast_host.update(cx, |host, cx| {
|
||||||
|
host.push(
|
||||||
|
ToastKind::Error,
|
||||||
|
format!("Failed to delete annotation: {}", e),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if let Some(task) = latest_task {
|
||||||
|
self.sync_task_detail(task, cx);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(task) = latest_task {
|
||||||
|
self.apply_task_update(task, cx);
|
||||||
|
} else {
|
||||||
|
self.task_detail_modal.update(cx, |modal, cx| {
|
||||||
|
modal.cancel_edit(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
if matches!(self.focus_target, FocusTarget::Table) {
|
||||||
|
let filter_context = self.task_table.read(cx).get_active_filter_context();
|
||||||
|
if let Some(context) = filter_context {
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.focus_target.to_context()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ pub enum ContextId {
|
|||||||
SidebarProjects,
|
SidebarProjects,
|
||||||
SidebarTags,
|
SidebarTags,
|
||||||
Modal,
|
Modal,
|
||||||
|
ModalInput,
|
||||||
|
ModalDropdown,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
TextInput,
|
TextInput,
|
||||||
}
|
}
|
||||||
@@ -19,6 +21,8 @@ impl ContextId {
|
|||||||
"sidebarprojects" | "SidebarProjects" => Some(Self::SidebarProjects),
|
"sidebarprojects" | "SidebarProjects" => Some(Self::SidebarProjects),
|
||||||
"sidebartags" | "SidebarTags" => Some(Self::SidebarTags),
|
"sidebartags" | "SidebarTags" => Some(Self::SidebarTags),
|
||||||
"modal" | "Modal" => Some(Self::Modal),
|
"modal" | "Modal" => Some(Self::Modal),
|
||||||
|
"modalinput" | "ModalInput" => Some(Self::ModalInput),
|
||||||
|
"modaldropdown" | "ModalDropdown" => Some(Self::ModalDropdown),
|
||||||
"filterbar" | "FilterBar" => Some(Self::FilterBar),
|
"filterbar" | "FilterBar" => Some(Self::FilterBar),
|
||||||
"textinput" | "TextInput" => Some(Self::TextInput),
|
"textinput" | "TextInput" => Some(Self::TextInput),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -33,6 +37,8 @@ impl ContextId {
|
|||||||
Self::SidebarProjects => "SidebarProjects",
|
Self::SidebarProjects => "SidebarProjects",
|
||||||
Self::SidebarTags => "SidebarTags",
|
Self::SidebarTags => "SidebarTags",
|
||||||
Self::Modal => "Modal",
|
Self::Modal => "Modal",
|
||||||
|
Self::ModalInput => "ModalInput",
|
||||||
|
Self::ModalDropdown => "ModalDropdown",
|
||||||
Self::FilterBar => "FilterBar",
|
Self::FilterBar => "FilterBar",
|
||||||
Self::TextInput => "TextInput",
|
Self::TextInput => "TextInput",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -429,5 +429,77 @@ pub fn build_default_keymap() -> KeymapLayer {
|
|||||||
Command::SaveModal,
|
Command::SaveModal,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalInput,
|
||||||
|
KeyChord::new(Key::Escape, Mods::none()),
|
||||||
|
Command::CloseModal,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalInput,
|
||||||
|
KeyChord::new(Key::Enter, Mods::ctrl()),
|
||||||
|
Command::SaveModal,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalInput,
|
||||||
|
KeyChord::new(Key::Tab, Mods::none()),
|
||||||
|
Command::FocusFilterNext,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalInput,
|
||||||
|
KeyChord::new(Key::Tab, Mods::shift()),
|
||||||
|
Command::FocusFilterPrev,
|
||||||
|
);
|
||||||
|
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalDropdown,
|
||||||
|
KeyChord::new(Key::Escape, Mods::none()),
|
||||||
|
Command::CloseModal,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalDropdown,
|
||||||
|
KeyChord::new(Key::Enter, Mods::ctrl()),
|
||||||
|
Command::SaveModal,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalDropdown,
|
||||||
|
KeyChord::new(Key::Enter, Mods::none()),
|
||||||
|
Command::ToggleDropdown,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalDropdown,
|
||||||
|
KeyChord::new(Key::Space, Mods::none()),
|
||||||
|
Command::ToggleDropdown,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalDropdown,
|
||||||
|
KeyChord::new(Key::Char('j'), Mods::none()),
|
||||||
|
Command::SelectNextOption,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalDropdown,
|
||||||
|
KeyChord::new(Key::Char('k'), Mods::none()),
|
||||||
|
Command::SelectPrevOption,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalDropdown,
|
||||||
|
KeyChord::new(Key::ArrowDown, Mods::none()),
|
||||||
|
Command::SelectNextOption,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalDropdown,
|
||||||
|
KeyChord::new(Key::ArrowUp, Mods::none()),
|
||||||
|
Command::SelectPrevOption,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalDropdown,
|
||||||
|
KeyChord::new(Key::Tab, Mods::none()),
|
||||||
|
Command::FocusFilterNext,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalDropdown,
|
||||||
|
KeyChord::new(Key::Tab, Mods::shift()),
|
||||||
|
Command::FocusFilterPrev,
|
||||||
|
);
|
||||||
|
|
||||||
layer
|
layer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use crate::app::App;
|
|||||||
mod app;
|
mod app;
|
||||||
mod components;
|
mod components;
|
||||||
mod dispatcher;
|
mod dispatcher;
|
||||||
|
mod handler;
|
||||||
mod keymap;
|
mod keymap;
|
||||||
mod models;
|
mod models;
|
||||||
mod task;
|
mod task;
|
||||||
|
|||||||
+7
-1
@@ -155,9 +155,15 @@ impl TaskFilter {
|
|||||||
None => return false,
|
None => return false,
|
||||||
Some(task_project) => {
|
Some(task_project) => {
|
||||||
if self.project_include_children {
|
if self.project_include_children {
|
||||||
if !task_project.starts_with(project) {
|
// Match exact project or children (separated by '.')
|
||||||
|
// e.g., "gpui.task" should NOT match "gpui.task-warrior"
|
||||||
|
// but SHOULD match "gpui.task" and "gpui.task.subtask"
|
||||||
|
if task_project != project {
|
||||||
|
let prefix_with_dot = format!("{}.", project);
|
||||||
|
if !task_project.starts_with(&prefix_with_dot) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if task_project != project {
|
} else if task_project != project {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,3 +180,17 @@ pub fn mix_color(base: Color, tint: Color, amount: f32) -> Color {
|
|||||||
a: 1.0,
|
a: 1.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn focus_wrap(child: impl IntoElement, focused: bool, theme: &Theme) -> gpui::Div {
|
||||||
|
let border_color = if focused {
|
||||||
|
theme.focus_ring
|
||||||
|
} else {
|
||||||
|
gpui::rgba(0x00000000)
|
||||||
|
};
|
||||||
|
|
||||||
|
gpui::div()
|
||||||
|
.border_2()
|
||||||
|
.border_color(border_color)
|
||||||
|
.rounded_md()
|
||||||
|
.child(child)
|
||||||
|
}
|
||||||
|
|||||||
+10
-3
@@ -72,6 +72,15 @@ pub fn render_app_layout(
|
|||||||
.child(sidebar)
|
.child(sidebar)
|
||||||
.child(main);
|
.child(main);
|
||||||
|
|
||||||
|
let main = gpui::div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_1()
|
||||||
|
.min_h_0()
|
||||||
|
.gap(SECTION_GAP)
|
||||||
|
.child(content)
|
||||||
|
.child(status_bar);
|
||||||
|
|
||||||
let mut root = gpui::div()
|
let mut root = gpui::div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
@@ -79,11 +88,9 @@ pub fn render_app_layout(
|
|||||||
.relative()
|
.relative()
|
||||||
.bg(theme.background)
|
.bg(theme.background)
|
||||||
.p(ROOT_PADDING)
|
.p(ROOT_PADDING)
|
||||||
.gap(SECTION_GAP)
|
|
||||||
.track_focus(focus_handle)
|
.track_focus(focus_handle)
|
||||||
.on_key_down(on_root_key_down)
|
.on_key_down(on_root_key_down)
|
||||||
.child(content)
|
.child(main);
|
||||||
.child(status_bar);
|
|
||||||
|
|
||||||
if let Some(modal) = modal {
|
if let Some(modal) = modal {
|
||||||
root = root.child(modal);
|
root = root.child(modal);
|
||||||
|
|||||||
+1626
-92
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
keymap::{Command, CommandDispatcher},
|
keymap::{Command, CommandDispatcher},
|
||||||
models::{DueFilter, FilterState, PriorityFilter, StatusFilter},
|
models::{DueFilter, FilterState, PriorityFilter, StatusFilter},
|
||||||
task::{self, TaskFilter, TaskService, TaskSummary},
|
task::{self, TaskFilter},
|
||||||
theme::{self, ActiveTheme},
|
theme::{self, ActiveTheme},
|
||||||
ui::{
|
ui::{
|
||||||
DATE_FORMAT, TABLE_FILTER_BAR_INITIAL_HEIGHT, TABLE_MAX_DESCRIPTION_LENGTH, priority_badge,
|
DATE_FORMAT, TABLE_FILTER_BAR_INITIAL_HEIGHT, TABLE_MAX_DESCRIPTION_LENGTH, priority_badge,
|
||||||
@@ -141,10 +141,6 @@ impl PaginationState {
|
|||||||
self.total_items = total_items;
|
self.total_items = total_items;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn page_size(&mut self, page_size: usize) {
|
|
||||||
self.page_size = page_size;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_page(&mut self, current_page: usize) {
|
pub fn current_page(&mut self, current_page: usize) {
|
||||||
self.current_page = current_page;
|
self.current_page = current_page;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user