feat: add edit-mode commands and refactor modal UI

Introduces modal edit navigation commands and a dedicated keymap context, supports opening tasks directly in edit mode,
and reorganizes the task detail modal into reusable components with sectioned render/state helpers to reduce duplication.
This commit is contained in:
Ignacio Perez
2026-01-04 07:13:01 -03:00
parent 00cfc3ff4d
commit eabe07d489
24 changed files with 4650 additions and 2340 deletions
+1 -1
View File
@@ -287,7 +287,7 @@ impl App {
cx.subscribe(&task_table_events, |app, _table, event, cx| match event {
TaskTableEvent::OpenTask(task_id) => {
if !app.task_detail_modal.read(cx).is_open() {
app.open_task_detail(*task_id, None, cx);
app.open_task_detail(*task_id, false, None, cx);
}
}
})
+133
View File
@@ -0,0 +1,133 @@
use gpui::prelude::*;
use std::sync::Arc;
use crate::components::label::Label;
use crate::theme::{ActiveTheme, Theme};
#[derive(Debug, Clone, Copy)]
pub enum ActionButtonVariant {
Normal,
Danger,
Ghost,
}
#[derive(Clone, IntoElement)]
pub struct ActionButton {
id: Option<gpui::ElementId>,
label: gpui::SharedString,
variant: ActionButtonVariant,
enabled: bool,
on_click:
Option<Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>>,
}
impl ActionButton {
pub fn new(label: impl Into<gpui::SharedString>) -> Self {
Self {
id: None,
label: label.into(),
variant: ActionButtonVariant::Normal,
enabled: true,
on_click: None,
}
}
pub fn id(mut self, id: impl Into<gpui::ElementId>) -> Self {
self.id = Some(id.into());
self
}
pub fn variant(mut self, variant: ActionButtonVariant) -> Self {
self.variant = variant;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn on_click(
mut self,
handler: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
) -> Self {
self.on_click = Some(Arc::new(handler));
self
}
}
impl RenderOnce for ActionButton {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
let theme = cx.theme();
let (bg, fg, hover_bg) = match self.variant {
ActionButtonVariant::Normal => (theme.raised, theme.foreground, theme.hover),
ActionButtonVariant::Danger => (
theme.error,
theme.selection_foreground,
Theme::alpha(theme.error, 0.8),
),
ActionButtonVariant::Ghost => (gpui::rgba(0x00000000), theme.foreground, theme.hover),
};
if let Some(id) = self.id {
let mut button = gpui::div()
.id(id)
.px(gpui::rems(0.75))
.py(gpui::rems(0.35))
.rounded_md()
.text_sm();
match self.variant {
ActionButtonVariant::Normal | ActionButtonVariant::Ghost => {
button = button.border_1().border_color(theme.divider);
}
ActionButtonVariant::Danger => {}
}
if self.enabled {
button = button.bg(bg).text_color(fg);
if let Some(on_click) = self.on_click {
button = button
.cursor_pointer()
.hover(move |s| s.bg(hover_bg))
.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
(on_click)(event, window, app);
});
}
} else {
button = button.bg(bg).text_color(theme.muted);
}
return button.child(Label::new(self.label)).into_any_element();
}
let mut button = gpui::div()
.px(gpui::rems(0.75))
.py(gpui::rems(0.35))
.rounded_md()
.text_sm();
match self.variant {
ActionButtonVariant::Normal | ActionButtonVariant::Ghost => {
button = button.border_1().border_color(theme.divider);
}
ActionButtonVariant::Danger => {}
}
if self.enabled {
button = button.bg(bg).text_color(fg);
if let Some(on_click) = self.on_click {
button = button
.cursor_pointer()
.hover(move |s| s.bg(hover_bg))
.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
(on_click)(event, window, app);
});
}
} else {
button = button.bg(bg).text_color(theme.muted);
}
button.child(Label::new(self.label)).into_any_element()
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
use std::sync::Arc;
use gpui::prelude::*;
use gpui::{Corner, anchored, deferred, px, point};
use gpui::{Corner, anchored, deferred, point, px};
use crate::components::button::Button;
use crate::components::label::Label;
+124
View File
@@ -0,0 +1,124 @@
use gpui::prelude::*;
use std::sync::Arc;
use crate::theme::{ActiveTheme, Theme};
#[derive(Debug, Clone, Copy)]
pub enum ChipVariant {
Info,
Success,
Warning,
Danger,
Muted,
Accent,
Custom {
background: gpui::Rgba,
foreground: gpui::Rgba,
},
}
impl ChipVariant {
fn colors(self, theme: &Theme) -> (gpui::Rgba, gpui::Rgba) {
match self {
ChipVariant::Info => (Theme::alpha(theme.info, 0.18), theme.info),
ChipVariant::Success => (Theme::alpha(theme.success, 0.18), theme.success),
ChipVariant::Warning => (Theme::alpha(theme.warning, 0.18), theme.warning),
ChipVariant::Danger => (Theme::alpha(theme.error, 0.18), theme.error),
ChipVariant::Muted => (Theme::alpha(theme.muted, 0.18), theme.muted),
ChipVariant::Accent => (Theme::alpha(theme.accent, 0.15), theme.accent),
ChipVariant::Custom {
background,
foreground,
} => (background, foreground),
}
}
}
#[derive(Clone, IntoElement)]
pub struct Chip {
label: gpui::SharedString,
variant: ChipVariant,
selected: bool,
on_remove:
Option<Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>>,
}
impl Chip {
pub fn new(label: impl Into<gpui::SharedString>) -> Self {
Self {
label: label.into(),
variant: ChipVariant::Info,
selected: false,
on_remove: None,
}
}
pub fn variant(mut self, variant: ChipVariant) -> Self {
self.variant = variant;
self
}
pub fn custom(self, background: gpui::Rgba, foreground: gpui::Rgba) -> Self {
self.variant(ChipVariant::Custom {
background,
foreground,
})
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn removable(
mut self,
on_remove: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
) -> Self {
self.on_remove = Some(on_remove);
self
}
}
impl RenderOnce for Chip {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
let theme = cx.theme();
let (bg, fg) = self.variant.colors(theme);
let mut chip = gpui::div()
.flex()
.items_center()
.gap_1()
.px(gpui::rems(0.5))
.py(gpui::rems(0.125))
.rounded(gpui::rems(0.25))
.text_xs()
.font_weight(gpui::FontWeight::MEDIUM);
if self.selected {
chip = chip
.bg(theme.accent)
.text_color(theme.selection_foreground)
.border_2()
.border_color(theme.focus_ring);
} else {
chip = chip.bg(bg).text_color(fg);
}
chip = chip.child(self.label);
if let Some(on_remove) = self.on_remove {
chip = chip.child(
gpui::div()
.text_color(theme.muted)
.cursor_pointer()
.hover(|s| s.text_color(theme.error))
.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
(on_remove)(event, window, app);
})
.child("×"),
);
}
chip
}
}
+97
View File
@@ -0,0 +1,97 @@
use gpui::prelude::*;
use std::sync::Arc;
use crate::components::dialog::{Dialog, DialogButton, DialogButtonVariant};
use crate::theme::Theme;
#[derive(Clone)]
pub struct ConfirmDialog {
title: String,
hint: Option<String>,
cancel: Option<(
String,
Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
)>,
confirm: Option<(
String,
DialogButtonVariant,
Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
)>,
on_backdrop_click:
Option<Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>>,
}
impl ConfirmDialog {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
hint: None,
cancel: None,
confirm: None,
on_backdrop_click: None,
}
}
pub fn hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
pub fn cancel(
mut self,
label: impl Into<String>,
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
) -> Self {
self.cancel = Some((label.into(), on_click));
self
}
pub fn primary(
mut self,
label: impl Into<String>,
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
) -> Self {
self.confirm = Some((label.into(), DialogButtonVariant::Primary, on_click));
self
}
pub fn danger(
mut self,
label: impl Into<String>,
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
) -> Self {
self.confirm = Some((label.into(), DialogButtonVariant::Danger, on_click));
self
}
pub fn on_backdrop_click(
mut self,
handler: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
) -> Self {
self.on_backdrop_click = Some(handler);
self
}
pub fn render(self, theme: &Theme) -> gpui::Div {
let mut dialog = Dialog::new(self.title);
if let Some(hint) = self.hint {
dialog = dialog.hint(hint);
}
if let Some((label, on_click)) = self.cancel {
dialog = dialog.button(DialogButton::default(label, on_click));
}
if let Some((label, variant, on_click)) = self.confirm {
let button = match variant {
DialogButtonVariant::Primary => DialogButton::primary(label, on_click),
DialogButtonVariant::Danger => DialogButton::danger(label, on_click),
DialogButtonVariant::Default => DialogButton::default(label, on_click),
};
dialog = dialog.button(button);
}
if let Some(handler) = self.on_backdrop_click {
dialog = dialog.on_backdrop_click(handler);
}
dialog.render(theme)
}
}
+209
View File
@@ -0,0 +1,209 @@
use crate::components::label::Label;
use crate::theme::Theme;
use gpui::prelude::*;
use std::sync::Arc;
pub struct DialogButton {
label: String,
variant: DialogButtonVariant,
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
}
#[derive(Clone, Copy)]
pub enum DialogButtonVariant {
Default,
Primary,
Danger,
}
impl DialogButton {
pub fn new(
label: impl Into<String>,
variant: DialogButtonVariant,
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
) -> Self {
Self {
label: label.into(),
variant,
on_click,
}
}
pub fn default(
label: impl Into<String>,
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
) -> Self {
Self::new(label, DialogButtonVariant::Default, on_click)
}
pub fn primary(
label: impl Into<String>,
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
) -> Self {
Self::new(label, DialogButtonVariant::Primary, on_click)
}
pub fn danger(
label: impl Into<String>,
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
) -> Self {
Self::new(label, DialogButtonVariant::Danger, on_click)
}
}
pub struct Dialog {
title: String,
message: Option<String>,
hint: Option<String>,
buttons: Vec<DialogButton>,
on_backdrop_click:
Option<Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>>,
}
impl Dialog {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
message: None,
hint: None,
buttons: Vec::new(),
on_backdrop_click: None,
}
}
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
}
pub fn button(mut self, button: DialogButton) -> Self {
self.buttons.push(button);
self
}
pub fn on_backdrop_click(
mut self,
handler: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
) -> Self {
self.on_backdrop_click = Some(handler);
self
}
pub fn render(self, theme: &Theme) -> gpui::Div {
let card = gpui::div()
.flex()
.flex_col()
.gap_3()
.p(gpui::rems(1.0))
.min_w(gpui::rems(20.0))
.bg(theme.panel)
.border_2()
.border_color(theme.error)
.rounded_md()
.shadow_lg()
.child(
Label::new(&self.title)
.text_color(theme.foreground)
.text_sm()
.font_weight(gpui::FontWeight::MEDIUM),
);
let card = if let Some(message) = &self.message {
card.child(Label::new(message).text_color(theme.foreground).text_sm())
} else {
card
};
let card = if let Some(hint) = &self.hint {
card.child(
gpui::div()
.flex()
.gap_2()
.text_xs()
.text_color(theme.muted)
.child(hint.clone()),
)
} else {
card
};
let card = if !self.buttons.is_empty() {
let button_row = self.buttons.into_iter().fold(
gpui::div().flex().gap_2().justify_end(),
|row, button| {
let (bg_color, text_color, hover_bg) = match button.variant {
DialogButtonVariant::Default => {
(theme.raised, theme.foreground, theme.hover)
}
DialogButtonVariant::Primary => (
theme.accent,
theme.selection_foreground,
Theme::alpha(theme.accent, 0.8),
),
DialogButtonVariant::Danger => (
theme.error,
theme.selection_foreground,
Theme::alpha(theme.error, 0.8),
),
};
let btn = gpui::div()
.px(gpui::rems(0.75))
.py(gpui::rems(0.35))
.rounded_md()
.text_sm()
.cursor_pointer();
let btn = match button.variant {
DialogButtonVariant::Default => btn
.border_1()
.border_color(theme.divider)
.bg(bg_color)
.text_color(text_color)
.hover(move |s| s.bg(hover_bg)),
_ => btn
.bg(bg_color)
.text_color(text_color)
.hover(move |s| s.bg(hover_bg)),
};
let handler = button.on_click.clone();
let btn = btn
.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
(handler)(event, window, app);
})
.child(Label::new(&button.label));
row.child(btn)
},
);
card.child(button_row)
} else {
card
};
let card = card.on_mouse_down(gpui::MouseButton::Left, |_event, _window, _app| {});
let backdrop = gpui::div()
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.bg(Theme::alpha(theme.backdrop, 0.7))
.child(card);
if let Some(on_backdrop_click) = self.on_backdrop_click {
backdrop.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
(on_backdrop_click)(event, window, app);
})
} else {
backdrop
}
}
}
+104
View File
@@ -0,0 +1,104 @@
use gpui::prelude::*;
use crate::components::label::Label;
use crate::theme::{ActiveTheme, Theme};
#[derive(IntoElement)]
pub struct FieldRow {
label: gpui::SharedString,
value: gpui::AnyElement,
error: Option<gpui::SharedString>,
label_width: gpui::Length,
style: gpui::StyleRefinement,
}
impl FieldRow {
pub fn new(label: impl Into<gpui::SharedString>, value: impl IntoElement) -> Self {
Self {
label: label.into(),
value: value.into_any_element(),
error: None,
label_width: gpui::rems(10.0).into(),
style: gpui::StyleRefinement::default(),
}
}
pub fn error(mut self, error: Option<gpui::SharedString>) -> Self {
self.error = error;
self
}
pub fn label_width(mut self, width: gpui::Length) -> Self {
self.label_width = width;
self
}
}
impl Styled for FieldRow {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl RenderOnce for FieldRow {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
let theme = cx.theme();
let label_color = Theme::alpha(theme.foreground, 0.72);
let row = gpui::div()
.flex()
.items_start()
.gap_3()
.child(
Label::new(self.label)
.text_color(label_color)
.text_sm()
.w(self.label_width),
)
.child(gpui::div().flex_1().min_w_0().child(self.value));
let mut container = gpui::div().flex().flex_col().gap_1().child(row);
if let Some(error) = self.error {
container = container.child(
gpui::div()
.flex()
.items_start()
.gap_3()
.child(gpui::div().w(self.label_width))
.child(Label::new(error).text_xs().text_color(theme.error)),
);
}
container.style().refine(&self.style);
container
}
}
#[derive(IntoElement)]
pub struct KvRow {
label: gpui::SharedString,
value: gpui::AnyElement,
label_width: gpui::Length,
}
impl KvRow {
pub fn new(label: impl Into<gpui::SharedString>, value: impl IntoElement) -> Self {
Self {
label: label.into(),
value: value.into_any_element(),
label_width: gpui::rems(10.0).into(),
}
}
pub fn label_width(mut self, width: gpui::Length) -> Self {
self.label_width = width;
self
}
}
impl RenderOnce for KvRow {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
FieldRow::new(self.label, self.value).label_width(self.label_width)
}
}
+26 -8
View File
@@ -2,7 +2,7 @@ mod suggestion;
use crate::theme::{ActiveTheme, Theme};
use gpui::prelude::*;
use gpui::{Corner, anchored, deferred, px, point};
use gpui::{Corner, anchored, deferred, point, px};
use std::sync::Arc;
pub use suggestion::Suggestion;
@@ -348,6 +348,9 @@ impl Input {
_window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) {
// Note: This handler is only attached when the input has real focus
// (see render() method), so we don't need to check is_focused here anymore
let key = event.keystroke.key.as_str();
let ctrl = event.keystroke.modifiers.control;
let shift = event.keystroke.modifiers.shift;
@@ -358,11 +361,13 @@ impl Input {
match key {
"enter" => {
if self.multiline && shift {
// For multiline, Enter inserts newline (modal handles exit via commands)
if self.multiline {
self.insert_text("\n", cx);
return;
}
// For single-line, check suggestions then submit
if self.suggestions_open {
self.accept_suggestion(cx);
} else {
@@ -481,7 +486,10 @@ impl Input {
}
}
pub fn render_suggestions_external(&self, cx: &gpui::Context<Self>) -> Option<gpui::AnyElement> {
pub fn render_suggestions_external(
&self,
cx: &gpui::Context<Self>,
) -> Option<gpui::AnyElement> {
if !self.suggestions_open {
return None;
}
@@ -514,7 +522,8 @@ impl Input {
})
.collect();
Some(gpui::div()
Some(
gpui::div()
.mt_1()
.border_1()
.border_color(theme.border)
@@ -522,7 +531,8 @@ impl Input {
.rounded_md()
.overflow_hidden()
.children(items)
.into_any_element())
.into_any_element(),
)
}
fn render_suggestions(&self, cx: &gpui::Context<Self>) -> impl IntoElement {
@@ -706,11 +716,10 @@ impl gpui::Render for Input {
let focus_handle = self.focus.clone();
gpui::div()
let base = gpui::div()
.id(self.id.clone())
.key_context("Input")
.track_focus(&self.focus)
.on_key_down(cx.listener(Self::handle_key_down))
.on_mouse_down(gpui::MouseButton::Left, move |_ev, window, _cx| {
window.focus(&focus_handle);
})
@@ -728,7 +737,16 @@ impl gpui::Render for Input {
.p_2()
.cursor(gpui::CursorStyle::IBeam)
.child(content)
.child(self.render_suggestions(cx))
.child(self.render_suggestions(cx));
// Only attach key handler when this input actually has focus
// This prevents the input from intercepting keyboard events
// when it only has "visual focus" (focus ring) but not real focus
if is_focused {
base.on_key_down(cx.listener(Self::handle_key_down))
} else {
base
}
}
}
+6
View File
@@ -1,9 +1,15 @@
pub mod action_button;
pub mod button;
pub mod chip;
pub mod confirm_dialog;
pub mod dialog;
pub mod divider;
pub mod field_row;
pub mod icon;
pub mod input;
pub mod label;
pub mod list;
pub mod modal;
pub mod panel;
pub mod section_card;
pub mod toast;
+73
View File
@@ -0,0 +1,73 @@
use gpui::prelude::*;
use crate::components::label::Label;
use crate::theme::{ActiveTheme, Theme};
#[derive(IntoElement)]
pub struct SectionCard {
title: gpui::SharedString,
content: Vec<gpui::AnyElement>,
style: gpui::StyleRefinement,
}
impl SectionCard {
pub fn new(title: impl Into<gpui::SharedString>) -> Self {
Self {
title: title.into(),
content: Vec::new(),
style: gpui::StyleRefinement::default(),
}
}
pub fn child(mut self, child: impl IntoElement) -> Self {
self.content.push(child.into_any_element());
self
}
pub fn children<E>(mut self, children: impl IntoIterator<Item = E>) -> Self
where
E: IntoElement,
{
self.content
.extend(children.into_iter().map(|c| c.into_any_element()));
self
}
}
impl Styled for SectionCard {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl RenderOnce for SectionCard {
fn render(mut self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
let theme = cx.theme();
let title = self.title.to_string().to_uppercase();
let section_title_color = Theme::alpha(theme.foreground, 0.88);
let header = Label::new(title)
.text_sm()
.text_color(section_title_color)
.font_weight(gpui::FontWeight::BOLD);
let children: Vec<gpui::AnyElement> = self.content.drain(..).collect();
let mut container = gpui::div()
.flex()
.flex_col()
.gap_2()
.bg(theme.raised)
.border_1()
.border_color(theme.divider)
.rounded_md()
.px(gpui::rems(0.75))
.py(gpui::rems(0.5))
.child(header)
.children(children);
container.style().refine(&self.style);
container
}
}
+23 -5
View File
@@ -4,12 +4,16 @@ use crate::{
};
impl App {
fn close_task_detail(&mut self, cx: &mut gpui::Context<Self>) {
fn close_task_detail(
&mut self,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
self.task_detail_modal.update(cx, |modal, cx| {
if modal.is_editing() {
modal.cancel_edit(cx);
modal.cancel_edit(window, cx);
} else {
modal.close(cx);
modal.close(window, cx);
}
});
}
@@ -111,12 +115,26 @@ impl CommandDispatcher for App {
}
true
}
Command::OpenTaskEdit => {
match self.focus_target {
FocusTarget::SidebarProjects | FocusTarget::SidebarTags => {
// For sidebar, just open in view mode (or ignore)
self.sidebar.update(cx, |sidebar, cx| {
sidebar.dispatch(Command::OpenSelectedTask, cx)
});
}
_ => {
self.open_selected_task_edit(None, cx);
}
}
true
}
Command::CloseModal => {
self.close_task_detail(cx);
self.close_task_detail(None, cx);
true
}
Command::SaveModal => {
self.close_task_detail(cx);
self.close_task_detail(None, cx);
true
}
Command::ModalScrollUp => {
+24 -2
View File
@@ -137,6 +137,23 @@ impl App {
&mut self,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
self.open_selected_task_mode(false, window, cx);
}
pub(super) fn open_selected_task_edit(
&mut self,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
self.open_selected_task_mode(true, window, cx);
}
fn open_selected_task_mode(
&mut self,
edit_mode: bool,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
if self.task_detail_modal.read(cx).is_open() {
return;
@@ -147,12 +164,13 @@ impl App {
return;
};
self.open_task_detail(task_id, window, cx);
self.open_task_detail(task_id, edit_mode, window, cx);
}
pub(super) fn open_task_detail(
&mut self,
task_id: uuid::Uuid,
edit_mode: bool,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
@@ -162,7 +180,11 @@ impl App {
match self.task_service.get_task_detail(task_id, &tasks) {
Ok(detail) => {
self.task_detail_modal.update(cx, |modal, cx| {
if edit_mode {
modal.open_with_detail_edit(detail, window, cx);
} else {
modal.open_with_detail(detail, window, cx);
}
});
}
Err(e) => {
@@ -337,7 +359,7 @@ impl App {
self.apply_task_update(task, cx);
} else {
self.task_detail_modal.update(cx, |modal, cx| {
modal.cancel_edit(cx);
modal.cancel_edit(None, cx);
});
}
}
+48
View File
@@ -11,6 +11,7 @@ pub enum Command {
// Actions
OpenSelectedTask,
OpenTaskEdit,
Sync,
// Focus
@@ -27,6 +28,21 @@ pub enum Command {
SaveModal,
ModalScrollUp,
ModalScrollDown,
EnterEditMode,
ExitEditField,
EnterEditField,
ModalFocusNext,
ModalFocusPrev,
SubmitOrExitField,
ModalItemPrev,
ModalItemNext,
DeleteSelectedItem,
EditSelectedItem,
CopySelectedItem,
ConfirmYes,
ConfirmNo,
Undo,
Redo,
// Filter
ApplySearch,
@@ -62,6 +78,7 @@ impl Command {
"PrevPage" => Some(Self::PrevPage),
"ClearSelection" => Some(Self::ClearSelection),
"OpenSelectedTask" => Some(Self::OpenSelectedTask),
"OpenTaskEdit" => Some(Self::OpenTaskEdit),
"Sync" => Some(Self::Sync),
"FocusSearch" => Some(Self::FocusSearch),
"FocusTable" => Some(Self::FocusTable),
@@ -74,6 +91,21 @@ impl Command {
"SaveModal" => Some(Self::SaveModal),
"ModalScrollUp" => Some(Self::ModalScrollUp),
"ModalScrollDown" => Some(Self::ModalScrollDown),
"EnterEditMode" => Some(Self::EnterEditMode),
"ExitEditField" => Some(Self::ExitEditField),
"EnterEditField" => Some(Self::EnterEditField),
"ModalFocusNext" => Some(Self::ModalFocusNext),
"ModalFocusPrev" => Some(Self::ModalFocusPrev),
"SubmitOrExitField" => Some(Self::SubmitOrExitField),
"ModalItemPrev" => Some(Self::ModalItemPrev),
"ModalItemNext" => Some(Self::ModalItemNext),
"DeleteSelectedItem" => Some(Self::DeleteSelectedItem),
"EditSelectedItem" => Some(Self::EditSelectedItem),
"CopySelectedItem" => Some(Self::CopySelectedItem),
"ConfirmYes" => Some(Self::ConfirmYes),
"ConfirmNo" => Some(Self::ConfirmNo),
"Undo" => Some(Self::Undo),
"Redo" => Some(Self::Redo),
"ApplySearch" => Some(Self::ApplySearch),
"ClearFilters" => Some(Self::ClearFilters),
"ClearAllFilters" => Some(Self::ClearAllFilters),
@@ -104,6 +136,7 @@ impl Command {
Self::PrevPage => "PrevPage",
Self::ClearSelection => "ClearSelection",
Self::OpenSelectedTask => "OpenSelectedTask",
Self::OpenTaskEdit => "OpenTaskEdit",
Self::Sync => "Sync",
Self::FocusSearch => "FocusSearch",
Self::FocusTable => "FocusTable",
@@ -116,6 +149,21 @@ impl Command {
Self::SaveModal => "SaveModal",
Self::ModalScrollUp => "ModalScrollUp",
Self::ModalScrollDown => "ModalScrollDown",
Self::EnterEditMode => "EnterEditMode",
Self::ExitEditField => "ExitEditField",
Self::EnterEditField => "EnterEditField",
Self::ModalFocusNext => "ModalFocusNext",
Self::ModalFocusPrev => "ModalFocusPrev",
Self::SubmitOrExitField => "SubmitOrExitField",
Self::ModalItemPrev => "ModalItemPrev",
Self::ModalItemNext => "ModalItemNext",
Self::DeleteSelectedItem => "DeleteSelectedItem",
Self::EditSelectedItem => "EditSelectedItem",
Self::CopySelectedItem => "CopySelectedItem",
Self::ConfirmYes => "ConfirmYes",
Self::ConfirmNo => "ConfirmNo",
Self::Undo => "Undo",
Self::Redo => "Redo",
Self::ApplySearch => "ApplySearch",
Self::ClearFilters => "ClearFilters",
Self::ClearAllFilters => "ClearAllFilters",
+3
View File
@@ -6,6 +6,7 @@ pub enum ContextId {
SidebarProjects,
SidebarTags,
Modal,
ModalEditNav,
ModalInput,
ModalDropdown,
FilterBar,
@@ -21,6 +22,7 @@ impl ContextId {
"sidebarprojects" | "SidebarProjects" => Some(Self::SidebarProjects),
"sidebartags" | "SidebarTags" => Some(Self::SidebarTags),
"modal" | "Modal" => Some(Self::Modal),
"modaleditnav" | "ModalEditNav" => Some(Self::ModalEditNav),
"modalinput" | "ModalInput" => Some(Self::ModalInput),
"modaldropdown" | "ModalDropdown" => Some(Self::ModalDropdown),
"filterbar" | "FilterBar" => Some(Self::FilterBar),
@@ -37,6 +39,7 @@ impl ContextId {
Self::SidebarProjects => "SidebarProjects",
Self::SidebarTags => "SidebarTags",
Self::Modal => "Modal",
Self::ModalEditNav => "ModalEditNav",
Self::ModalInput => "ModalInput",
Self::ModalDropdown => "ModalDropdown",
Self::FilterBar => "FilterBar",
+117 -21
View File
@@ -131,6 +131,11 @@ pub fn build_default_keymap() -> KeymapLayer {
KeyChord::new(Key::Enter, Mods::none()),
Command::OpenSelectedTask,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::Char('e'), Mods::none()),
Command::OpenTaskEdit,
);
layer.bind(
ContextId::Table,
KeyChord::new(Key::ArrowLeft, Mods::none()),
@@ -397,7 +402,7 @@ pub fn build_default_keymap() -> KeymapLayer {
Command::BlurInput,
);
// Modal
// Modal (View mode)
layer.bind(
ContextId::Modal,
KeyChord::new(Key::Escape, Mods::none()),
@@ -428,36 +433,137 @@ pub fn build_default_keymap() -> KeymapLayer {
KeyChord::new(Key::Enter, Mods::ctrl()),
Command::SaveModal,
);
// Enter edit mode with 'e'
layer.bind(
ContextId::ModalInput,
ContextId::Modal,
KeyChord::new(Key::Char('e'), Mods::none()),
Command::EnterEditMode,
);
// ModalEditNav - Edit mode, navigating between fields (not typing)
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Escape, Mods::none()),
Command::CloseModal,
);
layer.bind(
ContextId::ModalInput,
KeyChord::new(Key::Enter, Mods::ctrl()),
ContextId::ModalEditNav,
KeyChord::new(Key::Char('s'), Mods::ctrl()),
Command::SaveModal,
);
layer.bind(
ContextId::ModalInput,
ContextId::ModalEditNav,
KeyChord::new(Key::Char('j'), Mods::none()),
Command::ModalFocusNext,
);
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Char('k'), Mods::none()),
Command::ModalFocusPrev,
);
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::ArrowDown, Mods::none()),
Command::ModalFocusNext,
);
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::ArrowUp, Mods::none()),
Command::ModalFocusPrev,
);
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Tab, Mods::none()),
Command::FocusFilterNext,
Command::ModalFocusNext,
);
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Tab, Mods::shift()),
Command::ModalFocusPrev,
);
// Enter/Space - Edit selected item (if any), or enter field (fallback)
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Enter, Mods::none()),
Command::EditSelectedItem,
);
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Space, Mods::none()),
Command::EditSelectedItem,
);
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Char('u'), Mods::none()),
Command::Undo,
);
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Char('r'), Mods::none()),
Command::Redo,
);
// h/l - Navigate items within tags
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Char('h'), Mods::none()),
Command::ModalItemPrev,
);
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Char('l'), Mods::none()),
Command::ModalItemNext,
);
// Delete/Backspace - Delete selected item
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Delete, Mods::none()),
Command::DeleteSelectedItem,
);
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Backspace, Mods::none()),
Command::DeleteSelectedItem,
);
// y - Copy selected annotation OR confirm in delete dialog
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Char('y'), Mods::none()),
Command::ConfirmYes,
);
// n - Cancel delete dialog
layer.bind(
ContextId::ModalEditNav,
KeyChord::new(Key::Char('n'), Mods::none()),
Command::ConfirmNo,
);
// ModalInput - Edit mode, actively typing in an input
layer.bind(
ContextId::ModalInput,
KeyChord::new(Key::Escape, Mods::none()),
Command::ExitEditField,
);
layer.bind(
ContextId::ModalInput,
KeyChord::new(Key::Tab, Mods::shift()),
Command::FocusFilterPrev,
KeyChord::new(Key::Char('s'), Mods::ctrl()),
Command::SaveModal,
);
// Enter submits or exits field depending on field type
layer.bind(
ContextId::ModalInput,
KeyChord::new(Key::Enter, Mods::none()),
Command::SubmitOrExitField,
);
// ModalDropdown - Edit mode, dropdown is open
layer.bind(
ContextId::ModalDropdown,
KeyChord::new(Key::Escape, Mods::none()),
Command::CloseModal,
Command::ExitEditField,
);
layer.bind(
ContextId::ModalDropdown,
KeyChord::new(Key::Enter, Mods::ctrl()),
KeyChord::new(Key::Char('s'), Mods::ctrl()),
Command::SaveModal,
);
layer.bind(
@@ -490,16 +596,6 @@ pub fn build_default_keymap() -> KeymapLayer {
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
}
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
use chrono::{DateTime, Utc};
use gpui::SharedString;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use crate::task::TaskDetailVm;
pub(super) type AnnotationId = u64;
#[derive(Debug, Clone)]
pub(super) struct AnnotationState {
pub(super) items: Vec<AnnotationView>,
pub(super) draft: SharedString,
}
impl Default for AnnotationState {
fn default() -> Self {
Self {
items: Vec::new(),
draft: SharedString::default(),
}
}
}
#[derive(Debug, Clone)]
pub(super) struct AnnotationView {
pub(super) id: AnnotationId,
pub(super) created_at: DateTime<Utc>,
pub(super) text: SharedString,
pub(super) origin: AnnotationOrigin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum AnnotationOrigin {
Original,
Added,
Deleted,
}
fn annotation_id(entry: DateTime<Utc>, text: &str, index: usize) -> AnnotationId {
let mut hasher = DefaultHasher::new();
entry.timestamp_millis().hash(&mut hasher);
text.hash(&mut hasher);
index.hash(&mut hasher);
hasher.finish()
}
impl AnnotationState {
pub(super) fn from_detail(detail: &TaskDetailVm) -> Self {
let items: Vec<AnnotationView> = detail
.annotations
.iter()
.enumerate()
.map(|(index, annotation)| AnnotationView {
id: annotation_id(annotation.entry, &annotation.content, index),
created_at: annotation.entry,
text: annotation.content.clone().into(),
origin: AnnotationOrigin::Original,
})
.collect();
Self {
items,
draft: SharedString::default(),
}
}
pub(super) fn set_draft(&mut self, value: &str) {
self.draft = value.to_string().into();
}
pub(super) fn clear_draft(&mut self) {
self.draft = SharedString::default();
}
pub(super) fn add_local(&mut self, text: SharedString, created_at: DateTime<Utc>) {
let id = annotation_id(created_at, text.as_ref(), self.items.len());
self.items.push(AnnotationView {
id,
created_at,
text,
origin: AnnotationOrigin::Added,
});
}
pub(super) fn mark_deleted(&mut self, id: AnnotationId) -> Option<DateTime<Utc>> {
let index = self.items.iter().position(|item| item.id == id)?;
let item = &mut self.items[index];
if item.origin == AnnotationOrigin::Added {
self.items.remove(index);
return None;
}
if item.origin == AnnotationOrigin::Deleted {
return None;
}
item.origin = AnnotationOrigin::Deleted;
Some(item.created_at)
}
}
+29
View File
@@ -0,0 +1,29 @@
use super::state::ModalFocus;
pub(super) struct FocusMap;
impl FocusMap {
pub(super) fn section_index(focus: ModalFocus) -> usize {
match focus {
ModalFocus::None
| ModalFocus::StatusDropdown
| ModalFocus::Description
| ModalFocus::Project
| ModalFocus::PriorityDropdown
| ModalFocus::Due => 0,
ModalFocus::TagsInput => 1,
ModalFocus::AnnotationsInput => 3,
}
}
pub(super) fn wants_input_focus(focus: ModalFocus) -> bool {
matches!(
focus,
ModalFocus::Description
| ModalFocus::Project
| ModalFocus::Due
| ModalFocus::TagsInput
| ModalFocus::AnnotationsInput
)
}
}
+341
View File
@@ -0,0 +1,341 @@
use chrono::{DateTime, NaiveDate, TimeZone, Utc};
use gpui::SharedString;
use std::collections::{HashMap, HashSet};
use crate::task::{self, TaskDetailVm};
use crate::ui::DATE_FORMAT;
use super::annotations::{AnnotationOrigin, AnnotationState};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(super) enum FieldId {
Description,
Due,
}
#[derive(Debug, Clone, Default)]
pub(super) struct TaskForm {
pub(super) description: String,
pub(super) project: String,
pub(super) priority: task::TaskPriority,
pub(super) status: task::TaskStatus,
pub(super) due: String,
pub(super) tags: Vec<String>,
pub(super) tag_draft: String,
}
impl TaskForm {
pub(super) fn from_detail(detail: &TaskDetailVm) -> Self {
let project = detail.overview.project.clone().unwrap_or_default();
let due = detail
.dates
.due
.map(|date| date.format(DATE_FORMAT).to_string())
.unwrap_or_default();
Self {
description: detail.overview.description.clone(),
project,
priority: detail.overview.priority,
status: detail.overview.status.clone(),
due,
tags: detail.tags.tags.clone(),
tag_draft: String::new(),
}
}
pub(super) fn is_dirty(&self, original: &TaskDetailVm) -> bool {
let original_due = original
.dates
.due
.map(|date| date.format(DATE_FORMAT).to_string())
.unwrap_or_default();
let original_project = original.overview.project.clone().unwrap_or_default();
if self.description != original.overview.description {
return true;
}
if self.project.trim() != original_project.trim() {
return true;
}
if self.priority != original.overview.priority {
return true;
}
if self.status != original.overview.status {
return true;
}
if self.due.trim() != original_due.trim() {
return true;
}
let original_tags: HashSet<String> = original.tags.tags.iter().cloned().collect();
let draft_tags: HashSet<String> = self.tags.iter().cloned().collect();
draft_tags != original_tags
}
pub(super) fn validate_field(&self, field: FieldId) -> Option<SharedString> {
match field {
FieldId::Description => {
if self.description.trim().is_empty() {
Some("Description is required".into())
} else {
None
}
}
FieldId::Due => {
if self.due.trim().is_empty() {
None
} else if NaiveDate::parse_from_str(self.due.trim(), DATE_FORMAT).is_err() {
Some("Use YYYY-MM-DD".into())
} else {
None
}
}
}
}
pub(super) fn validate(&self) -> HashMap<FieldId, SharedString> {
let mut errors = HashMap::new();
for field in [FieldId::Description, FieldId::Due] {
if let Some(message) = self.validate_field(field) {
errors.insert(field, message);
}
}
errors
}
pub(super) fn add_tags(&mut self, raw: &str) -> bool {
let mut added = false;
for tag in raw.split(|ch: char| ch.is_whitespace() || ch == ',') {
let tag = tag.trim();
if tag.is_empty() {
continue;
}
if !self.tags.iter().any(|t| t == tag) {
self.tags.push(tag.to_string());
added = true;
}
}
if added {
self.tags
.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
}
added
}
pub(super) fn remove_tag(&mut self, tag: &str) -> bool {
let before = self.tags.len();
self.tags.retain(|t| t != tag);
before != self.tags.len()
}
}
#[derive(Debug, Clone, Default)]
pub struct TaskEditUpdate {
pub description: Option<String>,
pub project: Option<Option<String>>,
pub priority: Option<String>,
pub status: Option<task::TaskStatus>,
pub due: Option<Option<DateTime<Utc>>>,
pub tags: Option<HashSet<String>>,
pub annotations_add: Vec<String>,
pub annotations_delete: Vec<DateTime<Utc>>,
}
impl TaskEditUpdate {
pub(super) fn is_empty(&self) -> bool {
self.description.is_none()
&& self.project.is_none()
&& self.priority.is_none()
&& self.status.is_none()
&& self.due.is_none()
&& self.tags.is_none()
&& self.annotations_add.is_empty()
&& self.annotations_delete.is_empty()
}
}
pub(super) fn build_task_update(
original: &TaskDetailVm,
draft: &TaskForm,
annotations: &AnnotationState,
) -> TaskEditUpdate {
let mut update = TaskEditUpdate::default();
if draft.description != original.overview.description {
update.description = Some(draft.description.clone());
}
let draft_project = draft.project.trim();
let original_project = original.overview.project.as_deref().unwrap_or("").trim();
if draft_project != original_project {
update.project = Some(if draft_project.is_empty() {
None
} else {
Some(draft_project.to_string())
});
}
if draft.priority != original.overview.priority {
let priority: String = draft.priority.into();
update.priority = Some(priority);
}
if draft.status != original.overview.status {
update.status = Some(draft.status.clone());
}
let draft_due = draft.due.trim();
let original_due = original
.dates
.due
.map(|date| date.format(DATE_FORMAT).to_string())
.unwrap_or_default();
if draft_due != original_due.trim() {
if draft_due.is_empty() {
update.due = Some(None);
} else if let Ok(date) = NaiveDate::parse_from_str(draft_due, DATE_FORMAT) {
let due = Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap());
update.due = Some(Some(due));
}
}
let draft_tags: HashSet<String> = draft.tags.iter().cloned().collect();
let original_tags: HashSet<String> = original.tags.tags.iter().cloned().collect();
if draft_tags != original_tags {
update.tags = Some(draft_tags);
}
for annotation in &annotations.items {
match annotation.origin {
AnnotationOrigin::Added => {
update.annotations_add.push(annotation.text.to_string());
}
AnnotationOrigin::Deleted => {
update.annotations_delete.push(annotation.created_at);
}
AnnotationOrigin::Original => {}
}
}
update
}
#[cfg(test)]
mod tests {
use super::super::annotations::AnnotationState;
use super::*;
use crate::task::model::{
TaskAnnotation, TaskDatesVm, TaskDependenciesVm, TaskDetailVm, TaskIdentityVm,
TaskMetricsVm, TaskOverviewVm, TaskTagsVm,
};
use crate::task::{TaskPriority, TaskStatus};
use chrono::{TimeZone, Utc};
fn base_detail() -> TaskDetailVm {
TaskDetailVm {
identity: TaskIdentityVm {
uuid: uuid::Uuid::nil(),
id: None,
working_id: None,
},
overview: TaskOverviewVm {
description: "Test task".to_string(),
status: TaskStatus::Pending,
project: Some("proj".to_string()),
priority: TaskPriority::None,
is_active: false,
},
dates: TaskDatesVm {
entry: None,
modified: None,
start: None,
end: None,
due: None,
scheduled: None,
wait: None,
until: None,
},
tags: TaskTagsVm {
tags: vec!["a".to_string(), "b".to_string()],
virtual_tags: Vec::new(),
},
dependencies: TaskDependenciesVm {
depends_on: Vec::new(),
blocked_by: Vec::new(),
blocking: Vec::new(),
},
annotations: Vec::new(),
udas: Vec::new(),
metrics: TaskMetricsVm::default(),
}
}
#[test]
fn build_update_project_cleared() {
let detail = base_detail();
let mut draft = TaskForm::from_detail(&detail);
draft.project = " ".to_string();
let annotations = AnnotationState::default();
let update = build_task_update(&detail, &draft, &annotations);
assert_eq!(update.project, Some(None));
}
#[test]
fn build_update_due_invalid_is_ignored() {
let detail = base_detail();
let mut draft = TaskForm::from_detail(&detail);
draft.due = "2023-99-99".to_string();
let annotations = AnnotationState::default();
let update = build_task_update(&detail, &draft, &annotations);
assert!(update.due.is_none());
}
#[test]
fn build_update_tags_ignore_order() {
let detail = base_detail();
let mut draft = TaskForm::from_detail(&detail);
draft.tags = vec!["b".to_string(), "a".to_string()];
let annotations = AnnotationState::default();
let update = build_task_update(&detail, &draft, &annotations);
assert!(update.tags.is_none());
}
#[test]
fn build_update_annotations_added_deleted() {
let mut detail = base_detail();
let deleted_at = Utc.with_ymd_and_hms(2023, 5, 2, 12, 0, 0).unwrap();
detail.annotations = vec![TaskAnnotation {
entry: deleted_at,
content: "Original".to_string(),
}];
let mut annotations = AnnotationState::from_detail(&detail);
let original_id = annotations.items[0].id;
annotations.mark_deleted(original_id);
annotations.add_local("Added".into(), Utc::now());
let draft = TaskForm::from_detail(&detail);
let update = build_task_update(&detail, &draft, &annotations);
assert_eq!(update.annotations_add, vec!["Added".to_string()]);
assert_eq!(update.annotations_delete, vec![deleted_at]);
}
}
+66
View File
@@ -0,0 +1,66 @@
use super::form::TaskForm;
/// History stack for undo/redo functionality
#[derive(Debug, Clone)]
pub(super) struct FormHistory {
states: Vec<TaskForm>,
current_index: usize,
max_size: usize,
}
impl Default for FormHistory {
fn default() -> Self {
Self {
states: Vec::new(),
current_index: 0,
max_size: 50,
}
}
}
impl FormHistory {
pub(super) fn clear(&mut self) {
self.states.clear();
self.current_index = 0;
}
pub(super) fn push(&mut self, state: TaskForm) {
if self.current_index < self.states.len() {
self.states.truncate(self.current_index);
}
self.states.push(state);
if self.states.len() > self.max_size {
self.states.remove(0);
} else {
self.current_index = self.states.len();
}
}
pub(super) fn undo(&mut self) -> Option<&TaskForm> {
if self.current_index > 1 {
self.current_index -= 1;
self.states.get(self.current_index - 1)
} else {
None
}
}
pub(super) fn redo(&mut self) -> Option<&TaskForm> {
if self.current_index < self.states.len() {
self.current_index += 1;
self.states.get(self.current_index - 1)
} else {
None
}
}
pub(super) fn can_undo(&self) -> bool {
self.current_index > 1
}
pub(super) fn can_redo(&self) -> bool {
self.current_index < self.states.len()
}
}
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
use gpui::prelude::*;
use crate::components::button::Dropdown;
use crate::components::input::Input;
use crate::components::modal::ModalFrame;
use crate::theme::ActiveTheme;
use super::TaskDetailModal;
use super::state::TaskModalState;
mod panel;
pub(super) fn render_task_detail_modal(
state: &TaskModalState,
annotation_input: &gpui::Entity<Input>,
description_input: &gpui::Entity<Input>,
project_input: &gpui::Entity<Input>,
due_input: &gpui::Entity<Input>,
tags_input: &gpui::Entity<Input>,
status_dropdown: &gpui::Entity<Dropdown>,
priority_dropdown: &gpui::Entity<Dropdown>,
focus_handle: &gpui::FocusHandle,
form_focus_handle: &gpui::FocusHandle,
scroll_handle: &gpui::ScrollHandle,
cx: &mut gpui::Context<TaskDetailModal>,
on_close_out: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
on_close_click: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
) -> gpui::AnyElement {
let theme = cx.theme().clone();
let panel = if let Some(message) = state.error.as_ref() {
panel::render_task_detail_placeholder_panel(
"Task Details",
message.as_ref(),
&theme,
on_close_click,
)
} else if state.loading || state.original.is_none() {
panel::render_task_detail_placeholder_panel(
"Task Details",
"Loading task...",
&theme,
on_close_click,
)
} else if let Some(detail) = state.original.as_ref() {
panel::render_task_detail_panel(
detail,
state.mode,
state.edit_state,
&state.form,
&state.errors,
&state.annotations,
state.modal_focus,
state.tag_selected,
state.annotation_selected,
&state.pending_confirm,
annotation_input,
description_input,
project_input,
due_input,
tags_input,
status_dropdown,
priority_dropdown,
form_focus_handle,
scroll_handle,
&theme,
cx,
on_close_click,
)
} else {
panel::render_task_detail_placeholder_panel(
"Task Details",
"Loading task...",
&theme,
on_close_click,
)
};
ModalFrame::new("task-detail-modal", focus_handle.clone(), theme.backdrop)
.panel(panel)
.on_close(on_close_out)
.into_any_element()
}
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
use std::collections::HashMap;
use gpui::SharedString;
use uuid::Uuid;
use crate::task::TaskDetailVm;
use super::annotations::AnnotationState;
use super::form::{FieldId, TaskForm};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ModalMode {
View,
Edit,
}
impl Default for ModalMode {
fn default() -> Self {
Self::View
}
}
/// State within Edit mode - determines how keyboard input is handled
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EditState {
/// Navigating between fields with j/k, not typing
#[default]
Navigating,
/// Actively typing in an input field
Editing,
/// A dropdown menu is open
DropdownOpen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModalFocus {
None,
Description,
Project,
StatusDropdown,
PriorityDropdown,
Due,
TagsInput,
AnnotationsInput,
}
impl Default for ModalFocus {
fn default() -> Self {
Self::StatusDropdown
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum InlineEditTarget {
Tag(usize),
Annotation(usize),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum ConfirmAction {
DeleteTag { index: usize, text: String },
DeleteAnnotation { index: usize, text: String },
DiscardUnsavedChanges,
DiscardUnsavedChangesAndClose,
}
#[derive(Debug, Clone)]
pub(super) struct TaskModalState {
pub(super) open: bool,
pub(super) task_id: Option<Uuid>,
pub(super) loading: bool,
pub(super) original: Option<TaskDetailVm>,
pub(super) form: TaskForm,
pub(super) errors: HashMap<FieldId, SharedString>,
pub(super) mode: ModalMode,
pub(super) edit_state: EditState,
pub(super) annotations: AnnotationState,
pub(super) error: Option<SharedString>,
pub(super) modal_focus: ModalFocus,
/// Which tag is selected for h/l navigation (None = input focused)
pub(super) tag_selected: Option<usize>,
/// Which annotation is selected for h/l navigation (None = input focused)
pub(super) annotation_selected: Option<usize>,
/// Currently editing an existing item (not creating new)
pub(super) inline_edit: Option<InlineEditTarget>,
/// Pending confirmation action
pub(super) pending_confirm: Option<ConfirmAction>,
}
impl Default for TaskModalState {
fn default() -> Self {
Self {
open: false,
task_id: None,
loading: false,
original: None,
form: TaskForm::default(),
errors: HashMap::new(),
mode: ModalMode::default(),
edit_state: EditState::default(),
annotations: AnnotationState::default(),
error: None,
modal_focus: ModalFocus::default(),
tag_selected: None,
annotation_selected: None,
inline_edit: None,
pending_confirm: None,
}
}
}