From 9ee66f711b8bae6007ecc7a7be483d8bd360ff5e Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 31 Jul 2026 11:43:41 +0900 Subject: [PATCH 01/13] fix: clear inherited git env vars so tests behave the same under hooks --- src/test_util.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/test_util.rs b/src/test_util.rs index a815e64..ad4de13 100644 --- a/src/test_util.rs +++ b/src/test_util.rs @@ -16,12 +16,23 @@ pub fn open_repo(path: &str) -> Repository { Repository::discover(path).expect("discover test repo") } +/// Git exports `GIT_DIR`/`GIT_INDEX_FILE` as *relative* paths to its hooks, so a +/// suite run from `.githooks/pre-commit` would resolve them inside the temp repo +/// and fail. Strip them so a test means the same thing wherever it runs. +const LEAKED_GIT_ENV: [&str; 5] = [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_COMMON_DIR", + "GIT_PREFIX", +]; + pub fn run_git(repo_path: &str, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(repo_path) - .output() - .unwrap(); + let mut command = Command::new("git"); + for key in LEAKED_GIT_ENV { + command.env_remove(key); + } + let output = command.args(args).current_dir(repo_path).output().unwrap(); assert!( output.status.success(), "git {} failed: {}", From b2d01380dfb50a380aa0ac3afa86dda6a5831e23 Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 31 Jul 2026 13:19:37 +0900 Subject: [PATCH 02/13] fix: relax the test read timeout so a loaded login still answers in time --- src/web/viewer/server/tests/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/web/viewer/server/tests/mod.rs b/src/web/viewer/server/tests/mod.rs index 4d3abd1..ec08f8f 100644 --- a/src/web/viewer/server/tests/mod.rs +++ b/src/web/viewer/server/tests/mod.rs @@ -54,8 +54,10 @@ pub(super) fn server_with( /// Send a raw request and return the full response text. pub(super) fn request(addr: SocketAddr, raw: &str) -> String { let mut stream = TcpStream::connect(addr).unwrap(); + // Only a hang guard, never a latency assertion: /login verifies an Argon2 + // hash, so a loaded machine running the suite in parallel needs the slack. stream - .set_read_timeout(Some(Duration::from_secs(2))) + .set_read_timeout(Some(Duration::from_secs(30))) .unwrap(); stream.write_all(raw.as_bytes()).unwrap(); let mut buf = Vec::new(); From 684d6768d76f9bd186b217990722a4d321bf0aec Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 31 Jul 2026 13:19:37 +0900 Subject: [PATCH 03/13] chore: drop the permission rule for a MultiEdit tool that does not exist --- .claude/settings.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 5b14e18..746a79c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,8 +5,7 @@ "WebFetch", "Bash(*)", "Write(**)", - "Edit(**)", - "MultiEdit(**)" + "Edit(**)" ] } } From b6e4e300eb7f1965e175462e0076a1c53fb6edbd Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 31 Jul 2026 13:34:13 +0900 Subject: [PATCH 04/13] feat: open the selected tree file fullscreen on Enter --- src/app/focus.rs | 8 +++++++- src/app/tree_nav.rs | 17 +++++++---------- src/application/input/handlers.rs | 13 ++++++++----- src/ui/hint_text.rs | 4 ++-- 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/app/focus.rs b/src/app/focus.rs index ad2ae74..44ff1aa 100644 --- a/src/app/focus.rs +++ b/src/app/focus.rs @@ -175,7 +175,13 @@ impl App { } pub fn toggle_diff_fullscreen(&mut self) { - self.diff.fullscreen = !self.diff.fullscreen; + self.set_diff_fullscreen(!self.diff.fullscreen); + } + + // Entering diff fullscreen has to clear the two competing fullscreens; + // callers that force it on (Tree `Enter`) share that rule with the toggle. + pub(crate) fn set_diff_fullscreen(&mut self, on: bool) { + self.diff.fullscreen = on; if self.diff.fullscreen { self.focus = Focus::DiffViewer; self.terminal.fullscreen = TerminalFullscreen::Off; diff --git a/src/app/tree_nav.rs b/src/app/tree_nav.rs index fa9a393..ac120f7 100644 --- a/src/app/tree_nav.rs +++ b/src/app/tree_nav.rs @@ -84,22 +84,19 @@ impl App { } } - // Enter toggles a directory open/closed; on a file row it (re)loads the - // preview, mirroring selection behaviour. - pub fn tree_toggle(&mut self) { + // Enter opens the selected file: load it into the file view and zoom the + // diff pane so reading is the whole screen. Expansion stays on `→`/`←`, so + // a directory row does nothing here. + pub fn tree_open_selected(&mut self) { let selected = self.tree_view.selected; let Some(row) = self.tree_view.visible_rows().into_iter().nth(selected) else { return; }; if row.is_dir { - if self.tree_view.expanded.contains(&row.path) { - self.tree_collapse(); - } else { - self.tree_expand(); - } - } else { - self.preview_tree_selected(); + return; } + self.preview_tree_selected(); + self.set_diff_fullscreen(true); } // Walk the whole tree once to build the search index, then keep showing diff --git a/src/application/input/handlers.rs b/src/application/input/handlers.rs index 1394343..eb314fe 100644 --- a/src/application/input/handlers.rs +++ b/src/application/input/handlers.rs @@ -195,11 +195,11 @@ fn handle_unmapped_upper_key(app: &mut App, key: KeyEvent) { KeyCode::Enter if app.mode == ViewMode::Log && !app.log_view.drill_down => { app.log_drill_in() } - // Tree navigation: Enter toggles a directory (or re-previews a - // file), Right expands, Left collapses / steps to the parent. These - // guarded arms shadow the generic Left/Right horizontal-scroll arms - // below while in Tree mode. - KeyCode::Enter if app.mode == ViewMode::Tree => app.tree_toggle(), + // Tree navigation: Enter opens the selected file fullscreen (no-op + // on a directory), Right expands, Left collapses / steps to the + // parent. These guarded arms shadow the generic Left/Right + // horizontal-scroll arms below while in Tree mode. + KeyCode::Enter if app.mode == ViewMode::Tree => app.tree_open_selected(), KeyCode::Right if app.mode == ViewMode::Tree => app.tree_expand(), KeyCode::Left if app.mode == ViewMode::Tree => app.tree_collapse(), // Log search Esc precedence sits ahead of `log_drill_out` so the @@ -235,6 +235,9 @@ fn handle_unmapped_upper_key(app: &mut App, key: KeyEvent) { _ => {} }, Focus::DiffViewer => match key.code { + // Counterpart to Tree `Enter`: the same key that zoomed the pane in + // zooms it back out. + KeyCode::Enter => app.toggle_diff_fullscreen(), _ if matches_text_command(key, 'v') => app.toggle_diff_file_view(), _ if matches_text_command(key, 's') => app.toggle_diff_split_view(), _ if matches_text_command(key, 'w') => app.toggle_diff_wrap(), diff --git a/src/ui/hint_text.rs b/src/ui/hint_text.rs index fee3081..155ce7b 100644 --- a/src/ui/hint_text.rs +++ b/src/ui/hint_text.rs @@ -112,7 +112,7 @@ pub(crate) fn normal_hint_literal(app: &App) -> &'static str { " f: exit zoom | j/k: navigate | /: search | l: log view | b: tree view | q: detach" } ViewMode::Tree => { - " f: exit zoom | j/k: navigate | /: search | →/enter: expand | ←: collapse | b: status view | l: log view | q: detach" + " f: exit zoom | j/k: navigate | /: search | →: expand | ←: collapse | enter: open file | b: status view | l: log view | q: detach" } }; return hint; @@ -140,7 +140,7 @@ pub(crate) fn normal_hint_literal(app: &App) -> &'static str { " shift+←/→: cycle | j/k: navigate | /: search | t: new pane | f: fullscreen | l: log view | b: tree view | o: open project | q: detach" } ViewMode::Tree => { - " shift+←/→: cycle | j/k: navigate | /: search | →/enter: expand | ←: collapse | b: status view | l: log view | q: detach" + " shift+←/→: cycle | j/k: navigate | /: search | →: expand | ←: collapse | enter: open file | b: status view | l: log view | q: detach" } }, Focus::DiffViewer => { From 5f8a27699580bddf643f3e7272e4e9be4b5b4c44 Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 31 Jul 2026 13:34:13 +0900 Subject: [PATCH 05/13] test: cover Enter opening a tree file and toggling the diff zoom --- src/app/tests/mod.rs | 1 + src/app/tests/tree_open.rs | 69 +++++++++++++++++++++++ src/application/tests/enter_fullscreen.rs | 19 +++++++ src/application/tests/mod.rs | 1 + 4 files changed, 90 insertions(+) create mode 100644 src/app/tests/tree_open.rs create mode 100644 src/application/tests/enter_fullscreen.rs diff --git a/src/app/tests/mod.rs b/src/app/tests/mod.rs index a51ddc8..f91d770 100644 --- a/src/app/tests/mod.rs +++ b/src/app/tests/mod.rs @@ -39,6 +39,7 @@ mod strip_escape; mod terminal_init; mod terminal_scrollback; mod tree; +mod tree_open; mod tree_session; mod tree_watcher; diff --git a/src/app/tests/tree_open.rs b/src/app/tests/tree_open.rs new file mode 100644 index 0000000..c97011c --- /dev/null +++ b/src/app/tests/tree_open.rs @@ -0,0 +1,69 @@ +//! Tree `Enter` (`tree_open_selected`): opens a file fullscreen, ignores dirs. + +use super::tree::{app_on, make_tree_repo, tree_index_of}; +use super::*; + +#[test] +fn tree_open_on_directory_row_does_not_change_expansion() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + app.tree_view.selected = tree_index_of(&app, "src"); + + app.tree_open_selected(); + assert!( + !app.tree_view.expanded.contains("src"), + "Enter must not expand a directory" + ); + assert!( + !app.diff.fullscreen, + "a directory row must not zoom the pane" + ); + + // Already expanded: Enter must not collapse it either. + app.tree_expand(); + app.tree_view.selected = tree_index_of(&app, "src"); + app.tree_open_selected(); + assert!( + app.tree_view.expanded.contains("src"), + "Enter must not collapse a directory" + ); + drop(dir); +} + +#[test] +fn tree_open_on_file_row_loads_file_view_and_goes_fullscreen() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + app.tree_view.selected = tree_index_of(&app, "README.md"); + + app.tree_open_selected(); + + assert_eq!(app.diff.view, DiffPaneView::File); + assert_eq!( + app.diff.file_view.key, + Some(FileViewKey::Status("README.md".to_string())) + ); + assert_eq!(app.diff.file_view.content, "# hi\n"); + assert!(app.diff.fullscreen); + assert_eq!(app.focus, Focus::DiffViewer); + drop(dir); +} + +#[test] +fn tree_open_on_file_row_clears_competing_fullscreens() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + app.list_fullscreen = true; + app.terminal.fullscreen = TerminalFullscreen::Grid; + app.tree_view.selected = tree_index_of(&app, "README.md"); + + app.tree_open_selected(); + + assert!(app.diff.fullscreen); + assert!(!app.list_fullscreen); + assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Off); + drop(dir); +} diff --git a/src/application/tests/enter_fullscreen.rs b/src/application/tests/enter_fullscreen.rs new file mode 100644 index 0000000..dd6603a --- /dev/null +++ b/src/application/tests/enter_fullscreen.rs @@ -0,0 +1,19 @@ +//! `Enter` key routing for the diff pane zoom. + +use super::helpers::*; +use crate::app::Focus; +use crate::app::tests::app_with_files; +use crate::application::input::dispatch::handle_key; +use crossterm::event::{KeyCode, KeyModifiers}; + +#[test] +fn enter_in_diff_viewer_toggles_diff_fullscreen() { + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::DiffViewer; + + let _ = handle_key(&mut app, press(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.diff.fullscreen, "Enter must zoom the diff pane"); + + let _ = handle_key(&mut app, press(KeyCode::Enter, KeyModifiers::NONE)); + assert!(!app.diff.fullscreen, "a second Enter must exit the zoom"); +} diff --git a/src/application/tests/mod.rs b/src/application/tests/mod.rs index 9ca0037..204ea99 100644 --- a/src/application/tests/mod.rs +++ b/src/application/tests/mod.rs @@ -1,4 +1,5 @@ mod accent; +mod enter_fullscreen; mod helpers; mod mouse; mod mouse_clicks; From 5004b36400ab1b7f4994ae4e2e0f2b7e127bbe69 Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 31 Jul 2026 13:34:13 +0900 Subject: [PATCH 06/13] docs: record that tree expansion is arrow-only and Enter opens a file --- README.md | 7 ++++--- docs/architecture.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 89352c4..f803857 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ The diff for a selected file shows the combined working-tree-with-index changes. **Commit log view** (` l`) — tig-like commit list on the left, full commit diff on the right. Commits ahead of the upstream are marked with `↑`. Press `Enter` on a commit to drill into its individual files; `Esc` to go back. The list auto-refreshes when the workdir HEAD changes (commits made in the terminal pane, amends, force-pushes, branch switches). History loads one page at a time — initial entry fetches `commit_log_page_size` commits and additional pages stream in on a background thread as the selection approaches the loaded tail, so deep histories stay responsive. Toggling while a terminal or diff pane is zoomed exits the zoom and focuses the list, so the view switch is always visible. -**Tree view** (` b`) — a read-only directory tree of the whole working tree on the left, with the selected file's raw contents on the right. Unlike the status view (which lists only changed files), the tree lets you browse and read *any* file next to the diff without leaving nightcrow. `j`/`k` move the cursor, `→`/`Enter` expand a directory (read lazily, one level at a time), `←` collapses it or steps up to the parent, and selecting a file previews it. Press `/` while the tree is focused for a recursive filename search across the whole tree — type to filter, `Enter` reveals the selected match in place (expanding its ancestor directories), `Esc` cancels. Focus the file preview with ` 2`, then press `/` to search within the file contents — `n`/`N` jump to the next/previous match, `Esc` clears the search. `.gitignore`-matched paths (e.g. `target/`, `node_modules/`) are hidden by default — toggle with `[tree] respect_gitignore`. Expanded directories are watched for filesystem changes, so files and folders created, moved, or deleted by another process (an editor, `git`, an LLM CLI) appear without leaving the view; set `[tree] live_watch = false` to refresh only on entry instead. The tree never writes, renames, or deletes anything. Expansion state and the selected path persist across sessions. +**Tree view** (` b`) — a read-only directory tree of the whole working tree on the left, with the selected file's raw contents on the right. Unlike the status view (which lists only changed files), the tree lets you browse and read *any* file next to the diff without leaving nightcrow. `j`/`k` move the cursor, `→` expands a directory (read lazily, one level at a time), `←` collapses it or steps up to the parent, and selecting a file previews it. `Enter` on a file row opens it in the preview pane and zooms that pane fullscreen (`Enter` again, or ` f`, exits the zoom); on a directory row it does nothing. Press `/` while the tree is focused for a recursive filename search across the whole tree — type to filter, `Enter` reveals the selected match in place (expanding its ancestor directories), `Esc` cancels. Focus the file preview with ` 2`, then press `/` to search within the file contents — `n`/`N` jump to the next/previous match, `Esc` clears the search. `.gitignore`-matched paths (e.g. `target/`, `node_modules/`) are hidden by default — toggle with `[tree] respect_gitignore`. Expanded directories are watched for filesystem changes, so files and folders created, moved, or deleted by another process (an editor, `git`, an LLM CLI) appear without leaving the view; set `[tree] live_watch = false` to refresh only on entry instead. The tree never writes, renames, or deletes anything. Expansion state and the selected path persist across sessions. **Notice row** — a one-row strip just above the hint bar shows the repo path (home-relative, e.g. `~/projects/myapp`), the current branch, and ahead/behind counts (`↑N ↓M`) when the branch tracks an upstream. When something fails — a git snapshot, a diff load, a terminal pane, or a repo path you typed that doesn't exist — the message takes over this row in red until the problem is resolved or you act on the app again. A rejected repo path therefore appears directly above the input you're correcting. The repo dialog's completion candidates share this row (dimmed, and a notice outranks them), so a list too long for one line ends in `+N more`. @@ -158,7 +158,7 @@ The diff for a selected file shows the combined working-tree-with-index changes. | `Enter` | Take the selected path into the field and return to it — this does **not** open the repo. Press `Enter` again in the field for that, or keep refining the path with `Tab` first | | `Esc` | Leave the browser, keeping the text it started from. A second `Esc` cancels the dialog | -Directories only, hidden ones excluded, and nothing is ever written. Note that `Enter` means *select* here but *open* in the field — the browser's job is to fill the field, so `→` alone expands (unlike the file-tree view, where `Enter` expands too). Paths keep your own notation: browsing out of `~/coding` gives you back `~/coding/…`, not an absolute path. Mouse selection isn't supported; the browser is keyboard-only. +Directories only, hidden ones excluded, and nothing is ever written. Note that `Enter` means *select* here but *open* in the field — the browser's job is to fill the field, so `→` alone expands — matching the file-tree view, where `Enter` opens a file rather than expanding. Paths keep your own notation: browsing out of `~/coding` gives you back `~/coding/…`, not an absolute path. Mouse selection isn't supported; the browser is keyboard-only. ## Keyboard shortcuts @@ -236,7 +236,7 @@ through to the terminal program. | ` f` | Zoom the list pane to full screen (toggle) | | `/` | Incremental search (status: paths; log: commit summaries; drill-down: paths; tree: recursive filenames) | | `Esc` | Clear filter, then exit drill-down (log), then cancel search bar | -| `Enter` | Confirm filter (keeps query) or drill into commit's file list (log view) | +| `Enter` | Confirm filter (keeps query), drill into commit's file list (log view), or open the selected file fullscreen (tree view) | ### Diff viewer (right panel) @@ -251,6 +251,7 @@ through to the terminal program. | `s` | Toggle between the unified diff and a side-by-side split view (falls back to unified when the pane is too narrow) | | — | **Line numbers** are always shown in a pinned gutter. The unified view shows both sides (old, new) — an added line leaves the old column blank, a removed line leaves the new one blank. The split view numbers each half with the side it shows, and the file view (`v`) numbers the file itself. The gutter stays in place while `←`/`→` scroll the code | | ` f` | Zoom the diff/file pane to full screen (toggle) | +| `Enter` | Zoom the diff/file pane to full screen (toggle) — same as ` f` | | `/` | Open search (works in both diff and file preview, including tree mode) | | `n` / `N` | Next / previous search match | | `Esc` | Clear search | diff --git a/docs/architecture.md b/docs/architecture.md index b87e5e9..748cfec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -589,7 +589,7 @@ repo 헤더다. 플로팅 팝업을 쓰지 않은 이유는 `src/ui/`에 오버 거기다 — 배울 키 없이 도달하는 경로를 하나 남긴다. - **`Enter`는 확정이 아니라 필드로 되돌리며 경로를 채운다.** repo를 실제로 여는 지점은 필드의 `Enter` 한 곳뿐이다. 그래서 `Enter`의 의미가 두 surface에서 - 갈리고, 브라우저에서는 확장이 `→` 전용이다(트리 뷰는 `Enter`도 확장한다). + 갈리고, 브라우저에서는 확장이 `→` 전용이다(트리 뷰도 확장은 `→`/`←` 전용이며 `Enter`는 파일 열기다). - **평면 row 리스트**로 들고 있다. 확장은 자식을 부모 뒤에 splice, 접기는 아래 깊은 row를 drain — 선택이 화면 인덱스 그대로여서 프레임마다 flatten이 없다. - **사용자 표기를 보존한다**(완성기와 같은 이유). `root_text`(타이핑한 그대로)와 From 8aac98ededfee2bc30ff1ab26e297a2a0e062cdf Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 1 Aug 2026 00:26:32 +0900 Subject: [PATCH 07/13] chore: reformat a log call the current rustfmt rewraps --- src/web/viewer/terminal/hub_diag.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/web/viewer/terminal/hub_diag.rs b/src/web/viewer/terminal/hub_diag.rs index 8f33d00..aba08bb 100644 --- a/src/web/viewer/terminal/hub_diag.rs +++ b/src/web/viewer/terminal/hub_diag.rs @@ -57,11 +57,7 @@ impl ClearWatch { return; }; if let Some(total) = note.previous_burst_total { - tracing::info!( - pane, - total, - "viewer: end of a run of screen-clearing input" - ); + tracing::info!(pane, total, "viewer: end of a run of screen-clearing input"); } if note.suppressed { return; From 4b13f003119423d1347adc68db3b3ae387c89042 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 1 Aug 2026 00:26:32 +0900 Subject: [PATCH 08/13] refactor(log): move commit row rendering into its own module --- src/ui/{commit_list.rs => commit_list/mod.rs} | 58 ++------------- src/ui/commit_list/row.rs | 72 +++++++++++++++++++ 2 files changed, 76 insertions(+), 54 deletions(-) rename src/ui/{commit_list.rs => commit_list/mod.rs} (75%) create mode 100644 src/ui/commit_list/row.rs diff --git a/src/ui/commit_list.rs b/src/ui/commit_list/mod.rs similarity index 75% rename from src/ui/commit_list.rs rename to src/ui/commit_list/mod.rs index 9ad83f2..3b89c78 100644 --- a/src/ui/commit_list.rs +++ b/src/ui/commit_list/mod.rs @@ -1,3 +1,5 @@ +mod row; + use crate::app::{App, Focus}; use ratatui::{ Frame, @@ -6,34 +8,6 @@ use ratatui::{ text::{Line, Span}, widgets::ListItem, }; -use std::time::{SystemTime, UNIX_EPOCH}; - -const SECS_PER_MINUTE: i64 = 60; -const SECS_PER_HOUR: i64 = 3_600; -const SECS_PER_DAY: i64 = 86_400; -const SECS_PER_MONTH: i64 = SECS_PER_DAY * 30; -const SECS_PER_YEAR: i64 = SECS_PER_DAY * 365; - -fn format_relative_time(ts: i64) -> String { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - let secs = now.saturating_sub(ts).max(0); - if secs < SECS_PER_MINUTE { - format!("{secs}s") - } else if secs < SECS_PER_HOUR { - format!("{}m", secs / SECS_PER_MINUTE) - } else if secs < SECS_PER_DAY { - format!("{}h", secs / SECS_PER_HOUR) - } else if secs < SECS_PER_MONTH { - format!("{}d", secs / SECS_PER_DAY) - } else if secs < SECS_PER_YEAR { - format!("{}mo", secs / SECS_PER_MONTH) - } else { - format!("{}y", secs / SECS_PER_YEAR) - } -} pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { if app.log_view.drill_down { @@ -71,24 +45,7 @@ fn render_commit_list(frame: &mut Frame, app: &App, area: Rect, accent: Color) { .iter() .map(|&i| { let entry = &app.log_view.commits[i]; - let time_str = format_relative_time(entry.time); - let author_short: String = entry.author.chars().take(10).collect(); - let marker = if i < ahead_count { "↑ " } else { " " }; - let summary = super::char_offset(&entry.summary, scroll_x); - let line = Line::from(vec![ - Span::styled(marker, Style::default().fg(Color::Green)), - Span::styled(format!("{} ", entry.short_id), Style::default().fg(accent)), - Span::styled( - format!("{:>4} ", time_str), - Style::default().fg(Color::Gray), - ), - Span::styled( - format!("{:<10} ", author_short), - Style::default().fg(Color::Cyan), - ), - Span::raw(summary), - ]); - ListItem::new(line) + ListItem::new(row::commit_row(entry, i < ahead_count, scroll_x, accent)) }) .collect(); @@ -225,14 +182,7 @@ fn truncate_title(title: &str, max_chars: usize) -> String { #[cfg(test)] mod tests { - use super::{format_relative_time, truncate_title}; - - #[test] - fn format_relative_time_handles_far_future_timestamp() { - // Corrupt/malicious commit timestamps must not panic on i64 underflow. - let s = format_relative_time(i64::MAX); - assert_eq!(s, "0s"); - } + use super::truncate_title; #[test] fn truncate_title_handles_multibyte_text() { diff --git a/src/ui/commit_list/row.rs b/src/ui/commit_list/row.rs new file mode 100644 index 0000000..1dc2659 --- /dev/null +++ b/src/ui/commit_list/row.rs @@ -0,0 +1,72 @@ +use crate::git::diff::CommitEntry; +use ratatui::{ + style::{Color, Style}, + text::{Line, Span}, +}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SECS_PER_MINUTE: i64 = 60; +const SECS_PER_HOUR: i64 = 3_600; +const SECS_PER_DAY: i64 = 86_400; +const SECS_PER_MONTH: i64 = SECS_PER_DAY * 30; +const SECS_PER_YEAR: i64 = SECS_PER_DAY * 365; + +const AUTHOR_WIDTH: usize = 10; + +pub(super) fn format_relative_time(ts: i64) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let secs = now.saturating_sub(ts).max(0); + if secs < SECS_PER_MINUTE { + format!("{secs}s") + } else if secs < SECS_PER_HOUR { + format!("{}m", secs / SECS_PER_MINUTE) + } else if secs < SECS_PER_DAY { + format!("{}h", secs / SECS_PER_HOUR) + } else if secs < SECS_PER_MONTH { + format!("{}d", secs / SECS_PER_DAY) + } else if secs < SECS_PER_YEAR { + format!("{}mo", secs / SECS_PER_MONTH) + } else { + format!("{}y", secs / SECS_PER_YEAR) + } +} + +pub(super) fn commit_row<'a>( + entry: &'a CommitEntry, + ahead: bool, + scroll_x: usize, + accent: Color, +) -> Line<'a> { + let time_str = format_relative_time(entry.time); + let author_short: String = entry.author.chars().take(AUTHOR_WIDTH).collect(); + let marker = if ahead { "↑ " } else { " " }; + let summary = crate::ui::char_offset(&entry.summary, scroll_x); + Line::from(vec![ + Span::styled(marker, Style::default().fg(Color::Green)), + Span::styled(format!("{} ", entry.short_id), Style::default().fg(accent)), + Span::styled( + format!("{:>4} ", time_str), + Style::default().fg(Color::Gray), + ), + Span::styled( + format!("{author_short: Date: Sat, 1 Aug 2026 00:26:32 +0900 Subject: [PATCH 09/13] feat(ui): render an absolute date alongside the wall-clock time --- src/ui/wall_clock.rs | 115 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 97 insertions(+), 18 deletions(-) diff --git a/src/ui/wall_clock.rs b/src/ui/wall_clock.rs index 2b74d68..2924a19 100644 --- a/src/ui/wall_clock.rs +++ b/src/ui/wall_clock.rs @@ -1,9 +1,9 @@ //! Turning a unix epoch second into the `HH:MM` a person reads off their own //! clock. //! -//! Hand-rolled because nightcrow has no date crate and a deadline is the only -//! absolute time it ever renders: adding `chrono`/`time` for two integers would -//! buy a dependency and its transitive tree for one format string. +//! Hand-rolled because nightcrow has no date crate: adding `chrono`/`time` for +//! a handful of integers would buy a dependency and its transitive tree for two +//! format strings. #[cfg(any(not(unix), test))] const SECS_PER_MINUTE: i64 = 60; @@ -18,15 +18,34 @@ const SECS_PER_DAY: i64 = 86_400; /// `None` rather than a fallback on purpose: a wrong wall-clock time reads as /// fact, and the caller is expected to show nothing instead. pub(crate) fn local_hour_minute(epoch: i64) -> Option { - let (hour, minute) = local_hm(epoch)?; - Some(format!("{hour:02}:{minute:02}")) + let t = local_parts(epoch)?; + Some(format!("{:02}:{:02}", t.hour, t.minute)) +} + +/// `YYYY-MM-DD HH:MM` in the machine's local zone, with the same `None` +/// contract as [`local_hour_minute`]. +pub(crate) fn local_date_time(epoch: i64) -> Option { + let t = local_parts(epoch)?; + Some(format!( + "{:04}-{:02}-{:02} {:02}:{:02}", + t.year, t.month, t.day, t.hour, t.minute + )) +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct DateTimeParts { + pub year: i64, + pub month: u32, + pub day: u32, + pub hour: u32, + pub minute: u32, } // The conversion is identity where `time_t` is 64-bit and a real narrowing check // where it is 32-bit, so it has to stay even though it looks redundant here. #[allow(clippy::useless_conversion)] #[cfg(unix)] -fn local_hm(epoch: i64) -> Option<(u32, u32)> { +fn local_parts(epoch: i64) -> Option { let seconds: libc::time_t = epoch.try_into().ok()?; let mut parts: libc::tm = unsafe { std::mem::zeroed() }; // SAFETY: `seconds` is a live `time_t` and `parts` a live `tm` for the whole @@ -36,18 +55,22 @@ fn local_hm(epoch: i64) -> Option<(u32, u32)> { if filled.is_null() { return None; } - Some(( - u32::try_from(parts.tm_hour).ok()?, - u32::try_from(parts.tm_min).ok()?, - )) + Some(DateTimeParts { + // `tm` counts years from 1900 and months from 0. + year: i64::from(parts.tm_year) + 1900, + month: u32::try_from(parts.tm_mon).ok()? + 1, + day: u32::try_from(parts.tm_mday).ok()?, + hour: u32::try_from(parts.tm_hour).ok()?, + minute: u32::try_from(parts.tm_min).ok()?, + }) } /// UTC on platforms with no `localtime_r`. The zone database is the OS's to /// expose, and guessing an offset would be worse than being explicit about the /// one this falls back to. #[cfg(not(unix))] -fn local_hm(epoch: i64) -> Option<(u32, u32)> { - utc_hm(epoch) +fn local_parts(epoch: i64) -> Option { + utc_parts(epoch) } /// `HH:MM` in UTC, which is what the epoch already counts. @@ -55,27 +78,83 @@ fn local_hm(epoch: i64) -> Option<(u32, u32)> { /// `epoch.rem_euclid` rather than `%` so a pre-1970 timestamp lands on the right /// side of midnight instead of producing a negative hour. #[cfg(any(not(unix), test))] -fn utc_hm(epoch: i64) -> Option<(u32, u32)> { +fn utc_parts(epoch: i64) -> Option { let into_day = epoch.rem_euclid(SECS_PER_DAY); + let (year, month, day) = civil_from_days(epoch.div_euclid(SECS_PER_DAY))?; + Some(DateTimeParts { + year, + month, + day, + hour: u32::try_from(into_day / SECS_PER_HOUR).ok()?, + minute: u32::try_from((into_day % SECS_PER_HOUR) / SECS_PER_MINUTE).ok()?, + }) +} + +/// Days since the epoch to a proleptic Gregorian date, via Howard Hinnant's +/// `civil_from_days`: shift the era to start in March so the leap day lands at +/// the end of a year and the month arithmetic needs no table. +#[cfg(any(not(unix), test))] +fn civil_from_days(days: i64) -> Option<(i64, u32, u32)> { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; Some(( - u32::try_from(into_day / SECS_PER_HOUR).ok()?, - u32::try_from((into_day % SECS_PER_HOUR) / SECS_PER_MINUTE).ok()?, + if m <= 2 { y + 1 } else { y }, + u32::try_from(m).ok()?, + u32::try_from(d).ok()?, )) } #[cfg(test)] mod tests { - use super::{local_hour_minute, utc_hm}; + use super::{civil_from_days, local_date_time, local_hour_minute, utc_parts}; + + fn hm(epoch: i64) -> Option<(u32, u32)> { + utc_parts(epoch).map(|t| (t.hour, t.minute)) + } #[test] fn the_epoch_itself_is_midnight_utc() { - assert_eq!(utc_hm(0), Some((0, 0))); + assert_eq!(hm(0), Some((0, 0))); } #[test] fn a_timestamp_before_the_epoch_stays_inside_the_day() { // One minute before 1970 is 23:59, not a negative hour. - assert_eq!(utc_hm(-60), Some((23, 59))); + assert_eq!(hm(-60), Some((23, 59))); + } + + #[test] + fn the_epoch_itself_is_the_first_of_january_1970() { + assert_eq!(civil_from_days(0), Some((1970, 1, 1))); + } + + #[test] + fn a_day_before_the_epoch_is_the_last_of_december_1969() { + assert_eq!(civil_from_days(-1), Some((1969, 12, 31))); + } + + #[test] + fn a_leap_day_is_placed_on_the_twenty_ninth_of_february() { + // 2024-02-29 00:00 UTC. + assert_eq!(civil_from_days(19_782), Some((2024, 2, 29))); + } + + #[test] + fn a_commit_time_renders_as_a_date_and_a_clock() { + let rendered = local_date_time(1_700_000_000).expect("a plausible commit time must render"); + assert_eq!(rendered.len(), 16, "{rendered}"); + } + + #[test] + fn a_commit_time_no_platform_clock_can_place_renders_nothing() { + assert_eq!(local_date_time(i64::MIN), None); } #[test] From e46089423c8a6cee5c912fe4738fbf6157df2c14 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 1 Aug 2026 00:26:32 +0900 Subject: [PATCH 10/13] feat(git): map refs onto commits and split ahead/behind by oid --- src/git/diff.rs | 2 + src/git/diff/commit_log.rs | 9 +- src/git/diff/refs.rs | 185 +++++++++++++++++++++++++++++++++++++ src/git/diff/snapshot.rs | 1 + src/git/diff/tests/mod.rs | 1 + src/git/diff/tests/refs.rs | 109 ++++++++++++++++++++++ src/git/diff/types.rs | 23 +++++ 7 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 src/git/diff/refs.rs create mode 100644 src/git/diff/tests/refs.rs diff --git a/src/git/diff.rs b/src/git/diff.rs index 104bf6f..767b8b2 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -1,5 +1,6 @@ mod commit_log; mod diff_load; +mod refs; mod snapshot; mod types; @@ -12,6 +13,7 @@ pub use diff_load::{ load_commit_diff, load_commit_file_blob, load_commit_file_diff, load_commit_files, load_file_diff, load_workdir_file, parse_hunk_new_start, }; +pub use refs::{LogDecorations, RefKind, RefLabel, load_log_decorations}; pub use snapshot::load_snapshot; #[cfg(test)] pub use types::DiffLine; diff --git a/src/git/diff/commit_log.rs b/src/git/diff/commit_log.rs index a5aacea..314b947 100644 --- a/src/git/diff/commit_log.rs +++ b/src/git/diff/commit_log.rs @@ -67,9 +67,14 @@ pub fn load_commit_log_from( let oid = oid_result.context("revwalk error")?; let commit = repo.find_commit(oid).context("failed to find commit")?; let summary = commit.summary().ok().flatten().unwrap_or("").to_string(); - let author = commit.author().name().unwrap_or("Unknown").to_string(); + let signature = commit.author(); + let author = signature.name().unwrap_or("Unknown").to_string(); + let email = signature.email().unwrap_or("").to_string(); let time = commit.time().seconds(); - entries.push(CommitEntry::new(oid, short_oid(oid), summary, author, time)); + entries.push( + CommitEntry::new(oid, short_oid(oid), summary, author, time) + .with_commit_meta(email, commit.parent_count()), + ); } Ok(entries) } diff --git a/src/git/diff/refs.rs b/src/git/diff/refs.rs new file mode 100644 index 0000000..c20acb1 --- /dev/null +++ b/src/git/diff/refs.rs @@ -0,0 +1,185 @@ +use anyhow::{Context, Result}; +use git2::{Oid, Repository}; +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; + +/// Upper bound on the oids collected per divergence side. A repository can +/// diverge from its upstream by an arbitrary number of commits, and the sets +/// exist only to mark rows the user can actually scroll to; the walk yields +/// newest-first, so the cap drops the far tail rather than the visible head. +const MAX_DIVERGENCE_OIDS: usize = 1_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum RefKind { + /// The branch HEAD points at, or a detached HEAD. + Head, + LocalBranch, + Tag, + RemoteBranch, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RefLabel { + pub kind: RefKind, + /// Shorthand form (`dev`, `origin/dev`, `v1.2.0`). For a detached HEAD + /// this is `HEAD`. + pub name: String, +} + +/// Everything the commit log needs to decorate rows: which refs point at which +/// commit, and which commits are ahead of / behind the upstream. +/// +/// Built from refs alone, so it stays valid until a ref moves. Callers rebuild +/// it when [`refs_fingerprint`] changes rather than per frame or per poll. +#[derive(Debug, Default)] +pub struct LogDecorations { + labels: HashMap>, + ahead: HashSet, + behind: HashSet, + head: Option, +} + +impl LogDecorations { + pub fn labels_for(&self, oid: Oid) -> &[RefLabel] { + self.labels.get(&oid).map_or(&[], Vec::as_slice) + } + + pub fn is_ahead(&self, oid: Oid) -> bool { + self.ahead.contains(&oid) + } + + pub fn is_behind(&self, oid: Oid) -> bool { + self.behind.contains(&oid) + } + + pub fn is_head(&self, oid: Oid) -> bool { + self.head == Some(oid) + } +} + +/// Cheap summary of every ref's name and target, used to decide whether +/// [`load_log_decorations`] needs to run again. A fetch that advances +/// `origin/dev` changes this even though HEAD did not move. +pub fn refs_fingerprint(repo: &Repository) -> u64 { + let Ok(refs) = repo.references() else { + return 0; + }; + // Order-independent so libgit2's iteration order is not part of the + // contract: each ref hashes on its own and the digests are summed. + let mut sum: u64 = 0; + for reference in refs.flatten() { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + reference.name_bytes().hash(&mut hasher); + reference + .target() + .map(|oid| oid.as_bytes().to_vec()) + .hash(&mut hasher); + sum = sum.wrapping_add(hasher.finish()); + } + sum +} + +pub fn load_log_decorations(repo: &Repository) -> Result { + let mut labels: HashMap> = HashMap::new(); + + for reference in repo + .references() + .context("failed to list references")? + .flatten() + { + let kind = if reference.is_branch() { + RefKind::LocalBranch + } else if reference.is_remote() { + RefKind::RemoteBranch + } else if reference.is_tag() { + RefKind::Tag + } else { + // refs/stash, refs/notes/*, and the symbolic HEAD itself — HEAD is + // folded into its branch label below instead of listed twice. + continue; + }; + // Peels rather than reading `target()` so an annotated tag decorates + // the commit it points at instead of its own tag object. + let Ok(commit) = reference.peel_to_commit() else { + continue; + }; + let Some(name) = reference.shorthand().ok().map(String::from) else { + continue; + }; + labels + .entry(commit.id()) + .or_default() + .push(RefLabel { kind, name }); + } + + let head_ref = repo.head().ok(); + let head = head_ref.as_ref().and_then(|h| h.target()); + if let Some(head_oid) = head { + let branch = head_ref + .as_ref() + .filter(|h| h.is_branch()) + .and_then(|h| h.shorthand().ok().map(String::from)); + let entry = labels.entry(head_oid).or_default(); + match branch { + // Promote the local branch label rather than adding a second one, + // so the row reads `HEAD -> dev` and not `HEAD dev`. + Some(name) => { + if let Some(label) = entry + .iter_mut() + .find(|l| l.kind == RefKind::LocalBranch && l.name == name) + { + label.kind = RefKind::Head; + } else { + entry.push(RefLabel { + kind: RefKind::Head, + name, + }); + } + } + None => entry.push(RefLabel { + kind: RefKind::Head, + name: "HEAD".to_string(), + }), + } + } + + for chips in labels.values_mut() { + chips.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.cmp(&b.name))); + } + + let (ahead, behind) = divergence_oids(repo).unwrap_or_default(); + Ok(LogDecorations { + labels, + ahead, + behind, + head, + }) +} + +/// Oids that exist on exactly one side of the HEAD/upstream split. +/// +/// `None` when HEAD is detached, unborn, or has no upstream — there is nothing +/// to diverge from, which is not an error. +fn divergence_oids(repo: &Repository) -> Option<(HashSet, HashSet)> { + let head = repo.head().ok()?; + if !head.is_branch() { + return None; + } + let local = head.target()?; + let upstream = git2::Branch::wrap(head).upstream().ok()?.get().target()?; + Some(( + exclusive_oids(repo, local, upstream), + exclusive_oids(repo, upstream, local), + )) +} + +/// Commits reachable from `from` but not from `hidden` — `git rev-list from ^hidden`. +fn exclusive_oids(repo: &Repository, from: Oid, hidden: Oid) -> HashSet { + let Ok(mut revwalk) = repo.revwalk() else { + return HashSet::new(); + }; + if revwalk.push(from).is_err() || revwalk.hide(hidden).is_err() { + return HashSet::new(); + } + revwalk.flatten().take(MAX_DIVERGENCE_OIDS).collect() +} diff --git a/src/git/diff/snapshot.rs b/src/git/diff/snapshot.rs index 334671e..b88412a 100644 --- a/src/git/diff/snapshot.rs +++ b/src/git/diff/snapshot.rs @@ -64,6 +64,7 @@ pub fn load_snapshot(repo: &Repository) -> Result { tracking, head_oid, branch_name, + refs_fingerprint: crate::git::diff::refs::refs_fingerprint(repo), }) } diff --git a/src/git/diff/tests/mod.rs b/src/git/diff/tests/mod.rs index 7af6d0b..12dfade 100644 --- a/src/git/diff/tests/mod.rs +++ b/src/git/diff/tests/mod.rs @@ -1,5 +1,6 @@ mod commit_log; mod diff_lineno; mod diff_load; +mod refs; mod snapshot; mod status_scope; diff --git a/src/git/diff/tests/refs.rs b/src/git/diff/tests/refs.rs new file mode 100644 index 0000000..0abf1d0 --- /dev/null +++ b/src/git/diff/tests/refs.rs @@ -0,0 +1,109 @@ +use crate::git::diff::refs::refs_fingerprint; +use crate::git::diff::{RefKind, load_commit_log, load_log_decorations}; +use crate::test_util::{make_repo, open_repo, run_git}; +use std::path::Path; + +fn commit(path: &str, name: &str) { + std::fs::write(Path::new(path).join(name), name).unwrap(); + run_git(path, &["add", "."]); + run_git(path, &["commit", "-m", name]); +} + +#[test] +fn head_decorates_the_branch_it_points_at() { + let (dir, path) = make_repo(); + commit(&path, "a"); + let repo = open_repo(&path); + let head = repo.head().unwrap().target().unwrap(); + + let decorations = load_log_decorations(&repo).unwrap(); + + assert!(decorations.is_head(head)); + let labels = decorations.labels_for(head); + assert_eq!(labels.len(), 1, "{labels:?}"); + assert_eq!(labels[0].kind, RefKind::Head); + drop(dir); +} + +#[test] +fn a_tag_decorates_the_commit_it_points_at() { + let (dir, path) = make_repo(); + commit(&path, "a"); + commit(&path, "b"); + run_git(&path, &["tag", "-a", "v1", "-m", "release", "HEAD~1"]); + let repo = open_repo(&path); + let commits = load_commit_log(&repo, 10).unwrap(); + let tagged = commits[1].oid; + + let decorations = load_log_decorations(&repo).unwrap(); + + let labels = decorations.labels_for(tagged); + assert!( + labels + .iter() + .any(|l| l.kind == RefKind::Tag && l.name == "v1"), + "{labels:?}" + ); + drop(dir); +} + +#[test] +fn a_commit_no_ref_points_at_gets_no_labels() { + let (dir, path) = make_repo(); + commit(&path, "a"); + commit(&path, "b"); + let repo = open_repo(&path); + let commits = load_commit_log(&repo, 10).unwrap(); + + let decorations = load_log_decorations(&repo).unwrap(); + + assert!(decorations.labels_for(commits[1].oid).is_empty()); + drop(dir); +} + +#[test] +fn a_repo_with_no_upstream_marks_nothing_as_diverged() { + let (dir, path) = make_repo(); + commit(&path, "a"); + let repo = open_repo(&path); + let head = repo.head().unwrap().target().unwrap(); + + let decorations = load_log_decorations(&repo).unwrap(); + + assert!(!decorations.is_ahead(head)); + assert!(!decorations.is_behind(head)); + drop(dir); +} + +#[test] +fn an_empty_repo_yields_no_decorations() { + let (dir, path) = make_repo(); + + let decorations = load_log_decorations(&open_repo(&path)).unwrap(); + + assert!(decorations.labels_for(git2::Oid::ZERO_SHA1).is_empty()); + drop(dir); +} + +#[test] +fn the_fingerprint_changes_when_a_ref_moves() { + let (dir, path) = make_repo(); + commit(&path, "a"); + let before = refs_fingerprint(&open_repo(&path)); + + commit(&path, "b"); + + assert_ne!(before, refs_fingerprint(&open_repo(&path))); + drop(dir); +} + +#[test] +fn the_fingerprint_is_stable_when_refs_do_not_move() { + let (dir, path) = make_repo(); + commit(&path, "a"); + + let first = refs_fingerprint(&open_repo(&path)); + + assert_eq!(first, refs_fingerprint(&open_repo(&path))); + drop(dir); +} diff --git a/src/git/diff/types.rs b/src/git/diff/types.rs index f54df6d..8d2e743 100644 --- a/src/git/diff/types.rs +++ b/src/git/diff/types.rs @@ -193,6 +193,10 @@ pub struct RepoSnapshot { /// `None` for detached HEAD, unborn branch, or bare repo so the header /// can decide whether to render the branch chip. pub branch_name: Option, + /// Digest over every ref name and target. Refs move without HEAD moving + /// (a fetch advances `origin/dev`), so the Log view rebuilds its ref + /// decoration map when this changes rather than on HEAD changes alone. + pub refs_fingerprint: u64, } #[derive(Debug, Clone)] @@ -205,7 +209,12 @@ pub struct CommitEntry { /// keystroke. Mirrors `ChangedFile::search_lower`. pub summary_lower: String, pub author: String, + /// Author email, shown only in the wide layout. Empty when the commit + /// carries none. + pub author_email: String, pub time: i64, + /// Number of parents. `> 1` marks a merge commit; 0 marks a root commit. + pub parent_count: usize, } impl CommitEntry { @@ -217,9 +226,23 @@ impl CommitEntry { summary, summary_lower, author, + author_email: String::new(), time, + parent_count: 1, } } + + /// Attach the fields that come off the same `git2::Commit` object as the + /// rest of the entry, so the loader pays no extra ODB lookup for them. + pub fn with_commit_meta(mut self, author_email: String, parent_count: usize) -> Self { + self.author_email = author_email; + self.parent_count = parent_count; + self + } + + pub fn is_merge(&self) -> bool { + self.parent_count > 1 + } } impl std::fmt::Display for CommitEntry { From bed743d091daf1b4782ad1557ca7423d286c3717 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 1 Aug 2026 00:26:32 +0900 Subject: [PATCH 11/13] feat(app): rebuild the ref decoration map when a ref moves --- src/app.rs | 5 +++++ src/app/app_impl.rs | 2 ++ src/app/snapshot_io.rs | 17 +++++++++++++++++ src/app/tests/auto_follow.rs | 1 + src/app/tests/diff_file_view.rs | 1 + src/app/tests/head_change.rs | 1 + src/app/tests/helpers.rs | 2 ++ src/app/tests/snapshot.rs | 3 +++ src/app/tests/snapshot_refresh.rs | 5 +++++ src/app/tests/tree_session.rs | 1 + src/web/viewer/runtime/runtime_tests.rs | 1 + 11 files changed, 39 insertions(+) diff --git a/src/app.rs b/src/app.rs index 3deb26b..f49b6dc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -141,6 +141,11 @@ pub struct App { pub list_fullscreen: bool, // `None` for detached HEAD / unborn branch / bare repo. pub branch_name: Option, + // Ref chips and ahead/behind sets for the Log view. Rebuilt only when + // `last_refs_fingerprint` disagrees with the newest snapshot's, so a fetch + // that moves `origin/dev` refreshes it but an idle poll does not. + pub log_decorations: crate::git::diff::LogDecorations, + pub(crate) last_refs_fingerprint: Option, pub leader: KeyEvent, // No timeout: stays armed until a follow-up key or `Esc`/`Ctrl+C` resolves it. pub prefix_armed: bool, diff --git a/src/app/app_impl.rs b/src/app/app_impl.rs index 27e387d..4bcff3d 100644 --- a/src/app/app_impl.rs +++ b/src/app/app_impl.rs @@ -118,6 +118,8 @@ impl App { auto_follow: AutoFollow::default(), list_fullscreen: false, branch_name: None, + log_decorations: Default::default(), + last_refs_fingerprint: None, leader, prefix_armed: false, awaiting_swap_target: false, diff --git a/src/app/snapshot_io.rs b/src/app/snapshot_io.rs index bf40600..588e124 100644 --- a/src/app/snapshot_io.rs +++ b/src/app/snapshot_io.rs @@ -51,6 +51,7 @@ impl App { }); let new_head = snapshot.head_oid; self.branch_name = snapshot.branch_name; + self.refresh_log_decorations(snapshot.refs_fingerprint); self.status_view.set_files(snapshot.files); self.status_view.recompute_filter(); self.tracking = snapshot.tracking; @@ -87,6 +88,22 @@ impl App { } } + // Rebuilding walks every ref and peels each one, so it is gated on the + // fingerprint rather than run per poll. A failure leaves the previous map in + // place: stale chips beat chips vanishing on a transient read error. + fn refresh_log_decorations(&mut self, fingerprint: u64) { + if self.last_refs_fingerprint == Some(fingerprint) { + return; + } + match self.with_repo(crate::git::diff::load_log_decorations) { + Ok(decorations) => { + self.log_decorations = decorations; + self.last_refs_fingerprint = Some(fingerprint); + } + Err(e) => tracing::warn!(error = %e, "failed to load ref decorations"), + } + } + // A path whose previous mtime was newer than the freshly observed one keeps // its previous mtime — a rename-from-stash can resurrect older mtimes for // the same path and must not demote a recent edit to cool. Updates in place diff --git a/src/app/tests/auto_follow.rs b/src/app/tests/auto_follow.rs index 9050200..f846ff4 100644 --- a/src/app/tests/auto_follow.rs +++ b/src/app/tests/auto_follow.rs @@ -9,6 +9,7 @@ fn snapshot_with(paths: &[&str]) -> RepoSnapshot { tracking: None, head_oid: None, branch_name: None, + refs_fingerprint: 0, } } diff --git a/src/app/tests/diff_file_view.rs b/src/app/tests/diff_file_view.rs index 1e7bacb..aa0601e 100644 --- a/src/app/tests/diff_file_view.rs +++ b/src/app/tests/diff_file_view.rs @@ -166,6 +166,7 @@ fn snapshot_refresh_with_no_filter_matches_clears_file_view() { tracking: None, head_oid: None, branch_name: None, + refs_fingerprint: 0, }, HashMap::new(), )) diff --git a/src/app/tests/head_change.rs b/src/app/tests/head_change.rs index 8083bdb..6a682e5 100644 --- a/src/app/tests/head_change.rs +++ b/src/app/tests/head_change.rs @@ -10,6 +10,7 @@ fn snapshot_with_head(repo_path: &str) -> RepoSnapshot { tracking: None, head_oid: head, branch_name: None, + refs_fingerprint: 0, } } diff --git a/src/app/tests/helpers.rs b/src/app/tests/helpers.rs index 1c9160a..4edb56f 100644 --- a/src/app/tests/helpers.rs +++ b/src/app/tests/helpers.rs @@ -74,6 +74,8 @@ pub(crate) fn app_with_files(files: Vec<&str>) -> App { auto_follow: AutoFollow::default(), list_fullscreen: false, branch_name: None, + log_decorations: Default::default(), + last_refs_fingerprint: None, leader: KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), prefix_armed: false, awaiting_swap_target: false, diff --git a/src/app/tests/snapshot.rs b/src/app/tests/snapshot.rs index a90db32..c6b4f63 100644 --- a/src/app/tests/snapshot.rs +++ b/src/app/tests/snapshot.rs @@ -18,6 +18,7 @@ fn drain_snapshot_empties_the_queue_without_applying_it() { tracking: None, head_oid: None, branch_name: None, + refs_fingerprint: 0, }, HashMap::new(), ) @@ -65,6 +66,7 @@ fn a_saved_mode_lands_immediately_and_survives_being_changed() { tracking: None, head_oid: None, branch_name: None, + refs_fingerprint: 0, }, HashMap::new(), )) @@ -100,6 +102,7 @@ fn a_saved_selection_is_restored_by_the_first_snapshot() { tracking: None, head_oid: None, branch_name: None, + refs_fingerprint: 0, }, HashMap::new(), )) diff --git a/src/app/tests/snapshot_refresh.rs b/src/app/tests/snapshot_refresh.rs index 5e23a38..c1d1205 100644 --- a/src/app/tests/snapshot_refresh.rs +++ b/src/app/tests/snapshot_refresh.rs @@ -20,6 +20,7 @@ fn successful_snapshot_preserves_terminal_status() { tracking: None, head_oid: None, branch_name: None, + refs_fingerprint: 0, }, HashMap::new(), )) @@ -48,6 +49,7 @@ fn successful_snapshot_clears_git_status() { tracking: None, head_oid: None, branch_name: None, + refs_fingerprint: 0, }, HashMap::new(), )) @@ -77,6 +79,7 @@ fn snapshot_refresh_clamps_selection_to_active_filter() { tracking: None, head_oid: None, branch_name: None, + refs_fingerprint: 0, }, HashMap::new(), )) @@ -113,6 +116,7 @@ fn snapshot_invalidates_path_width_cache_on_same_length_rename() { tracking: None, head_oid: None, branch_name: None, + refs_fingerprint: 0, }, HashMap::new(), )) @@ -147,6 +151,7 @@ fn snapshot_refresh_with_no_filter_matches_clears_stale_diff() { tracking: None, head_oid: None, branch_name: None, + refs_fingerprint: 0, }, HashMap::new(), )) diff --git a/src/app/tests/tree_session.rs b/src/app/tests/tree_session.rs index e6572df..85fca4d 100644 --- a/src/app/tests/tree_session.rs +++ b/src/app/tests/tree_session.rs @@ -102,6 +102,7 @@ fn tree_preview_survives_status_snapshot() { tracking: None, head_oid: None, branch_name: None, + refs_fingerprint: 0, }, HashMap::new(), )) diff --git a/src/web/viewer/runtime/runtime_tests.rs b/src/web/viewer/runtime/runtime_tests.rs index 4e2b6fa..154877c 100644 --- a/src/web/viewer/runtime/runtime_tests.rs +++ b/src/web/viewer/runtime/runtime_tests.rs @@ -40,6 +40,7 @@ fn snapshot(branch: &str, files: usize) -> RepoSnapshot { tracking: None, head_oid: None, branch_name: Some(branch.to_string()), + refs_fingerprint: 0, } } From ab87001b45f5decf98253b3f10b8af9d9cd7bebd Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 1 Aug 2026 00:26:32 +0900 Subject: [PATCH 12/13] feat(log): decorate commit rows with refs, HEAD, and merge glyphs --- src/ui/commit_list/mod.rs | 10 ++- src/ui/commit_list/row.rs | 180 ++++++++++++++++++++++++++++++++++---- 2 files changed, 168 insertions(+), 22 deletions(-) diff --git a/src/ui/commit_list/mod.rs b/src/ui/commit_list/mod.rs index 3b89c78..b9ed1bf 100644 --- a/src/ui/commit_list/mod.rs +++ b/src/ui/commit_list/mod.rs @@ -21,8 +21,6 @@ fn render_commit_list(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let focused = app.focus == Focus::FileList; let border_style = super::focused_border_style(focused, accent); - let ahead_count = app.tracking.as_ref().map_or(0, |t| t.ahead); - let show_search = app.log_view.commit_search_active || !app.log_view.commit_search_query.is_empty(); @@ -45,7 +43,13 @@ fn render_commit_list(frame: &mut Frame, app: &App, area: Rect, accent: Color) { .iter() .map(|&i| { let entry = &app.log_view.commits[i]; - ListItem::new(row::commit_row(entry, i < ahead_count, scroll_x, accent)) + ListItem::new(row::commit_row( + entry, + &app.log_decorations, + list_area.width, + scroll_x, + accent, + )) }) .collect(); diff --git a/src/ui/commit_list/row.rs b/src/ui/commit_list/row.rs index 1dc2659..26cb4cf 100644 --- a/src/ui/commit_list/row.rs +++ b/src/ui/commit_list/row.rs @@ -1,6 +1,7 @@ -use crate::git::diff::CommitEntry; +use crate::git::diff::{CommitEntry, LogDecorations, RefKind, RefLabel}; +use crate::ui::wall_clock::local_date_time; use ratatui::{ - style::{Color, Style}, + style::{Color, Modifier, Style}, text::{Line, Span}, }; use std::time::{SystemTime, UNIX_EPOCH}; @@ -11,7 +12,18 @@ const SECS_PER_DAY: i64 = 86_400; const SECS_PER_MONTH: i64 = SECS_PER_DAY * 30; const SECS_PER_YEAR: i64 = SECS_PER_DAY * 365; +/// Terminal width at which the row switches to absolute time, full author, and +/// untruncated ref chips. A width rule rather than the `list_fullscreen` flag: +/// a wide monitor has the room outside fullscreen too, and it keeps the +/// decision to one threshold — the same shape as `diff_viewer::MIN_SPLIT_WIDTH`. +pub(super) const MIN_DETAIL_WIDTH: u16 = 120; + const AUTHOR_WIDTH: usize = 10; +const WIDE_AUTHOR_WIDTH: usize = 24; +const WIDE_SHORT_ID: usize = 10; +/// Chip budget in the narrow layout, as a fraction of the row. Chips compete +/// with the summary there, so they get a slice rather than the whole width. +const NARROW_CHIP_BUDGET: usize = 24; pub(super) fn format_relative_time(ts: i64) -> String { let now = SystemTime::now() @@ -34,34 +46,133 @@ pub(super) fn format_relative_time(ts: i64) -> String { } } +/// Divergence glyph (`↑` ahead of upstream, `v` behind) and shape glyph +/// (`*` HEAD, `Y` merge). Two fixed cells so rows stay column-aligned. +fn glyphs(entry: &CommitEntry, decorations: &LogDecorations) -> (Span<'static>, Span<'static>) { + let divergence = if decorations.is_ahead(entry.oid) { + Span::styled("↑", Style::default().fg(Color::Green)) + } else if decorations.is_behind(entry.oid) { + Span::styled("v", Style::default().fg(Color::Yellow)) + } else { + Span::raw(" ") + }; + let shape = if decorations.is_head(entry.oid) { + Span::styled( + "*", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + } else if entry.is_merge() { + Span::styled("Y", Style::default().fg(Color::Magenta)) + } else { + Span::raw(" ") + }; + (divergence, shape) +} + +fn chip_style(kind: RefKind) -> Style { + // git's own `--decorate` palette, so the colors read the same as the CLI. + match kind { + RefKind::Head => Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + RefKind::LocalBranch => Style::default().fg(Color::Green), + RefKind::Tag => Style::default().fg(Color::Yellow), + RefKind::RemoteBranch => Style::default().fg(Color::Red), + } +} + +fn chip_text(label: &RefLabel) -> String { + match label.kind { + RefKind::Head if label.name != "HEAD" => format!("HEAD -> {}", label.name), + _ => label.name.clone(), + } +} + +/// Chips for one commit, dropped from the end once `budget` chars are used. +/// Labels arrive sorted by [`RefKind`] priority (HEAD > local > tag > remote), +/// so a short budget keeps the most orienting ones. +fn chip_spans(labels: &[RefLabel], budget: usize) -> Vec> { + let mut spans = Vec::new(); + let mut used = 0; + for label in labels { + let text = chip_text(label); + let width = text.chars().count() + 1; + if used + width > budget { + break; + } + used += width; + spans.push(Span::styled(text, chip_style(label.kind))); + spans.push(Span::raw(" ")); + } + spans +} + pub(super) fn commit_row<'a>( entry: &'a CommitEntry, - ahead: bool, + decorations: &LogDecorations, + width: u16, scroll_x: usize, accent: Color, ) -> Line<'a> { - let time_str = format_relative_time(entry.time); - let author_short: String = entry.author.chars().take(AUTHOR_WIDTH).collect(); - let marker = if ahead { "↑ " } else { " " }; - let summary = crate::ui::char_offset(&entry.summary, scroll_x); - Line::from(vec![ - Span::styled(marker, Style::default().fg(Color::Green)), - Span::styled(format!("{} ", entry.short_id), Style::default().fg(accent)), - Span::styled( - format!("{:>4} ", time_str), + let wide = width >= MIN_DETAIL_WIDTH; + let (divergence, shape) = glyphs(entry, decorations); + let mut spans = vec![divergence, shape, Span::raw(" ")]; + + let id = if wide { + entry.oid.to_string().chars().take(WIDE_SHORT_ID).collect() + } else { + entry.short_id.clone() + }; + spans.push(Span::styled(format!("{id} "), Style::default().fg(accent))); + + if wide { + // A timestamp the platform cannot place renders as blanks rather than a + // wrong date, keeping the column aligned. Same contract as `wall_clock`. + let stamp = local_date_time(entry.time).unwrap_or_else(|| " ".repeat(16)); + spans.push(Span::styled( + format!("{stamp} "), Style::default().fg(Color::Gray), - ), - Span::styled( - format!("{author_short: entry.author.clone(), + email => format!("{} <{email}>", entry.author), + }; + let who: String = who.chars().take(WIDE_AUTHOR_WIDTH).collect(); + spans.push(Span::styled( + format!("{who:4} ", format_relative_time(entry.time)), + Style::default().fg(Color::Gray), + )); + let author: String = entry.author.chars().take(AUTHOR_WIDTH).collect(); + spans.push(Span::styled( + format!("{author: RefLabel { + RefLabel { + kind, + name: name.to_string(), + } + } #[test] fn format_relative_time_handles_far_future_timestamp() { @@ -69,4 +180,35 @@ mod tests { let s = format_relative_time(i64::MAX); assert_eq!(s, "0s"); } + + #[test] + fn the_head_chip_names_the_branch_it_points_at() { + assert_eq!(chip_text(&label(RefKind::Head, "dev")), "HEAD -> dev"); + } + + #[test] + fn a_detached_head_chip_stays_bare() { + assert_eq!(chip_text(&label(RefKind::Head, "HEAD")), "HEAD"); + } + + #[test] + fn a_tight_budget_keeps_the_higher_priority_chips() { + let labels = vec![ + label(RefKind::Head, "dev"), + label(RefKind::RemoteBranch, "origin/dev"), + ]; + + let spans = chip_spans(&labels, 12); + + // "HEAD -> dev" plus its trailing space exactly fills the budget. + let text: String = spans.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(text, "HEAD -> dev "); + } + + #[test] + fn a_budget_too_small_for_any_chip_renders_none() { + let labels = vec![label(RefKind::RemoteBranch, "origin/dev")]; + + assert!(chip_spans(&labels, 4).is_empty()); + } } From 5b354cbbd46ee61f0d57ad8d5b0a210e90ce0f44 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 1 Aug 2026 00:26:32 +0900 Subject: [PATCH 13/13] docs: describe how the commit log decides its decorations --- docs/architecture.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index b3f57f1..7b9baa9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -889,6 +889,33 @@ hint bar는 오버레이(repo 입력·prefix armed·swap target)가 열리면 snapshot worker는 매 폴 사이클마다 현재 HEAD oid를 함께 보고한다. UI 스레드는 `poll_snapshot`에서 oid 변동을 감지하면 `refresh_commit_log_after_head_change`로 commit log와 drill-down 상태를 동일 oid 기준으로 재정렬해, 터미널에서 새 커밋·amend·force-push·브랜치 전환이 일어났을 때도 로그 뷰가 즉시 따라잡는다. +### Commit Log Decoration + +`git log --decorate`가 주는 방향 감각을 로그 뷰에 옮긴 것이다. `src/git/diff/refs.rs`가 +`repo.references()`를 한 번 걸어 `Oid -> Vec` 맵을 만들고, HEAD·로컬 브랜치·태그· +원격 브랜치를 구분해 커밋 행에 chip으로 그린다. 비용은 커밋 수가 아니라 **ref 수**에 +비례하고, annotated tag은 `peel_to_commit`으로 가리키는 커밋에 붙인다. + +- **재생성 시점은 refs fingerprint가 정한다**: fetch가 `origin/dev`를 옮기면 HEAD는 + 그대로여도 chip은 달라져야 한다. snapshot worker가 매 폴마다 ref 이름·타깃의 다이제스트를 + `RepoSnapshot::refs_fingerprint`로 실어 보내고, UI 스레드는 그 값이 바뀔 때만 맵을 다시 + 만든다. 재생성 실패는 이전 맵을 유지한다 — 일시적 읽기 오류로 chip이 사라지는 것보다 + 낫다. +- **ahead/behind는 위치가 아니라 oid 집합으로 판정한다**: 이전 구현은 "위에서 N개가 + ahead"라는 위치 가정이었고, anchor가 HEAD가 아니거나 필터가 걸리면 마커가 엉뚱한 행에 + 붙었다. 지금은 `revwalk.push(local)` + `hide(upstream)`(과 그 반대)로 각 방향의 oid + 집합을 만들어 멤버십으로 판정한다. 집합은 방향당 `MAX_DIVERGENCE_OIDS`개로 끊는다 — + walk가 최신순이므로 잘리는 쪽은 화면에 닿지 않는 꼬리다. +- **1 커밋 = 1 행을 유지한다**: `log_view.selected`가 커밋 인덱스이자 화면 위치라는 전제를 + 선택·스크롤·tail prefetch가 공유한다. 여유 공간은 행이 아니라 **컬럼**으로 쓴다. + `area.width >= MIN_DETAIL_WIDTH`이면 상대 시각 대신 절대 시각, author에 email, short_id + 10자, chip 무절단으로 넓힌다. 판정 기준이 `list_fullscreen` 플래그가 아니라 폭인 이유는 + 넓은 모니터에서는 fullscreen이 아니어도 자리가 남기 때문이고, 이는 + `diff_viewer::MIN_SPLIT_WIDTH`가 이미 세운 선례와 같은 모양이다. +- **commit graph는 범위 밖이다**: lane graph는 topological 정렬을 전제하는데 현재 revwalk에는 + `set_sorting`이 없고, 정렬을 바꾸면 위의 anchor+skip 페이지네이션 계약까지 함께 다시 + 설계해야 한다. + ### Plugin Host (`src/plugin/`, `plugins/`) 어떤 CLI가 사용량 한도에 걸렸는지 알아보고 한도가 풀린 뒤 세션을 재개하는 일은 provider를