feat: add editor tabs

This commit is contained in:
Valdemar Lorenzen
2026-10-12 09:34:54 +02:00
parent b72a3e206f
commit fc382529c5
11 changed files with 1241 additions and 105 deletions
+321 -9
View File
@@ -403,9 +403,10 @@ impl BufferViewports {
let focused = editor.pane_id();
self.activate(focused, editor);
self.parked
.retain(|id, _| editor.pane_layout().contains(*id));
.retain(|id, _| editor.pane_buffer_id(*id).is_some());
self.syntax.retain(|id, _| editor.has_buffer(*id));
let area = frame.area();
let area = draw_tab_bar(frame, editor, area);
let multiple = editor.pane_layout().len() > 1;
let chrome = if !multiple && editor.diff().is_some() {
None
@@ -557,8 +558,9 @@ impl BufferViewports {
.iter()
.chain(
self.parked
.values()
.filter_map(|pane| pane.panels.diff.as_ref()),
.iter()
.filter(|(id, _)| editor.pane_layout().contains(**id))
.filter_map(|(_, pane)| pane.panels.diff.as_ref()),
)
.any(|geometry| geometry.viewport.is_some());
}
@@ -573,7 +575,10 @@ impl BufferViewports {
{
session.set_diff_viewport(comparison_id, file, top, left_before, left_after);
}
for pane in self.parked.values() {
for (pane_id, _) in &self.rects {
let Some(pane) = self.parked.get(pane_id) else {
continue;
};
if let Some((id, file, top, before, after)) = pane
.panels
.diff
@@ -909,9 +914,11 @@ fn translate(key: KeyEvent) -> Option<Input> {
}
if key.modifiers.contains(KeyModifiers::ALT) {
return match key.code {
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Input::Alt(c.to_ascii_lowercase()))
}
KeyCode::Char(c) => Some(if key.modifiers.contains(KeyModifiers::CONTROL) {
Input::CtrlAlt(c.to_ascii_lowercase())
} else {
Input::Alt(c.to_ascii_lowercase())
}),
_ => None,
};
}
@@ -994,6 +1001,75 @@ fn diagnostic_on_row(diagnostic: &Diagnostic, row: usize) -> bool {
|| diagnostic.start == diagnostic.end)
}
fn draw_tab_bar(frame: &mut Frame, editor: &Editor, area: Rect) -> Rect {
if editor.tab_count() <= 1 || area.height == 0 {
return area;
}
let bar = Rect::new(area.x, area.y, area.width, 1);
let workspace = Rect::new(
area.x,
area.y.saturating_add(1),
area.width,
area.height - 1,
);
if bar.width == 0 {
return workspace;
}
let labels: Vec<_> = (0..editor.tab_count())
.map(|index| {
let buffer = editor.tab_buffer(index);
let name = buffer
.as_ref()
.and_then(|buffer| buffer.path)
.map(|path| {
path.file_name()
.unwrap_or(path.as_os_str())
.to_string_lossy()
})
.unwrap_or(Cow::Borrowed("[No Name]"));
display_text(&format!(
"{} {name}{} ",
index + 1,
if buffer.is_some_and(|buffer| buffer.dirty) {
" [+]"
} else {
""
},
))
})
.collect();
let active = editor.tab_index();
let mut first = active;
let mut width = labels[active].width();
while first > 0 && width + labels[first - 1].width() <= usize::from(bar.width) {
first -= 1;
width += labels[first].width();
}
let inactive = Style::default().fg(Color::Gray).bg(Color::DarkGray);
frame.render_widget(Paragraph::new("").style(inactive), bar);
let mut x = bar.x;
for (index, label) in labels.iter().enumerate().skip(first) {
let width = label.width().min(usize::from(bar.right() - x)) as u16;
let style = if index == active {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
inactive
};
frame.render_widget(
Paragraph::new(label.as_str()).style(style),
Rect::new(x, bar.y, width, 1),
);
x += width;
if x == bar.right() {
break;
}
}
workspace
}
fn document_layout(area: Rect, editor: &Editor) -> [Rect; 3] {
let prompting = matches!(editor.mode(), Mode::Command | Mode::Search);
let dock_focused = editor
@@ -1018,6 +1094,7 @@ fn draw(
panels: &mut WheelPanels,
) {
let area = frame.area();
let area = draw_tab_bar(frame, editor, area);
let areas = if editor.diff().is_some() {
[area, Rect::default(), Rect::default()]
} else {
@@ -1497,6 +1574,7 @@ fn draw_overlays(
draw_palette(
frame,
palette,
area,
editor.mode(),
editor.selection_navigation_hint(),
);
@@ -3720,8 +3798,13 @@ fn draw_file_list(
);
}
fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode, navigation_hint: &str) {
let area = frame.area();
fn draw_palette(
frame: &mut Frame,
palette: &Palette,
area: Rect,
mode: Mode,
navigation_hint: &str,
) {
if area.width == 0 || area.height == 0 {
return;
}
@@ -5083,6 +5166,212 @@ mod tests {
.join("\n")
}
#[test]
fn tab_bar_shows_shared_dirty_titles_and_reserves_document_and_overlay_rows() {
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("a\tname.txt", "DOCUMENT")), vec![]).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(views.document.geometry.unwrap().1.y, 0);
assert!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)).contains("DOCUMENT"));
editor.execute("tab.new");
editor.handle(Input::Char('i'));
editor.handle(Input::Paste("changed ".into()));
editor.handle(Input::Escape);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let titles = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
assert!(titles.contains("1 a�name.txt [+]"), "{titles}");
assert!(titles.contains("2 a�name.txt [+]"), "{titles}");
let active_x = display_column(&titles[..titles.find("2 a�name.txt").unwrap()], usize::MAX);
assert_eq!(terminal.backend().buffer()[(0, 0)].bg, Color::DarkGray);
assert_eq!(
terminal.backend().buffer()[(active_x as u16, 0)].bg,
Color::Cyan
);
assert_eq!(views.document.geometry.unwrap().1.y, 1);
assert!(terminal.get_cursor_position().unwrap().y >= 1);
assert!(pane_screen(&terminal, views.rects[0].1).contains("changed DOCUMENT"));
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)), titles);
assert!(terminal.get_cursor_position().unwrap().y >= 1);
assert!(screen_text(&terminal).contains("Commands"));
}
#[test]
fn narrow_tab_bar_keeps_the_active_title_visible_and_handles_empty_areas() {
let directory = Directory::new();
let mut editor = Editor::open(
Some(directory.file("a-very-long-inactive-title.txt", "first")),
vec![],
)
.unwrap();
editor.execute("tab.new");
editor.execute("tab.new");
editor.execute(&format!(
"edit {}",
directory.file("active.txt", "ACTIVE").display()
));
let mut views = BufferViewports::default();
for (width, height) in [(12, 9), (7, 9), (1, 9), (0, 9), (12, 1), (12, 0)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
if width > 0 && height > 0 {
let title = pane_screen(&terminal, Rect::new(0, 0, width, 1));
let expected: String = "3 active.txt".chars().take(usize::from(width)).collect();
assert_eq!(title, expected);
assert_eq!(terminal.backend().buffer()[(0, 0)].bg, Color::Cyan);
}
}
let mut editor = Editor::open(None, vec![]).unwrap();
editor.execute("tab.new");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let titles = pane_screen(&terminal, Rect::new(0, 0, 40, 1));
assert!(titles.contains("1 [No Name]"), "{titles}");
assert!(titles.contains("2 [No Name]"), "{titles}");
}
#[test]
fn tab_switches_restore_independent_pane_viewports_and_retire_only_closed_panes() {
let directory = Directory::new();
let text = (0..80)
.map(|row| format!("row-{row:02} {}", "long ".repeat(30)))
.collect::<Vec<_>>()
.join("\n");
let mut session =
EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap();
let buffer = session.editor().buffer_id();
session.execute("tab.new");
let other = session.editor().pane_id();
session.execute("tab.previous");
session.execute("pane.split_vertical");
let split = session.editor().pane_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
let area = views.rects.iter().find(|(id, _)| *id == split).unwrap().1;
assert_eq!(area.y, 1);
assert!(!views.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 2, 0)
));
for _ in 0..2 {
for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] {
assert!(views.scroll(&mut session, document_wheel(kind, area.x + 2, area.y + 2)));
}
}
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
let split_offset = views.offsets[&buffer];
assert!(split_offset.0 > 0 && split_offset.1 > 0);
session.execute("tab.next");
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(views.rects.len(), 1);
assert_eq!(views.parked[&split].offsets[&buffer], split_offset);
for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] {
assert!(views.scroll(&mut session, document_wheel(kind, 2, 2)));
}
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
let other_offset = views.offsets[&buffer];
assert_ne!(other_offset, split_offset);
session.execute("tab.previous");
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(session.editor().pane_id(), split);
assert_eq!(views.rects.len(), 2);
assert_eq!(views.offsets[&buffer], split_offset);
assert_eq!(views.parked[&other].offsets[&buffer], other_offset);
session.execute("tab.next");
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(views.offsets[&buffer], other_offset);
session.execute("tab.close");
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(!views.parked.contains_key(&other));
assert_eq!(views.offsets[&buffer], split_offset);
}
#[test]
fn hidden_tab_diff_caches_do_not_poll_colors_or_report_stale_viewports() {
let directory = Directory::new();
let mut session = EditorSession::open(
Some(directory.file("source.txt", "WORKSPACE")),
vec![],
None,
)
.unwrap();
let source = (0..80)
.map(|row| format!("old-{row:02} {}", "x ".repeat(30)))
.collect::<Vec<_>>()
.join("\n");
install_readonly_diff(
session.editor_mut(),
syntax_diff_file("history.txt", &source, &source.replace("old", "new")),
);
let owner = session.editor().pane_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(views.diff_visible);
let (id, file, _, _, _) = views.panels.diff.as_ref().unwrap().viewport.unwrap();
session.set_diff_viewport(id, file, 24, 7, 8);
session.execute("tab.new");
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(!views.diff_visible);
assert!(views.parked[&owner].panels.diff.is_some());
assert!(views.rects.iter().all(|(id, _)| *id != owner));
session.execute("tab.previous");
let diff = session.editor().diff().unwrap();
assert_eq!((diff.top, diff.left_before, diff.left_after), (24, 7, 8));
terminal
.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
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(views.panels.diff.as_ref().unwrap().regions[0].0.y >= 1);
}
#[test]
fn explorer_dock_reserves_space_outside_all_workspace_panes() {
let directory = Directory::new();
@@ -7566,6 +7855,26 @@ end
(
KeyCode::Char('j'),
KeyModifiers::ALT | KeyModifiers::CONTROL,
Some(Input::CtrlAlt('j')),
),
(
KeyCode::Char('H'),
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT,
Some(Input::CtrlAlt('h')),
),
(
KeyCode::Char('L'),
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT,
Some(Input::CtrlAlt('l')),
),
(
KeyCode::Char('j'),
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
None,
),
(
KeyCode::Left,
KeyModifiers::CONTROL | KeyModifiers::ALT,
None,
),
(KeyCode::Char('j'), KeyModifiers::SUPER, None),
@@ -10567,6 +10876,7 @@ end
draw_palette(
frame,
&palette,
area,
Mode::Visual,
editor.selection_navigation_hint(),
)
@@ -10605,9 +10915,11 @@ end
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
draw_palette(
frame,
&palette,
area,
Mode::Normal,
editor.selection_navigation_hint(),
)