feat: make the Activity tab navigable and easier to read

Activity rows are now a cursor list: ↑↓ (and the scroll wheel) move the
highlight, Enter jumps to that repo and opens its detail. Each row shows
a precise local timestamp (MM-DD HH:MM) instead of only a relative age,
colour-codes the kind, colours the repo by its project and widens that
column so long workspace paths are readable, and keeps the relative age
as a dim suffix on the detail. A › marks rows whose repo is still in the
workspace (Enter-able).
This commit is contained in:
dualfroz committed 2026-07-11 22:25:55 +02:00
1 parent abb1e92064
commit 4723d18c59
5 files changed
+107 -39

No files matched your search

+8
View File
@@ -187,7 +187,15 @@ pub fn tui_phase(state: &mut HubState, existing_term: Option<crate::tui::Tty>) -
} }
if let Some(Event::Mouse(m)) = &event { if let Some(Event::Mouse(m)) = &event {
let on_activity = state.tab == Tab::Activity;
match m.kind { match m.kind {
MouseEventKind::ScrollDown if on_activity => {
let n = state.activity_filtered().len();
if n > 0 && state.activity_cursor + 1 < n { state.activity_cursor += 1; }
}
MouseEventKind::ScrollUp if on_activity => {
state.activity_cursor = state.activity_cursor.saturating_sub(1);
}
MouseEventKind::ScrollDown => { state.scroll_down(); } MouseEventKind::ScrollDown => { state.scroll_down(); }
MouseEventKind::ScrollUp => { state.scroll_up(); } MouseEventKind::ScrollUp => { state.scroll_up(); }
_ => {} _ => {}
+25 -3
View File
@@ -28,6 +28,8 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
if state.tab == Tab::HubSettings { if state.tab == Tab::HubSettings {
state.hub_settings_cur = state.hub_settings_cur.saturating_sub(1); state.hub_settings_cur = state.hub_settings_cur.saturating_sub(1);
} else if state.tab == Tab::Activity {
state.activity_cursor = state.activity_cursor.saturating_sub(1);
} else { } else {
state.scroll_up(); state.scroll_up();
} }
@@ -36,6 +38,9 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
if state.tab == Tab::HubSettings { if state.tab == Tab::HubSettings {
let max = 10 + 1 + state.templates.len(); let max = 10 + 1 + state.templates.len();
if state.hub_settings_cur + 1 < max { state.hub_settings_cur += 1; } if state.hub_settings_cur + 1 < max { state.hub_settings_cur += 1; }
} else if state.tab == Tab::Activity {
let n = state.activity_filtered().len();
if n > 0 && state.activity_cursor + 1 < n { state.activity_cursor += 1; }
} else { } else {
state.scroll_down(); state.scroll_down();
} }
@@ -100,7 +105,7 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
Tab::Activity => Tab::HubSettings, Tab::Activity => Tab::HubSettings,
Tab::HubSettings => Tab::Overview, Tab::HubSettings => Tab::Overview,
}; };
state.cursor = 0; state.offset = 0; state.selected.clear(); state.cursor = 0; state.offset = 0; state.activity_cursor = 0; state.selected.clear();
state.rebuild_display(); state.rebuild_display();
super::keys::maybe_load_global_stats(state); super::keys::maybe_load_global_stats(state);
super::keys::maybe_load_activity(state); super::keys::maybe_load_activity(state);
@@ -117,7 +122,7 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
Tab::Activity => Tab::Stats, Tab::Activity => Tab::Stats,
Tab::HubSettings => Tab::Activity, Tab::HubSettings => Tab::Activity,
}; };
state.cursor = 0; state.offset = 0; state.selected.clear(); state.cursor = 0; state.offset = 0; state.activity_cursor = 0; state.selected.clear();
state.rebuild_display(); state.rebuild_display();
super::keys::maybe_load_global_stats(state); super::keys::maybe_load_global_stats(state);
super::keys::maybe_load_activity(state); super::keys::maybe_load_activity(state);
@@ -159,7 +164,23 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
toggle_todo_group(state, key); toggle_todo_group(state, key);
} }
} }
Tab::Overview | Tab::Stats | Tab::Activity => {} Tab::Activity => {
// Jump from an activity row to that repo: switch to the
// Repos tab focused on it and open its detail.
if let Some(ri) = state.activity_repo_at_cursor() {
state.tab = Tab::Repos;
state.rebuild_display();
if let Some(pos) = state.display.iter().position(|row|
matches!(row, DisplayRow::Repo { repo_idx } if *repo_idx == ri)) {
state.cursor = pos;
}
state.open_detail = true;
return Some(PhaseResult::OpenDetail);
} else {
state.status_msg = Some("That repo isn't in the workspace".into());
}
}
Tab::Overview | Tab::Stats => {}
Tab::HubSettings => { Tab::HubSettings => {
match state.hub_settings_cur { match state.hub_settings_cur {
0 => { 0 => {
@@ -321,6 +342,7 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
if idx + 1 < cycle.len() { Some(cycle[idx + 1]) } else { None } if idx + 1 < cycle.len() { Some(cycle[idx + 1]) } else { None }
} }
}; };
state.activity_cursor = 0;
} }
KeyCode::Char('p') => { KeyCode::Char('p') => {
let mut opts = vec!["Create project".to_string()]; let mut opts = vec!["Create project".to_string()];
+1
View File
@@ -142,6 +142,7 @@ pub async fn run_overview_tui(
activity_feed: None, activity_feed: None,
activity_rx: None, activity_rx: None,
activity_filter: None, activity_filter: None,
activity_cursor: 0,
vulp_log: Vec::new(), vulp_log: Vec::new(),
pending_cursor_restore: None, pending_cursor_restore: None,
hub_settings_cur: 0, hub_settings_cur: 0,
+21
View File
@@ -98,6 +98,8 @@ pub struct HubState {
pub activity_feed: Option<Vec<crate::activity::ActivityEntry>>, pub activity_feed: Option<Vec<crate::activity::ActivityEntry>>,
pub activity_rx: Option<std::sync::mpsc::Receiver<Vec<crate::activity::ActivityEntry>>>, pub activity_rx: Option<std::sync::mpsc::Receiver<Vec<crate::activity::ActivityEntry>>>,
pub activity_filter: Option<crate::activity::ActivityKind>, pub activity_filter: Option<crate::activity::ActivityKind>,
/// Cursor into the (filtered) Activity feed — the row Enter jumps from.
pub activity_cursor: usize,
// Overview "vulp log" tile: recent vulp-performed actions, newest first // Overview "vulp log" tile: recent vulp-performed actions, newest first
pub vulp_log: Vec<crate::activity::ActivityEntry>, pub vulp_log: Vec<crate::activity::ActivityEntry>,
/// Repo path to re-focus once the startup scan completes (session restore). /// Repo path to re-focus once the startup scan completes (session restore).
@@ -332,6 +334,25 @@ impl HubState {
} }
} }
/// The Activity feed narrowed by the active kind filter — the exact list
/// the Activity tab renders and `activity_cursor` indexes into.
pub fn activity_filtered(&self) -> Vec<&crate::activity::ActivityEntry> {
match &self.activity_feed {
Some(feed) => feed.iter()
.filter(|e| self.activity_filter.map(|k| k == e.kind).unwrap_or(true))
.collect(),
None => vec![],
}
}
/// The workspace repo index the Activity row under the cursor refers to,
/// if that repo is still in the workspace — the target of Enter/click.
pub fn activity_repo_at_cursor(&self) -> Option<usize> {
let feed = self.activity_filtered();
let entry = feed.get(self.activity_cursor)?;
self.repos.iter().position(|r| r.name == entry.repo)
}
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()
.filter_map(|rel| { .filter_map(|rel| {
+52 -36
View File
@@ -149,10 +149,9 @@ pub fn render_activity_global(f: &mut Frame, state: &HubState, area: Rect) {
use ratatui::widgets::{Block, Borders}; use ratatui::widgets::{Block, Borders};
let dim = Style::default().fg(Color::DarkGray); let dim = Style::default().fg(Color::DarkGray);
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let wht = Style::default().fg(Color::White);
let filter_label = state.activity_filter.map(|k| k.label()).unwrap_or("all"); let filter_label = state.activity_filter.map(|k| k.label()).unwrap_or("all");
let title = format!(" activity — filter: {} ", filter_label); let title = format!(" activity — filter: {} · [↑↓] move [Enter] open repo [f] filter ", filter_label);
let blk = Block::default().borders(Borders::ALL) let blk = Block::default().borders(Borders::ALL)
.title(Span::styled(title, yel)) .title(Span::styled(title, yel))
.title_alignment(Alignment::Center) .title_alignment(Alignment::Center)
@@ -160,50 +159,67 @@ pub fn render_activity_global(f: &mut Frame, state: &HubState, area: Rect) {
let inner = blk.inner(area); let inner = blk.inner(area);
f.render_widget(blk, area); f.render_widget(blk, area);
let Some(feed) = &state.activity_feed else { if state.activity_feed.is_none() {
let msg = if state.activity_rx.is_some() { " Loading activity..." } else { " Press [Tab] or re-enter this tab to load activity" }; let msg = if state.activity_rx.is_some() { " Loading activity..." } else { " Press [Tab] or re-enter this tab to load activity" };
f.render_widget(Paragraph::new(Line::from(Span::styled(msg, dim))), inner); f.render_widget(Paragraph::new(Line::from(Span::styled(msg, dim))), inner);
return; return;
}; }
let filtered: Vec<&crate::activity::ActivityEntry> = feed.iter() let filtered = state.activity_filtered();
.filter(|e| state.activity_filter.map(|k| k == e.kind).unwrap_or(true)) if filtered.is_empty() {
.collect();
let max_rows = inner.height.saturating_sub(1) as usize;
let detail_w = (inner.width as usize).saturating_sub(36).max(10);
let header = Row::new(vec![
Cell::from("TIMESTAMP").style(dim), Cell::from("KIND").style(dim),
Cell::from("REPO").style(dim), Cell::from("DETAIL").style(dim),
]);
let rows: Vec<Row> = filtered.iter().take(max_rows).map(|e| {
let kind_col = match e.kind {
crate::activity::ActivityKind::Commit => Style::default().fg(Color::White),
crate::activity::ActivityKind::Push | crate::activity::ActivityKind::Pull => Style::default().fg(Color::Cyan),
crate::activity::ActivityKind::Release => Style::default().fg(Color::Green),
crate::activity::ActivityKind::NewRepo => Style::default().fg(Color::Magenta),
crate::activity::ActivityKind::Todo => Style::default().fg(Color::Yellow),
crate::activity::ActivityKind::Pr => Style::default().fg(Color::Cyan),
crate::activity::ActivityKind::Issue => Style::default().fg(Color::Yellow),
crate::activity::ActivityKind::License => Style::default().fg(Color::Magenta),
};
let detail = e.detail.chars().take(detail_w).collect::<String>();
Row::new(vec![
Cell::from(format_age_pub(e.ts)).style(dim),
Cell::from(e.kind.label()).style(kind_col),
Cell::from(e.repo.chars().take(18).collect::<String>()).style(wht),
Cell::from(detail).style(Style::default().fg(Color::Gray)),
])
}).collect();
if rows.is_empty() {
f.render_widget(Paragraph::new(Line::from(Span::styled(" no activity recorded yet", dim))), inner); f.render_widget(Paragraph::new(Line::from(Span::styled(" no activity recorded yet", dim))), inner);
return; return;
} }
// Keep the cursor in range and scroll a window around it.
let max_rows = (inner.height.saturating_sub(1) as usize).max(1);
let cursor = state.activity_cursor.min(filtered.len() - 1);
let offset = if cursor >= max_rows { cursor - max_rows + 1 } else { 0 };
let repo_w = 34usize;
let detail_w = (inner.width as usize).saturating_sub(20 + 10 + repo_w + 4).max(10);
let header = Row::new(vec![
Cell::from(" WHEN").style(dim), Cell::from("KIND").style(dim),
Cell::from("REPO").style(dim), Cell::from("DETAIL").style(dim),
]);
let rows: Vec<Row> = filtered.iter().enumerate().skip(offset).take(max_rows).map(|(i, e)| {
let is_cur = i == cursor;
let kind_col = match e.kind {
crate::activity::ActivityKind::Commit => Color::White,
crate::activity::ActivityKind::Push => Color::Green,
crate::activity::ActivityKind::Pull => Color::Cyan,
crate::activity::ActivityKind::Release => Color::LightGreen,
crate::activity::ActivityKind::NewRepo => Color::Magenta,
crate::activity::ActivityKind::Todo => Color::Yellow,
crate::activity::ActivityKind::Pr => Color::LightCyan,
crate::activity::ActivityKind::Issue => Color::LightYellow,
crate::activity::ActivityKind::License => Color::Blue,
};
// Precise local timestamp + relative age, instead of age alone.
let when = chrono::DateTime::from_timestamp(e.ts, 0)
.map(|d| d.with_timezone(&chrono::Local).format("%m-%d %H:%M").to_string())
.unwrap_or_else(|| "—".into());
// Colour the repo by its project, matching the Repos table; the ›
// marks it as a jump target.
let repo_ri = state.repos.iter().position(|r| r.name == e.repo);
let repo_col = repo_ri.and_then(|ri| state.repo_proj_color(ri)).unwrap_or(Color::White);
let repo_txt = format!("{}{}", if repo_ri.is_some() { "› " } else { " " },
e.repo.chars().take(repo_w - 2).collect::<String>());
let detail = e.detail.chars().take(detail_w).collect::<String>();
let mark = if is_cur { "▶ " } else { " " };
let row = Row::new(vec![
Cell::from(format!("{}{}", mark, when)).style(Style::default().fg(if is_cur { Color::White } else { Color::Gray })),
Cell::from(e.kind.label()).style(Style::default().fg(kind_col).add_modifier(Modifier::BOLD)),
Cell::from(repo_txt).style(Style::default().fg(repo_col)),
Cell::from(format!("{} {}", detail, format_age_pub(e.ts))).style(dim),
]);
if is_cur { row.style(Style::default().bg(Color::Rgb(40, 40, 55))) } else { row }
}).collect();
f.render_widget( f.render_widget(
Table::new(rows, [ Table::new(rows, [
Constraint::Length(9), Constraint::Length(9), Constraint::Length(18), Constraint::Fill(1), Constraint::Length(20), Constraint::Length(10),
Constraint::Length(repo_w as u16), Constraint::Fill(1),
]).header(header), ]).header(header),
inner, inner,
); );