From 6adc22d6653233787c8c893bd096af73fc5a4dbb Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 7 Jul 2026 19:17:28 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20redesign=20Todos=20tab=20=E2=80=94=20gr?= =?UTF-8?q?ouping,=20collapsible=20headers,=20cleaned=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the approved mockup from the UX debate report: - new DisplayRow::TodoHeader variant: collapsible group headers, toggled by Enter/Space, tracked in HubState.todo_group_collapsed - [g] cycles grouping: status (default) -> project -> repo -> none - [s] cycles which statuses are included before grouping: active (open+in-progress, default) -> open -> done -> all - table: dropped SRC/EST (near-always empty), code-scan todos show a dim 'no status icon' marker instead of a separate source column, project name colored by the project's own color (was DarkGray, wasting the most useful visual identifier in the tool), age now tracks updated_at not created_at ('what's moving' beats 'what's old') - right panel: added Repo/Difficulty/Estimate, previously only visible in the table despite the panel having more room for them - block title shows live counts + current view/group/code-scan state Simplified vs. the literal ASCII mockup: [s] filters the underlying set before grouping, so e.g. the default 'active' view omits Done/Cancelled groups entirely rather than always showing them collapsed - a simpler, more internally consistent model. Flagged for foksiu to request the exact mockup behavior if he wants it. Verified via pyte: group/status-view cycling, collapse/expand (arrow flips and child rows appear/disappear), right panel on header vs. todo row. --- src/hub/keys_dispatch.rs | 30 +++++++++++- src/hub/mod.rs | 4 ++ src/hub/modals_spawn.rs | 5 +- src/hub/render.rs | 18 +++++-- src/hub/right_panel.rs | 9 ++++ src/hub/state.rs | 102 ++++++++++++++++++++++++++++++++++++--- src/hub/state_misc.rs | 75 +++++++++++++++++++++++++++- src/hub/state_rows.rs | 5 ++ src/hub/tabs_tables.rs | 94 +++++++++++++++++++++--------------- src/todos/mod.rs | 7 --- 10 files changed, 288 insertions(+), 61 deletions(-) diff --git a/src/hub/keys_dispatch.rs b/src/hub/keys_dispatch.rs index 18a34fe..997795e 100644 --- a/src/hub/keys_dispatch.rs +++ b/src/hub/keys_dispatch.rs @@ -80,6 +80,9 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option state.toggle_select(), @@ -151,7 +154,12 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option {} + Tab::Todos => { + if let Some(key) = state.cur_todo_header_key() { + toggle_todo_group(state, key); + } + } + Tab::Overview | Tab::Stats | Tab::Activity => {} Tab::HubSettings => { match state.hub_settings_cur { 0 => { @@ -249,6 +257,18 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option { + state.todo_status_view = state.todo_status_view.next(); + state.rebuild_display(); + } + KeyCode::Char('g') if state.tab == Tab::Todos => { + state.todo_group_by = state.todo_group_by.next(); + state.rebuild_display(); + } + KeyCode::Char('c') if state.tab == Tab::Todos => { + state.show_code_todos = !state.show_code_todos; + state.rebuild_display(); + } KeyCode::Char('s') => { if !state.action_targets().is_empty() { state.modal = Modal::Select { @@ -565,3 +585,11 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option { entries.push(entry("Cycle todo status", "Space", kc(KeyCode::Char(' ')))); + entries.push(entry("Expand/collapse group","↵", kc(KeyCode::Enter))); entries.push(entry("Edit todo", "e", k('e'))); entries.push(entry("Delete todo", "Del", kc(KeyCode::Delete))); entries.push(entry("New todo", "+", k('+'))); - entries.push(entry("Toggle code todos", "X", k('X'))); + entries.push(entry("Cycle status view", "s", k('s'))); + entries.push(entry("Cycle grouping", "g", k('g'))); + entries.push(entry("Toggle code todos", "c", k('c'))); entries.push(entry("Scan repos for TODOs","T", k('T'))); } Tab::PRs => { diff --git a/src/hub/render.rs b/src/hub/render.rs index b8a1ff1..e4e83c9 100644 --- a/src/hub/render.rs +++ b/src/hub/render.rs @@ -60,10 +60,13 @@ pub static HELP_LINES: &[&str] = &[ " p Project menu (create / assign / rename / delete)", " + New todo", " e Edit todo (Todos tab)", - " Space (Todos tab) Cycle todo status", + " Space (Todos tab) Cycle todo status, or expand/collapse a group", + " Enter (Todos tab) Expand/collapse a group header", + " s (Todos tab) Cycle status view: active → open → done → all", + " g (Todos tab) Cycle grouping: status → project → repo → none", + " c / X (Todos tab) Toggle code-scan todos", " D / Del Delete todo or project", " T Scan repos for TODO/FIXME/HACK → import to Todos", - " X (Todos tab) Toggle code-scan todos", "", "── Help ──────────────────────────────────────", " ? Show this help", @@ -165,7 +168,16 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) { .constraints([Constraint::Percentage(73), Constraint::Percentage(27)]) .split(outer[1]); - let left_blk = Block::default().borders(Borders::ALL); + let mut left_blk = Block::default().borders(Borders::ALL); + if state.tab == Tab::Todos { + let in_progress = state.todos.iter().filter(|t| t.status == crate::todos::TodoStatus::InProgress).count(); + let open = state.todos.iter().filter(|t| t.status == crate::todos::TodoStatus::Open).count(); + left_blk = left_blk.title(format!( + " todos · {} in progress · {} open · [s]tatus:{} · [g]roup:{} · [c]ode-scan:{} ", + in_progress, open, state.todo_status_view.label(), state.todo_group_by.label(), + if state.show_code_todos { "on" } else { "off" }, + )); + } let left_inner = left_blk.inner(cols[0]); f.render_widget(left_blk, cols[0]); state.visible_rows = left_inner.height.saturating_sub(1) as usize; diff --git a/src/hub/right_panel.rs b/src/hub/right_panel.rs index be4e065..6d3b6ae 100644 --- a/src/hub/right_panel.rs +++ b/src/hub/right_panel.rs @@ -367,6 +367,13 @@ fn render_right_todo(f: &mut Frame, state: &HubState, area: Rect) { lines.push(r_kv_color("Status", &format!("{} {}", t.status.icon(), t.status.label()), t.status.color())); lines.push(r_kv("Project", t.project.as_deref().unwrap_or("— not assigned"), Color::DarkGray)); + lines.push(r_kv("Repo", t.repo.as_deref().unwrap_or("— not assigned"), Color::DarkGray)); + let diff_str = match t.difficulty { + Some(d) => "★".repeat(d.min(5) as usize) + &"☆".repeat(5usize.saturating_sub(d as usize)), + None => "—".into(), + }; + lines.push(r_kv("Difficulty", &diff_str, Color::Yellow)); + lines.push(r_kv("Estimate", t.estimate.as_deref().unwrap_or("—"), Color::DarkGray)); lines.push(Line::from("")); if !t.description.is_empty() { @@ -383,6 +390,8 @@ fn render_right_todo(f: &mut Frame, state: &HubState, area: Rect) { lines.push(r_section("Timeline")); lines.push(r_kv("Created", &format_age_pub(t.created_at), Color::DarkGray)); lines.push(r_kv("Updated", &format_age_pub(t.updated_at), Color::DarkGray)); + } else if state.cur_todo_header_key().is_some() { + lines.push(r_dim(" Group header — [↵/Space] expand/collapse".into())); } else { lines.push(r_dim(" ← select a todo".into())); } diff --git a/src/hub/state.rs b/src/hub/state.rs index 11dfd3b..eac2944 100644 --- a/src/hub/state.rs +++ b/src/hub/state.rs @@ -13,7 +13,8 @@ pub use super::state_rows::*; use crate::projects::{self, Project}; use crate::providers::{IssueDetail, PrDetail}; use crate::status::{count_tracked_files, repo_recent_commits, RepoSummary}; -use crate::todos::{Todo, TodoSource}; +use crate::status::format_age_pub; +use crate::todos::{Todo, TodoSource, TodoStatus}; use ratatui::style::Color; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -70,6 +71,13 @@ pub struct HubState { pub editor: String, pub clipboard_cmd: String, pub show_code_todos:bool, + pub todo_status_view: TodoStatusView, + pub todo_group_by: TodoGroupBy, + /// Group keys currently collapsed on the Todos tab (e.g. "Done", + /// "Cancelled", a project name, a repo name — whatever the current + /// `todo_group_by` mode keys groups by). Toggled by Enter/Space on a + /// header row; not persisted across restarts. + pub todo_group_collapsed: HashSet, pub debug_mode: bool, pub debug_log: Vec, /// Live receiver from the background scan thread; None when scan is complete. @@ -178,12 +186,7 @@ impl HubState { d.push(DisplayRow::Project { proj_idx: pi }); } } - Tab::Todos => { - for (ti, t) in self.todos.iter().enumerate() { - if !self.show_code_todos && t.source == TodoSource::CodeScan { continue; } - d.push(DisplayRow::Todo { todo_idx: ti }); - } - } + Tab::Todos => self.build_todos_display(&mut d), Tab::PRs => { for i in 0..self.prs.len() { d.push(DisplayRow::Pr { pr_idx: i }); @@ -212,6 +215,83 @@ impl HubState { self.display = d; } + /// Builds the Todos tab's display rows: status-view-filters the todo + /// list, then groups what survives per `todo_group_by` — `None` just + /// sorts by recency, the other modes interleave a collapsible + /// `TodoHeader` before each group's rows (omitted entirely when that + /// group's key is in `todo_group_collapsed`). + fn build_todos_display(&self, d: &mut Vec) { + let filtered: Vec = (0..self.todos.len()) + .filter(|&ti| { + let t = &self.todos[ti]; + if !self.show_code_todos && t.source == TodoSource::CodeScan { return false; } + self.todo_status_view.allows(&t.status) + }) + .collect(); + + let by_recency = |a: &usize, b: &usize| self.todos[*b].updated_at.cmp(&self.todos[*a].updated_at); + + match self.todo_group_by { + TodoGroupBy::None => { + let mut sorted = filtered; + sorted.sort_by(by_recency); + for ti in sorted { d.push(DisplayRow::Todo { todo_idx: ti }); } + } + TodoGroupBy::Status => { + for status in [TodoStatus::InProgress, TodoStatus::Open, TodoStatus::Done, TodoStatus::Cancelled] { + let mut group: Vec = filtered.iter().copied() + .filter(|&ti| self.todos[ti].status == status).collect(); + if group.is_empty() { continue; } + group.sort_by(by_recency); + let key = status.label().to_string(); + let extra = if status == TodoStatus::Done { + format!(" · ostatni {}", format_age_pub(self.todos[group[0]].updated_at)) + } else { + String::new() + }; + self.push_todo_group(d, key.clone(), status.label().to_string(), group, extra); + } + } + TodoGroupBy::Project => { + let mut keys: Vec = filtered.iter() + .map(|&ti| self.todos[ti].project.clone().unwrap_or_else(|| "— brak projektu —".into())) + .collect(); + keys.sort(); keys.dedup(); + for key in keys { + let mut group: Vec = filtered.iter().copied() + .filter(|&ti| self.todos[ti].project.as_deref().unwrap_or("— brak projektu —") == key) + .collect(); + group.sort_by(by_recency); + self.push_todo_group(d, key.clone(), key, group, String::new()); + } + } + TodoGroupBy::Repo => { + let mut keys: Vec = filtered.iter() + .map(|&ti| self.todos[ti].repo.clone().unwrap_or_else(|| "— brak repo —".into())) + .collect(); + keys.sort(); keys.dedup(); + for key in keys { + let mut group: Vec = filtered.iter().copied() + .filter(|&ti| self.todos[ti].repo.as_deref().unwrap_or("— brak repo —") == key) + .collect(); + group.sort_by(by_recency); + self.push_todo_group(d, key.clone(), key, group, String::new()); + } + } + } + } + + /// Pushes one group's header row, then its todo rows unless the group + /// is collapsed. + fn push_todo_group(&self, d: &mut Vec, key: String, label: String, group: Vec, extra: String) { + let count = group.len(); + let collapsed = self.todo_group_collapsed.contains(&key); + d.push(DisplayRow::TodoHeader { key, label, count, extra }); + if !collapsed { + for ti in group { d.push(DisplayRow::Todo { todo_idx: ti }); } + } + } + pub fn rebuild_project_map(&mut self) { self.project_map = projects::index_by_repo(&self.projects); } @@ -237,6 +317,14 @@ impl HubState { _ => None, } } + /// The group key of the `TodoHeader` row under the cursor, if any — + /// used to toggle that group's collapsed state on Enter/Space. + pub fn cur_todo_header_key(&self) -> Option { + match self.display.get(self.cur_di()?)? { + DisplayRow::TodoHeader { key, .. } => Some(key.clone()), + _ => None, + } + } pub fn project_repos(&self, pi: usize) -> Vec { self.projects[pi].repos.iter() diff --git a/src/hub/state_misc.rs b/src/hub/state_misc.rs index 7e503ff..ae7bf7a 100644 --- a/src/hub/state_misc.rs +++ b/src/hub/state_misc.rs @@ -1,6 +1,77 @@ //! Small standalone types that don't fit the other state groupings: the -//! repo-list sort order, and the user-defined repo template format -//! (persisted to `templates.toml`). +//! repo-list sort order, the Todos tab's status-view/grouping modes, and +//! the user-defined repo template format (persisted to `templates.toml`). + +/// Which todo statuses the Todos tab currently shows, cycled with `[s]`. +/// Filters the underlying set *before* grouping — e.g. in `Active`, a +/// `Status` grouping only ever produces "In Progress"/"Open" headers. +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum TodoStatusView { + #[default] + Active, + OpenOnly, + DoneOnly, + All, +} + +impl TodoStatusView { + pub fn label(self) -> &'static str { + match self { + Self::Active => "active", + Self::OpenOnly => "open", + Self::DoneOnly => "done", + Self::All => "all", + } + } + pub fn next(self) -> Self { + match self { + Self::Active => Self::OpenOnly, + Self::OpenOnly => Self::DoneOnly, + Self::DoneOnly => Self::All, + Self::All => Self::Active, + } + } + /// Whether `status` survives this view's filter. + pub fn allows(self, status: &crate::todos::TodoStatus) -> bool { + use crate::todos::TodoStatus as S; + match self { + Self::Active => matches!(status, S::Open | S::InProgress), + Self::OpenOnly => matches!(status, S::Open), + Self::DoneOnly => matches!(status, S::Done), + Self::All => true, + } + } +} + +/// How the Todos tab groups its (already status-view-filtered) list, +/// cycled with `[g]`. +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum TodoGroupBy { + #[default] + Status, + Project, + Repo, + None, +} + +impl TodoGroupBy { + pub fn label(self) -> &'static str { + match self { + Self::Status => "status", + Self::Project => "project", + Self::Repo => "repo", + Self::None => "none", + } + } + pub fn next(self) -> Self { + match self { + Self::Status => Self::Project, + Self::Project => Self::Repo, + Self::Repo => Self::None, + Self::None => Self::Status, + } + } +} /// Repo-list ordering for the Repos tab, cycled with a single key. Pinned /// repos always sort to the top regardless of this setting (see diff --git a/src/hub/state_rows.rs b/src/hub/state_rows.rs index 1e8ef56..5b604eb 100644 --- a/src/hub/state_rows.rs +++ b/src/hub/state_rows.rs @@ -11,6 +11,11 @@ pub enum DisplayRow { Repo { repo_idx: usize }, Project { proj_idx: usize }, Todo { todo_idx: usize }, + /// A collapsible group header on the Todos tab. `key` identifies the + /// group for collapse-state lookups (`HubState::todo_group_collapsed`); + /// `label`/`count`/`extra` are purely for display (`extra` carries e.g. + /// "· last 3h ago" on the Done group). + TodoHeader { key: String, label: String, count: usize, extra: String }, Pr { pr_idx: usize }, Issue { issue_idx: usize }, } diff --git a/src/hub/tabs_tables.rs b/src/hub/tabs_tables.rs index 5660019..16febfc 100644 --- a/src/hub/tabs_tables.rs +++ b/src/hub/tabs_tables.rs @@ -163,58 +163,72 @@ pub fn render_todos_table(f: &mut Frame, state: &HubState, area: Rect) { let avail = area.height.saturating_sub(1) as usize; let cur_di = state.cur_di().unwrap_or(usize::MAX); - let header = Row::new(vec![" SRC", " STATUS", "TITLE", "REPO", "PROJECT", "EST", "DIFF", "CREATED"]) + let header = Row::new(vec![" ", "TITLE", "REPO", "PROJECT", "DIFF", "AGE"]) .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); let rows: Vec = state.display.iter().enumerate() .skip(state.offset).take(avail) .filter_map(|(di, row)| { - let DisplayRow::Todo { todo_idx: ti } = row else { return None; }; - let t = &state.todos[*ti]; let is_cur = di == cur_di; - let status_color = t.status.color(); - let proj_name = t.project.as_deref().unwrap_or("—"); - let repo_name = t.repo.as_deref().unwrap_or("—"); - let created = format_age_pub(t.created_at); - let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::(); - let diff_str = match t.difficulty { - Some(d) => format!("{}{}", "★".repeat(d.min(5) as usize), "☆".repeat(5usize.saturating_sub(d as usize))), - None => "—".into(), - }; - let title_short: String = if t.source == TodoSource::CodeScan { - let raw = &t.title; - let trimmed = if let Some(end) = raw.find(']') { raw[end+1..].trim() } else { raw.as_str() }; - trimmed.chars().take(55).collect() - } else { - t.title.chars().take(55).collect() - }; - let src_icon = t.source.icon(); - let src_col = match t.source { TodoSource::Manual => Color::Cyan, TodoSource::CodeScan => Color::DarkGray }; - let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() }; - Some(Row::new(vec![ - Cell::from(format!(" {}", src_icon)).style(Style::default().fg(src_col)), - Cell::from(format!(" {} {}", t.status.icon(), t.status.label())) - .style(Style::default().fg(status_color)), - Cell::from(title_short), - Cell::from(repo_name).style(Style::default().fg(Color::DarkGray)), - Cell::from(proj_name).style(Style::default().fg(Color::DarkGray)), - Cell::from(est_str).style(Style::default().fg(Color::DarkGray)), - Cell::from(diff_str).style(Style::default().fg(Color::Yellow)), - Cell::from(created).style(Style::default().fg(Color::DarkGray)), - ]).style(row_sty)) + let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() }; + match row { + DisplayRow::TodoHeader { label, count, extra, key } => { + let arrow = if state.todo_group_collapsed.contains(key) { "▸" } else { "▾" }; + let text = format!("{arrow} {} ── {}{}", label.to_uppercase(), count, extra); + Some(Row::new(vec![ + Cell::from(""), + Cell::from(Span::styled(text, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))), + Cell::from(""), Cell::from(""), Cell::from(""), Cell::from(""), + ]).style(row_sty)) + } + DisplayRow::Todo { todo_idx: ti } => { + let t = &state.todos[*ti]; + let is_code_scan = t.source == TodoSource::CodeScan; + let (icon, icon_col) = if is_code_scan { + ("⌁", Color::DarkGray) + } else { + (t.status.icon(), t.status.color()) + }; + let repo_name = t.repo.as_deref().unwrap_or("—"); + let proj_name = t.project.as_deref().unwrap_or("—"); + let proj_col = t.project.as_deref() + .and_then(|name| state.projects.iter().find(|p| p.name == name)) + .map(|p| { let (r, g, b) = crate::projects::hex_to_rgb(&p.color); Color::Rgb(r, g, b) }) + .unwrap_or(Color::DarkGray); + let diff_str = match t.difficulty { + Some(d) => "★".repeat(d.min(5) as usize), + None => "—".into(), + }; + let age = format_age_pub(t.updated_at); + let title_short: String = if is_code_scan { + let raw = &t.title; + let trimmed = if let Some(end) = raw.find(']') { raw[end+1..].trim() } else { raw.as_str() }; + trimmed.chars().take(55).collect() + } else { + t.title.chars().take(55).collect() + }; + Some(Row::new(vec![ + Cell::from(icon).style(Style::default().fg(icon_col)), + Cell::from(title_short), + Cell::from(repo_name).style(Style::default().fg(Color::DarkGray)), + Cell::from(proj_name).style(Style::default().fg(proj_col)), + Cell::from(diff_str).style(Style::default().fg(Color::Yellow)), + Cell::from(age).style(Style::default().fg(age_color(t.updated_at))), + ]).style(row_sty)) + } + _ => None, + } }) .collect(); f.render_widget( Table::new(rows, [ - Constraint::Length(4), - Constraint::Length(14), + Constraint::Length(2), Constraint::Fill(1), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(10), - Constraint::Length(7), - Constraint::Length(10), + Constraint::Length(14), + Constraint::Length(14), + Constraint::Length(6), + Constraint::Length(9), ]).header(header), area, ); diff --git a/src/todos/mod.rs b/src/todos/mod.rs index b7d50c9..e318d2c 100644 --- a/src/todos/mod.rs +++ b/src/todos/mod.rs @@ -70,13 +70,6 @@ pub enum TodoSource { CodeScan, } -impl TodoSource { - /// One-character glyph shown next to a todo's title to mark its origin. - pub fn icon(&self) -> &'static str { - match self { Self::Manual => "✎", Self::CodeScan => "⌂" } - } -} - /// One todo item. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Todo {