refactor: task detail modal into its own component

Move task detail modal logic from App into a dedicated TaskDetailModal
component. The modal now manages its own state (open/closed, loading,
error states) and handles its own focus and scroll. App delegates modal
operations to the component and reacts to its events.
This commit is contained in:
Ignacio Perez
2025-12-30 21:16:12 -03:00
parent f0ffa033b0
commit 125456e50a
3 changed files with 206 additions and 97 deletions
+35 -92
View File
@@ -3,16 +3,15 @@ use std::collections::HashMap;
use gpui::prelude::*; use gpui::prelude::*;
use crate::{ use crate::{
components::modal::ModalState,
keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord, KeymapStack}, keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord, KeymapStack},
models::{FilterState, ProjectTree}, models::{FilterState, ProjectTree},
task::{self, TaskDetailState, 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, SyncState},
task_detail_modal, task_detail_modal::{TaskDetailModal, TaskDetailModalEvent},
task_table::{TaskTable, TaskTableEvent}, task_table::{TaskTable, TaskTableEvent},
}, },
}; };
@@ -25,14 +24,10 @@ pub(super) struct App {
pub(super) filter_state: gpui::Entity<FilterState>, pub(super) filter_state: gpui::Entity<FilterState>,
pub(super) status_bar: gpui::Entity<StatusBar>, pub(super) status_bar: gpui::Entity<StatusBar>,
pub(super) task_table: gpui::Entity<TaskTable>, pub(super) task_table: gpui::Entity<TaskTable>,
pub(super) task_detail_modal: gpui::Entity<TaskDetailModal>,
pub(super) task_service: TaskService, pub(super) task_service: TaskService,
pub(super) tasks: Vec<TaskSummary>, pub(super) tasks: Vec<TaskSummary>,
pub(super) selected_task_id: Option<uuid::Uuid>,
pub(super) task_detail_state: TaskDetailState,
pub(super) modal_state: ModalState,
pub(super) modal_focus_handle: gpui::FocusHandle,
pub(super) focus_before_modal: FocusTarget, pub(super) focus_before_modal: FocusTarget,
pub(super) modal_scroll_handle: gpui::ScrollHandle,
} }
impl gpui::Render for App { impl gpui::Render for App {
@@ -63,22 +58,8 @@ impl gpui::Render for App {
} }
}); });
let modal = if self.modal_state.open { let modal = if self.task_detail_modal.read(cx).is_open() {
let on_close_backdrop = Some(self.task_detail_modal.clone().into_any_element())
cx.listener(|app, _event: &gpui::MouseDownEvent, window, cx| {
app.close_task_detail(Some(window), cx);
});
let on_close_click = cx.listener(|app, _event: &gpui::MouseDownEvent, window, cx| {
app.close_task_detail(Some(window), cx);
});
Some(task_detail_modal::render_task_detail_modal(
&self.task_detail_state,
&self.modal_focus_handle,
&self.modal_scroll_handle,
theme,
on_close_backdrop,
on_close_click,
))
} else { } else {
None None
}; };
@@ -207,7 +188,9 @@ impl App {
let context = self.active_context(cx); let context = self.active_context(cx);
if let Some(command) = self.keymap.resolve(context, &chord) { if let Some(command) = self.keymap.resolve(context, &chord) {
if self.modal_state.open { let modal_is_open = self.task_detail_modal.read(cx).is_open();
if modal_is_open {
match command { match command {
Command::CloseModal Command::CloseModal
| Command::SaveModal | Command::SaveModal
@@ -295,7 +278,7 @@ impl App {
window: Option<&mut gpui::Window>, window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>, cx: &mut gpui::Context<Self>,
) { ) {
if self.modal_state.open { if self.task_detail_modal.read(cx).is_open() {
return; return;
} }
@@ -314,76 +297,26 @@ impl App {
cx: &mut gpui::Context<Self>, cx: &mut gpui::Context<Self>,
) { ) {
self.focus_before_modal = self.focus_target; self.focus_before_modal = self.focus_target;
self.modal_state.open = true;
if let Some(window) = window {
window.focus(&self.modal_focus_handle);
}
self.modal_scroll_handle = gpui::ScrollHandle::new();
self.modal_scroll_handle.scroll_to_item(0);
if self.selected_task_id == Some(task_id) {
if matches!(self.task_detail_state, TaskDetailState::Ready(_)) {
cx.notify();
return;
}
}
self.selected_task_id = Some(task_id);
self.task_detail_state = TaskDetailState::Loading(task_id);
cx.notify();
let tasks = self.tasks.clone(); let tasks = self.tasks.clone();
match self.task_service.get_task_detail(task_id, &tasks) { match self.task_service.get_task_detail(task_id, &tasks) {
Ok(detail) => { Ok(detail) => {
self.task_detail_state = TaskDetailState::Ready(detail); self.task_detail_modal.update(cx, |modal, cx| {
modal.open_with_detail(detail, window, cx);
});
} }
Err(e) => { Err(e) => {
self.task_detail_state = TaskDetailState::Error(task_id, e.to_string()); self.task_detail_modal.update(cx, |modal, cx| {
modal.open_with_error(task_id, e.to_string(), window, cx);
});
} }
} }
cx.notify(); cx.notify();
} }
pub(super) fn close_task_detail(
&mut self,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
if !self.modal_state.open {
return;
}
self.modal_state.open = false;
self.selected_task_id = None;
self.focus_target = self.focus_before_modal;
if let Some(window) = window {
window.focus(&self.focus_handle);
}
cx.notify();
}
pub(super) fn scroll_task_detail(&self, delta: i32, cx: &mut gpui::Context<Self>) {
let handle = &self.modal_scroll_handle;
let current = if delta > 0 {
handle.bottom_item()
} else {
handle.top_item()
};
let next = if delta > 0 {
current.saturating_add(1)
} else {
current.saturating_sub(1)
};
handle.scroll_to_item(next);
cx.notify();
}
fn active_context(&self, cx: &gpui::Context<Self>) -> ContextId { fn active_context(&self, cx: &gpui::Context<Self>) -> ContextId {
if self.modal_state.open { if self.task_detail_modal.read(cx).is_open() {
return ContextId::Modal; return ContextId::Modal;
} }
if matches!(self.focus_target, FocusTarget::Table) { if matches!(self.focus_target, FocusTarget::Table) {
@@ -440,8 +373,12 @@ impl App {
let task_table = cx let task_table = cx
.new(|cx| TaskTable::new("main-task-table", filter_state.clone(), cx)); .new(|cx| TaskTable::new("main-task-table", filter_state.clone(), cx));
let task_detail_modal = cx.new(|cx| TaskDetailModal::new(cx));
let task_table_events = task_table.clone(); let task_table_events = task_table.clone();
let sidebar_events = sidebar.clone(); let sidebar_events = sidebar.clone();
let modal_events = task_detail_modal.clone();
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);
@@ -450,7 +387,7 @@ impl App {
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());
let app = App { let app_instance = App {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
focus_target: FocusTarget::Table, focus_target: FocusTarget::Table,
keymap, keymap,
@@ -458,17 +395,13 @@ impl App {
filter_state: filter_state.clone(), filter_state: filter_state.clone(),
status_bar: status_bar.clone(), status_bar: status_bar.clone(),
task_table, task_table,
task_detail_modal,
task_service, task_service,
tasks: task_summaries, tasks: task_summaries,
selected_task_id: None,
task_detail_state: TaskDetailState::default(),
modal_state: ModalState::default(),
modal_focus_handle: cx.focus_handle(),
focus_before_modal: FocusTarget::Table, focus_before_modal: FocusTarget::Table,
modal_scroll_handle: gpui::ScrollHandle::new(),
}; };
window.focus(&app.focus_handle); window.focus(&app_instance.focus_handle);
cx.observe(&filter_state, |app, _, cx| { cx.observe(&filter_state, |app, _, cx| {
app.reload_tasks(cx); app.reload_tasks(cx);
@@ -481,6 +414,7 @@ impl App {
} }
}) })
.detach(); .detach();
cx.subscribe(&sidebar_events, |app, _sidebar, event, cx| match event { cx.subscribe(&sidebar_events, |app, _sidebar, event, cx| match event {
SidebarEvent::Focused(section) => { SidebarEvent::Focused(section) => {
app.focus_target = match section { app.focus_target = match section {
@@ -491,16 +425,25 @@ impl App {
} }
}) })
.detach(); .detach();
cx.subscribe(&task_table_events, |app, _table, event, cx| match event { cx.subscribe(&task_table_events, |app, _table, event, cx| match event {
TaskTableEvent::OpenTask(task_id) => { TaskTableEvent::OpenTask(task_id) => {
if !app.modal_state.open { if !app.task_detail_modal.read(cx).is_open() {
app.open_task_detail(*task_id, None, cx); app.open_task_detail(*task_id, None, cx);
} }
} }
}) })
.detach(); .detach();
app cx.subscribe(&modal_events, |app, _modal, event, cx| match event {
TaskDetailModalEvent::Closed => {
app.focus_target = app.focus_before_modal;
cx.notify();
}
})
.detach();
app_instance
}) })
}, },
) )
+16 -2
View File
@@ -3,6 +3,20 @@ use crate::{
keymap::{Command, CommandDispatcher, FocusTarget}, keymap::{Command, CommandDispatcher, FocusTarget},
}; };
impl App {
fn close_task_detail(&mut self, cx: &mut gpui::Context<Self>) {
self.task_detail_modal.update(cx, |modal, cx| {
modal.close(cx);
});
}
fn scroll_task_detail(&self, delta: i32, cx: &mut gpui::Context<Self>) {
self.task_detail_modal.update(cx, |modal, cx| {
modal.scroll(delta, cx);
});
}
}
impl CommandDispatcher for App { impl CommandDispatcher for App {
fn dispatch(&mut self, command: Command, cx: &mut gpui::Context<Self>) -> bool { fn dispatch(&mut self, command: Command, cx: &mut gpui::Context<Self>) -> bool {
match command { match command {
@@ -94,11 +108,11 @@ impl CommandDispatcher for App {
true true
} }
Command::CloseModal => { Command::CloseModal => {
self.close_task_detail(None, cx); self.close_task_detail(cx);
true true
} }
Command::SaveModal => { Command::SaveModal => {
self.close_task_detail(None, cx); self.close_task_detail(cx);
true true
} }
Command::ModalScrollUp => { Command::ModalScrollUp => {
+155 -3
View File
@@ -4,11 +4,163 @@ use std::sync::Arc;
use crate::components::label::Label; use crate::components::label::Label;
use crate::components::modal::ModalFrame; use crate::components::modal::ModalFrame;
use crate::task::model::TaskLinkVm; use crate::task::model::TaskLinkVm;
use crate::task::{self, TaskDetailState}; use crate::task::{self, TaskDetailState, TaskDetailVm};
use crate::theme::Theme; use crate::theme::{ActiveTheme, Theme};
use crate::ui::{DATE_FORMAT, DATE_TIME_FORMAT}; use crate::ui::{DATE_FORMAT, DATE_TIME_FORMAT};
pub fn render_task_detail_modal( pub enum TaskDetailModalEvent {
Closed,
}
pub struct TaskDetailModal {
state: TaskDetailState,
is_open: bool,
focus_handle: gpui::FocusHandle,
scroll_handle: gpui::ScrollHandle,
}
impl TaskDetailModal {
pub fn new(cx: &mut gpui::Context<Self>) -> Self {
Self {
state: TaskDetailState::default(),
is_open: false,
focus_handle: cx.focus_handle(),
scroll_handle: gpui::ScrollHandle::new(),
}
}
pub fn is_open(&self) -> bool {
self.is_open
}
pub fn focus_handle(&self) -> &gpui::FocusHandle {
&self.focus_handle
}
pub fn open_with_detail(
&mut self,
detail: TaskDetailVm,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
if let Some(window) = window {
window.focus(&self.focus_handle);
}
self.is_open = true;
self.scroll_handle = gpui::ScrollHandle::new();
self.scroll_handle.scroll_to_item(0);
self.state = TaskDetailState::Ready(detail);
cx.notify();
}
pub fn open_with_error(
&mut self,
task_id: uuid::Uuid,
error: String,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
if let Some(window) = window {
window.focus(&self.focus_handle);
}
self.is_open = true;
self.scroll_handle = gpui::ScrollHandle::new();
self.scroll_handle.scroll_to_item(0);
self.state = TaskDetailState::Error(task_id, error);
cx.notify();
}
pub fn open_loading(
&mut self,
task_id: uuid::Uuid,
window: Option<&mut gpui::Window>,
cx: &mut gpui::Context<Self>,
) {
if let Some(window) = window {
window.focus(&self.focus_handle);
}
self.is_open = true;
self.scroll_handle = gpui::ScrollHandle::new();
self.scroll_handle.scroll_to_item(0);
self.state = TaskDetailState::Loading(task_id);
cx.notify();
}
pub fn set_detail(&mut self, detail: TaskDetailVm, cx: &mut gpui::Context<Self>) {
self.state = TaskDetailState::Ready(detail);
cx.notify();
}
pub fn set_error(&mut self, task_id: uuid::Uuid, error: String, cx: &mut gpui::Context<Self>) {
self.state = TaskDetailState::Error(task_id, error);
cx.notify();
}
pub fn close(&mut self, cx: &mut gpui::Context<Self>) {
if !self.is_open {
return;
}
self.is_open = false;
self.state = TaskDetailState::Idle;
cx.emit(TaskDetailModalEvent::Closed);
cx.notify();
}
pub fn scroll(&self, delta: i32, cx: &mut gpui::Context<Self>) {
let handle = &self.scroll_handle;
let current = if delta > 0 {
handle.bottom_item()
} else {
handle.top_item()
};
let next = if delta > 0 {
current.saturating_add(1)
} else {
current.saturating_sub(1)
};
handle.scroll_to_item(next);
cx.notify();
}
}
impl gpui::EventEmitter<TaskDetailModalEvent> for TaskDetailModal {}
impl gpui::Render for TaskDetailModal {
fn render(
&mut self,
_window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
if !self.is_open {
return gpui::div().into_any_element();
}
let theme = cx.theme();
let on_close_backdrop = cx.listener(|modal, _event: &gpui::MouseDownEvent, _window, cx| {
modal.close(cx);
});
let on_close_click = cx.listener(|modal, _event: &gpui::MouseDownEvent, _window, cx| {
modal.close(cx);
});
render_task_detail_modal(
&self.state,
&self.focus_handle,
&self.scroll_handle,
theme,
on_close_backdrop,
on_close_click,
)
}
}
fn render_task_detail_modal(
detail_state: &TaskDetailState, detail_state: &TaskDetailState,
focus_handle: &gpui::FocusHandle, focus_handle: &gpui::FocusHandle,
scroll_handle: &gpui::ScrollHandle, scroll_handle: &gpui::ScrollHandle,