feat: add editor tabs
This commit is contained in:
+733
-40
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user