Skip to content

Commit 656dbd4

Browse files
committed
bigger fold triangles, ages on changes, repo name in header, version in help
- fold triangles are ▼/▶ instead of the small ▾/▸ - each change shows how long ago its file was written, hidden before the squares when the path would run short - the UI wakes when an age on screen next reads differently, so ages tick each second under a minute and are never stale; polling keeps its own timer so this does not refresh git more often - the header starts with the repo's folder name when there is room - the help box title shows the version, centered
1 parent 8b90fe2 commit 656dbd4

18 files changed

Lines changed: 303 additions & 101 deletions

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "gitst"
3-
version = "0.1.13"
3+
version = "0.1.14"
44
edition = "2024"
55
description = "Live, glanceable git status TUI for small terminal panes"
66
license = "MIT"

‎src/app.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! UI state and input handling. Drawing fills in the hit map and navigation
22
//! list; input is resolved against whatever was drawn last.
33
4+
use std::cell::Cell;
45
use std::collections::{BTreeSet, HashMap, HashSet};
56
use std::sync::Arc;
67
use std::time::{Duration, Instant, SystemTime};
@@ -155,6 +156,8 @@ pub struct App {
155156
pub snap: Option<Arc<Snapshot>>,
156157
pub error: Option<String>,
157158
pub config_warning: Option<String>,
159+
/// The work tree's folder name, shown before the branch.
160+
pub repo_name: Option<String>,
158161
pub fetch: FetchStatus,
159162
pub live: Vec<ActivityEvent>,
160163
pub pulses: HashMap<String, Instant>,
@@ -185,6 +188,8 @@ pub struct App {
185188
pub detail_targets: Vec<Option<Target>>,
186189
/// Filled by draw: rows of the section or detail body, for paging.
187190
pub page: usize,
191+
/// Filled by draw: how long until an age on screen reads differently.
192+
pub age_wake: Cell<Option<Duration>>,
188193
/// Whether something on screen may have changed since the last draw.
189194
pub redraw: bool,
190195
}
@@ -218,6 +223,7 @@ impl App {
218223
snap: None,
219224
error: None,
220225
config_warning: None,
226+
repo_name: None,
221227
fetch,
222228
live: Vec::new(),
223229
pulses: HashMap::new(),
@@ -238,6 +244,7 @@ impl App {
238244
row_targets: HashMap::new(),
239245
detail_targets: Vec::new(),
240246
page: 10,
247+
age_wake: Cell::new(None),
241248
redraw: true,
242249
}
243250
}
@@ -341,6 +348,7 @@ impl App {
341348
.filter(|a| *a < STALE_LOCK)
342349
.map(|a| (STALE_LOCK - a).max(Duration::from_millis(200)));
343350
let wake = self.base_wakeup(ttl);
351+
let wake = self.age_wake.get().map_or(wake, |a| a.min(wake));
344352
lock.map_or(wake, |l| l.min(wake))
345353
}
346354

‎src/main.rs‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ fn run(
127127
},
128128
);
129129
app.config_warning = warning;
130+
app.repo_name = repo
131+
.root
132+
.file_name()
133+
.map(|n| n.to_string_lossy().into_owned());
130134
// Without a watcher, fall back to refreshing every few seconds.
131135
let (_watch, poll) = match watch::spawn(&repo, worker.sender()) {
132136
Ok(h) => (Some(h), None),
@@ -144,24 +148,31 @@ fn run(
144148
// Ages, the spinner and pulses change with time alone, so a redraw is
145149
// due by this time even if every message until then is one that does
146150
// not redraw, such as a pointer move.
151+
// Polling keeps its own time, so redraws for ages, which can come each
152+
// second, do not refresh any more often.
147153
let mut due = Instant::now();
154+
let mut poll_due = poll.map(|p| Instant::now() + p);
148155
loop {
149156
if app.take_redraw() {
150157
app.now = SystemTime::now();
151158
terminal.draw(|f| ui::draw(f, &mut app, theme))?;
152-
let wait = poll.map_or(app.next_wakeup(), |p| p.min(app.next_wakeup()));
153-
due = Instant::now() + wait;
159+
due = Instant::now() + app.next_wakeup();
154160
}
155-
let first = match ui_rx.recv_timeout(due.saturating_duration_since(Instant::now())) {
161+
let until = poll_due.map_or(due, |p| p.min(due));
162+
let first = match ui_rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
156163
Ok(m) => Some(m),
157164
Err(RecvTimeoutError::Timeout) => None,
158165
Err(RecvTimeoutError::Disconnected) => break,
159166
};
160-
if Instant::now() >= due {
167+
let now = Instant::now();
168+
if now >= due {
161169
app.redraw = true;
162-
if poll.is_some() {
163-
let _ = worker.send(WorkerMsg::Refresh);
164-
}
170+
}
171+
if let (Some(p), Some(at)) = (poll, poll_due)
172+
&& now >= at
173+
{
174+
let _ = worker.send(WorkerMsg::Refresh);
175+
poll_due = Some(now + p);
165176
}
166177
let mut quit = false;
167178
app.now = SystemTime::now();

‎src/ui/dashboard.rs‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,29 @@
11
//! The stacked, collapsible dashboard.
22
33
use std::collections::HashSet;
4-
use std::time::{Duration, Instant};
4+
use std::time::{Duration, Instant, UNIX_EPOCH};
55

66
use ratatui::Frame;
77
use ratatui::layout::Rect;
88
use ratatui::style::{Modifier, Style};
99
use ratatui::text::{Line, Span};
1010
use ratatui::widgets::{Block, BorderType, Paragraph};
1111

12-
use super::fmt::{clock, rel_age, truncate_left};
12+
use super::fmt::{clock, truncate_left, width};
1313
use super::layout::{Density, SectionId, SectionReq, Slot, allocate};
1414
use super::theme::Theme;
15-
use super::{leak_marker, line_lr, now_secs, spans_width, truncate_spans};
15+
use super::{age_of, leak_marker, line_lr, spans_width, truncate_spans};
1616
use crate::activity::{ActivityKind, merged};
1717
use crate::app::{Action, App, NavItem, ScrollTarget, Target};
1818
use crate::model::{Change, Commit, Counts, Snapshot};
1919

2020
/// Most activity rows kept in the section.
2121
const ACTIVITY_ROWS: usize = 100;
2222
const METER_CELLS: usize = 5;
23+
/// Path cells a change row keeps before it gives up its age.
24+
const MIN_PATH: usize = 20;
25+
/// Cells set aside for a change's age: a space and up to `12mo`.
26+
const AGE_RESERVE: usize = 5;
2327

2428
struct Row {
2529
line: Line<'static>,
@@ -253,7 +257,7 @@ fn pad_right(mut spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
253257

254258
fn title_spans(s: &Section, expanded: bool, theme: &Theme) -> Vec<Span<'static>> {
255259
let mut spans = vec![
256-
Span::styled(if expanded { "▾ " } else { "▸ " }, theme.dim),
260+
Span::styled(if expanded { "▼ " } else { "▶ " }, theme.dim),
257261
Span::styled(s.id.title(), Style::new().add_modifier(Modifier::BOLD)),
258262
];
259263
if let Some(n) = s.count {
@@ -435,12 +439,28 @@ fn changes(snap: &Snapshot, app: &App, theme: &Theme, w: usize, d: Density) -> S
435439
.filter(|l| l.source.oid().is_none())
436440
.map(|l| l.path.as_str())
437441
.collect();
442+
// The age goes before the squares do: only while the path keeps room.
443+
let fixed = 3 + stats_w + if d.stats { 1 + METER_CELLS } else { 0 } + 2;
444+
let path_room = w.saturating_sub(fixed + 1);
445+
let ages: Vec<Option<String>> = if d.ages && path_room >= MIN_PATH + AGE_RESERVE {
446+
snap.changes
447+
.iter()
448+
.map(|c| {
449+
let t = c.modified?.duration_since(UNIX_EPOCH).ok()?;
450+
Some(age_of(app, t.as_secs() as i64))
451+
})
452+
.collect()
453+
} else {
454+
Vec::new()
455+
};
456+
let age_w = ages.iter().flatten().map(|a| width(a)).max();
438457
let ttl = Duration::from_secs(app.pulse_secs);
439458
let now = Instant::now();
440459
let mut rows: Vec<Row> = snap
441460
.changes
442461
.iter()
443-
.map(|c| {
462+
.enumerate()
463+
.map(|(i, c)| {
444464
let mut left = status_spans(c, theme);
445465
left.push(Span::raw(" "));
446466
let mut right = Vec::new();
@@ -470,6 +490,10 @@ fn changes(snap: &Snapshot, app: &App, theme: &Theme, w: usize, d: Density) -> S
470490
};
471491
right.push(Span::raw(" "));
472492
right.push(pulse);
493+
if let Some(age_w) = age_w {
494+
let age = ages[i].as_deref().unwrap_or("");
495+
right.push(Span::styled(format!(" {age:>age_w$}"), theme.dim));
496+
}
473497
let display = match &c.orig_path {
474498
Some(orig) => format!("{orig} → {}", c.path),
475499
None => c.path.clone(),
@@ -543,7 +567,7 @@ fn activity(snap: &Snapshot, app: &App, theme: &Theme, w: usize, d: Density) ->
543567

544568
fn age_span(time: i64, app: &App, theme: &Theme, d: Density) -> Vec<Span<'static>> {
545569
if d.ages {
546-
vec![Span::styled(rel_age(now_secs(app) - time), theme.dim)]
570+
vec![Span::styled(age_of(app, time), theme.dim)]
547571
} else {
548572
Vec::new()
549573
}

‎src/ui/detail.rs‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ use ratatui::widgets::{
99
};
1010

1111
use super::dashboard::{commit_row, meter, row, stats_spans};
12-
use super::fmt::{clock, clusters, rel_age, truncate_left, width};
12+
use super::fmt::{clock, clusters, truncate_left, width};
1313
use super::layout::Density;
1414
use super::theme::Theme;
15-
use super::{now_secs, spans_width};
15+
use super::{age_of, spans_width};
1616
use crate::app::{App, ScrollTarget, Target};
1717
use crate::model::{
1818
Commit, CommitDetail, DetailData, DiffBlock, DiffKind, Leak, LeakSource, Snapshot,
@@ -225,11 +225,7 @@ fn commit_lines(
225225
Span::styled(" commit ", theme.dim),
226226
Span::styled(c.oid.clone(), Style::new().fg(theme.modified)),
227227
])));
228-
let when = format!(
229-
" · {} ago · {}",
230-
rel_age(now_secs(app) - c.time),
231-
clock(c.time)
232-
);
228+
let when = format!(" · {} ago · {}", age_of(app, c.time), clock(c.time));
233229
out.push(plain(Line::from(vec![
234230
Span::raw(format!(" {}", c.author)),
235231
Span::styled(when, theme.dim),
@@ -525,10 +521,13 @@ pub fn draw_help(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
525521
let block = Block::bordered()
526522
.border_type(BorderType::Rounded)
527523
.border_style(Style::new().fg(theme.accent))
528-
.title(Span::styled(
529-
" gitst ",
530-
Style::new().add_modifier(Modifier::BOLD),
531-
));
524+
.title(
525+
Line::from(Span::styled(
526+
concat!(" gitst ", env!("CARGO_PKG_VERSION"), " "),
527+
Style::new().add_modifier(Modifier::BOLD),
528+
))
529+
.centered(),
530+
);
532531
let inner = block.inner(r);
533532
f.render_widget(Clear, r);
534533
f.render_widget(block, r);

‎src/ui/fmt.rs‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,49 @@ pub fn rel_age(secs: i64) -> String {
8787
}
8888
}
8989

90+
/// Seconds until `rel_age(secs)` reads differently, as `secs` grows by one
91+
/// a second.
92+
pub fn age_changes_in(secs: i64) -> i64 {
93+
const DAY: i64 = 86_400;
94+
if secs < -60 {
95+
// `in …` counts down: it changes as the age drops below the unit it
96+
// shows, and becomes `now` once less than a minute ahead.
97+
let ahead = secs.saturating_neg();
98+
let unit = unit_of(ahead);
99+
return (ahead % unit + 1).min(ahead - 60);
100+
}
101+
if secs < 10 {
102+
return 10 - secs;
103+
}
104+
// A unit's count changes each unit, and the unit itself at the end of
105+
// its range, which need not fall on a whole number of units.
106+
let end = match secs {
107+
s if s < 60 => 60,
108+
s if s < 3600 => 3600,
109+
s if s < DAY => DAY,
110+
s if s < 7 * DAY => 7 * DAY,
111+
s if s < 60 * DAY => 60 * DAY,
112+
s if s < 365 * DAY => 365 * DAY,
113+
_ => return 365 * DAY - secs % (365 * DAY),
114+
};
115+
let unit = unit_of(secs);
116+
(unit - secs % unit).min(end - secs)
117+
}
118+
119+
/// The unit `rel_age` counts a non-negative age in.
120+
fn unit_of(secs: i64) -> i64 {
121+
const DAY: i64 = 86_400;
122+
match secs {
123+
s if s < 60 => 1,
124+
s if s < 3600 => 60,
125+
s if s < DAY => 3600,
126+
s if s < 7 * DAY => DAY,
127+
s if s < 60 * DAY => 7 * DAY,
128+
s if s < 365 * DAY => 30 * DAY,
129+
_ => 365 * DAY,
130+
}
131+
}
132+
90133
/// Local wall-clock `HH:MM` for a unix time.
91134
pub fn clock(unix: i64) -> String {
92135
match Local.timestamp_opt(unix, 0) {
@@ -156,6 +199,46 @@ mod tests {
156199
assert_eq!(rel_age(800 * 86400), "2y");
157200
}
158201

202+
#[test]
203+
fn age_changes_exactly_when_its_label_does() {
204+
const DAY: i64 = 86_400;
205+
let starts = [
206+
-400 * DAY,
207+
-61 * DAY,
208+
-8 * DAY,
209+
-DAY - 5,
210+
-3601,
211+
-125,
212+
-61,
213+
-60,
214+
0,
215+
9,
216+
10,
217+
59,
218+
60,
219+
3599,
220+
DAY - 1,
221+
7 * DAY - 1,
222+
60 * DAY - 1,
223+
365 * DAY - 1,
224+
400 * DAY,
225+
];
226+
for s in starts {
227+
let n = age_changes_in(s);
228+
assert!(n >= 1, "{s}: {n}");
229+
// Checking each second is too slow for the longest units.
230+
if n <= 2 * DAY {
231+
for k in 1..n {
232+
assert_eq!(rel_age(s + k), rel_age(s), "{s}+{k}");
233+
}
234+
}
235+
assert_ne!(rel_age(s + n), rel_age(s), "{s}+{n}");
236+
}
237+
assert_eq!(age_changes_in(59 * DAY), DAY, "8w becomes 2mo");
238+
assert!(age_changes_in(i64::MAX) >= 1);
239+
assert!(age_changes_in(i64::MIN) >= 1);
240+
}
241+
159242
#[test]
160243
fn clock_is_hh_mm() {
161244
let c = clock(1_790_484_333);

0 commit comments

Comments
 (0)