From 029ab58d29e7e57139c27faffbffe13514be1cbf Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Sat, 5 Sep 2026 17:42:00 +0200 Subject: [PATCH] feat(ui): keep the cursor on its task when a background sync lands A sync completing mid-navigation rebuilds the task list, and the cursor is a position among selectable rows, so a task arriving above it slides the cursor onto a different task under the user's hands. Carry why the load was scheduled alongside it. A background sync re-anchors the cursor to the task it was on; a load the user asked for leaves it on its row, so marking an overdue task 'due today' does not drag the cursor into the Today section behind it. The reason has to travel with the load rather than sit in a field, since the origins overlap: a sync landing while the user navigates would otherwise consume the other's value. LoadKind replaces spawn_data_load's is_initial_load bool, which was already the same idea with one bit, and the InitialDataLoaded variant folds back into DataLoaded as a third reason. --- src/ui/app_component/actions.rs | 58 +++++++++------------- src/ui/app_component/mod.rs | 27 ++++------ src/ui/components/task_list_component.rs | 17 +++++++ src/ui/core/actions.rs | 18 ++++--- src/ui/core/mod.rs | 2 +- src/ui/core/task_manager.rs | 27 ++++------ tests/ui/components/task_list_component.rs | 38 ++++++++++++++ 7 files changed, 110 insertions(+), 77 deletions(-) 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)); +}