diff --git a/src/ui/app_component/actions.rs b/src/ui/app_component/actions.rs index 9f5d278..901864f 100644 --- a/src/ui/app_component/actions.rs +++ b/src/ui/app_component/actions.rs @@ -8,7 +8,7 @@ use crate::sync::SyncStatus; use crate::ui::components::toast::Toast; use crate::ui::core::actions::Action; use crate::ui::core::operations::{Due, Operation}; -use crate::ui::core::SidebarSelection; +use crate::ui::core::{LoadKind, SidebarSelection}; use log::info; impl AppComponent { @@ -36,7 +36,7 @@ impl AppComponent { Action::RefreshLocalData => { info!("Refreshing local data from database (debug mode)"); // Schedule a data fetch directly from local storage without API sync - self.schedule_data_fetch(); + self.schedule_data_load(LoadKind::User); Action::None } Action::SyncCompleted(status) => { @@ -105,7 +105,7 @@ impl AppComponent { self.is_initial_sync = false; self.state.sidebar_selection = selection.clone(); // Reload data for the new selection - self.schedule_data_fetch(); + self.schedule_data_load(LoadKind::User); info!("Navigation: Scheduled data fetch for new selection"); Action::None } @@ -221,53 +221,41 @@ impl AppComponent { }); Action::None } - Action::InitialDataLoaded { + Action::DataLoaded { + kind, projects, labels, sections, tasks, } => { info!( - "InitialData: Loaded {} projects, {} labels, {} sections, {} tasks", + "Data: Loaded {} projects, {} labels, {} sections, {} tasks ({:?})", projects.len(), labels.len(), sections.len(), - tasks.len() + tasks.len(), + kind ); - // Update app state with loaded data - self.state.update_data(projects, labels, sections, tasks); + // Read before the rebuild: the task list still holds the pre-reload items. + let anchor = match kind { + LoadKind::Background => self.task_list.get_selected_task().map(|task| task.uuid), + _ => None, + }; - // Set initial sidebar selection based on config (now we have projects loaded) - self.set_initial_sidebar_selection(); - info!("AppComponent: Set initial sidebar selection after initial data load"); + self.state.update_data(projects, labels, sections, tasks); - // Fetch data for the newly selected sidebar item - self.schedule_data_fetch(); - info!("AppComponent: Scheduled data fetch for initial sidebar selection"); + if kind == LoadKind::Initial { + // `default_project` is only resolvable post-load. + self.set_initial_sidebar_selection(); + self.schedule_data_load(LoadKind::User); + } self.sync_component_data(); - info!("InitialData: Updated all component data after initial data load"); - Action::None - } - Action::DataLoaded { - projects, - labels, - sections, - tasks, - } => { - info!( - "Data: Loaded {} projects, {} labels, {} sections, {} tasks", - projects.len(), - labels.len(), - sections.len(), - tasks.len() - ); - // Update app state with loaded data - self.state.update_data(projects, labels, sections, tasks); - self.sync_component_data(); - info!("Data: Updated all component data after data load"); + if let Some(task_uuid) = anchor { + self.task_list.select_task(task_uuid); + } Action::None } Action::SearchTasks(query) => { @@ -293,7 +281,7 @@ impl AppComponent { Action::RefreshData => { info!("Data: Refreshing UI data after task operation"); // Schedule a data fetch to reload current view with updated data - self.schedule_data_fetch(); + self.schedule_data_load(LoadKind::User); Action::None } // Help panel scrolling actions diff --git a/src/ui/app_component/mod.rs b/src/ui/app_component/mod.rs index 8b09e96..728932d 100644 --- a/src/ui/app_component/mod.rs +++ b/src/ui/app_component/mod.rs @@ -7,7 +7,6 @@ use crate::constants::*; use crate::sync::{SyncService, SyncStatus}; use crate::theme::{self, ThemeWarning}; use crate::ui::components::{toast::Toast, DialogComponent, SidebarComponent, TaskListComponent}; -use crate::ui::core::SidebarSelection; use crate::ui::core::{ actions::{Action, DialogType}, event_handler::EventType, @@ -15,6 +14,7 @@ use crate::ui::core::{ task_manager::{TaskId, TaskManager}, Component, }; +use crate::ui::core::{LoadKind, SidebarSelection}; use crossterm::event::KeyEvent; use log::info; use ratatui::{ @@ -114,13 +114,13 @@ impl AppComponent { info!("AppComponent: Skipping initial sync (debug mode)"); // In debug mode, just load existing data from database self.is_initial_sync = true; - self.schedule_initial_data_fetch(); + self.schedule_data_load(LoadKind::Initial); self.is_initial_sync = false; } else { info!("AppComponent: Loading cached data before initial sync"); if self.active_sync_task.is_none() { self.is_initial_sync = true; - self.schedule_initial_data_fetch(); + self.schedule_data_load(LoadKind::Initial); self.start_background_sync(); // A successful sync refreshes the view again. A failed sync leaves the // already-scheduled cached snapshot visible. @@ -265,28 +265,21 @@ impl AppComponent { // Only proceed if sync was successful if matches!(status, SyncStatus::Success) { if self.is_initial_sync { - // For initial sync, use initial data fetch which sets default selection - self.schedule_initial_data_fetch(); + // Nothing was on screen to preserve, and the default project is resolvable now. + self.schedule_data_load(LoadKind::Initial); self.is_initial_sync = false; } else { - // For manual refresh, use regular data fetch to maintain current selection - self.schedule_data_fetch(); + self.schedule_data_load(LoadKind::Background); } } } - /// Schedule a background task to fetch initial data after sync completion - fn schedule_initial_data_fetch(&mut self) { + /// Schedule a background task to reload the current view. `kind` travels with the load + /// and decides what happens to the cursor when it lands. + fn schedule_data_load(&mut self, kind: LoadKind) { let _task_id = self.task_manager - .spawn_data_load(self.sync_service.clone(), self.state.sidebar_selection.clone(), true); - } - - /// Schedule a background task to fetch data after navigation or changes - fn schedule_data_fetch(&mut self) { - let _task_id = - self.task_manager - .spawn_data_load(self.sync_service.clone(), self.state.sidebar_selection.clone(), false); + .spawn_data_load(self.sync_service.clone(), self.state.sidebar_selection.clone(), kind); } /// Process background actions from task manager diff --git a/src/ui/components/task_list_component.rs b/src/ui/components/task_list_component.rs index 70b49b4..5f349e2 100644 --- a/src/ui/components/task_list_component.rs +++ b/src/ui/components/task_list_component.rs @@ -453,6 +453,23 @@ impl TaskListComponent { None } + /// Move the cursor onto `task_uuid` if the rebuilt list still holds it. + /// + /// The cursor is a position among selectable rows, so a reload that adds or removes a + /// task above it silently slides it onto a different one. + pub fn select_task(&mut self, task_uuid: Uuid) { + let logical_index = self + .items + .iter() + .filter(|item| item.is_selectable()) + .position(|item| matches!(item, TaskListItemType::Task(task_item) if task_item.task.uuid == task_uuid)); + + if let Some(logical_index) = logical_index { + self.selected_index = logical_index; + self.update_list_state(); + } + } + pub fn get_selected_task(&self) -> Option<&task::Model> { // Find the currently selected task item if let Some(physical_index) = self.logical_to_physical_index(self.selected_index) { diff --git a/src/ui/core/actions.rs b/src/ui/core/actions.rs index a4c6749..5be63ce 100644 --- a/src/ui/core/actions.rs +++ b/src/ui/core/actions.rs @@ -12,6 +12,17 @@ pub enum SidebarSelection { Project(usize), // Index into projects vector } +/// Why a data load was scheduled; decides what happens to the cursor when the reload lands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoadKind { + /// First load of the session; resolves the configured `default_project`. + Initial, + /// User-triggered (navigation, task op, debug refresh). Cursor stays on its row. + User, + /// Background sync. Cursor re-anchors to the task it was on. + Background, +} + #[derive(Debug, Clone)] pub enum Action { // Navigation @@ -63,13 +74,8 @@ pub enum Action { RefreshLocalData, // Debug mode: refresh from local DB without API sync SyncCompleted(SyncStatus), SyncFailed(String), - InitialDataLoaded { - projects: Vec, - labels: Vec, - sections: Vec, - tasks: Vec, - }, DataLoaded { + kind: LoadKind, projects: Vec, labels: Vec, sections: Vec, diff --git a/src/ui/core/mod.rs b/src/ui/core/mod.rs index 14339d3..a8f6f47 100644 --- a/src/ui/core/mod.rs +++ b/src/ui/core/mod.rs @@ -32,7 +32,7 @@ pub mod operations; pub mod task_manager; // Re-export core types for easier access from other modules -pub use actions::{Action, DialogType, SidebarSelection}; +pub use actions::{Action, DialogType, LoadKind, SidebarSelection}; pub use component::Component; pub use event_handler::{EventHandler, EventType}; pub use task_manager::{TaskId, TaskKind, TaskManager, TaskResult}; diff --git a/src/ui/core/task_manager.rs b/src/ui/core/task_manager.rs index fdd2c60..8b3cb9c 100644 --- a/src/ui/core/task_manager.rs +++ b/src/ui/core/task_manager.rs @@ -1,4 +1,4 @@ -use super::actions::{Action, SidebarSelection}; +use super::actions::{Action, LoadKind, SidebarSelection}; use crate::sync::{SyncService, SyncStatus}; use std::collections::HashMap; use tokio::sync::mpsc; @@ -173,7 +173,7 @@ impl TaskManager { &mut self, sync_service: SyncService, sidebar_selection: SidebarSelection, - is_initial_load: bool, + kind: LoadKind, ) -> TaskId { let task_id = self.next_task_id; self.next_task_id += 1; @@ -215,22 +215,13 @@ impl TaskManager { tasks: tasks.clone(), }; - let action = if is_initial_load { - Action::InitialDataLoaded { - projects, - labels, - sections, - tasks, - } - } else { - Action::DataLoaded { - projects, - labels, - sections, - tasks, - } - }; - let _ = action_sender.send(action); + let _ = action_sender.send(Action::DataLoaded { + kind, + projects, + labels, + sections, + tasks, + }); Ok(result) } diff --git a/tests/ui/components/task_list_component.rs b/tests/ui/components/task_list_component.rs index e0cb081..5441f48 100644 --- a/tests/ui/components/task_list_component.rs +++ b/tests/ui/components/task_list_component.rs @@ -100,3 +100,41 @@ fn test_tasks_with_unknown_section_are_still_rendered() { vec!["in known section", "loose", "section not loaded", "section of another project",] ); } + +/// A background sync landing mid-navigation rebuilds the list. The cursor is a row number, +/// so a task arriving above it silently slides it onto a different task. +#[test] +fn test_select_task_re_anchors_the_cursor_after_a_reload() { + let project_uuid = Uuid::new_v4(); + let first = task_model(project_uuid, None, "first"); + let second = task_model(project_uuid, None, "second"); + let second_uuid = second.uuid; + let projects = vec![project_model(project_uuid)]; + + let mut component = TaskListComponent::new(); + component.update_data( + vec![first.clone(), second.clone()], + Vec::new(), + projects.clone(), + Vec::new(), + SidebarSelection::Project(0), + ); + component.selected_index = 1; + assert_eq!(component.get_selected_task().map(|task| task.uuid), Some(second_uuid)); + + component.update_data( + vec![task_model(project_uuid, None, "arrived"), first, second], + Vec::new(), + projects, + Vec::new(), + SidebarSelection::Project(0), + ); + assert_eq!( + component.get_selected_task().map(|task| task.content.as_str()), + Some("first"), + "the rebuild alone leaves the cursor on a row number" + ); + + component.select_task(second_uuid); + assert_eq!(component.get_selected_task().map(|task| task.uuid), Some(second_uuid)); +}