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();