Skip to content

Commit dfbda28

Browse files
committed
Wrap long warnings onto up to three rows, and clear them with c or a click
1 parent 3f32fe4 commit dfbda28

10 files changed

Lines changed: 291 additions & 26 deletions

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Added
6+
7+
- `c` clears the warnings under the header, and clicking a warning clears
8+
that one. A cleared warning shows again if it changes, or goes away and
9+
comes back. The secrets band and a failed refresh cannot be cleared.
10+
11+
### Changed
12+
13+
- A long warning wraps onto up to three rows, when there are rows to
14+
spare, instead of being cut off at the end of one. A pane at least 12
15+
rows tall has room for three warning rows, up from two.
16+
317
## 0.1.15 (2026-10-15)
418

519
### Changed

‎README.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ The dashboard has five sections:
5050

5151
Warnings appear under the header for possible secrets, conflicts, an
5252
`index.lock` held for more than 10 seconds, failed fetches, and config errors.
53-
When there are more than fit, the last row lists the rest.
53+
A long warning wraps onto up to three rows when there is room. When there are
54+
more than fit, the last row lists the rest.
55+
56+
Press `c`, or click a warning, to clear it. A cleared warning shows again if
57+
it changes, or goes away and comes back. The secrets band cannot be cleared,
58+
and neither can a failed refresh, which explains why the panes below are
59+
dimmed.
5460

5561
### Secret warnings
5662

@@ -90,6 +96,7 @@ warning says so, and the last findings stay.
9096
right-click to return.
9197
- **Click** a section title to fold it. The **wheel** scrolls what is under the pointer.
9298
- **Click** `↻` (or press `f`) to fetch now.
99+
- **Click** a warning to clear it.
93100

94101
### Keys
95102

@@ -105,6 +112,7 @@ warning says so, and the last findings stay.
105112
| `w` | | wrap long lines |
106113
| `f` | fetch now | fetch now |
107114
| `s` | possible secrets | possible secrets |
115+
| `c` | clear warnings | clear warnings |
108116
| `?` | help | help |
109117
| `q` / `Ctrl-C` | quit | quit |
110118
| `Ctrl-Z` | suspend (`fg` resumes; not on Windows) | suspend |

‎src/app.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ pub enum Action {
9393
Help,
9494
Quit,
9595
ToggleWrap,
96+
/// Hides these warnings until they change.
97+
Clear(Vec<String>),
9698
}
9799

98100
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -156,6 +158,11 @@ pub struct App {
156158
pub snap: Option<Arc<Snapshot>>,
157159
pub error: Option<String>,
158160
pub config_warning: Option<String>,
161+
/// Warnings cleared from view, by key. A key is dropped once its
162+
/// warning is gone, so the warning shows again if it comes back.
163+
pub cleared: HashSet<String>,
164+
/// Filled by draw: keys of the warnings that `c` clears.
165+
pub clearable: Vec<String>,
159166
/// The work tree's folder name, shown before the branch.
160167
pub repo_name: Option<String>,
161168
pub fetch: FetchStatus,
@@ -223,6 +230,8 @@ impl App {
223230
snap: None,
224231
error: None,
225232
config_warning: None,
233+
cleared: HashSet::new(),
234+
clearable: Vec::new(),
226235
repo_name: None,
227236
fetch,
228237
live: Vec::new(),
@@ -444,6 +453,10 @@ impl App {
444453
self.wrap = !self.wrap;
445454
Vec::new()
446455
}
456+
Action::Clear(keys) => {
457+
self.cleared.extend(keys);
458+
Vec::new()
459+
}
447460
}
448461
}
449462

