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
+733 -40
View File
@@ -26,6 +26,7 @@ pub enum Input {
Char(char),
Ctrl(char),
Alt(char),
CtrlAlt(char),
Paste(String),
Escape,
Enter,
@@ -387,6 +388,23 @@ core_actions![
false,
true
),
("core.tab.new", Action::TabNew, &[Mode::Normal], true),
(
"core.tab.previous",
Action::TabPrevious,
&[Mode::Normal],
true
),
("core.tab.next", Action::TabNext, &[Mode::Normal], true),
("core.tab.close", Action::TabClose, &[Mode::Normal], true),
(
"core.tab.force_close",
Action::TabForceClose,
&[Mode::Normal],
true,
false,
true
),
("core.insert", Action::Insert, &[Mode::Normal]),
(
"core.insert_first_nonblank",
@@ -800,6 +818,20 @@ fn parse_key(key: &str) -> Result<Input, String> {
return Ok(Input::Char(ch));
}
let invalid = || format!("Invalid binding key: {key:?}");
if let Some((first, rest)) = key.split_once('+')
&& let Some((second, name)) = rest.split_once('+')
{
let alt = |modifier: &str| {
modifier.eq_ignore_ascii_case("alt") || modifier.eq_ignore_ascii_case("option")
};
if ((first.eq_ignore_ascii_case("ctrl") && alt(second))
|| (alt(first) && second.eq_ignore_ascii_case("ctrl")))
&& let Some(ch) = character(name).filter(char::is_ascii_alphabetic)
{
return Ok(Input::CtrlAlt(ch.to_ascii_lowercase()));
}
return Err(invalid());
}
let (modifier, name) = key
.split_once('+')
.map_or((None, key), |(modifier, name)| (Some(modifier), name));
@@ -876,6 +908,7 @@ fn append_key(display: &mut String, input: &Input) {
Input::Char(ch) => display.push(*ch),
Input::Ctrl(ch) => write!(display, "Ctrl+{ch}").expect("writing to a String"),
Input::Alt(ch) => write!(display, "Alt+{ch}").expect("writing to a String"),
Input::CtrlAlt(ch) => write!(display, "Ctrl+Alt+{ch}").expect("writing to a String"),
Input::Escape => display.push_str("Esc"),
Input::Enter => display.push_str("Enter"),
Input::Backspace => display.push_str("Backspace"),
@@ -923,6 +956,8 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
(Input::Alt('j'), PaneFocusDown),
(Input::Alt('k'), PaneFocusUp),
(Input::Alt('l'), PaneFocusRight),
(Input::CtrlAlt('h'), TabPrevious),
(Input::CtrlAlt('l'), TabNext),
] {
bindings
.entry(Mode::Normal)
@@ -1170,6 +1205,26 @@ struct Pane {
views: BTreeMap<usize, ViewState>,
}
struct Tab {
pane_id: usize,
pane_layout: PaneTree,
file_picker: Option<FilePicker>,
diff: Option<DiffState>,
parked_diff: Option<DiffState>,
diff_owner: Option<usize>,
diff_prompt_mode: Option<Mode>,
diff_pane_bounds: [(Focus, PaneRect); 3],
diff_bound: Option<(u64, usize, Focus, usize)>,
diff_return_buffer: Option<usize>,
outstanding_diff_request: Option<u64>,
}
impl Tab {
fn diff(&self) -> Option<&DiffState> {
self.diff.as_ref().or(self.parked_diff.as_ref())
}
}
struct Buffer {
id: usize,
diff_snapshot: Option<(u64, usize, Focus)>,
@@ -1275,6 +1330,10 @@ pub struct Editor {
next_pane_id: usize,
panes: BTreeMap<usize, Pane>,
pane_layout: PaneTree,
tab_id: usize,
next_tab_id: usize,
tab_order: Vec<usize>,
tabs: BTreeMap<usize, Tab>,
pane_bounds: PaneRect,
render_pane_focused: bool,
diff_owner: Option<usize>,
@@ -1344,6 +1403,10 @@ impl Editor {
},
)]),
pane_layout: PaneTree::new(1),
tab_id: 1,
next_tab_id: 2,
tab_order: vec![1],
tabs: BTreeMap::new(),
pane_bounds: PaneRect {
x: 0,
y: 0,
@@ -1464,6 +1527,131 @@ impl Editor {
Ok(editor)
}
pub fn tab_id(&self) -> usize {
self.tab_id
}
pub fn tab_index(&self) -> usize {
self.tab_order
.iter()
.position(|id| *id == self.tab_id)
.unwrap()
}
pub fn tab_count(&self) -> usize {
self.tab_order.len()
}
pub fn tab_buffer(&self, index: usize) -> Option<BufferInfo<'_>> {
let id = *self.tab_order.get(index)?;
let pane = if id == self.tab_id {
self.pane_id
} else {
self.tabs.get(&id)?.pane_id
};
self.buffer(self.pane_owned_buffer(pane))
.map(|buffer| buffer.info(id == self.tab_id))
}
pub(crate) fn tab_diff(&self, id: usize) -> Option<&DiffState> {
if id == self.tab_id {
self.workspace_diff()
} else {
self.tabs.get(&id)?.diff()
}
}
fn select_tab(&mut self, id: usize) {
if id == self.tab_id {
return;
}
let next = self.tabs.remove(&id).expect("selected tab exists");
self.stash_view();
let previous = Tab {
pane_id: std::mem::replace(&mut self.pane_id, next.pane_id),
pane_layout: std::mem::replace(&mut self.pane_layout, next.pane_layout),
file_picker: std::mem::replace(&mut self.file_picker, next.file_picker),
diff: std::mem::replace(&mut self.diff, next.diff),
parked_diff: std::mem::replace(&mut self.parked_diff, next.parked_diff),
diff_owner: std::mem::replace(&mut self.diff_owner, next.diff_owner),
diff_pane_bounds: std::mem::replace(&mut self.diff_pane_bounds, next.diff_pane_bounds),
diff_prompt_mode: std::mem::replace(&mut self.diff_prompt_mode, next.diff_prompt_mode),
diff_bound: std::mem::replace(&mut self.diff_bound, next.diff_bound),
diff_return_buffer: std::mem::replace(
&mut self.diff_return_buffer,
next.diff_return_buffer,
),
outstanding_diff_request: std::mem::replace(
&mut self.outstanding_diff_request,
next.outstanding_diff_request,
),
};
self.tabs.insert(self.tab_id, previous);
self.tab_id = id;
let buffer_id = self.panes[&self.pane_id].buffer_id;
if buffer_id != self.active.id {
let buffer = self.parked.remove(&buffer_id).expect("tab buffer exists");
let previous = std::mem::replace(&mut self.active, buffer);
self.parked.insert(previous.id, previous);
}
self.load_view();
}
fn new_tab(&mut self) {
self.finish_pane_interaction();
let buffer_id = self.active.id;
let view = self.active.view.inherit();
let pane_id = self.next_pane_id;
self.next_pane_id += 1;
self.panes.insert(
pane_id,
Pane {
buffer_id,
origin: None,
views: BTreeMap::from([(buffer_id, view)]),
},
);
let id = self.next_tab_id;
self.next_tab_id += 1;
self.tabs.insert(
id,
Tab {
pane_id,
pane_layout: PaneTree::new(pane_id),
file_picker: None,
diff: None,
parked_diff: None,
diff_owner: None,
diff_pane_bounds: self.diff_pane_bounds,
diff_prompt_mode: None,
diff_bound: None,
diff_return_buffer: None,
outstanding_diff_request: None,
},
);
self.tab_order.push(id);
self.select_tab(id);
self.normalize_targets();
if self.active.view.mode == Mode::Insert {
self.begin_insert();
}
}
fn adjacent_tab(&mut self, forward: bool) {
self.finish_pane_interaction();
let index = self.tab_index();
let count = self.tab_count();
let next = if forward {
(index + 1) % count
} else {
(index + count - 1) % count
};
self.select_tab(self.tab_order[next]);
if self.active.view.mode == Mode::Insert {
self.begin_insert();
}
}
pub fn pane_id(&self) -> usize {
self.pane_id
}
@@ -1543,6 +1731,22 @@ impl Editor {
fn with_pane<R>(&mut self, id: usize, callback: impl FnOnce(&mut Self) -> R) -> R {
assert!(self.panes.contains_key(&id), "rendered pane exists");
let focused = self.pane_id;
let tab = self.tab_id;
let target_tab = if self.pane_layout.contains(id) {
tab
} else {
*self
.tabs
.iter()
.find(|(_, tab)| tab.pane_layout.contains(id))
.map(|(id, _)| id)
.expect("pane belongs to a tab")
};
let target_focused = if target_tab == tab {
focused
} else {
self.tabs[&target_tab].pane_id
};
let interaction = (id != focused).then(|| {
(
self.palette.take(),
@@ -1556,11 +1760,14 @@ impl Editor {
std::mem::take(&mut self.g_pending),
self.line_pending.take(),
self.seek_pending.take(),
std::mem::take(&mut self.message),
)
});
self.select_tab(target_tab);
self.select_pane(id);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(self)));
self.select_pane(focused);
self.select_pane(target_focused);
self.select_tab(tab);
if let Some(interaction) = interaction {
(
self.palette,
@@ -1574,6 +1781,7 @@ impl Editor {
self.g_pending,
self.line_pending,
self.seek_pending,
self.message,
) = interaction;
}
match result {
@@ -1679,22 +1887,104 @@ impl Editor {
}
}
fn pane_owned_buffer(&self, id: usize) -> usize {
if self.diff_owner == Some(id) {
self.diff
.as_ref()
.or(self.parked_diff.as_ref())
.and_then(|diff| diff.live_buffer)
.or(self.diff_return_buffer)
.unwrap_or(self.panes[&id].buffer_id)
fn pane_buffers(&self, id: usize) -> [Option<usize>; 3] {
let (diff, return_buffer) = if self.diff_owner == Some(id) {
(self.workspace_diff(), self.diff_return_buffer)
} else if let Some(tab) = self.tabs.values().find(|tab| tab.diff_owner == Some(id)) {
(tab.diff(), tab.diff_return_buffer)
} else {
self.panes[&id].buffer_id
(None, None)
};
[
Some(self.panes[&id].buffer_id),
diff.and_then(|diff| diff.live_buffer),
return_buffer,
]
}
fn pane_owned_buffer(&self, id: usize) -> usize {
let buffers = self.pane_buffers(id);
buffers[1].or(buffers[2]).or(buffers[0]).unwrap()
}
fn closing_buffers(&self, panes: &[usize]) -> Vec<usize> {
let mut buffers = Vec::new();
for pane in panes {
for buffer in self.pane_buffers(*pane).into_iter().flatten() {
if !buffers.contains(&buffer)
&& !self.panes.keys().any(|other| {
!panes.contains(other) && self.pane_buffers(*other).contains(&Some(buffer))
})
{
buffers.push(buffer);
}
}
}
buffers
}
fn forget_buffer(&mut self, id: usize) {
self.parked.remove(&id);
for pane in self.panes.values_mut() {
pane.views.remove(&id);
}
self.jumps.retain(|jump| jump.buffer_id != id);
self.jump_index = self.jump_index.min(self.jumps.len().saturating_sub(1));
}
fn close_tab(&mut self, force: bool) {
if self.tab_count() == 1 {
if !force && self.any_dirty() {
self.set_message("Unsaved changes; use :w or :tab.force_close");
} else {
self.quit = true;
}
return;
}
let panes: Vec<_> = self
.panes
.keys()
.copied()
.filter(|id| self.pane_layout.contains(*id))
.collect();
let buffers = self.closing_buffers(&panes);
if !force
&& buffers
.iter()
.any(|id| self.buffer(*id).is_some_and(Buffer::dirty))
{
self.set_message("Unsaved changes; use :w or :tab.force_close");
return;
}
self.finish_pane_interaction();
if let Some(owner) = self.diff_owner {
self.with_pane(owner, |editor| {
editor.release_diff_buffers();
editor.diff = None;
});
self.parked_diff = None;
self.diff_owner = None;
self.outstanding_diff_request = None;
}
let id = self.tab_id;
let index = self.tab_index();
let next = self.tab_order[(index + 1) % self.tab_count()];
self.select_tab(next);
self.tabs.remove(&id);
self.tab_order.remove(index);
for pane in panes {
self.panes.remove(&pane);
}
for buffer in buffers {
self.forget_buffer(buffer);
}
}
fn close_pane(&mut self, force: bool) {
if self.pane_layout.len() == 1 {
if !force && self.any_dirty() {
if self.tab_count() > 1 {
self.close_tab(force);
} else if !force && self.any_dirty() {
self.set_message("Unsaved changes; use :w or :pane.force_close");
} else {
self.quit = true;
@@ -1702,12 +1992,12 @@ impl Editor {
return;
}
let id = self.pane_id;
let buffer_id = self.pane_owned_buffer(id);
let shared = self.panes.iter().any(|(other, pane)| {
*other != id
&& (pane.buffer_id == buffer_id || self.pane_owned_buffer(*other) == buffer_id)
});
if !shared && !force && self.buffer(buffer_id).is_some_and(Buffer::dirty) {
let buffers = self.closing_buffers(&[id]);
if !force
&& let Some(buffer_id) = buffers
.iter()
.find(|buffer| self.buffer(**buffer).is_some_and(Buffer::dirty))
{
self.set_message(format!(
"Buffer {buffer_id} has unsaved changes; use :w or :pane.force_close"
));
@@ -1724,13 +2014,8 @@ impl Editor {
let next = self.pane_layout.close(id).expect("another pane exists");
self.select_pane(next);
self.panes.remove(&id);
if !shared {
self.parked.remove(&buffer_id);
for pane in self.panes.values_mut() {
pane.views.remove(&buffer_id);
}
self.jumps.retain(|jump| jump.buffer_id != buffer_id);
self.jump_index = self.jump_index.min(self.jumps.len().saturating_sub(1));
for buffer in buffers {
self.forget_buffer(buffer);
}
}
@@ -1874,12 +2159,19 @@ impl Editor {
}
pub(crate) fn apply_diff_result(&mut self, id: u64, result: Result<Outcome, String>) -> bool {
if self.outstanding_diff_request != Some(id) {
let owner = self
.tabs
.values()
.find(|tab| tab.outstanding_diff_request == Some(id))
.and_then(|tab| tab.diff_owner);
return owner.is_some_and(|owner| {
self.with_pane(owner, |editor| editor.apply_diff_result(id, result))
});
}
if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) {
return self.with_pane(owner, |editor| editor.apply_diff_result(id, result));
}
if self.outstanding_diff_request != Some(id) {
return false;
}
self.outstanding_diff_request = None;
let Some(diff) = &mut self.diff else {
return false;
@@ -2031,7 +2323,14 @@ impl Editor {
self.activate(buffer);
}
self.parked.retain(|id, buffer| {
buffer.diff_snapshot.is_none() || self.panes.values().any(|pane| pane.buffer_id == *id)
buffer.diff_snapshot.is_none()
|| self.panes.values().any(|pane| pane.buffer_id == *id)
|| buffer.diff_snapshot.is_some_and(|role| {
self.tabs.values().any(|tab| {
tab.diff()
.is_some_and(|diff| diff.comparison_id == Some(role.0))
})
})
});
for pane in self.panes.values_mut() {
pane.views
@@ -2199,7 +2498,16 @@ impl Editor {
}
pub(crate) fn refresh_live_diff(&mut self) {
for diff in self.diff.iter_mut().chain(self.parked_diff.iter_mut()) {
for diff in self
.diff
.iter_mut()
.chain(self.parked_diff.iter_mut())
.chain(
self.tabs
.values_mut()
.flat_map(|tab| tab.diff.iter_mut().chain(tab.parked_diff.iter_mut())),
)
{
let buffer = diff.live_buffer.and_then(|id| {
if id == self.active.id {
Some(&self.active)
@@ -2316,6 +2624,11 @@ impl Editor {
| Action::PaneFocusRight
| Action::PaneClose
| Action::PaneForceClose
| Action::TabNew
| Action::TabPrevious
| Action::TabNext
| Action::TabClose
| Action::TabForceClose
)
)
})
@@ -4331,6 +4644,11 @@ impl Editor {
| Action::PaneFocusDown
| Action::PaneFocusUp
| Action::PaneFocusRight
| Action::TabNew
| Action::TabPrevious
| Action::TabNext
| Action::TabClose
| Action::TabForceClose
)
{
let action = *action;
@@ -4588,6 +4906,11 @@ impl Editor {
| Action::PaneFocusRight
| Action::PaneClose
| Action::PaneForceClose
| Action::TabNew
| Action::TabPrevious
| Action::TabNext
| Action::TabClose
| Action::TabForceClose
| Action::Save
| Action::Quit
| Action::ForceQuit
@@ -5035,21 +5358,33 @@ impl Editor {
));
return;
}
if let Some(owner) = self.diff_owner
&& (self.diff_return_buffer == Some(id)
|| self
.diff
.as_ref()
.or(self.parked_diff.as_ref())
.is_some_and(|diff| diff.live_buffer == Some(id)))
{
let owners: Vec<_> = self
.diff_owner
.filter(|_| {
self.diff_return_buffer == Some(id)
|| self
.workspace_diff()
.is_some_and(|diff| diff.live_buffer == Some(id))
})
.into_iter()
.chain(
self.tabs
.values()
.filter(|tab| {
tab.diff_return_buffer == Some(id)
|| tab.diff().is_some_and(|diff| diff.live_buffer == Some(id))
})
.filter_map(|tab| tab.diff_owner),
)
.collect();
for owner in owners {
self.with_pane(owner, |editor| {
editor.release_diff_buffers();
editor.diff = None;
editor.parked_diff = None;
editor.diff_owner = None;
editor.outstanding_diff_request = None;
});
self.parked_diff = None;
self.diff_owner = None;
self.outstanding_diff_request = None;
}
let replacement_id = if id == self.active.id {
let next = self.adjacent_buffer(true);
@@ -5229,6 +5564,11 @@ impl Editor {
| Action::PaneFocusRight
| Action::PaneClose
| Action::PaneForceClose
| Action::TabNew
| Action::TabPrevious
| Action::TabNext
| Action::TabClose
| Action::TabForceClose
) {
if !args.is_empty() {
self.set_message(format!(
@@ -5246,6 +5586,11 @@ impl Editor {
Action::PaneFocusRight => self.focus_pane(PaneDirection::Right),
Action::PaneClose => self.close_pane(false),
Action::PaneForceClose => self.close_pane(true),
Action::TabNew => self.new_tab(),
Action::TabPrevious => self.adjacent_tab(false),
Action::TabNext => self.adjacent_tab(true),
Action::TabClose => self.close_tab(false),
Action::TabForceClose => self.close_tab(true),
_ => unreachable!("pane actions handled above"),
}
return None;
@@ -8308,6 +8653,354 @@ mod tests {
assert_eq!(editor.buffer_id(), 1);
}
#[test]
fn tabs_preserve_panes_explorers_and_independent_views_of_shared_history() {
let directory = Directory::new();
let mut editor = editor();
editor.project_root = directory.0.clone();
editor
.apply(vec![Effect::Replace("abcdef".into())])
.unwrap();
editor.execute("pane.split_vertical");
let source_pane = editor.pane_id();
set_carets(&mut editor, &[(0, 2)], 0);
editor.open_file_picker(FilePresentation::Docked);
let layout = format!("{:?}", editor.pane_layout());
editor.execute("tab.new");
let new_pane = editor.pane_id();
assert_ne!(source_pane, new_pane);
assert_eq!(editor.tab_index(), 1);
assert_eq!(editor.tab_count(), 2);
assert_eq!(editor.buffer_id(), 1);
assert_eq!(editor.buffers().count(), 1);
assert_eq!(editor.pane_layout().len(), 1);
assert!(editor.file_picker().is_none());
set_carets(&mut editor, &[(0, 4)], 0);
editor.handle(Input::CtrlAlt('h'));
assert_eq!(editor.pane_id(), source_pane);
assert_eq!(format!("{:?}", editor.pane_layout()), layout);
assert_eq!(editor.cursor().column, 2);
assert!(editor.file_picker().unwrap().focused);
editor.handle(Input::CtrlAlt('l'));
assert_eq!(editor.pane_id(), new_pane);
assert_eq!(editor.cursor().column, 4);
type_text(&mut editor, "iX");
editor.handle(Input::Escape);
editor.handle(Input::CtrlAlt('l'));
assert_eq!(editor.tab_index(), 0);
assert_eq!(editor.lines(), ["abcdXef"]);
assert_eq!(editor.cursor().column, 2);
editor.blur_file_picker();
editor.handle(Input::Char('u'));
assert_eq!(editor.lines(), ["abcdef"]);
editor.handle(Input::CtrlAlt('h'));
assert_eq!(editor.tab_index(), 1);
assert_eq!(editor.lines(), ["abcdef"]);
assert_eq!(editor.pane_buffer_id(source_pane), Some(1));
assert_eq!(editor.tab_buffer(0).unwrap().id, 1);
assert_eq!(editor.tab_buffer(1).unwrap().id, 1);
assert!(editor.tab_buffer(2).is_none());
}
#[test]
fn tab_close_guards_final_dirty_owners_and_force_keeps_shared_documents() {
let directory = Directory::new();
let mut editor = editor();
editor.apply(vec![Effect::Insert("shared".into())]).unwrap();
editor.execute("tab.new");
editor.execute("tab.force_close");
assert_eq!(editor.tab_count(), 1);
assert!(editor.has_buffer(1));
assert!(editor.dirty());
editor.execute("tab.new");
editor.execute(&format!(
"edit {}",
directory.path("exclusive.txt").display()
));
let exclusive = editor.buffer_id();
editor
.apply(vec![Effect::Insert("exclusive".into())])
.unwrap();
editor.execute("pane.split_horizontal");
editor.execute("tab.close");
assert_eq!(editor.tab_count(), 2);
assert!(editor.message().contains("tab.force_close"));
editor.execute("pane.close");
assert_eq!(editor.pane_layout().len(), 1);
editor.execute("pane.close");
assert_eq!(editor.tab_count(), 2);
editor.execute("pane.force_close");
assert_eq!(editor.tab_count(), 1);
assert!(editor.has_buffer(1));
assert!(!editor.has_buffer(exclusive));
assert_eq!(editor.lines(), ["shared"]);
editor.execute("tab.close");
assert!(!editor.should_quit());
editor.execute("tab.force_close");
assert!(editor.should_quit());
}
#[test]
fn closing_tab_checks_dirty_diff_return_buffer_even_when_live_buffer_is_shared() {
let directory = Directory::new();
let path = directory.path("file.txt");
fs::write(&path, "base").unwrap();
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.execute("tab.new");
editor.execute(&format!("edit {}", directory.path("return.txt").display()));
let return_buffer = editor.buffer_id();
editor
.apply(vec![Effect::Insert("dirty return".into())])
.unwrap();
working_diff(&mut editor, &directory.0, &["file.txt"]);
editor.diff.as_mut().unwrap().focus = Focus::Before;
editor.bind_diff_buffer();
assert!(!editor.dirty());
editor.execute("tab.close");
assert_eq!(editor.tab_count(), 2);
assert!(editor.message().contains("unsaved") || editor.message().contains("Unsaved"));
editor.execute("tab.force_close");
assert_eq!(editor.tab_count(), 1);
assert!(editor.has_buffer(1));
assert!(!editor.has_buffer(return_buffer));
assert_eq!(editor.lines(), ["base"]);
}
#[test]
fn tab_modifiers_aliases_catalog_and_palette_follow_pane_conventions() {
for key in ["ctrl+alt+h", "CTRL+Option+H", "alt+ctrl+h", "option+ctrl+h"] {
assert_eq!(parse_key(key).unwrap(), Input::CtrlAlt('h'));
}
for key in [
"ctrl+shift+h",
"ctrl+alt+shift+h",
"alt+alt+h",
"ctrl+alt+left",
] {
assert!(parse_key(key).is_err(), "{key}");
}
assert_eq!(display_key(&Input::CtrlAlt('h')), "Ctrl+Alt+h");
let mut editor = editor();
for command in [
"tab.new",
"tab.previous",
"tab.next",
"tab.close",
"tab.force_close",
] {
assert!(core_command(command).is_some());
assert!(Action::named(Mode::Normal, &format!("core.{command}")).is_some());
assert!(Action::named(Mode::Insert, &format!("core.{command}")).is_none());
}
editor.execute("tab.new extra");
assert_eq!(editor.tab_count(), 1);
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "core.tab.new");
editor.handle(Input::Enter);
assert_eq!(editor.tab_count(), 2);
editor.handle(Input::Char('i'));
editor.handle(Input::CtrlAlt('h'));
assert_eq!(editor.tab_index(), 1);
editor.handle(Input::Escape);
editor.bind("normal", "option+ctrl+h", None).unwrap();
editor.handle(Input::CtrlAlt('h'));
assert_eq!(editor.tab_index(), 1);
editor
.bind("normal", "ctrl+alt+h", Some("core.tab.previous".into()))
.unwrap();
editor.handle(Input::CtrlAlt('h'));
assert_eq!(editor.tab_index(), 0);
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "core.tab.force_close");
editor.handle(Input::Enter);
assert_eq!(editor.command_line(), "tab.force_close ");
assert_eq!(editor.tab_count(), 2);
editor.handle(Input::Enter);
assert_eq!(editor.tab_count(), 1);
}
#[test]
fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_ids() {
use crate::diff::{
Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision,
};
let mut editor = editor();
editor.execute("diff");
let first_tab = editor.tab_id();
let (first_request, _) = editor.take_diff_request().unwrap();
editor.execute("pane.split_vertical");
let hidden_focused_pane = editor.pane_id();
editor.execute("tab.new");
editor.execute("diff");
let second_tab = editor.tab_id();
let (second_request, _) = editor.take_diff_request().unwrap();
assert!(second_request > first_request);
editor.execute("tab.new");
let visible_tab = editor.tab_id();
let visible_pane = editor.pane_id();
type_text(&mut editor, "iX");
let cursor = editor.cursor();
editor.set_message("visible");
let catalog = || {
Outcome::Catalog(Catalog {
kind: RepoKind::Git,
entries: [
RevisionTarget::Commit("base".into()),
RevisionTarget::Working,
]
.into_iter()
.map(|target| Revision {
target,
label: "Revision".into(),
description: String::new(),
category: Category::Commits,
})
.collect(),
notice: None,
})
};
assert!(editor.apply_diff_result(second_request, Ok(catalog())));
assert!(editor.apply_diff_result(first_request, Ok(catalog())));
assert!(!editor.apply_diff_result(first_request, Ok(catalog())));
assert_eq!(editor.tab_id(), visible_tab);
assert_eq!(editor.pane_id(), visible_pane);
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(editor.message(), "visible");
assert_eq!(editor.lines(), ["X"]);
assert!(editor.diff().is_none());
assert!(!editor.tab_diff(first_tab).unwrap().loading);
assert!(!editor.tab_diff(second_tab).unwrap().loading);
let owner = editor.tabs[&first_tab].diff_owner.unwrap();
let (comparison_request, _) = editor.with_pane(owner, |editor| {
editor.handle(Input::Enter);
editor.handle(Input::Down);
editor.handle(Input::Enter);
editor.take_diff_request().unwrap()
});
let before = vec!["base".to_owned()];
let after = vec!["changed".to_owned()];
let hunks = crate::diff::line_hunks(&before, false, &after, false);
assert!(editor.apply_diff_result(
comparison_request,
Ok(Outcome::Comparison(Comparison {
root: editor.project_root().to_owned(),
before: "base".into(),
after: "working".into(),
editable_paths: Default::default(),
files: vec![ChangedFile {
loaded: true,
path: "file.txt".into(),
old_path: None,
status: FileStatus::Modified,
before,
after,
before_eof: false,
after_eof: false,
hunks,
notice: None,
}],
}))
));
assert_eq!(editor.tab_id(), visible_tab);
assert_eq!(editor.pane_id(), visible_pane);
assert_eq!(editor.buffer_id(), 1);
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(editor.message(), "visible");
assert_ne!(editor.pane_buffer_id(owner), Some(1));
assert_eq!(editor.tabs[&first_tab].pane_id, hidden_focused_pane);
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.lines(), [""]);
}
#[test]
fn tab_snapshots_remain_readonly_and_other_comparisons_survive_release() {
let directory = Directory::new();
let mut editor = editor();
historical_diff(
&mut editor,
&directory.0,
vec!["base".into()],
vec!["first".into()],
);
let first_tab = editor.tab_id();
let first_snapshot = editor.buffer_id();
let first_comparison = editor.diff().unwrap().comparison_id;
editor.execute("tab.new");
assert_eq!(editor.buffer_id(), first_snapshot);
assert!(editor.diff().is_none());
assert!(
editor
.apply(vec![Effect::Insert("rejected".into())])
.is_err()
);
editor.execute("buffer 1");
historical_diff(
&mut editor,
&directory.0,
vec!["base".into()],
vec!["second".into()],
);
let second_snapshot = editor.buffer_id();
assert_ne!(editor.diff().unwrap().comparison_id, first_comparison);
editor.execute("tab.new");
assert_eq!(editor.buffer_id(), second_snapshot);
editor.execute("tab.previous");
editor.execute("diff.close");
assert!(editor.tab_diff(first_tab).is_some());
assert!(editor.has_buffer(first_snapshot));
assert!(editor.has_buffer(second_snapshot));
editor.execute("tab.next");
assert_eq!(editor.buffer_id(), second_snapshot);
assert!(editor.mutation_blocked());
editor.execute("tab.next");
assert_eq!(editor.buffer_id(), first_snapshot);
assert_eq!(editor.diff().unwrap().comparison_id, first_comparison);
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
}
#[test]
fn hidden_tab_comparisons_refresh_shared_edits_and_deletion_closes_all_owners() {
let directory = Directory::new();
let path = directory.path("file.txt");
fs::write(&path, "base").unwrap();
let mut editor = Editor::open(Some(path), vec![]).unwrap();
working_diff(&mut editor, &directory.0, &["file.txt"]);
editor.diff.as_mut().unwrap().focus = Focus::After;
editor.bind_diff_buffer();
let first_tab = editor.tab_id();
editor.execute("tab.new");
working_diff(&mut editor, &directory.0, &["file.txt"]);
editor.diff.as_mut().unwrap().focus = Focus::After;
editor.bind_diff_buffer();
let second_tab = editor.tab_id();
editor.execute("tab.new");
editor
.apply(vec![Effect::Insert("changed".into())])
.unwrap();
let revision = editor.text_revision();
assert_eq!(
editor.tab_diff(first_tab).unwrap().live_revision,
Some(revision)
);
assert_eq!(
editor.tab_diff(second_tab).unwrap().live_revision,
Some(revision)
);
editor.execute("tab.close");
assert_eq!(editor.tab_count(), 2);
assert!(editor.has_buffer(1));
editor.execute("bdelete! 1");
assert!(!editor.has_buffer(1));
assert!(editor.tab_diff(first_tab).is_none());
assert!(editor.tab_diff(second_tab).is_none());
editor.execute("tab.next");
assert!(editor.diff().is_none());
assert!(editor.has_buffer(editor.buffer_id()));
}
#[test]
fn pane_views_survive_buffer_switches_and_deletion_replaces_every_reference() {
let directory = Directory::new();
+4
View File
@@ -21,6 +21,8 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
gf follows Markdown file links/reference links and heading fragments (no LSP)
Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported)
_ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes
Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
u undo, U redo, : command, / search, n/N next/previous match
dd deletes lines; cc changes lines and enters Insert; counts: 3dd, 2cc; Esc cancels prefix
@@ -42,6 +44,8 @@ Aliases: w=save, q=quit, q!=force_quit, wq=save_and_quit
Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N]
Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged
:quit refuses any dirty buffer; :save_and_quit saves active and quits only if all are clean
Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close
Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits
Markdown links: :follow_link (primary caret; local files only)
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
+46 -46
View File
@@ -169,7 +169,7 @@ pub struct EditorSession {
pending_hover: bool,
pending_completion: bool,
hover_owner: Option<String>,
diff_worker: Option<DiffWorker>,
diff_workers: BTreeMap<usize, DiffWorker>,
}
impl EditorSession {
@@ -261,7 +261,7 @@ impl EditorSession {
pending_hover: false,
pending_completion: false,
hover_owner: None,
diff_worker: None,
diff_workers: BTreeMap::new(),
};
session.synchronize_documents();
Ok(session)
@@ -319,27 +319,27 @@ impl EditorSession {
}
fn start_diff_request(&mut self) {
if self.editor.workspace_diff().is_none() {
self.diff_worker = None;
return;
}
if self
.editor
.workspace_diff()
.is_some_and(|diff| !diff.loading)
&& let Some(worker) = &mut self.diff_worker
&& let Some(id) = worker.pending.take()
{
worker.cancelled_request.store(id, Ordering::Release);
worker.cancelled.store(true, Ordering::Release);
}
self.diff_workers.retain(|tab, worker| {
let Some(diff) = self.editor.tab_diff(*tab) else {
return false;
};
if !diff.loading
&& let Some(id) = worker.pending.take()
{
worker.cancelled_request.store(id, Ordering::Release);
worker.cancelled.store(true, Ordering::Release);
}
true
});
let Some((id, operation)) = self.editor.take_diff_request() else {
return;
};
if self.diff_worker.is_none() {
let root = self.editor.diff_root();
match DiffWorker::start(root) {
Ok(worker) => self.diff_worker = Some(worker),
let tab = self.editor.tab_id();
if let std::collections::btree_map::Entry::Vacant(entry) = self.diff_workers.entry(tab) {
match DiffWorker::start(self.editor.diff_root()) {
Ok(worker) => {
entry.insert(worker);
}
Err(error) => {
self.editor.apply_diff_result(id, Err(error));
return;
@@ -347,46 +347,46 @@ impl EditorSession {
}
}
if let Err(error) = self
.diff_worker
.as_mut()
.diff_workers
.get_mut(&tab)
.expect("diff worker started")
.request(id, operation)
{
self.editor.apply_diff_result(id, Err(error));
self.diff_worker = None;
self.diff_workers.remove(&tab);
}
}
fn poll_diff(&mut self) -> bool {
self.start_diff_request();
let mut changed = false;
while let Some(worker) = self.diff_worker.as_ref() {
let result = worker
.results
.as_ref()
.expect("live diff worker has results")
.try_recv();
match result {
Ok((id, result)) => {
if let Some(worker) = &mut self.diff_worker
&& worker.pending == Some(id)
{
worker.pending = None;
self.diff_workers.retain(|_, worker| {
loop {
let result = worker
.results
.as_ref()
.expect("live diff worker has results")
.try_recv();
match result {
Ok((id, result)) => {
if worker.pending == Some(id) {
worker.pending = None;
}
changed |= self.editor.apply_diff_result(id, result);
}
changed |= self.editor.apply_diff_result(id, result);
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
if let Some(id) = self.diff_worker.as_ref().and_then(|worker| worker.pending) {
changed |= self
.editor
.apply_diff_result(id, Err("Diff worker stopped unexpectedly".into()));
Err(mpsc::TryRecvError::Empty) => return true,
Err(mpsc::TryRecvError::Disconnected) => {
if let Some(id) = worker.pending.take() {
changed |= self.editor.apply_diff_result(
id,
Err("Diff worker stopped unexpectedly".into()),
);
}
return false;
}
self.diff_worker = None;
break;
}
}
}
});
changed
}
+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(),
)