feat: redesign Todos tab — grouping, collapsible headers, cleaned columns
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.
This commit is contained in:
1 parent
3eb922559c
commit
6adc22d665
10 files changed
+288
-61
No files matched your search
@@ -80,6 +80,9 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
|
|||||||
state.todos[ti].status = state.todos[ti].status.cycle();
|
state.todos[ti].status = state.todos[ti].status.cycle();
|
||||||
state.todos[ti].touch();
|
state.todos[ti].touch();
|
||||||
let _ = todos::save(&state.todos);
|
let _ = todos::save(&state.todos);
|
||||||
|
state.rebuild_display();
|
||||||
|
} else if let Some(key) = state.cur_todo_header_key() {
|
||||||
|
toggle_todo_group(state, key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => state.toggle_select(),
|
_ => state.toggle_select(),
|
||||||
@@ -151,7 +154,12 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Tab::Todos | Tab::Overview | Tab::Stats | Tab::Activity => {}
|
Tab::Todos => {
|
||||||
|
if let Some(key) = state.cur_todo_header_key() {
|
||||||
|
toggle_todo_group(state, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tab::Overview | Tab::Stats | Tab::Activity => {}
|
||||||
Tab::HubSettings => {
|
Tab::HubSettings => {
|
||||||
match state.hub_settings_cur {
|
match state.hub_settings_cur {
|
||||||
0 => {
|
0 => {
|
||||||
@@ -249,6 +257,18 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('s') if state.tab == Tab::Todos => {
|
||||||
|
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') => {
|
KeyCode::Char('s') => {
|
||||||
if !state.action_targets().is_empty() {
|
if !state.action_targets().is_empty() {
|
||||||
state.modal = Modal::Select {
|
state.modal = Modal::Select {
|
||||||
@@ -565,3 +585,11 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collapses/expands one Todos-tab group header.
|
||||||
|
fn toggle_todo_group(state: &mut HubState, key: String) {
|
||||||
|
if !state.todo_group_collapsed.remove(&key) {
|
||||||
|
state.todo_group_collapsed.insert(key);
|
||||||
|
}
|
||||||
|
state.rebuild_display();
|
||||||
|
}
|
||||||
@@ -122,6 +122,10 @@ pub async fn run_overview_tui(
|
|||||||
editor: cfg.editor.clone(),
|
editor: cfg.editor.clone(),
|
||||||
clipboard_cmd: cfg.clipboard_cmd.clone(),
|
clipboard_cmd: cfg.clipboard_cmd.clone(),
|
||||||
show_code_todos: false,
|
show_code_todos: false,
|
||||||
|
todo_status_view: Default::default(),
|
||||||
|
todo_group_by: Default::default(),
|
||||||
|
// Done/Cancelled start collapsed — active work stays front and center.
|
||||||
|
todo_group_collapsed: ["Done", "Cancelled"].into_iter().map(String::from).collect(),
|
||||||
debug_mode,
|
debug_mode,
|
||||||
debug_log,
|
debug_log,
|
||||||
scan_rx,
|
scan_rx,
|
||||||
|
|||||||
@@ -618,10 +618,13 @@ pub fn open_action_menu(state: &mut HubState) {
|
|||||||
match state.tab {
|
match state.tab {
|
||||||
Tab::Todos => {
|
Tab::Todos => {
|
||||||
entries.push(entry("Cycle todo status", "Space", kc(KeyCode::Char(' '))));
|
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("Edit todo", "e", k('e')));
|
||||||
entries.push(entry("Delete todo", "Del", kc(KeyCode::Delete)));
|
entries.push(entry("Delete todo", "Del", kc(KeyCode::Delete)));
|
||||||
entries.push(entry("New todo", "+", k('+')));
|
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')));
|
entries.push(entry("Scan repos for TODOs","T", k('T')));
|
||||||
}
|
}
|
||||||
Tab::PRs => {
|
Tab::PRs => {
|
||||||
|
|||||||
+15
-3
@@ -60,10 +60,13 @@ pub static HELP_LINES: &[&str] = &[
|
|||||||
" p Project menu (create / assign / rename / delete)",
|
" p Project menu (create / assign / rename / delete)",
|
||||||
" + New todo",
|
" + New todo",
|
||||||
" e Edit todo (Todos tab)",
|
" 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",
|
" D / Del Delete todo or project",
|
||||||
" T Scan repos for TODO/FIXME/HACK → import to Todos",
|
" T Scan repos for TODO/FIXME/HACK → import to Todos",
|
||||||
" X (Todos tab) Toggle code-scan todos",
|
|
||||||
"",
|
"",
|
||||||
"── Help ──────────────────────────────────────",
|
"── Help ──────────────────────────────────────",
|
||||||
" ? Show this 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)])
|
.constraints([Constraint::Percentage(73), Constraint::Percentage(27)])
|
||||||
.split(outer[1]);
|
.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]);
|
let left_inner = left_blk.inner(cols[0]);
|
||||||
f.render_widget(left_blk, cols[0]);
|
f.render_widget(left_blk, cols[0]);
|
||||||
state.visible_rows = left_inner.height.saturating_sub(1) as usize;
|
state.visible_rows = left_inner.height.saturating_sub(1) as usize;
|
||||||
|
|||||||
@@ -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_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("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(""));
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
if !t.description.is_empty() {
|
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_section("Timeline"));
|
||||||
lines.push(r_kv("Created", &format_age_pub(t.created_at), Color::DarkGray));
|
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));
|
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 {
|
} else {
|
||||||
lines.push(r_dim(" ← select a todo".into()));
|
lines.push(r_dim(" ← select a todo".into()));
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-7
@@ -13,7 +13,8 @@ pub use super::state_rows::*;
|
|||||||
use crate::projects::{self, Project};
|
use crate::projects::{self, Project};
|
||||||
use crate::providers::{IssueDetail, PrDetail};
|
use crate::providers::{IssueDetail, PrDetail};
|
||||||
use crate::status::{count_tracked_files, repo_recent_commits, RepoSummary};
|
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 ratatui::style::Color;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -70,6 +71,13 @@ pub struct HubState {
|
|||||||
pub editor: String,
|
pub editor: String,
|
||||||
pub clipboard_cmd: String,
|
pub clipboard_cmd: String,
|
||||||
pub show_code_todos:bool,
|
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<String>,
|
||||||
pub debug_mode: bool,
|
pub debug_mode: bool,
|
||||||
pub debug_log: Vec<String>,
|
pub debug_log: Vec<String>,
|
||||||
/// Live receiver from the background scan thread; None when scan is complete.
|
/// 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 });
|
d.push(DisplayRow::Project { proj_idx: pi });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Tab::Todos => {
|
Tab::Todos => self.build_todos_display(&mut d),
|
||||||
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::PRs => {
|
Tab::PRs => {
|
||||||
for i in 0..self.prs.len() {
|
for i in 0..self.prs.len() {
|
||||||
d.push(DisplayRow::Pr { pr_idx: i });
|
d.push(DisplayRow::Pr { pr_idx: i });
|
||||||
@@ -212,6 +215,83 @@ impl HubState {
|
|||||||
self.display = d;
|
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<DisplayRow>) {
|
||||||
|
let filtered: Vec<usize> = (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<usize> = 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<String> = 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<usize> = 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<String> = 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<usize> = 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<DisplayRow>, key: String, label: String, group: Vec<usize>, 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) {
|
pub fn rebuild_project_map(&mut self) {
|
||||||
self.project_map = projects::index_by_repo(&self.projects);
|
self.project_map = projects::index_by_repo(&self.projects);
|
||||||
}
|
}
|
||||||
@@ -237,6 +317,14 @@ impl HubState {
|
|||||||
_ => None,
|
_ => 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<String> {
|
||||||
|
match self.display.get(self.cur_di()?)? {
|
||||||
|
DisplayRow::TodoHeader { key, .. } => Some(key.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn project_repos(&self, pi: usize) -> Vec<usize> {
|
pub fn project_repos(&self, pi: usize) -> Vec<usize> {
|
||||||
self.projects[pi].repos.iter()
|
self.projects[pi].repos.iter()
|
||||||
|
|||||||
+73
-2
@@ -1,6 +1,77 @@
|
|||||||
//! Small standalone types that don't fit the other state groupings: the
|
//! Small standalone types that don't fit the other state groupings: the
|
||||||
//! repo-list sort order, and the user-defined repo template format
|
//! repo-list sort order, the Todos tab's status-view/grouping modes, and
|
||||||
//! (persisted to `templates.toml`).
|
//! 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
|
/// Repo-list ordering for the Repos tab, cycled with a single key. Pinned
|
||||||
/// repos always sort to the top regardless of this setting (see
|
/// repos always sort to the top regardless of this setting (see
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ pub enum DisplayRow {
|
|||||||
Repo { repo_idx: usize },
|
Repo { repo_idx: usize },
|
||||||
Project { proj_idx: usize },
|
Project { proj_idx: usize },
|
||||||
Todo { todo_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 },
|
Pr { pr_idx: usize },
|
||||||
Issue { issue_idx: usize },
|
Issue { issue_idx: usize },
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-40
@@ -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 avail = area.height.saturating_sub(1) as usize;
|
||||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
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));
|
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||||
|
|
||||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||||
.skip(state.offset).take(avail)
|
.skip(state.offset).take(avail)
|
||||||
.filter_map(|(di, row)| {
|
.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 is_cur = di == cur_di;
|
||||||
let status_color = t.status.color();
|
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||||
let proj_name = t.project.as_deref().unwrap_or("—");
|
match row {
|
||||||
let repo_name = t.repo.as_deref().unwrap_or("—");
|
DisplayRow::TodoHeader { label, count, extra, key } => {
|
||||||
let created = format_age_pub(t.created_at);
|
let arrow = if state.todo_group_collapsed.contains(key) { "▸" } else { "▾" };
|
||||||
let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::<String>();
|
let text = format!("{arrow} {} ── {}{}", label.to_uppercase(), count, extra);
|
||||||
let diff_str = match t.difficulty {
|
Some(Row::new(vec![
|
||||||
Some(d) => format!("{}{}", "★".repeat(d.min(5) as usize), "☆".repeat(5usize.saturating_sub(d as usize))),
|
Cell::from(""),
|
||||||
None => "—".into(),
|
Cell::from(Span::styled(text, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))),
|
||||||
};
|
Cell::from(""), Cell::from(""), Cell::from(""), Cell::from(""),
|
||||||
let title_short: String = if t.source == TodoSource::CodeScan {
|
]).style(row_sty))
|
||||||
let raw = &t.title;
|
}
|
||||||
let trimmed = if let Some(end) = raw.find(']') { raw[end+1..].trim() } else { raw.as_str() };
|
DisplayRow::Todo { todo_idx: ti } => {
|
||||||
trimmed.chars().take(55).collect()
|
let t = &state.todos[*ti];
|
||||||
} else {
|
let is_code_scan = t.source == TodoSource::CodeScan;
|
||||||
t.title.chars().take(55).collect()
|
let (icon, icon_col) = if is_code_scan {
|
||||||
};
|
("⌁", Color::DarkGray)
|
||||||
let src_icon = t.source.icon();
|
} else {
|
||||||
let src_col = match t.source { TodoSource::Manual => Color::Cyan, TodoSource::CodeScan => Color::DarkGray };
|
(t.status.icon(), t.status.color())
|
||||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
};
|
||||||
Some(Row::new(vec![
|
let repo_name = t.repo.as_deref().unwrap_or("—");
|
||||||
Cell::from(format!(" {}", src_icon)).style(Style::default().fg(src_col)),
|
let proj_name = t.project.as_deref().unwrap_or("—");
|
||||||
Cell::from(format!(" {} {}", t.status.icon(), t.status.label()))
|
let proj_col = t.project.as_deref()
|
||||||
.style(Style::default().fg(status_color)),
|
.and_then(|name| state.projects.iter().find(|p| p.name == name))
|
||||||
Cell::from(title_short),
|
.map(|p| { let (r, g, b) = crate::projects::hex_to_rgb(&p.color); Color::Rgb(r, g, b) })
|
||||||
Cell::from(repo_name).style(Style::default().fg(Color::DarkGray)),
|
.unwrap_or(Color::DarkGray);
|
||||||
Cell::from(proj_name).style(Style::default().fg(Color::DarkGray)),
|
let diff_str = match t.difficulty {
|
||||||
Cell::from(est_str).style(Style::default().fg(Color::DarkGray)),
|
Some(d) => "★".repeat(d.min(5) as usize),
|
||||||
Cell::from(diff_str).style(Style::default().fg(Color::Yellow)),
|
None => "—".into(),
|
||||||
Cell::from(created).style(Style::default().fg(Color::DarkGray)),
|
};
|
||||||
]).style(row_sty))
|
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();
|
.collect();
|
||||||
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Table::new(rows, [
|
Table::new(rows, [
|
||||||
Constraint::Length(4),
|
Constraint::Length(2),
|
||||||
Constraint::Length(14),
|
|
||||||
Constraint::Fill(1),
|
Constraint::Fill(1),
|
||||||
Constraint::Length(12),
|
Constraint::Length(14),
|
||||||
Constraint::Length(12),
|
Constraint::Length(14),
|
||||||
Constraint::Length(10),
|
Constraint::Length(6),
|
||||||
Constraint::Length(7),
|
Constraint::Length(9),
|
||||||
Constraint::Length(10),
|
|
||||||
]).header(header),
|
]).header(header),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,13 +70,6 @@ pub enum TodoSource {
|
|||||||
CodeScan,
|
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.
|
/// One todo item.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Todo {
|
pub struct Todo {
|
||||||
|
|||||||
Reference in new issue
Block a user