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:
1 parent
abb1e92064
commit
4723d18c59
5 files changed
+107
-39
No files matched your search
@@ -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(); }
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -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()];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user