@@ -482,6 +495,7 @@ impl App {
482495
KeyCode::Char('f') => return self.perform(Action::Fetch),
483496
KeyCode::Char('?') => return self.perform(Action::Help),
484497
KeyCode::Char('s') => return self.open_leaks(),
498+
KeyCode::Char('c') => return self.perform(Action::Clear(self.clearable.clone())),
485499
_ => {}
486500
}
487501
if self.stack.is_empty() {

‎src/ui/detail.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ const DASHBOARD_HELP: &[(&str, &str)] = &[
443443
("space", "fold section"),
444444
("g G", "top / bottom"),
445445
("s", "possible secrets"),
446+
("c", "clear warnings"),
446447
("f", "fetch now"),
447448
("?", "close help"),
448449
("q", "quit"),
@@ -461,6 +462,7 @@ const DETAIL_HELP: &[(&str, &str)] = &[
461462
("g G", "top / bottom"),
462463
("w", "wrap long lines"),
463464
("s", "possible secrets"),
465+
("c", "clear warnings"),
464466
("f", "fetch now"),
465467
("?", "close help"),
466468
("q", "quit"),

‎src/ui/fmt.rs‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,45 @@ pub fn truncate_right(s: &str, max: usize) -> String {
6969
format!("{}…", &s[..end])
7070
}
7171

72+
/// Wraps `s` at spaces into at most `lines` lines of `max` cells, breaking
73+
/// a word only when it alone is wider. Text past the last line is cut,
74+
/// and that line ends in `…`.
75+
pub fn wrap_words(s: &str, max: usize, lines: usize) -> Vec<String> {
76+
let (max, lines) = (max.max(1), lines.max(1));
77+
let mut out = Vec::new();
78+
let mut cur = String::new();
79+
let mut used = 0;
80+
for word in s.split(' ').filter(|w| !w.is_empty()) {
81+
let w = width(word);
82+
if used > 0 && used + 1 + w <= max {
83+
cur.push(' ');
84+
cur.push_str(word);
85+
used += 1 + w;
86+
continue;
87+
}
88+
if used > 0 {
89+
out.push(std::mem::take(&mut cur));
90+
used = 0;
91+
}
92+
for (_, g, gw) in clusters(word) {
93+
if used + gw > max && used > 0 {
94+
out.push(std::mem::take(&mut cur));
95+
used = 0;
96+
}
97+
cur.push_str(g);
98+
used += gw;
99+
}
100+
}
101+
if used > 0 || out.is_empty() {
102+
out.push(cur);
103+
}
104+
if out.len() > lines {
105+
let rest = out.split_off(lines - 1).join(" ");
106+
out.push(truncate_right(&rest, max));
107+
}
108+
out
109+
}
110+
72111
/// Compact age: `now`, `45s`, `12m`, `3h`, `2d`, `5w`, `3mo`, `2y`. A time
73112
/// more than a minute ahead, after the clock was set back, is `in 3h`;
74113
/// less is clock skew, and `now`.
@@ -161,6 +200,27 @@ mod tests {
161200
assert!(width(&truncate_left("日本語", 1)) <= 1);
162201
}
163202

203+
#[test]
204+
fn wrap_words_breaks_at_spaces_and_cuts_the_last_line() {
205+
assert_eq!(
206+
wrap_words("fetch failed: offline", 30, 3),
207+
["fetch failed: offline"]
208+
);
209+
assert_eq!(
210+
wrap_words("config.toml: ignored colour, fetch_interval", 20, 3),
211+
["config.toml: ignored", "colour,", "fetch_interval"]
212+
);
213+
assert_eq!(
214+
wrap_words("one two three four five six", 9, 2),
215+
["one two", "three fo…"]
216+
);
217+
assert_eq!(wrap_words("abcdefgh", 3, 3), ["abc", "def", "gh"]);
218+
assert_eq!(wrap_words("", 10, 3), [""]);
219+
for line in wrap_words(&"日本語 ".repeat(9), 5, 3) {
220+
assert!(width(&line) <= 5, "{line:?}");
221+
}
222+
}
223+
164224
#[test]
165225
fn truncate_right_fits() {
166226
assert_eq!(truncate_right("hello world", 6), "hello…");

‎src/ui/mod.rs‎

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,21 @@ use ratatui::widgets::Paragraph;
1414

1515
use crate::app::{Action, App, STALE_LOCK, Target};
1616
use crate::model::{Head, Leak, LeakSource, RepoOp, Snapshot};
17-
use fmt::{age_changes_in, rel_age, truncate_right, width};
17+
use fmt::{age_changes_in, rel_age, truncate_right, width, wrap_words};
1818
use layout::Density;
1919
use theme::Theme;
2020

2121
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
22+
/// Most rows a long warning wraps onto.
23+
const WARNING_LINES: usize = 3;
2224

2325
/// Draws the whole UI and records the hit map for the next input.
2426
pub fn draw(f: &mut Frame, app: &mut App, theme: &Theme) {
2527
app.hits.clear();
2628
app.nav.clear();
2729
app.row_targets.clear();
2830
app.detail_targets.clear();
31+
app.clearable.clear();
2932
app.age_wake.set(None);
3033
let area = f.area();
3134
if area.width == 0 || area.height == 0 {
@@ -54,6 +57,7 @@ pub fn draw(f: &mut Frame, app: &mut App, theme: &Theme) {
5457
let mut y = area.y;
5558
let mut left = area.height;
5659
let max_warnings = match area.height {
60+
h if h >= 12 => 3,
5761
h if h >= 8 => 2,
5862
h if h >= 5 => 1,
5963
_ => 0,
@@ -83,19 +87,41 @@ pub fn draw(f: &mut Frame, app: &mut App, theme: &Theme) {
8387
let mut band = None;
8488
let mut error_shown = false;
8589
let rows = max_warnings.min((left - hint_rows) as usize);
86-
for w in fit_warnings(warnings(app, &snap, theme, area.width as usize), rows) {
87-
let r = Rect::new(area.x, y, area.width, 1);
88-
f.render_widget(
89-
Paragraph::new(fit_line(w.line, area.width as usize)).style(w.fill),
90-
r,
91-
);
90+
let mut all = warnings(app, &snap, theme, area.width as usize);
91+
// A cleared warning that has gone shows again if it comes back.
92+
app.cleared
93+
.retain(|k| all.iter().any(|w| w.key.as_ref() == Some(k)));
94+
all.retain(|w| !w.key.as_ref().is_some_and(|k| app.cleared.contains(k)));
95+
app.clearable = all.iter().filter_map(|w| w.key.clone()).collect();
96+
for w in &mut all {
97+
if let Some(k) = &w.key {
98+
w.action = Some(Action::Clear(vec![k.clone()]));
99+
}
100+
}
101+
let shown = fit_warnings(all, rows);
102+
// Rows the warnings leave free let a long one wrap.
103+
let mut spare = rows - shown.len();
104+
for w in shown {
105+
let lines = match &w.text {
106+
Some((text, style)) => {
107+
let max = 1 + spare.min(WARNING_LINES - 1);
108+
let lines = warning_lines(text, *style, area.width as usize, max);
109+
spare -= lines.len() - 1;
110+
lines
111+
}
112+
None => vec![fit_line(w.line, area.width as usize)],
113+
};
114+
let r = Rect::new(area.x, y, area.width, lines.len() as u16);
115+
f.render_widget(Paragraph::new(lines).style(w.fill), r);
92116
if let Some(action) = w.action {
117+
if matches!(action, Action::Open(_)) {
118+
band = Some(r);
119+
}
93120
app.hits.clicks.push((r, action));
94-
band = Some(r);
95121
}
96122
error_shown |= w.error;
97-
y += 1;
98-
left -= 1;
123+
y += r.height;
124+
left -= r.height;
99125
}
100126

101127
let body = Rect::new(area.x, y, area.width, left - hint_rows);
@@ -353,6 +379,9 @@ struct Warning {
353379
text: Option<(String, Style)>,
354380
/// The refresh error: the body below is stale.
355381
error: bool,
382+
/// What clearing it remembers; `None` for the secrets band and the
383+
/// refresh error, which cannot be cleared.
384+
key: Option<String>,
356385
}
357386

358387
/// Fits `warnings` into `rows`: when there are more, the last row carries
@@ -372,26 +401,45 @@ fn fit_warnings(mut warnings: Vec<Warning>, rows: usize) -> Vec<Warning> {
372401
.iter()
373402
.filter_map(|w| w.text.as_ref().map(|t| t.0.as_str()))
374403
.collect();
404+
let keys: Vec<String> = rest.iter().filter_map(|w| w.key.clone()).collect();
375405
warnings.push(Warning {
376406
line: Line::from(Span::styled(format!(" ⚠ {}", joined.join(" · ")), style)),
377407
fill: Style::new(),
378-
action: None,
408+
action: (!keys.is_empty()).then_some(Action::Clear(keys)),
379409
text: None,
380410
error: rest.iter().any(|w| w.error),
411+
key: None,
381412
});
382413
}
383414
None => {}
384415
}
385416
warnings
386417
}
387418

419+
/// `text` after the warning glyph, wrapped onto at most `max` rows of
420+
/// `cols` cells, the later rows lined up under the first.
421+
fn warning_lines(text: &str, style: Style, cols: usize, max: usize) -> Vec<Line<'static>> {
422+
wrap_words(text, cols.saturating_sub(3), max)
423+
.into_iter()
424+
.enumerate()
425+
.map(|(i, part)| {
426+
let lead = if i == 0 { " ⚠ " } else { " " };
427+
fit_line(
428+
Line::from(Span::styled(format!("{lead}{part}"), style)),
429+
cols,
430+
)
431+
})
432+
.collect()
433+
}
434+
388435
fn warnings(app: &App, snap: &Snapshot, theme: &Theme, cols: usize) -> Vec<Warning> {
389436
let err = Style::new().fg(theme.err).add_modifier(Modifier::BOLD);
390437
let warn = Style::new().fg(theme.warn);
391438
let line = |text: String, style: Style| Warning {
392439
line: Line::from(Span::styled(format!(" ⚠ {text}"), style)),
393440
fill: Style::new(),
394441
action: None,
442+
key: Some(text.clone()),
395443
text: Some((text, style)),
396444
error: false,
397445
};
@@ -404,12 +452,14 @@ fn warnings(app: &App, snap: &Snapshot, theme: &Theme, cols: usize) -> Vec<Warni
404452
action: Some(Action::Open(Target::Leaks)),
405453
text: None,
406454
error: false,
455+
key: None,
407456
});
408457
}
409458
// Next, so that it is never what a shared row cuts off.
410459
if let Some(e) = &app.error {
411460
out.push(Warning {
412461
error: true,
462+
key: None,
413463
..line(e.clone(), err)
414464
});
415465
}
@@ -425,7 +475,11 @@ fn warnings(app: &App, snap: &Snapshot, theme: &Theme, cols: usize) -> Vec<Warni
425475
out.push(line(format!("{conflicts} conflict{s}"), err));
426476
}
427477
if let Some(age) = app.lock_age().filter(|a| *a >= STALE_LOCK) {
428-
out.push(line(format!("index.lock held {}", age_for(app, age)), warn));
478+
// Keyed without the age, which changes each second.
479+
out.push(Warning {
480+
key: Some("index.lock".into()),
481+
..line(format!("index.lock held {}", age_for(app, age)), warn)
482+
});
429483
}
430484
if let Some(e) = &app.fetch.last_error {
431485
out.push(line(format!("fetch failed: {}", e.label()), warn));
@@ -440,23 +494,26 @@ fn warnings(app: &App, snap: &Snapshot, theme: &Theme, cols: usize) -> Vec<Warni
440494
}
441495

442496
fn hint_bar(f: &mut Frame, app: &mut App, theme: &Theme, r: Rect) {
443-
let items: &[(&str, &str, Action)] = if app.stack.is_empty() {
444-
&[
497+
let mut items: Vec<(&str, &str, Action)> = if app.stack.is_empty() {
498+
vec![
445499
("?", "help", Action::Help),
446500
("f", "fetch", Action::Fetch),
447501
("q", "quit", Action::Quit),
448502
]
449503
} else {
450-
&[
504+
vec![
451505
("esc", "back", Action::Back),
452506
("w", "wrap", Action::ToggleWrap),
453507
("q", "quit", Action::Quit),
454508
]
455509
};
510+
if !app.clearable.is_empty() {
511+
items.insert(2, ("c", "clear", Action::Clear(app.clearable.clone())));
512+
}
456513
let key_style = Style::new().fg(theme.accent).add_modifier(Modifier::BOLD);
457514
let mut spans = Vec::new();
458515
let mut x = 0usize;
459-
for (key, label, action) in items {
516+
for (key, label, action) in &items {
460517
let item_w = 1 + width(key) + 1 + width(label) + 1;
461518
if x + item_w > r.width as usize {
462519
break;

0 commit comments

Comments
 (0)