Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <anna@example.com> · 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
Expand Down Expand Up @@ -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 |

Expand Down
18 changes: 12 additions & 6 deletions README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <anna@example.com> · 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 變動,以及工作區
檔案異動都會自動反映,無需按鍵;游標與搜尋條件在刷新後仍保留
Expand Down Expand Up @@ -86,6 +91,7 @@ gitgraph-tui ~/src/foo # 指定路徑
| `/` | 增量搜尋(訊息、作者、hash) |
| `n` / `N` | 下一個 / 上一個符合(自動載入更舊的 commit) |
| `b` | 依分支篩選 |
| `c` | 在目前 branch 篩選下開啟 staged / unstaged changes 視圖 |
| `r` | 強制完整重新載入(畫面本來就會自動更新)|
| `Esc` / `q` | 返回 / 離開 |

Expand Down
187 changes: 186 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -33,6 +35,7 @@ pub enum Mode {
Search,
Diff,
BranchFilter,
BranchChanges,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand All @@ -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.
Expand All @@ -60,6 +69,23 @@ pub struct DiffState {
pub viewport_height: usize,
}

#[derive(Debug)]
pub struct BranchChangesState {
pub branch_name: String,
pub entries: Vec<BranchChangeEntry>,
pub selected: usize,
pub focus: BranchChangesFocus,
pub diff_lines: Vec<DiffLine>,
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 {
Expand Down Expand Up @@ -93,6 +119,7 @@ pub struct App {
pub mode: Mode,
pub search: SearchState,
pub diff: Option<DiffState>,
pub branch_changes: Option<BranchChangesState>,
pub branch_filter: Option<RefInfo>,
/// Branch-filter popup rows; None entry = "All branches".
pub filter_choices: Vec<Option<RefInfo>>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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:#}"),
Expand Down Expand Up @@ -584,6 +615,159 @@ 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<Option<RefInfo>> = vec![None];
choices.extend(
Expand Down Expand Up @@ -621,6 +805,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() {
Expand Down
Loading
Loading