fix: retain selections and float local review editors
This commit is contained in:
+48
-6
@@ -1773,6 +1773,7 @@ struct Pane {
|
||||
struct Tab {
|
||||
pane_id: usize,
|
||||
pane_layout: PaneTree,
|
||||
review_pane: Option<usize>,
|
||||
file_picker: Option<FilePicker>,
|
||||
diff: Option<DiffState>,
|
||||
parked_diff: Option<DiffState>,
|
||||
@@ -2060,6 +2061,7 @@ pub struct Editor {
|
||||
next_pane_id: usize,
|
||||
panes: BTreeMap<usize, Pane>,
|
||||
pane_layout: PaneTree,
|
||||
review_pane: Option<usize>,
|
||||
tab_id: usize,
|
||||
next_tab_id: usize,
|
||||
tab_order: Vec<usize>,
|
||||
@@ -2160,6 +2162,7 @@ impl Editor {
|
||||
},
|
||||
)]),
|
||||
pane_layout: PaneTree::new(1),
|
||||
review_pane: None,
|
||||
tab_id: 1,
|
||||
next_tab_id: 2,
|
||||
tab_order: vec![1],
|
||||
@@ -3485,6 +3488,7 @@ impl Editor {
|
||||
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),
|
||||
review_pane: std::mem::replace(&mut self.review_pane, next.review_pane),
|
||||
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),
|
||||
@@ -3533,6 +3537,7 @@ impl Editor {
|
||||
Tab {
|
||||
pane_id,
|
||||
pane_layout: PaneTree::new(pane_id),
|
||||
review_pane: None,
|
||||
file_picker: None,
|
||||
diff: None,
|
||||
parked_diff: None,
|
||||
@@ -3635,6 +3640,11 @@ impl Editor {
|
||||
|
||||
// No input dispatch or document copies: source documents stay unique in active/parked.
|
||||
fn select_pane(&mut self, id: usize) {
|
||||
let id = if self.review_pane == Some(id) && self.workspace_diff().is_none() {
|
||||
self.panes[&id].origin.unwrap_or(id)
|
||||
} else {
|
||||
id
|
||||
};
|
||||
if id == self.pane_id {
|
||||
return;
|
||||
}
|
||||
@@ -3660,13 +3670,13 @@ impl Editor {
|
||||
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) {
|
||||
let target_tab = if self.pane_layout.contains(id) || self.review_pane == Some(id) {
|
||||
tab
|
||||
} else {
|
||||
*self
|
||||
.tabs
|
||||
.iter()
|
||||
.find(|(_, tab)| tab.pane_layout.contains(id))
|
||||
.find(|(_, tab)| tab.pane_layout.contains(id) || tab.review_pane == Some(id))
|
||||
.map(|(id, _)| id)
|
||||
.expect("pane belongs to a tab")
|
||||
};
|
||||
@@ -3943,7 +3953,7 @@ impl Editor {
|
||||
.panes
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|id| self.pane_layout.contains(*id))
|
||||
.filter(|id| self.pane_layout.contains(*id) || self.review_pane == Some(*id))
|
||||
.collect();
|
||||
let mut buffers = self.closing_buffers(&panes);
|
||||
if let Some(resolver) = self.resolver() {
|
||||
@@ -3992,6 +4002,9 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn close_pane(&mut self, force: bool) {
|
||||
if self.dismiss_review_panel() {
|
||||
return;
|
||||
}
|
||||
if self.resolver().is_some() {
|
||||
self.close_tab(force);
|
||||
return;
|
||||
@@ -6685,7 +6698,9 @@ impl Editor {
|
||||
}
|
||||
/// Whole-row shape belongs to the active buffer, not the Visual binding context.
|
||||
pub fn visual_linewise(&self) -> bool {
|
||||
self.active.view.mode == Mode::Visual && self.active.view.linewise
|
||||
(self.active.view.mode == Mode::Visual
|
||||
|| (self.active.view.mode == Mode::Command && self.active.view.command_from_visual))
|
||||
&& self.active.view.linewise
|
||||
}
|
||||
|
||||
/// Inclusive selected rows, including an empty final row with an empty text range.
|
||||
@@ -6700,9 +6715,11 @@ impl Editor {
|
||||
})
|
||||
}
|
||||
|
||||
/// Ordered, end-exclusive Visual ranges in document order; empty in other modes.
|
||||
/// Ordered Visual ranges, including selections retained by command input.
|
||||
pub fn selections(&self) -> Vec<Selection> {
|
||||
if self.active.view.mode == Mode::Visual {
|
||||
if self.active.view.mode == Mode::Visual
|
||||
|| (self.active.view.mode == Mode::Command && self.active.view.command_from_visual)
|
||||
{
|
||||
self.active
|
||||
.view
|
||||
.targets
|
||||
@@ -9351,6 +9368,31 @@ impl Editor {
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if self.review_panel().is_some()
|
||||
&& matches!(
|
||||
entry.action,
|
||||
Action::PaneSplitHorizontal
|
||||
| Action::PaneSplitVertical
|
||||
| Action::PaneFocusLeft
|
||||
| Action::PaneFocusRight
|
||||
| Action::PaneFocusUp
|
||||
| Action::PaneFocusDown
|
||||
| Action::TabNew
|
||||
| Action::Edit
|
||||
| Action::Buffers
|
||||
| Action::Buffer
|
||||
| Action::BufferNext
|
||||
| Action::BufferPrevious
|
||||
| Action::BufferDelete
|
||||
| Action::BufferForceDelete
|
||||
| Action::Diff
|
||||
| Action::DiffClose
|
||||
| Action::PullRequests
|
||||
| Action::Resolve
|
||||
)
|
||||
{
|
||||
self.dismiss_review_panel();
|
||||
}
|
||||
if matches!(
|
||||
entry.action,
|
||||
Action::Resolve
|
||||
|
||||
+44
-15
@@ -22,6 +22,24 @@ impl Editor {
|
||||
.map(|buffer| buffer.title.as_str())
|
||||
}
|
||||
|
||||
pub fn review_panel(&self) -> Option<usize> {
|
||||
self.review_pane
|
||||
.filter(|pane| *pane == self.pane_id && self.workspace_diff().is_some())
|
||||
}
|
||||
|
||||
pub(super) fn dismiss_review_panel(&mut self) -> bool {
|
||||
let Some(panel) = self.review_panel() else {
|
||||
return false;
|
||||
};
|
||||
let source = self.panes[&panel]
|
||||
.origin
|
||||
.expect("review panel has a source pane");
|
||||
self.finish_pane_interaction();
|
||||
self.select_pane(source);
|
||||
self.bind_diff_buffer();
|
||||
true
|
||||
}
|
||||
|
||||
fn review_diff_mut(&mut self) -> Option<&mut DiffState> {
|
||||
self.diff.as_mut().or(self.parked_diff.as_mut())
|
||||
}
|
||||
@@ -546,22 +564,24 @@ impl Editor {
|
||||
.is_some_and(|binding| binding.owner == self.tab_id && binding.kind == kind)
|
||||
})
|
||||
.map(|buffer| buffer.id);
|
||||
let panel = self.panes.iter().find_map(|(id, pane)| {
|
||||
(self.pane_layout.contains(*id)
|
||||
&& self.buffer(pane.buffer_id).is_some_and(|buffer| {
|
||||
buffer
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||
}))
|
||||
.then_some(*id)
|
||||
});
|
||||
if let Some(panel) = panel {
|
||||
self.select_pane(panel);
|
||||
let panel = if let Some(panel) = self.review_pane {
|
||||
panel
|
||||
} else {
|
||||
self.select_pane(self.diff_owner.unwrap());
|
||||
self.split_pane(SplitAxis::Vertical);
|
||||
}
|
||||
let id = self.next_pane_id;
|
||||
self.next_pane_id += 1;
|
||||
self.panes.insert(
|
||||
id,
|
||||
Pane {
|
||||
buffer_id: self.active.id,
|
||||
origin: self.diff_owner,
|
||||
views: BTreeMap::from([(self.active.id, self.active.view.inherit())]),
|
||||
},
|
||||
);
|
||||
self.review_pane = Some(id);
|
||||
id
|
||||
};
|
||||
self.panes.get_mut(&panel).unwrap().origin = self.diff_owner;
|
||||
self.select_pane(panel);
|
||||
if let Some(id) = existing {
|
||||
self.switch_buffer(id);
|
||||
if kind == ReviewText::Overview {
|
||||
@@ -586,6 +606,15 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub(super) fn handle_review_overview(&mut self, input: &Input) -> bool {
|
||||
if *input == Input::Escape
|
||||
&& self.active.view.mode == Mode::Normal
|
||||
&& self.palette.is_none()
|
||||
&& self.sequence_pending().is_none()
|
||||
&& self.count.is_none()
|
||||
&& self.dismiss_review_panel()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if *input == Input::Enter
|
||||
&& self.active.view.mode == Mode::Normal
|
||||
&& self.palette.is_none()
|
||||
|
||||
+2
-1
@@ -86,7 +86,8 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
|
||||
Local reviews: :diff.comment at caret or on a Visual/Visual Line selection
|
||||
:diff.review.edit/delete [ID], :diff.review.next/previous, :diff.review.overview
|
||||
:diff.review.summary; :diff.review.verdict looks_good|changes_requested|none
|
||||
:diff.review.save PATH / :diff.review.open PATH; :w in review panes saves local JSON
|
||||
:diff.review.save PATH / :diff.review.open PATH; :w in floating review editors saves local JSON
|
||||
Normal Esc or :pane.close dismisses the floating editor and retains drafts
|
||||
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
|
||||
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search
|
||||
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
|
||||
|
||||
+229
-39
@@ -678,18 +678,23 @@ impl BufferViewports {
|
||||
.resolver()
|
||||
.is_some_and(|resolver| resolver.view == ResolverView::History);
|
||||
let multiple = !resolver_resolution && editor.pane_layout().len() > 1;
|
||||
let (workspace, dock) = match editor.file_picker() {
|
||||
Some(picker) if picker.presentation == FilePresentation::Docked => dock_layout(
|
||||
full_workspace,
|
||||
if editor.picker_diff().is_some() {
|
||||
DockSide::Right
|
||||
} else {
|
||||
editor.file_explorer_side()
|
||||
},
|
||||
picker.focused,
|
||||
),
|
||||
_ => (full_workspace, None),
|
||||
};
|
||||
let layout_pane = editor
|
||||
.review_panel()
|
||||
.and_then(|pane| editor.pane_origin(pane))
|
||||
.unwrap_or(focused);
|
||||
let (workspace, dock) =
|
||||
editor.with_render_pane(layout_pane, |view| match view.file_picker() {
|
||||
Some(picker) if picker.presentation == FilePresentation::Docked => dock_layout(
|
||||
full_workspace,
|
||||
if view.picker_diff().is_some() {
|
||||
DockSide::Right
|
||||
} else {
|
||||
view.file_explorer_side()
|
||||
},
|
||||
picker.focused,
|
||||
),
|
||||
_ => (full_workspace, None),
|
||||
});
|
||||
editor.set_pane_bounds(pane_rect(workspace));
|
||||
self.rects.clear();
|
||||
if resolver_history {
|
||||
@@ -737,9 +742,6 @@ impl BufferViewports {
|
||||
.retain(|id, _| view.has_buffer(*id));
|
||||
let (top, left) = self.offsets.entry(view.buffer_id()).or_default();
|
||||
let syntax = self.syntax.entry(view.buffer_id()).or_default();
|
||||
let leaf = view.review_panel_title().map_or(leaf, |title| {
|
||||
resolver_document_panel(frame, leaf, title, id == focused)
|
||||
});
|
||||
draw_document(
|
||||
frame,
|
||||
view,
|
||||
@@ -767,22 +769,25 @@ impl BufferViewports {
|
||||
self.panels.begin_frame();
|
||||
self.document.geometry = None;
|
||||
}
|
||||
if let Some(panel) = dock
|
||||
&& let Some(picker) = editor.file_picker()
|
||||
{
|
||||
self.panels.picker_scroll.distance = editor.scroll_distance();
|
||||
draw_file_list(
|
||||
frame,
|
||||
picker,
|
||||
panel,
|
||||
editor.file_navigation_hint(),
|
||||
&mut self.panels,
|
||||
editor.picker_diff().and_then(|diff| {
|
||||
diff.comparison
|
||||
.as_ref()
|
||||
.map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files))
|
||||
}),
|
||||
);
|
||||
if let Some(panel) = dock {
|
||||
self.activate(layout_pane, editor);
|
||||
editor.with_render_pane(layout_pane, |view| {
|
||||
if let Some(picker) = view.file_picker() {
|
||||
self.panels.picker_scroll.distance = view.scroll_distance();
|
||||
draw_file_list(
|
||||
frame,
|
||||
picker,
|
||||
panel,
|
||||
view.file_navigation_hint(),
|
||||
&mut self.panels,
|
||||
view.picker_diff().and_then(|diff| {
|
||||
diff.comparison.as_ref().map(|comparison| {
|
||||
(comparison.files.as_slice(), &diff.reviewed_files)
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
if let Some(geometry) = &mut self.panels.diff {
|
||||
geometry.regions[0] = (panel, Focus::Files);
|
||||
editor.set_diff_pane_bounds(
|
||||
@@ -827,6 +832,44 @@ impl BufferViewports {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(panel) = editor.review_panel() {
|
||||
let source = editor.pane_origin(panel);
|
||||
let area = self
|
||||
.rects
|
||||
.iter()
|
||||
.find(|(id, _)| Some(*id) == source)
|
||||
.map_or(full_workspace, |(_, area)| *area);
|
||||
let area = floating_panel(area);
|
||||
self.rects.insert(0, (panel, area));
|
||||
self.activate(panel, editor);
|
||||
self.panels.workspace_managed = true;
|
||||
editor.with_render_pane(panel, |view| {
|
||||
frame.render_widget(Clear, area);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.title(display_text(
|
||||
view.review_panel_title().unwrap_or("Local review"),
|
||||
));
|
||||
let content = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
self.activate_document(view.buffer_id());
|
||||
let (top, left) = self.offsets.entry(view.buffer_id()).or_default();
|
||||
let syntax = self.syntax.entry(view.buffer_id()).or_default();
|
||||
draw_document(
|
||||
frame,
|
||||
view,
|
||||
content,
|
||||
full_workspace,
|
||||
top,
|
||||
left,
|
||||
syntax,
|
||||
&mut self.document,
|
||||
&mut self.panels,
|
||||
);
|
||||
});
|
||||
self.panels.workspace_managed = false;
|
||||
}
|
||||
if let Some(diff) = editor.diff().filter(|diff| diff.phase != Phase::View) {
|
||||
draw_diff(
|
||||
frame,
|
||||
@@ -1097,10 +1140,18 @@ impl BufferViewports {
|
||||
return true;
|
||||
}
|
||||
let focused = session.editor().pane_id();
|
||||
let Some(target) = self.rects.iter().find_map(|(id, area)| {
|
||||
let target = self.rects.iter().find_map(|(id, area)| {
|
||||
area.contains(Position::new(mouse.column, mouse.row))
|
||||
.then_some(*id)
|
||||
}) else {
|
||||
});
|
||||
let target = if let Some(panel) = session.editor().review_panel() {
|
||||
if target != Some(panel) {
|
||||
return true;
|
||||
}
|
||||
panel
|
||||
} else if let Some(target) = target {
|
||||
target
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
if let Some(resolver) = session
|
||||
@@ -1729,11 +1780,13 @@ fn chrome_items<'a>(
|
||||
continue;
|
||||
}
|
||||
let mut text = match name.as_str() {
|
||||
"mode" => Cow::Owned(if editor.visual_linewise() {
|
||||
"VISUAL LINE".into()
|
||||
} else {
|
||||
editor.mode().to_string()
|
||||
}),
|
||||
"mode" => Cow::Owned(
|
||||
if editor.mode() == Mode::Visual && editor.visual_linewise() {
|
||||
"VISUAL LINE".into()
|
||||
} else {
|
||||
editor.mode().to_string()
|
||||
},
|
||||
),
|
||||
"path" => Cow::Owned(chrome_title(
|
||||
"{path}{modified}",
|
||||
editor.tab_index() + 1,
|
||||
@@ -13962,7 +14015,7 @@ end
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||||
root: std::path::PathBuf::new(),
|
||||
root: editor.project_root().to_path_buf(),
|
||||
editable_paths: Default::default(),
|
||||
before: "baseline".into(),
|
||||
after: "historic".into(),
|
||||
@@ -13983,6 +14036,143 @@ end
|
||||
terminal.get_cursor_position().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_input_keeps_characterwise_and_linewise_selection_highlights() {
|
||||
for context in ["document", "split", "unified"] {
|
||||
for selector in ['v', 'V'] {
|
||||
let mut editor = if context == "document" {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
"alpha beta\nsecond line".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor
|
||||
} else {
|
||||
let mut editor = readonly_diff_editor(syntax_diff_file(
|
||||
"file.txt",
|
||||
"alpha beta\nold line",
|
||||
"alpha beta\nnew line",
|
||||
));
|
||||
if context == "unified" {
|
||||
editor.execute("diff.view.toggle");
|
||||
}
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor
|
||||
};
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(140, 24)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let source = terminal.get_cursor_position().unwrap();
|
||||
editor.handle(Input::Char(selector));
|
||||
editor.handle(Input::Right);
|
||||
let ranges = editor.selections();
|
||||
editor.handle(Input::Char(':'));
|
||||
for ch in "diff.comment".chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert_eq!(editor.mode(), Mode::Command);
|
||||
assert_eq!(editor.selections(), ranges);
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(
|
||||
buffer[(source.x, source.y)].bg,
|
||||
Color::Blue,
|
||||
"{context} {selector}"
|
||||
);
|
||||
assert_eq!(
|
||||
buffer[(source.x + if selector == 'V' { 9 } else { 1 }, source.y)].bg,
|
||||
Color::Blue,
|
||||
"{context} {selector}"
|
||||
);
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.selections(), ranges);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_editor_floats_over_the_diff_without_changing_workspace_layout() {
|
||||
for unified in [false, true] {
|
||||
let mut editor = readonly_diff_editor(syntax_diff_file(
|
||||
"file.txt",
|
||||
"old first\nold last",
|
||||
"new first\nnew last",
|
||||
));
|
||||
if unified {
|
||||
editor.execute("diff.view.toggle");
|
||||
}
|
||||
editor.handle(Input::Char('\t'));
|
||||
let owner = editor.pane_id();
|
||||
let source_text = editor.context().text;
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 30)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let source_area = views
|
||||
.rects
|
||||
.iter()
|
||||
.find(|(pane, _)| *pane == owner)
|
||||
.unwrap()
|
||||
.1;
|
||||
editor.execute("diff.comment");
|
||||
let panel = editor.review_panel().expect("focused floating review");
|
||||
assert_eq!(editor.pane_layout().len(), 1);
|
||||
assert!(!editor.pane_layout().contains(panel));
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert_eq!(views.rects[0], (panel, floating_panel(source_area)));
|
||||
assert_eq!(
|
||||
views
|
||||
.rects
|
||||
.iter()
|
||||
.find(|(pane, _)| *pane == owner)
|
||||
.unwrap()
|
||||
.1,
|
||||
source_area
|
||||
);
|
||||
let area = views.rects[0].1;
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(area.contains(cursor));
|
||||
for input in [
|
||||
Input::Char('i'),
|
||||
Input::Paste("Retained draft".into()),
|
||||
Input::Escape,
|
||||
] {
|
||||
editor.handle(input);
|
||||
}
|
||||
assert_eq!(editor.context().text, "Retained draft");
|
||||
assert_eq!(editor.review_panel(), Some(panel));
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.review_panel().is_none());
|
||||
assert_eq!(editor.pane_id(), owner);
|
||||
assert_eq!(editor.context().text, source_text);
|
||||
editor.execute("diff.review.edit 1");
|
||||
assert_eq!(
|
||||
editor.context().text,
|
||||
"Retained draft",
|
||||
"{}",
|
||||
editor.message()
|
||||
);
|
||||
assert_eq!(editor.review_panel(), Some(panel));
|
||||
editor.execute("tab.new");
|
||||
editor.execute("tab.previous");
|
||||
editor.execute("diff.review.edit 1");
|
||||
assert_eq!(editor.review_panel(), Some(panel));
|
||||
assert_eq!(editor.context().text, "Retained draft");
|
||||
editor.execute("pane.close");
|
||||
assert!(editor.review_panel().is_none());
|
||||
assert_eq!(editor.pane_layout().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_projection_keeps_unified_wrapped_caret_and_selection_source_only() {
|
||||
let before = format!(
|
||||
|
||||
Reference in New Issue
Block a user