diff --git a/src/ui/app_component/actions.rs b/src/ui/app_component/actions.rs index 901864f..2ee5bbc 100644 --- a/src/ui/app_component/actions.rs +++ b/src/ui/app_component/actions.rs @@ -83,27 +83,21 @@ impl AppComponent { SidebarSelection::Today => "Today".to_string(), SidebarSelection::Tomorrow => "Tomorrow".to_string(), SidebarSelection::Upcoming => "Upcoming".to_string(), - SidebarSelection::Project(index) => { - if let Some(project) = self.state.projects.get(*index) { - format!("Project({}) '{}'", index, project.name) - } else { - format!("Project({}) [unknown]", index) - } - } - SidebarSelection::Label(index) => { - if let Some(label) = self.state.labels.get(*index) { - format!("Label({}) '{}'", index, label.name) - } else { - format!("Label({}) [unknown]", index) - } - } + SidebarSelection::Project(uuid) => match self.state.projects.iter().find(|p| p.uuid == *uuid) { + Some(project) => format!("Project {uuid} '{}'", project.name), + None => format!("Project {uuid} [unknown]"), + }, + SidebarSelection::Label(uuid) => match self.state.labels.iter().find(|l| l.uuid == *uuid) { + Some(label) => format!("Label {uuid} '{}'", label.name), + None => format!("Label {uuid} [unknown]"), + }, }; info!("Navigation: Sidebar selection changed to {}", selection_desc); // Once the user has picked a view, the initial sync no longer owns the // selection: completing it must not drag them back to `default_project`. self.is_initial_sync = false; - self.state.sidebar_selection = selection.clone(); + self.state.sidebar_selection = selection; // Reload data for the new selection self.schedule_data_load(LoadKind::User); info!("Navigation: Scheduled data fetch for new selection"); @@ -249,6 +243,11 @@ impl AppComponent { // `default_project` is only resolvable post-load. self.set_initial_sidebar_selection(); self.schedule_data_load(LoadKind::User); + } else if !self.state.selection_is_live() { + // The project or label being viewed was deleted from another client. + info!("Navigation: selection no longer exists, falling back to Today"); + self.state.sidebar_selection = SidebarSelection::Today; + self.schedule_data_load(LoadKind::User); } self.sync_component_data(); diff --git a/src/ui/app_component/keys.rs b/src/ui/app_component/keys.rs index 8d5a221..50ee63b 100644 --- a/src/ui/app_component/keys.rs +++ b/src/ui/app_component/keys.rs @@ -26,11 +26,11 @@ impl AppComponent { SidebarSelection::Today => Selected::View("Today"), SidebarSelection::Tomorrow => Selected::View("Tomorrow"), SidebarSelection::Upcoming => Selected::View("Upcoming"), - SidebarSelection::Project(index) => match self.state.projects.get(*index) { + SidebarSelection::Project(uuid) => match self.state.projects.iter().find(|p| p.uuid == *uuid) { Some(project) => Selected::Project(project), None => Selected::Missing("project"), }, - SidebarSelection::Label(index) => match self.state.labels.get(*index) { + SidebarSelection::Label(uuid) => match self.state.labels.iter().find(|l| l.uuid == *uuid) { Some(label) => Selected::Label(label), None => Selected::Missing("label"), }, diff --git a/src/ui/app_component/mod.rs b/src/ui/app_component/mod.rs index 728932d..68dcc61 100644 --- a/src/ui/app_component/mod.rs +++ b/src/ui/app_component/mod.rs @@ -131,37 +131,26 @@ impl AppComponent { /// Set initial sidebar selection based on config fn set_initial_sidebar_selection(&mut self) { - let selection = match self.config.ui.default_project.as_str() { - "inbox" => { - // Find inbox project - if let Some(inbox_index) = self.state.projects.iter().position(|p| p.is_inbox_project) { - SidebarSelection::Project(inbox_index) - } else { - SidebarSelection::Today - } - } + let default_project = self.config.ui.default_project.as_str(); + let selection = match default_project { "today" => SidebarSelection::Today, "tomorrow" => SidebarSelection::Tomorrow, "upcoming" => SidebarSelection::Upcoming, - project_id_or_name => { - // Try to find project by ID first (parse as UUID), then by name - if let Ok(uuid) = Uuid::parse_str(project_id_or_name) { - if let Some(project_index) = self.state.projects.iter().position(|p| p.uuid == uuid) { - SidebarSelection::Project(project_index) - } else if let Some(project_index) = - self.state.projects.iter().position(|p| p.name == project_id_or_name) - { - SidebarSelection::Project(project_index) - } else { - SidebarSelection::Today - } - } else if let Some(project_index) = - self.state.projects.iter().position(|p| p.name == project_id_or_name) - { - SidebarSelection::Project(project_index) - } else { - SidebarSelection::Today - } + // Anything else names a project: "inbox" for the backend's own inbox, otherwise a + // local UUID or a project name. An unknown name falls back to Today. + _ => { + let by_uuid = Uuid::parse_str(default_project).ok(); + self.state + .projects + .iter() + .find(|project| match by_uuid { + Some(uuid) => project.uuid == uuid, + None if default_project == "inbox" => project.is_inbox_project, + None => project.name == default_project, + }) + .map_or(SidebarSelection::Today, |project| { + SidebarSelection::Project(project.uuid) + }) } }; @@ -176,7 +165,7 @@ impl AppComponent { fn sync_component_data(&mut self) { // Update sidebar self.sidebar.update_data(self.state.projects.clone(), self.state.labels.clone()); - self.sidebar.selection = self.state.sidebar_selection.clone(); + self.sidebar.selection = self.state.sidebar_selection; self.sidebar.update_theme(self.config.theme.clone()); // Update task list @@ -187,7 +176,7 @@ impl AppComponent { self.state.sections.clone(), self.state.projects.clone(), self.state.labels.clone(), - self.state.sidebar_selection.clone(), + self.state.sidebar_selection, ); // Update dialog @@ -277,9 +266,9 @@ impl AppComponent { /// 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(), kind); + let _task_id = self + .task_manager + .spawn_data_load(self.sync_service.clone(), self.state.sidebar_selection, kind); } /// Process background actions from task manager diff --git a/src/ui/app_component/state.rs b/src/ui/app_component/state.rs index 195b436..b7dc4b0 100644 --- a/src/ui/app_component/state.rs +++ b/src/ui/app_component/state.rs @@ -31,4 +31,16 @@ impl AppState { self.sections = sections; self.tasks = tasks; } + + /// Whether the sidebar selection still names something that exists. + /// + /// A project or label deleted from another client is simply absent from the next sync, + /// which leaves the selection pointing at nothing. + pub fn selection_is_live(&self) -> bool { + match self.sidebar_selection { + SidebarSelection::Project(uuid) => self.projects.iter().any(|project| project.uuid == uuid), + SidebarSelection::Label(uuid) => self.labels.iter().any(|label| label.uuid == uuid), + SidebarSelection::Today | SidebarSelection::Tomorrow | SidebarSelection::Upcoming => true, + } + } } diff --git a/src/ui/components/sidebar_component.rs b/src/ui/components/sidebar_component.rs index d9f5582..7bf1b2a 100644 --- a/src/ui/components/sidebar_component.rs +++ b/src/ui/components/sidebar_component.rs @@ -104,30 +104,23 @@ impl SidebarComponent { }); // Add labels - for (index, label) in self.labels.iter().enumerate() { - self.items.push(SidebarItemType::Label { - label: label.clone(), - original_index: index, - }); + for label in &self.labels { + self.items.push(SidebarItemType::Label { label: label.clone() }); } // Add projects (sorted hierarchically), respecting fold states // Clone the data we need before mutating self.items - let sorted_projects: Vec<_> = self - .get_sorted_projects() - .into_iter() - .map(|(idx, proj)| (idx, proj.clone())) - .collect(); + let sorted_projects: Vec = self.get_sorted_projects().into_iter().cloned().collect(); // Build a map of which projects have children let mut has_children_map: HashMap = HashMap::new(); - for (_, project) in sorted_projects.iter() { + for project in sorted_projects.iter() { if let Some(parent_uuid) = project.parent_uuid { has_children_map.insert(parent_uuid, true); } } - for (i, (original_index, project)) in sorted_projects.iter().enumerate() { + for (i, project) in sorted_projects.iter().enumerate() { // Check if this project is a child of a collapsed parent if let Some(parent_uuid) = project.parent_uuid { let parent_key = parent_uuid.to_string(); @@ -141,13 +134,12 @@ impl SidebarComponent { let depth = if project.parent_uuid.is_some() { 1 } else { 0 }; let is_last_sibling = - i + 1 == sorted_projects.len() || sorted_projects[i + 1].1.parent_uuid != project.parent_uuid; + i + 1 == sorted_projects.len() || sorted_projects[i + 1].parent_uuid != project.parent_uuid; let has_children = has_children_map.get(&project.uuid).copied().unwrap_or(false); let is_expanded = self.folder_states.get(&project.uuid.to_string()).copied().unwrap_or(true); // Default to expanded self.items.push(SidebarItemType::Project { project: project.clone(), - original_index: *original_index, depth, is_last_sibling, has_children, @@ -216,11 +208,11 @@ impl SidebarComponent { self.scrollbar_helper.update_state(total_items, selection_index, None); } - fn get_sorted_projects(&self) -> Vec<(usize, &project::Model)> { - let mut projects_with_indices: Vec<(usize, &project::Model)> = self.projects.iter().enumerate().collect(); + fn get_sorted_projects(&self) -> Vec<&project::Model> { + let mut sorted: Vec<&project::Model> = self.projects.iter().collect(); // Sort projects hierarchically: root → parent → favorites → name - projects_with_indices.sort_by(|(_, a_project), (_, b_project)| { + sorted.sort_by(|a_project, b_project| { // First, sort by root project to keep tree structures together let a_root_project = self.get_root_project(a_project); let b_root_project = self.get_root_project(b_project); @@ -251,7 +243,7 @@ impl SidebarComponent { _ => a_project.name.cmp(&b_project.name), // Same favorite status, sort by name } }); - projects_with_indices + sorted } /// Get the root project ID (top-level parent) @@ -449,7 +441,7 @@ impl Component for SidebarComponent { fn update(&mut self, action: Action) -> Action { match action { Action::NavigateToSidebar(selection) => { - self.selection = selection.clone(); + self.selection = selection; self.update_list_state(); // Pass the action through to AppComponent for further processing Action::NavigateToSidebar(selection) diff --git a/src/ui/components/sidebar_item_component.rs b/src/ui/components/sidebar_item_component.rs index 2459748..9330ff3 100644 --- a/src/ui/components/sidebar_item_component.rs +++ b/src/ui/components/sidebar_item_component.rs @@ -21,14 +21,13 @@ pub enum SidebarItemType { /// Project item Project { project: project::Model, - original_index: usize, depth: usize, is_last_sibling: bool, has_children: bool, is_expanded: bool, }, /// Label item - Label { label: label::Model, original_index: usize }, + Label { label: label::Model }, } /// Trait for sidebar items that can be rendered and navigated @@ -84,17 +83,13 @@ impl SidebarItem for SidebarItemType { SidebarItemType::Project { project, - original_index, depth, is_last_sibling, has_children, is_expanded, .. } => { - let is_selected = matches!( - current_selection, - SidebarSelection::Project(idx) if idx == original_index - ); + let is_selected = matches!(current_selection, SidebarSelection::Project(uuid) if *uuid == project.uuid); let style = if is_selected { Style::default().fg(theme.accent).add_modifier(Modifier::BOLD) } else { @@ -134,12 +129,10 @@ impl SidebarItem for SidebarItemType { ListItem::new(Line::from(spans)) } - SidebarItemType::Label { - label, original_index, .. - } => { + SidebarItemType::Label { label, .. } => { let is_selected = matches!( current_selection, - SidebarSelection::Label(idx) if idx == original_index + SidebarSelection::Label(uuid) if *uuid == label.uuid ); let style = if is_selected { Style::default().fg(theme.accent).add_modifier(Modifier::BOLD) @@ -165,9 +158,9 @@ impl SidebarItem for SidebarItemType { fn get_selection(&self) -> Option { match self { - SidebarItemType::SpecialView { selection, .. } => Some(selection.clone()), - SidebarItemType::Project { original_index, .. } => Some(SidebarSelection::Project(*original_index)), - SidebarItemType::Label { original_index, .. } => Some(SidebarSelection::Label(*original_index)), + SidebarItemType::SpecialView { selection, .. } => Some(*selection), + SidebarItemType::Project { project, .. } => Some(SidebarSelection::Project(project.uuid)), + SidebarItemType::Label { label, .. } => Some(SidebarSelection::Label(label.uuid)), } } } diff --git a/src/ui/components/task_list_component.rs b/src/ui/components/task_list_component.rs index 5f349e2..3251d82 100644 --- a/src/ui/components/task_list_component.rs +++ b/src/ui/components/task_list_component.rs @@ -118,21 +118,13 @@ impl TaskListComponent { SidebarSelection::Today => self.build_today_items(), SidebarSelection::Tomorrow => self.build_tomorrow_items(), SidebarSelection::Upcoming => self.build_upcoming_items(), - SidebarSelection::Project(index) => { - if let Some(project) = self.projects.get(*index) { - let project_id = project.uuid; - self.build_project_items(&project_id); - } else { - self.build_simple_items(); - } + SidebarSelection::Project(uuid) => { + let uuid = *uuid; + self.build_project_items(&uuid); } - SidebarSelection::Label(index) => { - if let Some(label) = self.labels.get(*index) { - let label_id = label.uuid; - self.build_label_items(&label_id); - } else { - self.build_simple_items(); - } + SidebarSelection::Label(uuid) => { + let uuid = *uuid; + self.build_label_items(&uuid); } } } @@ -347,17 +339,6 @@ impl TaskListComponent { } } - /// Build simple items (no sectioning) - fn build_simple_items(&mut self) { - // SQL already provides proper ordering (completion status -> priority -> order_index) - let root_tasks: Vec = self.tasks.iter().filter(|t| t.parent_uuid.is_none()).cloned().collect(); - - // Add each root task and its children recursively - for task in root_tasks { - self.add_task_and_children_to_items(task, 0); - } - } - /// Recursively add a task and its children to the items list fn add_task_and_children_to_items(&mut self, task: task::Model, depth: usize) { // Calculate child count @@ -588,8 +569,8 @@ impl Component for TaskListComponent { } KeyCode::Char('a') => { // When viewing a specific project, preselect it as the default project - let default_project_uuid = match &self.sidebar_selection { - SidebarSelection::Project(index) => self.projects.get(*index).map(|p| p.uuid), + let default_project_uuid = match self.sidebar_selection { + SidebarSelection::Project(uuid) => Some(uuid), _ => None, }; Action::ShowDialog(DialogType::TaskCreation { default_project_uuid }) diff --git a/src/ui/core/actions.rs b/src/ui/core/actions.rs index 5be63ce..e7a6e0b 100644 --- a/src/ui/core/actions.rs +++ b/src/ui/core/actions.rs @@ -1,15 +1,19 @@ use crate::sync::SyncStatus; use uuid::Uuid; -/// Represents the currently selected item in the sidebar -#[derive(Debug, Clone, PartialEq, Default)] +/// Represents the currently selected item in the sidebar. +/// +/// Projects and labels are named by their local UUID rather than by a position in the loaded +/// vectors. A sync that adds, removes or reorders a project would otherwise slide the +/// selection onto a different one without anything noticing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SidebarSelection { #[default] Today, // Today view (special view) - Tomorrow, // Tomorrow view (special view) - Upcoming, // Upcoming view (tasks with future due dates) - Label(usize), // Index into labels vector - Project(usize), // Index into projects vector + Tomorrow, // Tomorrow view (special view) + Upcoming, // Upcoming view (tasks with future due dates) + Label(Uuid), + Project(Uuid), } /// Why a data load was scheduled; decides what happens to the cursor when the reload lands. diff --git a/src/ui/core/task_manager.rs b/src/ui/core/task_manager.rs index 8b3cb9c..68aa3cb 100644 --- a/src/ui/core/task_manager.rs +++ b/src/ui/core/task_manager.rs @@ -192,19 +192,11 @@ impl TaskManager { SidebarSelection::Today => sync_service.get_tasks_for_today().await.unwrap_or_default(), SidebarSelection::Tomorrow => sync_service.get_tasks_for_tomorrow().await.unwrap_or_default(), SidebarSelection::Upcoming => sync_service.get_tasks_for_upcoming().await.unwrap_or_default(), - SidebarSelection::Project(index) => { - if let Some(project) = projects.get(index) { - sync_service.get_tasks_for_project(&project.uuid).await.unwrap_or_default() - } else { - Vec::new() - } + SidebarSelection::Project(uuid) => { + sync_service.get_tasks_for_project(&uuid).await.unwrap_or_default() } - SidebarSelection::Label(index) => { - if let Some(label) = labels.get(index) { - sync_service.get_tasks_with_label(label.uuid).await.unwrap_or_default() - } else { - Vec::new() - } + SidebarSelection::Label(uuid) => { + sync_service.get_tasks_with_label(uuid).await.unwrap_or_default() } }; diff --git a/tests/ui/app_component/state.rs b/tests/ui/app_component/state.rs index bdec26c..9d2bf16 100644 --- a/tests/ui/app_component/state.rs +++ b/tests/ui/app_component/state.rs @@ -9,3 +9,31 @@ fn test_app_state_default() { let state = AppState::default(); assert!(!state.loading, "Default AppState should not be loading"); } + +/// A project deleted from another client is simply absent from the next sync, which leaves +/// the sidebar selection naming something that no longer exists. +#[test] +fn selection_is_live_only_while_its_project_exists() { + use terminalist::entities::project; + use terminalist::ui::core::SidebarSelection; + use uuid::Uuid; + + let uuid = Uuid::new_v4(); + let mut state = AppState::default(); + assert!(state.selection_is_live(), "Today always exists"); + + state.sidebar_selection = SidebarSelection::Project(uuid); + assert!(!state.selection_is_live()); + + state.projects = vec![project::Model { + uuid, + backend_uuid: Uuid::nil(), + remote_id: "p1".to_string(), + name: "Project".to_string(), + is_favorite: false, + is_inbox_project: false, + order_index: 0, + parent_uuid: None, + }]; + assert!(state.selection_is_live()); +} diff --git a/tests/ui/components.rs b/tests/ui/components.rs index 214a619..7e0c9b5 100644 --- a/tests/ui/components.rs +++ b/tests/ui/components.rs @@ -6,3 +6,6 @@ mod toast; #[path = "components/task_list_component.rs"] mod task_list_component; + +#[path = "components/sidebar_component.rs"] +mod sidebar_component; diff --git a/tests/ui/components/sidebar_component.rs b/tests/ui/components/sidebar_component.rs new file mode 100644 index 0000000..8f3f51b --- /dev/null +++ b/tests/ui/components/sidebar_component.rs @@ -0,0 +1,41 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use terminalist::entities::project; +use terminalist::ui::components::SidebarComponent; +use terminalist::ui::core::{Action, Component, SidebarSelection}; +use uuid::Uuid; + +fn project_model(name: &str) -> project::Model { + project::Model { + uuid: Uuid::new_v4(), + backend_uuid: Uuid::nil(), + remote_id: name.to_string(), + name: name.to_string(), + is_favorite: false, + is_inbox_project: false, + order_index: 0, + parent_uuid: None, + } +} + +/// The sidebar re-derives its highlighted row from the selection on every reload. A sync +/// returning the projects in another order used to slide the highlight onto a neighbour. +#[test] +fn selection_follows_its_project_across_a_reorder() { + let alpha = project_model("Alpha"); + let beta = project_model("Beta"); + + let mut sidebar = SidebarComponent::new(); + sidebar.update_data(vec![alpha.clone(), beta.clone()], Vec::new()); + sidebar.selection = SidebarSelection::Project(beta.uuid); + + // A sync hands back the same projects in a different order. + sidebar.update_data(vec![beta.clone(), alpha.clone()], Vec::new()); + + // Sorted display order is Today, Tomorrow, Upcoming, Alpha, Beta, so the item before the + // cursor is Alpha only if the cursor is still on Beta. + let previous = sidebar.handle_key_events(KeyEvent::new(KeyCode::Char('K'), KeyModifiers::SHIFT)); + assert!( + matches!(previous, Action::NavigateToSidebar(SidebarSelection::Project(uuid)) if uuid == alpha.uuid), + "expected the cursor to still be on Beta, got {previous:?}" + ); +} diff --git a/tests/ui/components/task_list_component.rs b/tests/ui/components/task_list_component.rs index 5441f48..b045c1a 100644 --- a/tests/ui/components/task_list_component.rs +++ b/tests/ui/components/task_list_component.rs @@ -90,7 +90,7 @@ fn test_tasks_with_unknown_section_are_still_rendered() { ], vec![project_model(project_uuid)], Vec::new(), - SidebarSelection::Project(0), + SidebarSelection::Project(project_uuid), ); let mut rendered = rendered_task_contents(&component); @@ -117,7 +117,7 @@ fn test_select_task_re_anchors_the_cursor_after_a_reload() { Vec::new(), projects.clone(), Vec::new(), - SidebarSelection::Project(0), + SidebarSelection::Project(project_uuid), ); component.selected_index = 1; assert_eq!(component.get_selected_task().map(|task| task.uuid), Some(second_uuid)); @@ -127,7 +127,7 @@ fn test_select_task_re_anchors_the_cursor_after_a_reload() { Vec::new(), projects, Vec::new(), - SidebarSelection::Project(0), + SidebarSelection::Project(project_uuid), ); assert_eq!( component.get_selected_task().map(|task| task.content.as_str()),