Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 23 additions & 35 deletions src/ui/app_component/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) => {
Expand All @@ -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
Expand Down
27 changes: 10 additions & 17 deletions src/ui/app_component/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ 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,
operations::{Due, Operation},
task_manager::{TaskId, TaskManager},
Component,
};
use crate::ui::core::{LoadKind, SidebarSelection};
use crossterm::event::KeyEvent;
use log::info;
use ratatui::{
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/ui/components/task_list_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 12 additions & 6 deletions src/ui/core/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,13 +74,8 @@ pub enum Action {
RefreshLocalData, // Debug mode: refresh from local DB without API sync
SyncCompleted(SyncStatus),
SyncFailed(String),
InitialDataLoaded {
projects: Vec<crate::entities::project::Model>,
labels: Vec<crate::entities::label::Model>,
sections: Vec<crate::entities::section::Model>,
tasks: Vec<crate::entities::task::Model>,
},
DataLoaded {
kind: LoadKind,
projects: Vec<crate::entities::project::Model>,
labels: Vec<crate::entities::label::Model>,
sections: Vec<crate::entities::section::Model>,
Expand Down
2 changes: 1 addition & 1 deletion src/ui/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
27 changes: 9 additions & 18 deletions src/ui/core/task_manager.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
}
Expand Down
38 changes: 38 additions & 0 deletions tests/ui/components/task_list_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}