diff --git a/src/hub/keys.rs b/src/hub/keys.rs index 5799708..0804513 100644 --- a/src/hub/keys.rs +++ b/src/hub/keys.rs @@ -187,7 +187,15 @@ pub fn tui_phase(state: &mut HubState, existing_term: Option) - } if let Some(Event::Mouse(m)) = &event { + let on_activity = state.tab == Tab::Activity; 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::ScrollUp => { state.scroll_up(); } _ => {} diff --git a/src/hub/keys_dispatch.rs b/src/hub/keys_dispatch.rs index e6d6687..84b5f9a 100644 --- a/src/hub/keys_dispatch.rs +++ b/src/hub/keys_dispatch.rs @@ -28,6 +28,8 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option { if state.tab == Tab::HubSettings { 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 { state.scroll_up(); } @@ -36,6 +38,9 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option 0 && state.activity_cursor + 1 < n { state.activity_cursor += 1; } } else { state.scroll_down(); } @@ -100,7 +105,7 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option Tab::HubSettings, 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(); super::keys::maybe_load_global_stats(state); super::keys::maybe_load_activity(state); @@ -117,7 +122,7 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option Tab::Stats, 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(); super::keys::maybe_load_global_stats(state); super::keys::maybe_load_activity(state); @@ -159,7 +164,23 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option {} + 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 => { match state.hub_settings_cur { 0 => { @@ -321,6 +342,7 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option { let mut opts = vec!["Create project".to_string()]; diff --git a/src/hub/mod.rs b/src/hub/mod.rs index d10e811..778ad46 100644 --- a/src/hub/mod.rs +++ b/src/hub/mod.rs @@ -142,6 +142,7 @@ pub async fn run_overview_tui( activity_feed: None, activity_rx: None, activity_filter: None, + activity_cursor: 0, vulp_log: Vec::new(), pending_cursor_restore: None, hub_settings_cur: 0, diff --git a/src/hub/state.rs b/src/hub/state.rs index 64397c7..814781c 100644 --- a/src/hub/state.rs +++ b/src/hub/state.rs @@ -98,6 +98,8 @@ pub struct HubState { pub activity_feed: Option>, pub activity_rx: Option>>, pub activity_filter: Option, + /// 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 pub vulp_log: Vec, /// 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 { + 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 { self.projects[pi].repos.iter() .filter_map(|rel| { diff --git a/src/hub/tabs_stats.rs b/src/hub/tabs_stats.rs index c1d7d22..e0f20b9 100644 --- a/src/hub/tabs_stats.rs +++ b/src/hub/tabs_stats.rs @@ -149,10 +149,9 @@ pub fn render_activity_global(f: &mut Frame, state: &HubState, area: Rect) { use ratatui::widgets::{Block, Borders}; let dim = Style::default().fg(Color::DarkGray); 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 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) .title(Span::styled(title, yel)) .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); 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" }; f.render_widget(Paragraph::new(Line::from(Span::styled(msg, dim))), inner); return; - }; + } - let filtered: Vec<&crate::activity::ActivityEntry> = feed.iter() - .filter(|e| state.activity_filter.map(|k| k == e.kind).unwrap_or(true)) - .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 = 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::(); - 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::()).style(wht), - Cell::from(detail).style(Style::default().fg(Color::Gray)), - ]) - }).collect(); - - if rows.is_empty() { + let filtered = state.activity_filtered(); + if filtered.is_empty() { f.render_widget(Paragraph::new(Line::from(Span::styled(" no activity recorded yet", dim))), inner); 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 = 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::()); + let detail = e.detail.chars().take(detail_w).collect::(); + 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( 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), inner, );