feat: add Git and Jujutsu gutter indicators

This commit is contained in:
2026-10-13 17:44:29 +02:00
parent e84d70463f
commit 55cdee1a13
14 changed files with 1392 additions and 32 deletions
+197 -2
View File
@@ -849,6 +849,7 @@ pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()
Event::Resize(_, _) => redraw = true,
Event::FocusGained => {
redraw |= session.refresh_blame();
redraw |= session.refresh_gutter();
query_theme = true;
}
Event::ColorSchemeChanged => query_theme = true,
@@ -1510,7 +1511,11 @@ fn draw_document(
_ => (workspace, None),
};
let digits = editor.lines().len().ilog10() as usize + 1;
let gutter_width = (digits + 1) as u16;
let marker_gutter = matches!(
editor.gutter_style(),
crate::gutter::GutterStyle::Bar | crate::gutter::GutterStyle::Symbols
);
let gutter_width = (digits + 1 + usize::from(marker_gutter)) as u16;
let [gutter, viewport] = Layout::horizontal([
Constraint::Length(if viewport.width > gutter_width {
gutter_width
@@ -1762,7 +1767,40 @@ fn draw_document(
} else {
row.abs_diff(cursor.row)
};
Line::styled(format!("{number:>digits$} "), style)
let change = editor.gutter_change(row);
let color = change.map(|change| match change {
crate::gutter::Change::Added => Color::Green,
crate::gutter::Change::Removed => Color::Red,
crate::gutter::Change::Modified => Color::Yellow,
});
let style = if editor.gutter_style() == crate::gutter::GutterStyle::LineNumbers
{
if row == cursor.row {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
color.map_or(style, |color| style.fg(color))
}
} else {
style
};
let mut spans = vec![Span::styled(format!("{number:>digits$}"), style)];
if marker_gutter {
let marker = match (editor.gutter_style(), change) {
(crate::gutter::GutterStyle::Bar, Some(_)) => "▌",
(_, Some(crate::gutter::Change::Added)) => "+",
(_, Some(crate::gutter::Change::Removed)) => "-",
(_, Some(crate::gutter::Change::Modified)) => "~",
(_, None) => " ",
};
spans.push(Span::styled(
marker,
Style::default().fg(color.unwrap_or(Color::DarkGray)),
));
}
spans.push(Span::raw(" "));
Line::from(spans)
})
.collect();
frame.render_widget(Paragraph::new(numbers), gutter);
@@ -7681,6 +7719,7 @@ end
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("source.txt", &"x".repeat(60))), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.execute("word_wrap.toggle");
let source = editor.blame_source().unwrap();
editor.set_line_blame(
@@ -9971,6 +10010,7 @@ end
CompletionEdit, CompletionItem, CompletionSnippet, SnippetTabstop, TextPosition,
};
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.handle(Input::Char('i'));
editor
.show_completion(vec![CompletionItem {
@@ -10207,6 +10247,7 @@ end
let directory = Directory::new();
let source = "#a\t界e\u{301}👩‍🚀z";
let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.replace_diagnostics(
"fixture",
@@ -10299,6 +10340,7 @@ end
#[test]
fn diagnostics_render_newlines_anchors_counts_and_messages_without_hiding_core_errors() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"abc\n\nxy\nlast".into(),
@@ -10492,6 +10534,9 @@ end
Some(&config),
)
.unwrap();
session
.editor_mut()
.set_gutter_style(crate::gutter::GutterStyle::None);
let source_id = session.editor().buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(18, 6)).unwrap();
let mut viewports = BufferViewports::default();
@@ -10600,6 +10645,7 @@ end
let directory = Directory::new();
let path = directory.file("number.rs", "42");
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.set_clipboard_mode("separate").unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
let mut viewports = BufferViewports::default();
@@ -10729,6 +10775,7 @@ end
#[test]
fn semantic_roles_remain_distinct_when_values_and_fields_are_readonly() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"ns Type local frozen field fixed param frozenparam".into(),
@@ -10785,6 +10832,7 @@ end
let directory = Directory::new();
let source = "#a\t界e\u{301}👩‍🚀z";
let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.set_clipboard_mode("separate").unwrap();
editor
.replace_semantic_tokens(
@@ -10931,6 +10979,7 @@ end
vec![],
)
.unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.replace_semantic_tokens(
editor.buffer_id(),
@@ -11030,6 +11079,7 @@ end
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("source.rs", "let value = 42;")), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
let mut viewports = BufferViewports::default();
terminal
@@ -11062,6 +11112,7 @@ end
fn syntax_caches_stay_independent_across_buffer_edits_and_retire_closed_ids() {
let directory = Directory::new();
let mut editor = Editor::open(Some(directory.file("a.rs", "42")), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
let mut viewports = BufferViewports::default();
@@ -12764,6 +12815,7 @@ end
#[test]
fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
vec!["word ".repeat(40); 20].join("\n"),
@@ -12864,6 +12916,7 @@ end
#[test]
fn floating_picker_clears_a_centered_overlay_without_moving_editor_regions() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
vec!["x".repeat(120); 30].join("\n"),
@@ -13225,6 +13278,9 @@ end
let text = format!("abc\t界e\u{301}{}", "xyz".repeat(20));
let path = directory.file("wide.txt", &text);
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
session
.editor_mut()
.set_gutter_style(crate::gutter::GutterStyle::None);
session.handle(Input::Char('v'));
session.handle(Input::Char('l'));
let cursor = session.editor().cursor();
@@ -13557,6 +13613,9 @@ end
);
let other = directory.file("other.txt", "other");
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
session
.editor_mut()
.set_gutter_style(crate::gutter::GutterStyle::None);
let buffer_id = session.editor().buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 6)).unwrap();
let mut viewports = BufferViewports::default();
@@ -13672,6 +13731,9 @@ end
let directory = Directory::new();
let path = directory.file("long.txt", "abcdefghijk");
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
session
.editor_mut()
.set_gutter_style(crate::gutter::GutterStyle::None);
for _ in 0..8 {
session.handle(Input::Right);
}
@@ -13722,6 +13784,7 @@ end
let source = "abcdefghijklmnop\ntail";
let path = directory.file("wrapped.txt", source);
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.execute("word_wrap.toggle");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap();
let mut viewports = BufferViewports::default();
@@ -13818,6 +13881,7 @@ end
let source = format!("#a\t界e\u{301} z\n{}", vec!["# unseen"; 100].join("\n"));
let path = directory.file("wrapped.py", &source);
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.replace_semantic_tokens(
editor.buffer_id(),
@@ -14017,6 +14081,7 @@ end
let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n"));
let b = directory.file("b", "other");
let mut editor = Editor::open(Some(a), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap();
let mut viewports = BufferViewports::default();
@@ -14075,6 +14140,7 @@ end
let a = directory.file("a", "word\nword\nword");
let b = directory.file("b", "other");
let mut editor = Editor::open(Some(a), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 6)).unwrap();
let mut viewports = BufferViewports::default();
@@ -14111,6 +14177,7 @@ end
#[test]
fn pending_motion_count_is_visible_and_escape_cancels_without_moving() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(["row"; 12].join("\n"))])
.unwrap();
@@ -14155,9 +14222,131 @@ end
);
}
#[test]
fn git_gutter_modes_colors_geometry_wrap_scroll_and_tiny_panes() {
use crate::gutter::{Change, GutterStyle};
let directory = Directory::new();
let path = directory.file("gutter.txt", "abcdefghijklmnop\nsecond\nthird\nfourth");
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
assert!(editor.set_gutter_changes(
editor.buffer_id(),
editor.text_revision(),
&path,
vec![
Some(Change::Added),
Some(Change::Removed),
Some(Change::Modified),
None
],
));
for (mode, marker) in [
(GutterStyle::Bar, "▌"),
(GutterStyle::Symbols, "+"),
(GutterStyle::LineNumbers, ""),
(GutterStyle::None, ""),
] {
editor.set_gutter_style(mode);
let mut views = BufferViewports::default();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let viewport = views.panels.caret.unwrap().0;
let markers = matches!(mode, GutterStyle::Bar | GutterStyle::Symbols);
assert_eq!(viewport.x, if markers { 3 } else { 2 });
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(viewport.x - 1, viewport.y)].symbol(), " ");
if markers {
assert_eq!(buffer[(1, viewport.y)].symbol(), marker);
for (offset, color) in [Color::Green, Color::Red, Color::Yellow]
.into_iter()
.enumerate()
{
assert_eq!(buffer[(1, viewport.y + offset as u16)].fg, color);
}
if mode == GutterStyle::Symbols {
assert_eq!(buffer[(1, viewport.y + 1)].symbol(), "-");
assert_eq!(buffer[(1, viewport.y + 2)].symbol(), "~");
}
} else if mode == GutterStyle::LineNumbers {
assert_eq!(buffer[(0, viewport.y)].fg, Color::Cyan);
assert_eq!(buffer[(0, viewport.y + 1)].fg, Color::Red);
assert_eq!(buffer[(0, viewport.y + 2)].fg, Color::Yellow);
}
assert_eq!(buffer[(viewport.x, viewport.y)].symbol(), "a");
}
editor.set_gutter_style(GutterStyle::Symbols);
editor.handle(Input::End);
let mut scrolled_views = BufferViewports::default();
let mut scrolled_terminal =
Terminal::new(ratatui::backend::TestBackend::new(8, 4)).unwrap();
scrolled_terminal
.draw(|frame| scrolled_views.draw(frame, &mut editor))
.unwrap();
assert!(scrolled_views.offsets[&editor.buffer_id()].1 > 0);
let text_area = scrolled_views.panels.caret.unwrap().0;
assert_eq!(
scrolled_terminal.backend().buffer()[(1, text_area.y)].symbol(),
"+"
);
assert_eq!(text_area.x, 3);
editor.handle(Input::Home);
editor.execute("word_wrap.toggle");
let mut views = BufferViewports::default();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 8)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let viewport = views.panels.caret.unwrap().0;
for (screen, &(_, continuation, _)) in views.document.visible.iter().enumerate() {
if continuation > 0 {
for x in 0..viewport.x {
assert_eq!(
terminal.backend().buffer()[(x, viewport.y + screen as u16)].symbol(),
" "
);
}
}
}
editor.execute("word_wrap.toggle");
editor.handle(Input::Down);
editor.handle(Input::Down);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 3)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let viewport = views.panels.caret.unwrap().0;
for (screen, &(row, _, _)) in views.document.visible.iter().enumerate() {
let expected = match row {
0 => "+",
1 => "-",
2 => "~",
_ => " ",
};
assert_eq!(
terminal.backend().buffer()[(1, viewport.y + screen as u16)].symbol(),
expected
);
}
for width in [1, 2, 3] {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 3)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(views.panels.caret.unwrap().0.x, 0);
}
editor.execute("pane.split_vertical");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains('~'));
}
#[test]
fn gutter_tracks_scrolled_rows_without_scrolling_with_text() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"0123456789界z\n".repeat(11),
@@ -14228,6 +14417,7 @@ end
#[test]
fn gutter_resizes_at_digit_boundaries_and_yields_to_narrow_text() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
for count in [9usize, 10, 100] {
@@ -14727,6 +14917,7 @@ end
#[test]
fn selection_set_renders_all_ranges_and_counts_offscreen_targets() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"界\n界\n界\n界\n界".into(),
@@ -14769,6 +14960,7 @@ end
#[test]
fn secondary_carets_render_on_empty_lines_and_at_line_end() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace("a\n\n界".into())])
.unwrap();
@@ -14814,6 +15006,7 @@ end
#[test]
fn linewise_selection_highlights_copied_text_and_newlines_without_filling_rows() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"\t界e\u{301} \n\n".into(),
@@ -14897,6 +15090,7 @@ end
#[test]
fn visual_selection_highlights_graphemes_newlines_and_clipped_ranges_over_search() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"a\t界e\u{301}z\n\nxy".into(),
@@ -15003,6 +15197,7 @@ end
#[test]
fn active_search_highlight_uses_display_cells_and_clips_with_horizontal_scroll() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor
.apply(vec![crate::editor::Effect::Replace(
"a\t界e\u{301}zz".into(),