diff --git a/docs/architecture.md b/docs/architecture.md index 963742da..8022aede 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를 diff --git a/src/app.rs b/src/app.rs index 3deb26bc..f49b6dcb 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 27e387dd..4bcff3de 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 bf406008..588e1248 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 9050200b..f846ff47 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 1e7bacb3..aa0601e7 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 8083bdbf..6a682e55 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 1c9160ab..4edb56f5 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 a90db32a..c6b4f63a 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 5e23a38a..c1d1205b 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 e6572df4..85fca4dc 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/git/diff.rs b/src/git/diff.rs index 104bf6fe..767b8b25 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 a5aacea4..314b947f 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 00000000..c20acb1b --- /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 334671e3..b88412a4 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 7af6d0b1..12dfadec 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 00000000..0abf1d0c --- /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 f54df6de..8d2e743d 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 { diff --git a/src/ui/commit_list.rs b/src/ui/commit_list/mod.rs similarity index 74% rename from src/ui/commit_list.rs rename to src/ui/commit_list/mod.rs index 9ad83f28..b9ed1bf0 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 { @@ -47,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(); @@ -71,24 +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]; - 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, + &app.log_decorations, + list_area.width, + scroll_x, + accent, + )) }) .collect(); @@ -225,14 +186,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 00000000..26cb4cf7 --- /dev/null +++ b/src/ui/commit_list/row.rs @@ -0,0 +1,214 @@ +use crate::git::diff::{CommitEntry, LogDecorations, RefKind, RefLabel}; +use crate::ui::wall_clock::local_date_time; +use ratatui::{ + style::{Color, Modifier, 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; + +/// 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() + .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) + } +} + +/// 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, + decorations: &LogDecorations, + width: u16, + scroll_x: usize, + accent: Color, +) -> Line<'a> { + 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), + )); + let who = match entry.author_email.as_str() { + "" => 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() { + // Corrupt/malicious commit timestamps must not panic on i64 underflow. + 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()); + } +} diff --git a/src/ui/wall_clock.rs b/src/ui/wall_clock.rs index 2b74d680..2924a196 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] diff --git a/src/web/viewer/runtime/runtime_tests.rs b/src/web/viewer/runtime/runtime_tests.rs index 4e2b6fa0..154877c4 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, } } diff --git a/src/web/viewer/terminal/hub_diag.rs b/src/web/viewer/terminal/hub_diag.rs index 8f33d002..aba08bb0 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;