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
29 changes: 14 additions & 15 deletions src/ui/app_component/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions src/ui/app_component/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
Expand Down
55 changes: 22 additions & 33 deletions src/ui/app_component/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
};

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/ui/app_component/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}
30 changes: 11 additions & 19 deletions src/ui/components/sidebar_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<project::Model> = self.get_sorted_projects().into_iter().cloned().collect();

// Build a map of which projects have children
let mut has_children_map: HashMap<Uuid, bool> = 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();
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 7 additions & 14 deletions src/ui/components/sidebar_item_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -165,9 +158,9 @@ impl SidebarItem for SidebarItemType {

fn get_selection(&self) -> Option<SidebarSelection> {
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)),
}
}
}
35 changes: 8 additions & 27 deletions src/ui/components/task_list_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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<task::Model> = 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
Expand Down Expand Up @@ -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 })
Expand Down
16 changes: 10 additions & 6 deletions src/ui/core/actions.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading