style: move diff metadata into pane borders

This commit is contained in:
Valdemar Lorenzen
2026-10-12 15:08:41 +02:00
parent 054225686e
commit 9f1ea04f4d
3 changed files with 349 additions and 122 deletions
+346 -121
View File
@@ -409,17 +409,6 @@ impl BufferViewports {
let syntax = self.syntax.entry(editor.buffer_id()).or_default();
let full_workspace = draw_bars(frame, editor, frame.area(), syntax);
let multiple = editor.pane_layout().len() > 1;
let diff_header = editor
.diff()
.filter(|diff| !multiple && diff.phase == Phase::View)
.map(|_| {
Rect::new(
full_workspace.x,
full_workspace.y,
full_workspace.width,
full_workspace.height.min(1),
)
});
let (workspace, dock) = match editor.file_picker() {
Some(picker) if picker.presentation == FilePresentation::Docked => dock_layout(
full_workspace,
@@ -432,13 +421,6 @@ impl BufferViewports {
),
_ => (full_workspace, None),
};
let dock = dock.map(|mut panel| {
if let Some(header) = diff_header {
panel.y = panel.y.saturating_add(header.height);
panel.height = panel.height.saturating_sub(header.height);
}
panel
});
editor.set_pane_bounds(pane_rect(workspace));
self.rects.clear();
editor
@@ -504,11 +486,6 @@ impl BufferViewports {
self.panels.begin_frame();
self.document.geometry = None;
}
if let Some(header) = diff_header
&& let Some(diff) = editor.diff()
{
draw_diff_header(frame, diff, Some(editor), header);
}
if let Some(panel) = dock
&& let Some(picker) = editor.file_picker()
{
@@ -2035,10 +2012,24 @@ struct DiffGeometry {
wrapping: bool,
}
fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect {
fn diff_panel(
frame: &mut Frame,
area: Rect,
title: &str,
state: &str,
revision: &str,
focused: bool,
) -> Rect {
if area.width < 4 || area.height < 4 {
return area;
}
let title_width = usize::from(area.width - 2)
.saturating_sub(UnicodeWidthStr::width(state) + usize::from(!state.is_empty()));
let title = if UnicodeWidthStr::width(title) > title_width {
Cow::Owned(visible_line(title, 0, title_width))
} else {
Cow::Borrowed(title)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(if focused {
@@ -2046,7 +2037,9 @@ fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect
} else {
Color::DarkGray
}))
.title(title);
.title_top(Line::from(title).left_aligned())
.title_top(Line::from(state).right_aligned())
.title_bottom(Line::from(revision).left_aligned());
let inner = block.inner(area);
frame.render_widget(block, area);
inner
@@ -2296,46 +2289,6 @@ fn draw_diff_popups(
}
}
fn draw_diff_header(frame: &mut Frame, diff: &DiffState, editor: Option<&Editor>, header: Rect) {
if header.width == 0 || header.height == 0 {
return;
}
let (before, after) = diff.comparison.as_ref().map_or(("", ""), |comparison| {
(comparison.before.as_str(), comparison.after.as_str())
});
frame.render_widget(
Paragraph::new(format!(
"Diff · {} → {} · {:?} focus · {}",
display_text(before),
display_text(after),
diff.focus,
editor
.filter(|editor| editor.diff_interacting())
.map_or_else(
|| "read-only".into(),
|editor| format!(
"{} · {}{} · {}:{}",
if editor.diff_editing() {
"editable"
} else {
"read-only"
},
if editor.visual_linewise() {
"VISUAL LINE".into()
} else {
editor.mode().to_string()
},
if editor.dirty() { " [+]" } else { "" },
editor.cursor().row + 1,
editor.cursor().column + 1,
),
)
))
.style(Style::default().fg(Color::Black).bg(Color::Cyan)),
header,
);
}
fn draw_diff(
frame: &mut Frame,
diff: &DiffState,
@@ -2362,7 +2315,7 @@ fn draw_diff(
"After"
};
let title = format!(" Diff · Choose {stage} ");
let content = diff_panel(frame, panel, &title, true);
let content = diff_panel(frame, panel, &title, "", "", true);
let notice = diff.error.as_deref().or_else(|| {
if diff.loading {
Some(if diff.after.is_some() {
@@ -2526,7 +2479,7 @@ fn draw_diff(
}
frame.render_widget(
Paragraph::new(format!(
"{navigation_hint} · Tab categories · type search · Enter select · Esc back/clear · :diff.close"
"{navigation_hint} · Tab categories · type search · Enter select · Esc close · Ctrl+p actions"
))
.style(Style::default().fg(Color::DarkGray)),
help,
@@ -2559,16 +2512,11 @@ fn draw_diff(
syntax_error.or_else(|| selected.and_then(|file| file.notice.as_deref()))
}
});
let [header, notice_area, workspace, help] = Layout::vertical([
Constraint::Length(1),
let [notice_area, workspace] = Layout::vertical([
Constraint::Length(u16::from(notice.is_some() && area.height >= 3)),
Constraint::Fill(1),
Constraint::Length(u16::from(area.height >= 3)),
])
.areas(area);
if !panels.workspace_managed || editor.is_none_or(|editor| editor.pane_layout().len() > 1) {
draw_diff_header(frame, diff, editor, header);
}
if let Some(notice) = notice {
frame.render_widget(
Paragraph::new(display_text(notice)).style(Style::default().fg(
@@ -2740,7 +2688,7 @@ fn draw_diff(
&& let Some(syntax) = &panels.diff_syntax
{
if pane_layout == PaneLayout::Empty {
diff_panel(frame, before_area, "", diff.focus == Focus::Before);
diff_pane_panel(frame, diff, Some(file), before_area, true);
} else {
if before_area != Rect::default() {
let text_area = draw_diff_pane(
@@ -2810,6 +2758,7 @@ fn draw_diff(
}
}
} else {
let content = diff_pane_panel(frame, diff, selected, panes, true);
frame.render_widget(
Paragraph::new(if diff.loading {
"Loading file…"
@@ -2818,16 +2767,9 @@ fn draw_diff(
} else {
"No changed files"
}),
panes,
content,
);
}
frame.render_widget(
Paragraph::new(
"Native buffer bindings · Tab focus · [/] expand above/below in Normal · :diff.close",
)
.style(Style::default().fg(Color::DarkGray)),
help,
);
panels.diff = Some(DiffGeometry {
regions: [
(navigator.unwrap_or_default(), Focus::Files),
@@ -2854,6 +2796,62 @@ fn diff_status(status: FileStatus) -> &'static str {
}
}
fn diff_pane_panel(
frame: &mut Frame,
diff: &DiffState,
file: Option<&ChangedFile>,
area: Rect,
before: bool,
) -> Rect {
if area.width < 4 || area.height < 4 {
return area;
}
let title = file.map_or_else(
|| " Diff ".into(),
|file| {
let label = if before { "Before" } else { "After" };
let path = if before {
file.old_path.as_ref().unwrap_or(&file.path)
} else {
&file.path
};
format!(
" {label} · {} · {} ",
diff_status(file.status),
display_text(&path.to_string_lossy())
)
},
);
let revision = diff
.comparison
.as_ref()
.map_or_else(String::new, |comparison| {
if diff.pane_layout() == PaneLayout::Split {
format!(
" {} ",
display_text(if before {
&comparison.before
} else {
&comparison.after
})
)
} else {
format!(
" {} → {} ",
display_text(&comparison.before),
display_text(&comparison.after)
)
}
});
let state = if !before && diff.after_editable() {
" editable "
} else {
" read-only "
};
let focus = if before { Focus::Before } else { Focus::After };
diff_panel(frame, area, &title, state, &revision, diff.focus == focus)
}
#[allow(clippy::too_many_arguments)]
fn draw_diff_pane(
frame: &mut Frame,
@@ -2870,19 +2868,8 @@ fn draw_diff_pane(
if area.width == 0 || area.height == 0 {
return area;
}
let label = if before { "Before" } else { "After" };
let path = if before {
file.old_path.as_ref().unwrap_or(&file.path)
} else {
&file.path
};
let title = format!(
" {label} · {} · {} ",
diff_status(file.status),
display_text(&path.to_string_lossy())
);
let focus = if before { Focus::Before } else { Focus::After };
let content = diff_panel(frame, area, &title, diff.focus == focus);
let content = diff_pane_panel(frame, diff, Some(file), area, before);
if diff.display_rows.is_empty() {
frame.render_widget(
Paragraph::new(file.notice.as_deref().map(display_text).unwrap_or_else(|| {
@@ -4592,6 +4579,178 @@ mod tests {
notice: None,
}
}
#[test]
fn diff_borders_keep_full_revisions_and_each_panes_own_editability() {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap();
let mut panels = WheelPanels::default();
for case in ["working", "unlisted", "historical", "binary"] {
let mut file = syntax_diff_file("new.rs", "fn old() {}", "fn new() {}");
file.status = FileStatus::Renamed;
file.old_path = Some("old.rs".into());
if case == "binary" {
file.notice = Some("Binary or non-UTF-8 file; text comparison unavailable".into());
}
let mut diff = diff_comparison(vec![file]);
diff.files_visible = false;
let comparison = diff.comparison.as_mut().unwrap();
comparison.before = "abcdef0123456789 (baseline)".into();
comparison.after = "fedcba9876543210 (destination)".into();
if case != "unlisted" {
comparison.editable_paths.insert("new.rs".into());
}
if case == "historical" {
diff.after.as_mut().unwrap().target =
crate::diff::RevisionTarget::Commit("fedcba9876543210".into());
}
let writable = case == "working";
assert_eq!(diff.after_editable(), writable, "{case}");
for focus in [Focus::Before, Focus::After] {
diff.focus = focus;
render_diff(&mut terminal, &diff, &mut panels);
let regions = panels.diff.as_ref().unwrap().regions;
for (index, label, path, state) in [
(1, "abcdef0123456789 (baseline)", "old.rs", "read-only"),
(
2,
"fedcba9876543210 (destination)",
"new.rs",
if writable { "editable" } else { "read-only" },
),
] {
let pane = regions[index].0;
let top = pane_screen(&terminal, Rect::new(pane.x, pane.y, pane.width, 1));
let bottom = pane_screen(
&terminal,
Rect::new(pane.x, pane.bottom() - 1, pane.width, 1),
);
assert!(top.contains(path), "{case}: {top}");
assert!(
pane_screen(&terminal, Rect::new(pane.right() - 13, pane.y, 12, 1),)
.contains(state),
"{case}: {top}",
);
assert!(
bottom.find(label).is_some_and(|offset| {
UnicodeWidthStr::width(&bottom[..offset]) <= 2
}),
"{bottom}",
);
assert!(!top.contains(label));
assert!(!bottom.contains('→'));
assert_eq!(screen_text(&terminal).matches(label).count(), 1);
assert_eq!(
terminal.backend().buffer()[(pane.x, pane.y)].fg,
if regions[index].1 == focus {
Color::Cyan
} else {
Color::DarkGray
},
);
}
assert!(!screen_text(&terminal).contains("Native buffer bindings"));
assert!(!screen_text(&terminal).contains("focus ·"));
}
}
}
#[test]
fn diff_workspace_reclaims_header_and_footer_without_hiding_notices() {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
let mut panels = WheelPanels::default();
let mut diff = diff_comparison(vec![deletion_file()]);
for notice in [None, Some("comparison failed"), Some("loading")] {
diff.error = notice
.filter(|notice| *notice != "loading")
.map(str::to_owned);
diff.loading = notice == Some("loading");
render_diff(&mut terminal, &diff, &mut panels);
let area = terminal.backend().buffer().area;
let regions = panels.diff.as_ref().unwrap().regions;
for (pane, _) in regions {
assert_eq!(pane.y, area.y + u16::from(notice.is_some()));
assert_eq!(pane.bottom(), area.bottom());
}
if let Some(notice) = notice {
let row = pane_screen(&terminal, Rect::new(area.x, area.y, area.width, 1));
assert!(row.contains(if notice == "loading" {
"Loading file"
} else {
notice
}));
}
let [_, before, after] = regions.map(|(pane, _)| pane);
assert_eq!(before.right(), after.x);
assert_eq!(
diff_inner(before).height,
area.height - 2 - u16::from(notice.is_some()),
);
}
}
#[test]
fn diff_border_titles_clip_unicode_without_overwriting_state_or_text() {
let mut file = syntax_diff_file(
&format!("界e\u{301}👩‍🚀\n\t{}", "long-path".repeat(12)),
"old",
"new",
);
file.old_path = Some(format!("e\u{301}界👩‍🚀\u{1b}{}", "old-path".repeat(12)).into());
let mut diff = diff_comparison(vec![file]);
diff.files_visible = false;
let comparison = diff.comparison.as_mut().unwrap();
comparison.before = "before 界e\u{301}👩‍🚀\n\t\u{1b}".into();
comparison.after = "after e\u{301}界👩‍🚀\r\u{7f}".into();
let mut panels = WheelPanels::default();
for (width, height) in [(180, 10), (64, 8), (36, 6), (8, 4), (3, 3), (1, 1)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
render_diff(&mut terminal, &diff, &mut panels);
for (pane, _) in panels.diff.as_ref().unwrap().regions.into_iter().skip(1) {
assert!(pane.right() <= width && pane.bottom() <= height);
let buffer = terminal.backend().buffer();
for y in pane.y..pane.bottom() {
for x in pane.x..pane.right() {
let symbol = buffer[(x, y)].symbol();
assert!(!symbol.chars().any(char::is_control), "{symbol:?}");
assert!(!symbol.starts_with('\u{301}'), "{symbol:?}");
assert!(!symbol.starts_with('\u{200d}'), "{symbol:?}");
if symbol.contains('👩') || symbol.contains('🚀') {
assert_eq!(symbol, "👩‍🚀");
}
}
}
if width >= 36 {
assert!(
pane_screen(&terminal, Rect::new(pane.right() - 13, pane.y, 12, 1))
.contains("read-only")
);
let inner = diff_inner(pane);
assert_eq!(buffer[(inner.x, inner.y)].symbol(), "1");
assert_eq!(
buffer[(inner.x + 3, inner.y)].symbol(),
if pane.x == 0 { "o" } else { "n" },
);
}
if width == 180 {
let bottom = pane_screen(
&terminal,
Rect::new(pane.x, pane.bottom() - 1, pane.width, 1),
);
for grapheme in [
if pane.x == 0 { "before" } else { "after" },
"界",
"e\u{301}",
"👩‍🚀",
] {
assert!(bottom.contains(grapheme), "{bottom}");
}
assert_eq!(bottom.matches('�').count(), if pane.x == 0 { 3 } else { 2 });
}
}
}
}
#[test]
fn diff_status_layouts_hide_absent_panes_and_leave_unchanged_renames_blank() {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 12)).unwrap();
@@ -4621,6 +4780,11 @@ mod tests {
}];
let mut diff = diff_comparison(vec![file]);
diff.files_visible = false;
diff.comparison
.as_mut()
.unwrap()
.editable_paths
.insert("deleted-lines.txt".into());
diff.focus = if added { Focus::After } else { Focus::Before };
diff.left_after = 2;
render_diff(&mut terminal, &diff, &mut panels);
@@ -4653,6 +4817,17 @@ mod tests {
}));
assert!(text.contains(if added { "After · A" } else { "Before · D" }));
assert!(!text.contains(if added { "Before ·" } else { "After ·" }));
let bottom = pane_screen(
&terminal,
Rect::new(present.x, present.bottom() - 1, present.width, 1),
);
assert!(bottom.contains("old revision → Working checkout"));
assert_eq!(text.matches("old revision").count(), 1);
assert_eq!(text.matches("Working checkout").count(), 1);
assert!(
pane_screen(&terminal, Rect::new(present.right() - 13, present.y, 12, 1),)
.contains(if added { "editable" } else { "read-only" })
);
assert!(!text.contains('╱'));
}
let mut empty_added = deletion_file();
@@ -4669,6 +4844,14 @@ mod tests {
assert_eq!(geometry.regions[1].0, Rect::default());
assert_eq!(geometry.regions[2].0.width, 160);
assert!(screen_text(&terminal).contains("metadata-only addition"));
let pane = geometry.regions[2].0;
assert!(
pane_screen(
&terminal,
Rect::new(pane.x, pane.bottom() - 1, pane.width, 1),
)
.contains("old revision → Working checkout")
);
let mut renamed = deletion_file();
renamed.status = FileStatus::Renamed;
@@ -4699,8 +4882,13 @@ mod tests {
}
}
let text = screen_text(&terminal);
assert!(!text.contains("Before ·"));
assert!(!text.contains("After ·"));
assert!(
pane_screen(
&terminal,
Rect::new(blank.x, blank.bottom() - 1, blank.width, 1),
)
.contains("old revision → Working checkout")
);
assert!(!text.contains("unchanged rename content"));
assert!(!text.contains('╱'));
@@ -4842,6 +5030,8 @@ mod tests {
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("Choose Before"));
assert!(text.contains("Tab categories"));
assert!(text.contains("Enter select"));
assert!(text.contains("Working checkout · pinned"));
assert!(text.contains("> commit-19"));
assert!(!text.contains("commit-00"));
@@ -4857,6 +5047,8 @@ mod tests {
diff.handle(Input::Enter);
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("Choose After · Before: commit-19"));
assert!(screen_text(&terminal).contains("Tab categories"));
assert!(screen_text(&terminal).contains("Enter select"));
diff.handle(Input::Ctrl('u'));
diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels);
@@ -4977,7 +5169,7 @@ mod tests {
}
assert_eq!(buffer[(before.x, before.y)].fg, Color::Cyan);
assert_eq!(buffer[(after.x, after.y)].fg, Color::DarkGray);
assert!(screen_text(&terminal).contains("Before focus"));
assert_eq!(diff.focus, Focus::Before);
}
#[test]
@@ -5149,9 +5341,6 @@ mod tests {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("Before · R · old-name.txt"));
assert!(text.contains("After · R · new-name.txt"));
let geometry = panels.diff.as_ref().unwrap();
let before = geometry.regions[1].0;
let after = geometry.regions[2].0;
@@ -5163,7 +5352,7 @@ mod tests {
assert_eq!(buffer[(after.x + 8, after.y + 1)].fg, Color::Reset);
diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("After focus"));
assert_eq!(diff.focus, Focus::After);
assert_eq!(
terminal.backend().buffer()[(after.x, after.y)].fg,
Color::Cyan
@@ -5184,7 +5373,7 @@ mod tests {
let navigator = regions[0].0;
let before = regions[1].0;
let after = regions[2].0;
let workspace = Rect::new(0, 1, 120, 16);
let workspace = terminal.backend().buffer().area;
let (panes, dock) = dock_layout(workspace, DockSide::Right, true);
assert_eq!(Some(navigator), dock);
assert_eq!(before.x, panes.x);
@@ -5424,10 +5613,40 @@ mod tests {
}]);
render_diff(&mut terminal, &blank, &mut panels);
assert!(screen_text(&terminal).contains("Empty file"));
let mut unloaded_file = deletion_file();
unloaded_file.loaded = false;
let mut unloaded = diff_comparison(vec![unloaded_file]);
unloaded.loading = false;
render_diff(&mut terminal, &unloaded, &mut panels);
assert!(screen_text(&terminal).contains("File details unavailable"));
for state in [&empty, &binary, &blank, &unloaded] {
render_diff(&mut terminal, state, &mut panels);
let pane = panels
.diff
.as_ref()
.unwrap()
.regions
.into_iter()
.skip(1)
.find(|(pane, _)| *pane != Rect::default())
.unwrap()
.0;
assert!(
pane_screen(
&terminal,
Rect::new(pane.x, pane.bottom() - 1, pane.width, 1),
)
.contains("old revision → Working checkout")
);
assert!(
pane_screen(&terminal, Rect::new(pane.right() - 13, pane.y, 12, 1),)
.contains("read-only")
);
}
for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (39, 5)] {
let mut tiny =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
for state in [&diff, &empty, &binary, &blank] {
for state in [&diff, &empty, &binary, &blank, &unloaded] {
render_diff(&mut tiny, state, &mut panels);
if let Some(geometry) = &panels.diff {
for (rect, _) in geometry.regions {
@@ -7118,7 +7337,7 @@ mod tests {
);
}
}
assert_eq!(views.panels.files.unwrap().0.y, 2);
assert_eq!(views.panels.files.unwrap().0.y, views.rects[0].1.y);
let title = pane_screen(&terminal, Rect::new(0, 0, 120, 1));
editor.handle(Input::Char(' '));
editor.handle(Input::Char('o'));
@@ -7242,8 +7461,6 @@ mod tests {
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(views.diff_visible);
assert_eq!(terminal.backend().buffer()[(0, 1)].bg, Color::Cyan);
assert_eq!(views.panels.files.unwrap().0.y, 2);
session.execute("diff");
terminal
@@ -7279,22 +7496,30 @@ mod tests {
}
#[test]
fn native_diff_dock_stays_below_the_full_width_header() {
let mut editor = readonly_diff_editor(deletion_file());
let mut views = BufferViewports::default();
fn native_diff_dock_and_panes_fill_the_workspace_below_only_configured_chrome() {
for (width, height) in [(120, 30), (32, 9)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let dock = views.panels.files.unwrap().0;
let before = views.panels.diff.as_ref().unwrap().regions[1].0;
assert_eq!(dock.y, before.y);
assert_eq!(dock.y, 1);
assert_eq!(dock.right(), width);
for x in 0..width {
assert_eq!(terminal.backend().buffer()[(x, 0)].bg, Color::Cyan);
for chrome in [false, true] {
let mut editor = readonly_diff_editor(deletion_file());
if chrome {
editor.set_label("battery".into(), "live".into()).unwrap();
}
let mut views = BufferViewports::default();
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let dock = views.panels.files.unwrap().0;
let workspace = views.rects[0].1;
let regions = views.panels.diff.as_ref().unwrap().regions;
assert_eq!(dock.y, workspace.y);
assert_eq!(dock.right(), width);
assert_eq!(dock.bottom(), workspace.bottom());
for (pane, _) in regions.into_iter().skip(1) {
assert_eq!(pane.y, workspace.y);
assert_eq!(pane.bottom(), workspace.bottom());
assert_eq!(pane.y, u16::from(chrome));
}
}
}
}