From bdec77c346db8065353f3c1a283871f4610cbb0c Mon Sep 17 00:00:00 2001 From: Pin <10946009@ntub.edu.tw> Date: Mon, 24 Aug 2026 22:26:50 +0800 Subject: [PATCH 1/9] fix: keep vertical lines at graph crossings --- src/graph/layout.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/graph/layout.rs b/src/graph/layout.rs index 5be0c4f..6300748 100644 --- a/src/graph/layout.rs +++ b/src/graph/layout.rs @@ -162,14 +162,14 @@ fn render_cells( } /// Horizontal run from the commit's lane to `to`, ending in a curve glyph. -/// Crossing a vertical becomes '┼' (keeping the vertical's color); existing +/// Crossing a vertical keeps the vertical glyph (and its color); existing /// curves from earlier connectors are left intact. fn connector(cells: &mut [Cell], from: usize, to: usize, color: usize, end: char) { let (lo, hi) = (from.min(to), from.max(to)); for cell in &mut cells[(2 * lo + 1)..(2 * hi)] { *cell = match cell.glyph { - '│' | '┼' => Cell { - glyph: '┼', + '│' => Cell { + glyph: '│', color: cell.color, }, ' ' | '─' => Cell { @@ -262,7 +262,7 @@ mod tests { } #[test] - fn criss_cross_crossing_uses_the_cross_glyph() { + fn criss_cross_crossing_keeps_the_vertical_glyph() { // a=merge(c,d), b=merge(c,d) — the close of b's lane at c crosses // a's second-parent lane. let mut e = LayoutEngine::new(); @@ -272,7 +272,7 @@ mod tests { c("c", &[]), c("d", &[]), ]); - assert_eq!(glyphs(&rows), vec!["●─╮", "│ │ ●─╮", "●─┼─╯ │", " ●───╯"]); + assert_eq!(glyphs(&rows), vec!["●─╮", "│ │ ●─╮", "●─│─╯ │", " ●───╯"]); } #[test] From 2e295092a0f838f20d7f16ed600caabedc6f6dd5 Mon Sep 17 00:00:00 2001 From: Pin <10946009@ntub.edu.tw> Date: Mon, 24 Aug 2026 22:26:50 +0800 Subject: [PATCH 2/9] feat: add short commit hash to the graph list --- src/ui/graph_view.rs | 8 +++++++- tests/ui_render.rs | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ui/graph_view.rs b/src/ui/graph_view.rs index cbe4932..3f7eb62 100644 --- a/src/ui/graph_view.rs +++ b/src/ui/graph_view.rs @@ -13,6 +13,7 @@ use crate::ui::util::{pad_to_width, relative_time, truncate_width}; use crate::ui::{lane_color, ref_style}; const AUTHOR_W: usize = 12; +const HASH_W: usize = 7; const TIME_W: usize = 5; /// Graph column cap: beyond 16 lanes the graph is unreadable anyway. const MAX_GRAPH_W: usize = 32; @@ -99,7 +100,7 @@ fn row_line(app: &App, i: usize, graph_w: usize, text_w: usize) -> Line<'static> } spans.push(Span::raw(" ")); // Ref labels, then the summary in whatever width is left. - let mut left = text_w.saturating_sub(AUTHOR_W + 1 + TIME_W); + let mut left = text_w.saturating_sub(AUTHOR_W + 1 + HASH_W + 1 + TIME_W); if let Some(refs) = app.ref_map.get(&commit.id) { for r in refs { let label = format!("[{}] ", r.name); @@ -125,6 +126,11 @@ fn row_line(app: &App, i: usize, graph_w: usize, text_w: usize) -> Line<'static> dim, )); spans.push(Span::raw(" ")); + spans.push(Span::styled( + pad_to_width(&commit.short_id, HASH_W), + dim, + )); + spans.push(Span::raw(" ")); spans.push(Span::styled(relative_time(commit.timestamp, app.now), dim)); Line::from(spans) } diff --git a/tests/ui_render.rs b/tests/ui_render.rs index 648f4b0..f0aa66d 100644 --- a/tests/ui_render.rs +++ b/tests/ui_render.rs @@ -78,6 +78,10 @@ fn graph_rows_show_dots_labels_summary_author_and_age() { ); assert!(all.contains("merge feature"), "summary renders"); assert!(all.contains("Test Author"), "author renders"); + assert!( + all.contains(&app.commits[0].short_id), + "short hash renders in the list" + ); assert!( all.contains("2h"), "relative age renders (c2/c1 rows are 8_000-9_000s old = 2h)" From da463ae149c41d3311628a180cd3f5c7ed1364da Mon Sep 17 00:00:00 2001 From: Pin <10946009@ntub.edu.tw> Date: Mon, 24 Aug 2026 23:12:51 +0800 Subject: [PATCH 3/9] feat: add staged changes view for filtered branches --- src/app.rs | 184 +++++++++++++++++++++++++++++++- src/git/diff.rs | 85 +++++++++++++-- src/git/types.rs | 7 ++ src/ui/branch_changes_view.rs | 195 ++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 11 +- tests/app_state.rs | 68 +++++++++++- tests/git_diff.rs | 74 +++++++++++++ tests/ui_render.rs | 65 ++++++++++++ 8 files changed, 678 insertions(+), 11 deletions(-) create mode 100644 src/ui/branch_changes_view.rs diff --git a/src/app.rs b/src/app.rs index 6ffcfba..fadfa01 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,7 +10,9 @@ use lru::LruCache; use ratatui::widgets::ListState; use crate::git::GitRepo; -use crate::git::types::{CommitId, CommitInfo, DiffLine, FileChange, RefInfo, RefKind}; +use crate::git::types::{ + BranchChanges, CommitId, CommitInfo, DiffLine, FileChange, RefInfo, RefKind, +}; use crate::git::watch::Fingerprint; use crate::graph::{GraphRow, LayoutEngine}; @@ -33,6 +35,7 @@ pub enum Mode { Search, Diff, BranchFilter, + BranchChanges, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -41,6 +44,12 @@ pub enum Focus { Files, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BranchChangesFocus { + Files, + Diff, +} + #[derive(Debug, Default)] pub struct SearchState { /// Text being typed in the search bar. @@ -60,6 +69,23 @@ pub struct DiffState { pub viewport_height: usize, } +#[derive(Debug)] +pub struct BranchChangesState { + pub branch_name: String, + pub entries: Vec, + pub selected: usize, + pub focus: BranchChangesFocus, + pub diff_lines: Vec, + pub diff_scroll: usize, + pub diff_viewport_height: usize, +} + +#[derive(Debug, Clone)] +pub struct BranchChangeEntry { + pub staged: bool, + pub file: FileChange, +} + /// Fully staged repository data. Reloads build this off to the side so a /// transient libgit2 failure never destroys the last successfully drawn view. struct ReloadData { @@ -93,6 +119,7 @@ pub struct App { pub mode: Mode, pub search: SearchState, pub diff: Option, + pub branch_changes: Option, pub branch_filter: Option, /// Branch-filter popup rows; None entry = "All branches". pub filter_choices: Vec>, @@ -144,6 +171,7 @@ impl App { mode: Mode::Normal, search: SearchState::default(), diff: None, + branch_changes: None, branch_filter: None, filter_choices: Vec::new(), filter_selected: 0, @@ -172,6 +200,7 @@ impl App { self.search.input.clear(); self.search.query.clear(); self.search.matches.clear(); + self.branch_changes = None; self.selected = self.selected.min(self.display_len().saturating_sub(1)); self.file_selected = 0; self.sync_list_state(); @@ -463,6 +492,7 @@ impl App { Mode::Search => self.handle_search_key(key), Mode::Diff => self.handle_diff_key(key), Mode::BranchFilter => self.handle_filter_key(key), + Mode::BranchChanges => self.handle_branch_changes_key(key), } } @@ -492,6 +522,7 @@ impl App { (_, KeyCode::Char('g')) => self.select_top(), (_, KeyCode::Char('G')) => self.select_bottom(), (_, KeyCode::Char('b')) => self.open_branch_filter(), + (_, KeyCode::Char('c')) => self.open_branch_changes(), (_, KeyCode::Char('r')) => match self.reload() { Ok(()) => self.status = "reloaded".to_string(), Err(e) => self.status = format!("reload failed: {e:#}"), @@ -584,6 +615,156 @@ impl App { } } + fn open_branch_changes(&mut self) { + let Some(branch) = self.branch_filter.clone() else { + self.status = "branch changes require a branch filter".to_string(); + return; + }; + match self.repo.branch_changes(&branch) { + Ok(BranchChanges { + branch_name, + staged, + unstaged, + }) => { + let entries = staged + .into_iter() + .map(|file| BranchChangeEntry { staged: true, file }) + .chain( + unstaged + .into_iter() + .map(|file| BranchChangeEntry { staged: false, file }), + ) + .collect(); + self.branch_changes = Some(BranchChangesState { + branch_name, + entries, + selected: 0, + focus: BranchChangesFocus::Files, + diff_lines: Vec::new(), + diff_scroll: 0, + diff_viewport_height: 1, + }); + self.reload_branch_changes_diff(); + self.mode = Mode::BranchChanges; + } + Err(e) => self.status = format!("changes failed: {e:#}"), + } + } + + fn reload_branch_changes_diff(&mut self) { + let Some(branch) = self.branch_filter.clone() else { + self.branch_changes = None; + self.mode = Mode::Normal; + self.status = "branch filter cleared".to_string(); + return; + }; + let Some(changes) = self.branch_changes.as_mut() else { + return; + }; + changes.selected = changes.selected.min(changes.entries.len().saturating_sub(1)); + let Some(entry) = changes.entries.get(changes.selected) else { + changes.diff_lines.clear(); + changes.diff_scroll = 0; + return; + }; + match self + .repo + .branch_file_diff(&branch, entry.staged, &entry.file.path) + { + Ok(lines) => { + changes.diff_lines = lines; + changes.diff_scroll = 0; + } + Err(e) => { + changes.diff_lines.clear(); + changes.diff_scroll = 0; + self.status = format!("changes diff failed: {e:#}"); + } + } + } + + fn handle_branch_changes_key(&mut self, key: KeyEvent) { + if self.branch_changes.is_none() { + self.mode = Mode::Normal; + return; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => { + self.branch_changes = None; + self.mode = Mode::Normal; + } + KeyCode::Tab => { + let changes = self.branch_changes.as_mut().unwrap(); + changes.focus = match changes.focus { + BranchChangesFocus::Files => BranchChangesFocus::Diff, + BranchChangesFocus::Diff => BranchChangesFocus::Files, + }; + } + KeyCode::Char('j') | KeyCode::Down => match self.branch_changes.as_ref().unwrap().focus { + BranchChangesFocus::Files => self.move_branch_changes_selection(1), + BranchChangesFocus::Diff => self.scroll_branch_changes_diff(1), + }, + KeyCode::Char('k') | KeyCode::Up => match self.branch_changes.as_ref().unwrap().focus { + BranchChangesFocus::Files => self.move_branch_changes_selection(-1), + BranchChangesFocus::Diff => self.scroll_branch_changes_diff(-1), + }, + KeyCode::Char('g') => match self.branch_changes.as_ref().unwrap().focus { + BranchChangesFocus::Files => { + self.branch_changes.as_mut().unwrap().selected = 0; + self.reload_branch_changes_diff(); + } + BranchChangesFocus::Diff => self.branch_changes.as_mut().unwrap().diff_scroll = 0, + }, + KeyCode::Char('G') => match self.branch_changes.as_ref().unwrap().focus { + BranchChangesFocus::Files => { + let last = self + .branch_changes + .as_ref() + .unwrap() + .entries + .len() + .saturating_sub(1); + self.branch_changes.as_mut().unwrap().selected = last; + self.reload_branch_changes_diff(); + } + BranchChangesFocus::Diff => { + let max = self + .branch_changes + .as_ref() + .unwrap() + .diff_lines + .len() + .saturating_sub(self.branch_changes.as_ref().unwrap().diff_viewport_height); + self.branch_changes.as_mut().unwrap().diff_scroll = max; + } + }, + _ => {} + } + } + + fn move_branch_changes_selection(&mut self, delta: isize) { + let Some(changes) = self.branch_changes.as_mut() else { + return; + }; + let len = changes.entries.len(); + if len == 0 { + return; + } + changes.selected = (changes.selected as isize + delta).clamp(0, len as isize - 1) as usize; + self.reload_branch_changes_diff(); + } + + fn scroll_branch_changes_diff(&mut self, delta: isize) { + let Some(changes) = self.branch_changes.as_mut() else { + return; + }; + let max = changes + .diff_lines + .len() + .saturating_sub(changes.diff_viewport_height); + changes.diff_scroll = (changes.diff_scroll as isize + delta).clamp(0, max as isize) as usize; + } + fn open_branch_filter(&mut self) { let mut choices: Vec> = vec![None]; choices.extend( @@ -621,6 +802,7 @@ impl App { let previous_filter = self.branch_filter.clone(); let previous_selected = self.selected; self.branch_filter = self.filter_choices[self.filter_selected].clone(); + self.branch_changes = None; self.mode = Mode::Normal; self.selected = 0; if let Err(e) = self.reload() { diff --git a/src/git/diff.rs b/src/git/diff.rs index 1ff1cd4..213de5e 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -6,7 +6,7 @@ use anyhow::{Context, Result}; use git2::{Delta, Diff, DiffOptions, Oid}; use super::repo::GitRepo; -use super::types::{ChangeKind, CommitId, DiffLine, FileChange}; +use super::types::{BranchChanges, ChangeKind, CommitId, DiffLine, FileChange, RefInfo}; impl GitRepo { /// Files changed by a commit, diffed against its first parent @@ -42,20 +42,91 @@ impl GitRepo { collect_diff_lines(&diff, Some(path.as_ref())) } + /// Worktree changes while a branch filter is active. The left pane groups + /// them into staged (`git add`ed) vs not-yet-staged changes. + pub fn branch_changes(&self, branch: &RefInfo) -> Result { + Ok(BranchChanges { + branch_name: branch.name.clone(), + staged: self.staged_status()?, + unstaged: self.unstaged_status()?, + }) + } + + /// Unified diff of one staged/unstaged file in the current worktree. + pub fn branch_file_diff( + &self, + _branch: &RefInfo, + staged: bool, + path: impl AsRef, + ) -> Result> { + let mut diff = if staged { + self.staged_diff()? + } else { + self.unstaged_diff()? + }; + diff.find_similar(None)?; + collect_diff_lines(&diff, Some(path.as_ref())) + } + fn commit_diff(&self, id: &CommitId) -> Result> { let oid = Oid::from_str(id).context("invalid commit id")?; - let commit = self.inner.find_commit(oid)?; - let tree = commit.tree()?; - let parent_tree = commit.parent(0).ok().map(|p| p.tree()).transpose()?; + self.diff_between_commits(self.inner.find_commit(oid)?.parent_id(0).ok(), oid) + } + + fn worktree_diff(&self) -> Result> { + let head_tree = self.inner.head().ok().and_then(|h| h.peel_to_tree().ok()); + let mut opts = DiffOptions::new(); + opts.include_untracked(true) + .recurse_untracked_dirs(true) + .show_untracked_content(true) + .context_lines(3); + Ok(self + .inner + .diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut opts))?) + } + + fn staged_status(&self) -> Result> { + if self.inner.is_bare() { + return Ok(Vec::new()); + } + let mut diff = self.staged_diff()?; + diff.find_similar(None)?; + collect_file_changes(&diff) + } + + fn unstaged_status(&self) -> Result> { + if self.inner.is_bare() { + return Ok(Vec::new()); + } + let mut diff = self.unstaged_diff()?; + diff.find_similar(None)?; + collect_file_changes(&diff) + } + + fn diff_between_commits(&self, base_oid: Option, target_oid: Oid) -> Result> { + let target_tree = self.inner.find_commit(target_oid)?.tree()?; + let base_tree = base_oid + .map(|oid| self.inner.find_commit(oid)?.tree()) + .transpose()?; let mut opts = DiffOptions::new(); opts.context_lines(3); Ok(self .inner - .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut opts))?) + .diff_tree_to_tree(base_tree.as_ref(), Some(&target_tree), Some(&mut opts))?) } - fn worktree_diff(&self) -> Result> { + fn staged_diff(&self) -> Result> { let head_tree = self.inner.head().ok().and_then(|h| h.peel_to_tree().ok()); + let index = self.inner.index()?; + let mut opts = DiffOptions::new(); + opts.context_lines(3); + Ok(self + .inner + .diff_tree_to_index(head_tree.as_ref(), Some(&index), Some(&mut opts))?) + } + + fn unstaged_diff(&self) -> Result> { + let index = self.inner.index()?; let mut opts = DiffOptions::new(); opts.include_untracked(true) .recurse_untracked_dirs(true) @@ -63,7 +134,7 @@ impl GitRepo { .context_lines(3); Ok(self .inner - .diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut opts))?) + .diff_index_to_workdir(Some(&index), Some(&mut opts))?) } } diff --git a/src/git/types.rs b/src/git/types.rs index 930ad07..6e46b5e 100644 --- a/src/git/types.rs +++ b/src/git/types.rs @@ -57,3 +57,10 @@ pub struct DiffLine { pub origin: char, pub content: String, } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchChanges { + pub branch_name: String, + pub staged: Vec, + pub unstaged: Vec, +} diff --git a/src/ui/branch_changes_view.rs b/src/ui/branch_changes_view.rs new file mode 100644 index 0000000..f6e3c50 --- /dev/null +++ b/src/ui/branch_changes_view.rs @@ -0,0 +1,195 @@ +//! Split-pane branch changes view: changed files on the left, diff on the right. +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Paragraph}, +}; + +use crate::app::{App, BranchChangesFocus}; +use crate::git::types::ChangeKind; + +pub fn render(frame: &mut Frame, area: Rect, app: &mut App) { + let Some(changes) = app.branch_changes.as_mut() else { + return; + }; + let [files_area, diff_area] = + Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]).areas(area); + + let rows = file_rows(changes); + let selected_row = rows + .iter() + .position(|row| row.file_index == Some(changes.selected)) + .unwrap_or(0); + let visible_height = files_area.height.saturating_sub(2) as usize; + let mut offset = 0; + if selected_row >= visible_height && visible_height > 0 { + offset = selected_row + 1 - visible_height; + } + let lines: Vec = rows + .into_iter() + .skip(offset) + .take(visible_height) + .map(|row| row.line) + .collect(); + let files_title = format!(" changes {} ", changes.branch_name); + let files = Paragraph::new(lines).block(Block::bordered().title(files_title)); + frame.render_widget(files, files_area); + + let diff_title = changes + .entries + .get(changes.selected) + .map(|entry| format!(" {} ", entry.file.path.to_string_lossy())) + .unwrap_or_else(|| " no changes ".to_string()); + changes.diff_viewport_height = diff_area.height.saturating_sub(2) as usize; + changes.diff_scroll = changes + .diff_scroll + .min(changes.diff_lines.len().saturating_sub(changes.diff_viewport_height)); + let lines: Vec = if changes.diff_lines.is_empty() { + vec![Line::from(Span::styled( + "No changed content for this file", + Style::new().fg(Color::DarkGray), + ))] + } else { + changes + .diff_lines + .iter() + .skip(changes.diff_scroll) + .take(changes.diff_viewport_height) + .map(diff_line) + .collect() + }; + let para = Paragraph::new(lines).block(Block::bordered().title(diff_title).border_style( + if changes.focus == BranchChangesFocus::Diff { + Style::new().fg(Color::White) + } else { + Style::new() + }, + )); + frame.render_widget(para, diff_area); +} + +fn file_line(f: &crate::git::types::FileChange) -> Line<'static> { + let (letter, color) = match &f.kind { + ChangeKind::Added => ("A", Color::Green), + ChangeKind::Modified => ("M", Color::Rgb(255, 165, 0)), + ChangeKind::Deleted => ("D", Color::Red), + ChangeKind::Renamed { .. } => ("R", Color::Cyan), + }; + let mut spans = vec![ + Span::styled(format!(" {letter} "), Style::new().fg(color)), + Span::raw(f.path.to_string_lossy().into_owned()), + ]; + if f.is_binary { + spans.push(Span::styled(" (binary)", Style::new().fg(Color::Magenta))); + } else { + spans.push(Span::styled( + format!(" +{}", f.additions), + Style::new().fg(Color::Green), + )); + spans.push(Span::styled( + format!(" -{}", f.deletions), + Style::new().fg(Color::Red), + )); + } + Line::from(spans) +} + +struct FileRow { + file_index: Option, + line: Line<'static>, +} + +fn file_rows(changes: &crate::app::BranchChangesState) -> Vec { + let mut rows = Vec::new(); + push_section( + &mut rows, + "Added", + changes + .entries + .iter() + .enumerate() + .filter(|(_, entry)| entry.staged), + changes, + ); + push_section( + &mut rows, + "Not added", + changes + .entries + .iter() + .enumerate() + .filter(|(_, entry)| !entry.staged), + changes, + ); + rows +} + +fn push_section<'a, I>( + rows: &mut Vec, + title: &'static str, + files: I, + changes: &crate::app::BranchChangesState, +) where + I: Iterator, +{ + let files: Vec<(usize, &'a crate::app::BranchChangeEntry)> = files.collect(); + rows.push(FileRow { + file_index: None, + line: Line::from(Span::styled( + format!(" {title} "), + Style::new() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + )), + }); + if files.is_empty() { + rows.push(FileRow { + file_index: None, + line: Line::from(Span::styled( + " (none)", + Style::new().fg(Color::DarkGray), + )), + }); + return; + } + for (index, entry) in files { + let style = if changes.focus == BranchChangesFocus::Files && changes.selected == index { + Style::new().add_modifier(Modifier::REVERSED) + } else { + Style::new() + }; + rows.push(FileRow { + file_index: Some(index), + line: styled_line(file_line(&entry.file), style), + }); + } +} + +fn styled_line(line: Line<'static>, style: Style) -> Line<'static> { + let spans = line + .spans + .into_iter() + .map(|span| span.patch_style(style)) + .collect::>(); + Line::from(spans) +} + +fn diff_line(l: &crate::git::types::DiffLine) -> Line<'static> { + let style = match l.origin { + '+' => Style::new().fg(Color::Green), + '-' => Style::new().fg(Color::Red), + '@' => Style::new().fg(Color::Cyan), + 'B' => Style::new() + .fg(Color::Magenta) + .add_modifier(Modifier::ITALIC), + _ => Style::new(), + }; + let prefix = if matches!(l.origin, '@' | 'B' | '\\') { + String::new() + } else { + l.origin.to_string() + }; + Line::from(Span::styled(format!("{prefix}{}", l.content), style)) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 17a0914..67c2b8d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ +pub mod branch_changes_view; pub mod detail_view; pub mod diff_view; pub mod graph_view; @@ -51,6 +52,11 @@ pub fn render(frame: &mut Frame, app: &mut App) { render_help(frame, help_area, app); return; } + if app.mode == Mode::BranchChanges { + branch_changes_view::render(frame, main_area, app); + render_help(frame, help_area, app); + return; + } let [graph_area, detail_area] = Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)]).areas(main_area); graph_view::render(frame, graph_area, app); @@ -66,9 +72,12 @@ fn render_help(frame: &mut Frame, area: Rect, app: &App) { Mode::Search => format!(" /{}▌ enter:confirm esc:cancel", app.search.input), Mode::Diff => " j/k:scroll g/G:top/bottom esc:back".to_string(), Mode::BranchFilter => " j/k:choose enter:apply esc:close".to_string(), + Mode::BranchChanges => { + " j/k:move tab:focus g/G:top/bottom esc:back".to_string() + } Mode::Normal if !app.status.is_empty() => format!(" {}", app.status), Mode::Normal => { - " j/k:move g/G:top/bot tab:focus enter:diff /:search n/N:next b:branches r:reload q:quit" + " j/k:move g/G:top/bot tab:focus enter:diff /:search n/N:next b:branches c:changes r:reload q:quit" .to_string() } }; diff --git a/tests/app_state.rs b/tests/app_state.rs index 3320eef..6948e0f 100644 --- a/tests/app_state.rs +++ b/tests/app_state.rs @@ -4,6 +4,7 @@ use common::Fixture; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use gitgraph_tui::app::App; use gitgraph_tui::app::Focus; +use gitgraph_tui::app::{BranchChangesFocus, Mode}; use gitgraph_tui::git::GitRepo; use gitgraph_tui::git::types::ChangeKind; @@ -213,8 +214,6 @@ fn moving_the_commit_cursor_resets_file_focus_state() { assert_eq!(app.file_selected, 0); } -use gitgraph_tui::app::Mode; - #[test] fn enter_on_a_file_opens_the_diff_and_esc_closes_it() { let f = Fixture::new(); @@ -339,6 +338,71 @@ fn esc_cancels_the_search_input() { assert!(app.search.matches.is_empty()); } +#[test] +fn c_opens_branch_changes_only_when_a_branch_filter_is_active() { + let (_f, mut app) = linear_app(3, 300); + app.handle_key(ch('c')); + assert_eq!(app.mode, Mode::Normal); + assert!(app.status.contains("branch changes require a branch filter")); +} + +#[test] +fn branch_changes_open_and_support_focus_switching_and_scrolling() { + let f = Fixture::new(); + let base = f.commit("base", &[("shared.txt", "base\n")], &[], &[], 1_000); + f.branch("main", base); + f.branch("feature", base); + f.set_head("refs/heads/main"); + f.write_file("shared.txt", "staged\n"); + { + let mut index = f.repo.index().unwrap(); + index.add_path(std::path::Path::new("shared.txt")).unwrap(); + index.write().unwrap(); + } + f.write_file("extra.txt", "one\ntwo\nthree\nfour\nfive\n"); + let repo = GitRepo::discover(f.path()).unwrap(); + let mut app = App::new_at(repo, 10_000).unwrap(); + + app.handle_key(ch('b')); + let pos = app + .filter_choices + .iter() + .position(|c| c.as_ref().is_some_and(|r| r.name == "feature")) + .unwrap(); + for _ in 0..pos { + app.handle_key(ch('j')); + } + app.handle_key(key(KeyCode::Enter)); + + app.handle_key(ch('c')); + assert_eq!(app.mode, Mode::BranchChanges); + assert_eq!(app.branch_changes.as_ref().unwrap().focus, BranchChangesFocus::Files); + assert_eq!(app.branch_changes.as_ref().unwrap().entries.len(), 2); + + let extra_pos = app + .branch_changes + .as_ref() + .unwrap() + .entries + .iter() + .position(|entry| entry.file.path == std::path::Path::new("extra.txt")) + .unwrap(); + for _ in 0..extra_pos { + app.handle_key(ch('j')); + } + assert_eq!(app.branch_changes.as_ref().unwrap().selected, extra_pos); + + app.handle_key(key(KeyCode::Tab)); + assert_eq!(app.branch_changes.as_ref().unwrap().focus, BranchChangesFocus::Diff); + app.branch_changes.as_mut().unwrap().diff_viewport_height = 2; + app.handle_key(ch('j')); + assert_eq!(app.branch_changes.as_ref().unwrap().diff_scroll, 1); + + app.handle_key(key(KeyCode::Esc)); + assert_eq!(app.mode, Mode::Normal); + assert!(app.branch_changes.is_none()); +} + #[test] fn n_wraps_around_when_everything_is_loaded() { let (_f, mut app) = linear_app(5, 300); diff --git a/tests/git_diff.rs b/tests/git_diff.rs index f0bd232..452f72b 100644 --- a/tests/git_diff.rs +++ b/tests/git_diff.rs @@ -242,6 +242,80 @@ fn worktree_status_on_empty_repo_lists_untracked_files() { assert_eq!(files[0].kind, ChangeKind::Added); } +#[test] +fn branch_changes_split_staged_and_unstaged_worktree_changes() { + let f = Fixture::new(); + let base = f.commit( + "base", + &[("staged.txt", "old\n"), ("unstaged.txt", "before\n")], + &[], + &[], + 1_000, + ); + f.branch("feature", base); + f.set_head("refs/heads/feature"); + f.write_file("staged.txt", "new staged\n"); + { + let mut index = f.repo.index().unwrap(); + index.add_path(std::path::Path::new("staged.txt")).unwrap(); + index.write().unwrap(); + } + f.write_file("unstaged.txt", "new unstaged\n"); + f.write_file("untracked.txt", "hello\n"); + let repo = GitRepo::discover(f.path()).unwrap(); + let branch = repo + .refs() + .unwrap() + .into_iter() + .find(|r| r.refname == "refs/heads/feature") + .unwrap(); + + let changes = repo.branch_changes(&branch).unwrap(); + assert_eq!(changes.branch_name, "feature"); + assert_eq!(changes.staged.len(), 1); + assert_eq!(changes.staged[0].path, std::path::Path::new("staged.txt")); + assert!( + changes + .unstaged + .iter() + .any(|f| f.path == std::path::Path::new("unstaged.txt")) + ); + assert!( + changes + .unstaged + .iter() + .any(|f| f.path == std::path::Path::new("untracked.txt")) + ); +} + +#[test] +fn branch_file_diff_reads_staged_and_unstaged_sources_separately() { + let f = Fixture::new(); + let base = f.commit("base", &[("a.txt", "old\n")], &[], &[], 1_000); + f.branch("feature", base); + f.set_head("refs/heads/feature"); + f.write_file("a.txt", "staged\n"); + { + let mut index = f.repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + index.write().unwrap(); + } + f.write_file("a.txt", "unstaged\n"); + let repo = GitRepo::discover(f.path()).unwrap(); + let branch = repo + .refs() + .unwrap() + .into_iter() + .find(|r| r.refname == "refs/heads/feature") + .unwrap(); + + let staged_lines = repo.branch_file_diff(&branch, true, "a.txt").unwrap(); + assert!(staged_lines.iter().any(|l| l.origin == '+' && l.content == "staged")); + let unstaged_lines = repo.branch_file_diff(&branch, false, "a.txt").unwrap(); + assert!(unstaged_lines.iter().any(|l| l.origin == '-' && l.content == "staged")); + assert!(unstaged_lines.iter().any(|l| l.origin == '+' && l.content == "unstaged")); +} + #[cfg(unix)] #[test] fn non_utf8_worktree_path_round_trips_into_the_diff() { diff --git a/tests/ui_render.rs b/tests/ui_render.rs index f0aa66d..392a6a1 100644 --- a/tests/ui_render.rs +++ b/tests/ui_render.rs @@ -264,6 +264,71 @@ fn empty_repo_shows_a_placeholder_message_in_the_graph_panel() { assert!(lines[1].contains("No commits yet")); } +#[test] +fn branch_changes_view_renders_split_panes() { + let f = Fixture::new(); + let base = f.commit("base", &[("a.txt", "base\n")], &[], &[], 1_000); + f.branch("main", base); + f.branch("feature", base); + f.set_head("refs/heads/main"); + f.write_file("a.txt", "staged\n"); + { + let mut index = f.repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + index.write().unwrap(); + } + f.write_file("b.txt", "new\n"); + let mut app = app_of(&f); + + app.handle_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('b'), + crossterm::event::KeyModifiers::NONE, + )); + let pos = app + .filter_choices + .iter() + .position(|c| c.as_ref().is_some_and(|r| r.name == "feature")) + .unwrap(); + for _ in 0..pos { + app.handle_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('j'), + crossterm::event::KeyModifiers::NONE, + )); + } + app.handle_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + )); + app.handle_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('c'), + crossterm::event::KeyModifiers::NONE, + )); + let b_pos = app + .branch_changes + .as_ref() + .unwrap() + .entries + .iter() + .position(|entry| entry.file.path == std::path::Path::new("b.txt")) + .unwrap(); + for _ in 0..b_pos { + app.handle_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('j'), + crossterm::event::KeyModifiers::NONE, + )); + } + + let lines = render_app(&mut app, 90, 18); + let all = lines.join("\n"); + assert!(all.contains("changes feature")); + assert!(all.contains("Added")); + assert!(all.contains("Not added")); + assert!(all.contains("b.txt")); + assert!(all.contains("a.txt")); + assert!(all.contains("+new")); + assert!(!all.contains("all branches"), "branch changes view covers the graph"); +} + #[test] fn tiny_terminal_does_not_panic() { let f = merge_fixture(); From d9cd75d275faa5e9f5a9d71b544fff36fd7314c8 Mon Sep 17 00:00:00 2001 From: Pin <10946009@ntub.edu.tw> Date: Mon, 24 Aug 2026 23:52:56 +0800 Subject: [PATCH 4/9] docs: update README for changes view and hash column --- README.md | 18 ++++++++++++------ README.zh-TW.md | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 71ce6fb..a392dde 100644 --- a/README.md +++ b/README.md @@ -13,26 +13,31 @@ ever writes to your repository. ```text ┌ my-repo — all branches — 128/500 commits ────────────────────────────┐ │ ● Uncommitted changes (1 files) │ -│ ●─╮ [HEAD] [main] [v1.0] merge: dev into main anna 2h │ -│ ● │ fix: main work anna 5h │ -│ │ ● [dev] feat: dev work ben 1d │ -│ ●─╯ init anna 2d │ +│ ●─╮ [HEAD] [main] [v1.0] merge: dev into main anna a1b2c3d 2h │ +│ ● │ fix: main work anna d4e5f6a 5h │ +│ │ ● [dev] feat: dev work ben 0f1e2d3 1d │ +│ ●─╯ init anna 9a8b7c6 2d │ ├──────────────────────────────────────────────────────────────────────┤ │ commit a1b2c3d · anna · 2026-07-06 14:30 │ │ M src/lib.rs +12 -3 │ -└ j/k:move g/G:top/bot tab:focus enter:diff /:search b:branches q:quit ┘ +└ j/k:move g/G:top/bot tab:focus enter:diff /:search b:branches c:changes q:quit ┘ ``` ## Features - **Colored branch graph** — lane-assignment layout handles forks, merges, - octopus merges, and criss-cross histories + octopus merges, and criss-cross histories, while keeping crossings readable - **Ref labels** — local / remote branches, tags, HEAD, right on the rows +- **Short commit hashes in the list** — each row shows the abbreviated hash + beside the author and relative time - **Commit details** — full message, author, date, changed files with +/- counts - **Full-screen diffs** — per file, colored, scrollable - **Incremental search** — message / author / hash; `n`/`N` auto-load older history until the next match - **Branch filter** — show only what's reachable from one branch +- **Changes view for filtered branches** — press `c` to open a split view of + the current worktree, grouped into staged (`Added`) and unstaged / + untracked (`Not added`) files, with a per-file diff on the right - **Uncommitted changes** — a live row above the newest commit - **Live auto-refresh** — external commits, checkouts, branch/tag edits, and worktree changes show up on their own, no keypress; your cursor and active @@ -90,6 +95,7 @@ Tip: `alias gg=gitgraph-tui` | `/` | incremental search (message, author, hash) | | `n` / `N` | next / previous match (auto-loads older commits) | | `b` | filter by branch | +| `c` | open the staged / unstaged changes view for the active branch filter | | `r` | force a full reload (the view also auto-refreshes on its own) | | `Esc` / `q` | back / quit | diff --git a/README.zh-TW.md b/README.zh-TW.md index 782f733..4fa41c8 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -12,24 +12,29 @@ commit 詳情、diff、搜尋——而且絕對不會寫入你的 repository。 ```text ┌ my-repo — all branches — 128/500 commits ────────────────────────────┐ │ ● Uncommitted changes (1 files) │ -│ ●─╮ [HEAD] [main] [v1.0] merge: dev into main anna 2h │ -│ ● │ fix: main work anna 5h │ -│ │ ● [dev] feat: dev work ben 1d │ -│ ●─╯ init anna 2d │ +│ ●─╮ [HEAD] [main] [v1.0] merge: dev into main anna a1b2c3d 2h │ +│ ● │ fix: main work anna d4e5f6a 5h │ +│ │ ● [dev] feat: dev work ben 0f1e2d3 1d │ +│ ●─╯ init anna 9a8b7c6 2d │ ├──────────────────────────────────────────────────────────────────────┤ │ commit a1b2c3d · anna · 2026-07-06 14:30 │ │ M src/lib.rs +12 -3 │ -└ j/k:move g/G:top/bot tab:focus enter:diff /:search b:branches q:quit ┘ +└ j/k:move g/G:top/bot tab:focus enter:diff /:search b:branches c:changes q:quit ┘ ``` ## 功能 -- **彩色分支圖** — lane 分配演算法,正確處理分叉、合併、octopus merge 與交錯歷史 +- **彩色分支圖** — lane 分配演算法,正確處理分叉、合併、octopus merge 與交錯歷史, + 並保留清楚的交會線條 - **Ref 標籤** — 本地/遠端分支、tag、HEAD 直接顯示在列上 +- **清單短 hash 欄位** — 每列在作者與相對時間旁顯示縮短版 commit hash - **Commit 詳情** — 完整訊息、作者、日期、變更檔案(+/- 行數) - **全螢幕 diff** — 逐檔、上色、可捲動 - **增量搜尋** — 訊息/作者/hash;`n`/`N` 自動載入更舊的歷史直到下一個符合 - **分支篩選** — 只看某條分支可達的 commit +- **分支 changes 視圖** — 選擇特定 branch 篩選後可按 `c` 開啟分割畫面, + 左側依 `Added` 與 `Not added` 顯示 staged 與 unstaged / untracked 檔案, + 右側顯示所選檔案的 diff - **未提交變更** — 最新 commit 上方的即時狀態列 - **即時自動更新** — 其他 terminal 的 commit、切換分支、branch/tag 變動,以及工作區 檔案異動都會自動反映,無需按鍵;游標與搜尋條件在刷新後仍保留 @@ -86,6 +91,7 @@ gitgraph-tui ~/src/foo # 指定路徑 | `/` | 增量搜尋(訊息、作者、hash) | | `n` / `N` | 下一個 / 上一個符合(自動載入更舊的 commit) | | `b` | 依分支篩選 | +| `c` | 在目前 branch 篩選下開啟 staged / unstaged changes 視圖 | | `r` | 強制完整重新載入(畫面本來就會自動更新)| | `Esc` / `q` | 返回 / 離開 | From 2c2a149daff071b0dcaeb92ce7d4cef76adcc45e Mon Sep 17 00:00:00 2001 From: Pin <10946009@ntub.edu.tw> Date: Tue, 25 Aug 2026 22:43:39 +0800 Subject: [PATCH 5/9] trigger CI From 98d098632ad7029aeac6d5f025f35ef3cc2f3a91 Mon Sep 17 00:00:00 2001 From: Pin <10946009@ntub.edu.tw> Date: Tue, 25 Aug 2026 22:47:03 +0800 Subject: [PATCH 6/9] Format code with rustfmt --- src/app.rs | 19 +++++++++++-------- src/ui/branch_changes_view.rs | 14 +++++++------- src/ui/graph_view.rs | 5 +---- tests/app_state.rs | 15 ++++++++++++--- tests/git_diff.rs | 18 +++++++++++++++--- tests/ui_render.rs | 5 ++++- 6 files changed, 50 insertions(+), 26 deletions(-) diff --git a/src/app.rs b/src/app.rs index fadfa01..b292811 100644 --- a/src/app.rs +++ b/src/app.rs @@ -629,11 +629,10 @@ impl App { let entries = staged .into_iter() .map(|file| BranchChangeEntry { staged: true, file }) - .chain( - unstaged - .into_iter() - .map(|file| BranchChangeEntry { staged: false, file }), - ) + .chain(unstaged.into_iter().map(|file| BranchChangeEntry { + staged: false, + file, + })) .collect(); self.branch_changes = Some(BranchChangesState { branch_name, @@ -661,7 +660,9 @@ impl App { let Some(changes) = self.branch_changes.as_mut() else { return; }; - changes.selected = changes.selected.min(changes.entries.len().saturating_sub(1)); + changes.selected = changes + .selected + .min(changes.entries.len().saturating_sub(1)); let Some(entry) = changes.entries.get(changes.selected) else { changes.diff_lines.clear(); changes.diff_scroll = 0; @@ -700,7 +701,8 @@ impl App { BranchChangesFocus::Diff => BranchChangesFocus::Files, }; } - KeyCode::Char('j') | KeyCode::Down => match self.branch_changes.as_ref().unwrap().focus { + KeyCode::Char('j') | KeyCode::Down => match self.branch_changes.as_ref().unwrap().focus + { BranchChangesFocus::Files => self.move_branch_changes_selection(1), BranchChangesFocus::Diff => self.scroll_branch_changes_diff(1), }, @@ -762,7 +764,8 @@ impl App { .diff_lines .len() .saturating_sub(changes.diff_viewport_height); - changes.diff_scroll = (changes.diff_scroll as isize + delta).clamp(0, max as isize) as usize; + changes.diff_scroll = + (changes.diff_scroll as isize + delta).clamp(0, max as isize) as usize; } fn open_branch_filter(&mut self) { diff --git a/src/ui/branch_changes_view.rs b/src/ui/branch_changes_view.rs index f6e3c50..8870375 100644 --- a/src/ui/branch_changes_view.rs +++ b/src/ui/branch_changes_view.rs @@ -43,9 +43,12 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) { .map(|entry| format!(" {} ", entry.file.path.to_string_lossy())) .unwrap_or_else(|| " no changes ".to_string()); changes.diff_viewport_height = diff_area.height.saturating_sub(2) as usize; - changes.diff_scroll = changes - .diff_scroll - .min(changes.diff_lines.len().saturating_sub(changes.diff_viewport_height)); + changes.diff_scroll = changes.diff_scroll.min( + changes + .diff_lines + .len() + .saturating_sub(changes.diff_viewport_height), + ); let lines: Vec = if changes.diff_lines.is_empty() { vec![Line::from(Span::styled( "No changed content for this file", @@ -147,10 +150,7 @@ fn push_section<'a, I>( if files.is_empty() { rows.push(FileRow { file_index: None, - line: Line::from(Span::styled( - " (none)", - Style::new().fg(Color::DarkGray), - )), + line: Line::from(Span::styled(" (none)", Style::new().fg(Color::DarkGray))), }); return; } diff --git a/src/ui/graph_view.rs b/src/ui/graph_view.rs index 3f7eb62..a57db50 100644 --- a/src/ui/graph_view.rs +++ b/src/ui/graph_view.rs @@ -126,10 +126,7 @@ fn row_line(app: &App, i: usize, graph_w: usize, text_w: usize) -> Line<'static> dim, )); spans.push(Span::raw(" ")); - spans.push(Span::styled( - pad_to_width(&commit.short_id, HASH_W), - dim, - )); + spans.push(Span::styled(pad_to_width(&commit.short_id, HASH_W), dim)); spans.push(Span::raw(" ")); spans.push(Span::styled(relative_time(commit.timestamp, app.now), dim)); Line::from(spans) diff --git a/tests/app_state.rs b/tests/app_state.rs index 6948e0f..acfc651 100644 --- a/tests/app_state.rs +++ b/tests/app_state.rs @@ -343,7 +343,10 @@ fn c_opens_branch_changes_only_when_a_branch_filter_is_active() { let (_f, mut app) = linear_app(3, 300); app.handle_key(ch('c')); assert_eq!(app.mode, Mode::Normal); - assert!(app.status.contains("branch changes require a branch filter")); + assert!( + app.status + .contains("branch changes require a branch filter") + ); } #[test] @@ -376,7 +379,10 @@ fn branch_changes_open_and_support_focus_switching_and_scrolling() { app.handle_key(ch('c')); assert_eq!(app.mode, Mode::BranchChanges); - assert_eq!(app.branch_changes.as_ref().unwrap().focus, BranchChangesFocus::Files); + assert_eq!( + app.branch_changes.as_ref().unwrap().focus, + BranchChangesFocus::Files + ); assert_eq!(app.branch_changes.as_ref().unwrap().entries.len(), 2); let extra_pos = app @@ -393,7 +399,10 @@ fn branch_changes_open_and_support_focus_switching_and_scrolling() { assert_eq!(app.branch_changes.as_ref().unwrap().selected, extra_pos); app.handle_key(key(KeyCode::Tab)); - assert_eq!(app.branch_changes.as_ref().unwrap().focus, BranchChangesFocus::Diff); + assert_eq!( + app.branch_changes.as_ref().unwrap().focus, + BranchChangesFocus::Diff + ); app.branch_changes.as_mut().unwrap().diff_viewport_height = 2; app.handle_key(ch('j')); assert_eq!(app.branch_changes.as_ref().unwrap().diff_scroll, 1); diff --git a/tests/git_diff.rs b/tests/git_diff.rs index 452f72b..70dff79 100644 --- a/tests/git_diff.rs +++ b/tests/git_diff.rs @@ -310,10 +310,22 @@ fn branch_file_diff_reads_staged_and_unstaged_sources_separately() { .unwrap(); let staged_lines = repo.branch_file_diff(&branch, true, "a.txt").unwrap(); - assert!(staged_lines.iter().any(|l| l.origin == '+' && l.content == "staged")); + assert!( + staged_lines + .iter() + .any(|l| l.origin == '+' && l.content == "staged") + ); let unstaged_lines = repo.branch_file_diff(&branch, false, "a.txt").unwrap(); - assert!(unstaged_lines.iter().any(|l| l.origin == '-' && l.content == "staged")); - assert!(unstaged_lines.iter().any(|l| l.origin == '+' && l.content == "unstaged")); + assert!( + unstaged_lines + .iter() + .any(|l| l.origin == '-' && l.content == "staged") + ); + assert!( + unstaged_lines + .iter() + .any(|l| l.origin == '+' && l.content == "unstaged") + ); } #[cfg(unix)] diff --git a/tests/ui_render.rs b/tests/ui_render.rs index 392a6a1..0ee8385 100644 --- a/tests/ui_render.rs +++ b/tests/ui_render.rs @@ -326,7 +326,10 @@ fn branch_changes_view_renders_split_panes() { assert!(all.contains("b.txt")); assert!(all.contains("a.txt")); assert!(all.contains("+new")); - assert!(!all.contains("all branches"), "branch changes view covers the graph"); + assert!( + !all.contains("all branches"), + "branch changes view covers the graph" + ); } #[test] From bc6221fc03ead36fb1af7ac05d364b1909b5c8f2 Mon Sep 17 00:00:00 2001 From: Pin <10946009@ntub.edu.tw> Date: Tue, 25 Aug 2026 23:03:58 +0800 Subject: [PATCH 7/9] Keep quit shortcut visible in help line --- src/ui/mod.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 67c2b8d..8fd190c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -72,12 +72,10 @@ fn render_help(frame: &mut Frame, area: Rect, app: &App) { Mode::Search => format!(" /{}▌ enter:confirm esc:cancel", app.search.input), Mode::Diff => " j/k:scroll g/G:top/bottom esc:back".to_string(), Mode::BranchFilter => " j/k:choose enter:apply esc:close".to_string(), - Mode::BranchChanges => { - " j/k:move tab:focus g/G:top/bottom esc:back".to_string() - } + Mode::BranchChanges => " j/k:move tab:focus g/G:top/bottom esc:back".to_string(), Mode::Normal if !app.status.is_empty() => format!(" {}", app.status), Mode::Normal => { - " j/k:move g/G:top/bot tab:focus enter:diff /:search n/N:next b:branches c:changes r:reload q:quit" + " j/k:move g/G:top/bot tab:focus enter:diff /:search n/N:next b:branch c:changes q:quit" .to_string() } }; From ed13c07990760e7574b4118be2b1c680aa84b5c8 Mon Sep 17 00:00:00 2001 From: Pin <10946009@ntub.edu.tw> Date: Tue, 25 Aug 2026 23:09:12 +0800 Subject: [PATCH 8/9] Adjust help line render test width --- src/ui/mod.rs | 2 +- tests/ui_render.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8fd190c..d7ceaeb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -75,7 +75,7 @@ fn render_help(frame: &mut Frame, area: Rect, app: &App) { Mode::BranchChanges => " j/k:move tab:focus g/G:top/bottom esc:back".to_string(), Mode::Normal if !app.status.is_empty() => format!(" {}", app.status), Mode::Normal => { - " j/k:move g/G:top/bot tab:focus enter:diff /:search n/N:next b:branch c:changes q:quit" + " j/k:move g/G:top/bot tab:focus enter:diff /:search n/N:next b:branches c:changes r:reload q:quit" .to_string() } }; diff --git a/tests/ui_render.rs b/tests/ui_render.rs index 0ee8385..d330213 100644 --- a/tests/ui_render.rs +++ b/tests/ui_render.rs @@ -110,7 +110,7 @@ fn uncommitted_row_renders_at_the_top() { fn help_line_lists_the_key_bindings() { let f = merge_fixture(); let mut app = app_of(&f); - let lines = render_app(&mut app, 90, 16); + let lines = render_app(&mut app, 100, 16); let last = lines.last().unwrap(); assert!(last.contains("q:quit")); assert!(last.contains("/:search")); From d79f5f0e813d7a4f31ebe5a5017797a459815fec Mon Sep 17 00:00:00 2001 From: Pin <10946009@ntub.edu.tw> Date: Tue, 25 Aug 2026 23:34:37 +0800 Subject: [PATCH 9/9] Fix shellcheck warning in installer test --- tests/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/install.sh b/tests/install.sh index d1df2e8..4e0e83a 100644 --- a/tests/install.sh +++ b/tests/install.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu -project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +project_dir=$(CDPATH=; cd -- "$(dirname -- "$0")/.." && pwd) test_dir=$(mktemp -d) trap 'rm -rf "$test_dir"' EXIT mkdir -p "$test_dir/bin"