feat: add configurable three-pane conflict views
This commit is contained in:
+23
-8
@@ -1596,6 +1596,13 @@ pub enum ResolverView {
|
||||
History,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResolverLayout {
|
||||
Columns,
|
||||
BaseBelow,
|
||||
}
|
||||
|
||||
pub struct ResolverFile {
|
||||
pub path: PathBuf,
|
||||
pub draft_buffer: usize,
|
||||
@@ -2088,6 +2095,7 @@ pub struct Editor {
|
||||
tabs: BTreeMap<usize, Tab>,
|
||||
resolvers: BTreeMap<usize, ResolverWorkspace>,
|
||||
resolver_requests: VecDeque<ResolverRequest>,
|
||||
pub(crate) resolve_history_layout: ResolverLayout,
|
||||
pane_bounds: PaneRect,
|
||||
render_pane_focused: bool,
|
||||
diff_owner: Option<usize>,
|
||||
@@ -2190,6 +2198,7 @@ impl Editor {
|
||||
tabs: BTreeMap::new(),
|
||||
resolvers: BTreeMap::new(),
|
||||
resolver_requests: VecDeque::new(),
|
||||
resolve_history_layout: ResolverLayout::Columns,
|
||||
pane_bounds: PaneRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -2836,14 +2845,20 @@ impl Editor {
|
||||
.collect(),
|
||||
ResolverView::History => {
|
||||
let mut panes = Vec::new();
|
||||
for index in [
|
||||
Some(resolver.history_sources[0]),
|
||||
resolver.selected_base,
|
||||
Some(resolver.history_sources[1]),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let sources = if self.resolve_history_layout == ResolverLayout::BaseBelow {
|
||||
[
|
||||
Some(resolver.history_sources[0]),
|
||||
Some(resolver.history_sources[1]),
|
||||
resolver.selected_base,
|
||||
]
|
||||
} else {
|
||||
[
|
||||
Some(resolver.history_sources[0]),
|
||||
resolver.selected_base,
|
||||
Some(resolver.history_sources[1]),
|
||||
]
|
||||
};
|
||||
for index in sources.into_iter().flatten() {
|
||||
if let Some(&pane) = resolver.history_panes.get(index)
|
||||
&& !panes.contains(&pane)
|
||||
{
|
||||
|
||||
@@ -102,6 +102,7 @@ Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs
|
||||
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
|
||||
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
|
||||
Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base
|
||||
config.resolve_history_layout = \"columns\" (left/base/right) or \"base_below\" (left/right above base)
|
||||
:resolve.next/previous navigates differing hunks; :resolve.use_source [N] takes a hunk
|
||||
:resolve.source N selects Alternative; :resolve.base N selects History base
|
||||
:resolve.draft returns to Draft; :resolve.view.toggle switches Resolution/History
|
||||
|
||||
+14
-3
@@ -58,6 +58,7 @@ pub struct Config {
|
||||
pub diff_view_mode: crate::diff::ViewMode,
|
||||
pub diff_split_orientation: crate::panes::SplitAxis,
|
||||
pub diff_follow_working_copy: bool,
|
||||
pub resolve_history_layout: crate::editor::ResolverLayout,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -111,6 +112,7 @@ impl Default for Config {
|
||||
diff_view_mode: crate::diff::ViewMode::Split,
|
||||
diff_split_orientation: crate::panes::SplitAxis::Vertical,
|
||||
diff_follow_working_copy: true,
|
||||
resolve_history_layout: crate::editor::ResolverLayout::Columns,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +153,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
diff_view_mode,
|
||||
diff_split_orientation,
|
||||
diff_follow_working_copy,
|
||||
resolve_history_layout,
|
||||
..
|
||||
} = *response;
|
||||
let mut config = Config::default();
|
||||
@@ -192,6 +195,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
config.diff_view_mode = diff_view_mode;
|
||||
config.diff_split_orientation = diff_split_orientation;
|
||||
config.diff_follow_working_copy = diff_follow_working_copy;
|
||||
config.resolve_history_layout = resolve_history_layout;
|
||||
Ok(config)
|
||||
}
|
||||
Response::Error {
|
||||
@@ -552,6 +556,7 @@ struct ConfigFrame {
|
||||
diff_view_mode: crate::diff::ViewMode,
|
||||
diff_split_orientation: crate::panes::SplitAxis,
|
||||
diff_follow_working_copy: bool,
|
||||
resolve_history_layout: crate::editor::ResolverLayout,
|
||||
}
|
||||
|
||||
fn whitespace_characters<'de, D: serde::Deserializer<'de>>(
|
||||
@@ -1084,6 +1089,7 @@ mod tests {
|
||||
"tab_spaces": null, "language_tab_spaces": {},
|
||||
"diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
"resolve_history_layout": "columns",
|
||||
"diff_follow_working_copy": true
|
||||
});
|
||||
for value in [serde_json::Value::Null, serde_json::json!("reviews")] {
|
||||
@@ -1512,9 +1518,9 @@ end
|
||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||
}
|
||||
for frame in [
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||
}
|
||||
@@ -1599,6 +1605,7 @@ end
|
||||
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
|
||||
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
"resolve_history_layout": "columns",
|
||||
"diff_follow_working_copy": true
|
||||
});
|
||||
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
|
||||
@@ -1739,6 +1746,7 @@ end"#,
|
||||
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
|
||||
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
"resolve_history_layout": "columns",
|
||||
"diff_follow_working_copy": true
|
||||
});
|
||||
for value in ["all", "leading"] {
|
||||
@@ -2002,6 +2010,7 @@ end
|
||||
"popup": {"command": {}, "search": {}},
|
||||
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
"resolve_history_layout": "columns",
|
||||
"diff_follow_working_copy": true
|
||||
});
|
||||
for setting in ["diff_context_lines", "diff_expand_lines"] {
|
||||
@@ -2114,6 +2123,7 @@ end"
|
||||
"popup": {"command": {}, "search": {}},
|
||||
"diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
"resolve_history_layout": "columns",
|
||||
"diff_follow_working_copy": true, "scroll_distance": 0
|
||||
});
|
||||
for distance in [None, Some(0), Some(7), Some(usize::MAX)] {
|
||||
@@ -2547,6 +2557,7 @@ end"#,
|
||||
"popup": {"command": {}, "search": {}},
|
||||
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
"resolve_history_layout": "columns",
|
||||
"diff_follow_working_copy": true
|
||||
});
|
||||
let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap()
|
||||
|
||||
+13
-1
@@ -157,6 +157,7 @@ module Vamp
|
||||
@diff_view_mode = "split"
|
||||
@diff_split_orientation = "vertical"
|
||||
@diff_follow_working_copy = true
|
||||
@resolve_history_layout = "columns"
|
||||
end
|
||||
|
||||
# @param path [String] relative to the configuration file
|
||||
@@ -326,6 +327,16 @@ module Vamp
|
||||
@diff_follow_working_copy = enabled
|
||||
end
|
||||
|
||||
# @param layout [String] "columns" or "base_below"
|
||||
# @return [String]
|
||||
def resolve_history_layout=(layout)
|
||||
layout = nonempty_string!(layout, "resolve history layout")
|
||||
unless %w[columns base_below].include?(layout)
|
||||
raise ArgumentError, "resolve history layout must be columns or base_below"
|
||||
end
|
||||
@resolve_history_layout = layout
|
||||
end
|
||||
|
||||
# @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
|
||||
def core
|
||||
Core
|
||||
@@ -370,7 +381,8 @@ module Vamp
|
||||
"popup" => { "command" => @popup.command, "search" => @popup.search },
|
||||
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
|
||||
"diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation,
|
||||
"diff_follow_working_copy" => @diff_follow_working_copy }
|
||||
"diff_follow_working_copy" => @diff_follow_working_copy,
|
||||
"resolve_history_layout" => @resolve_history_layout }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -523,6 +523,7 @@ impl EditorSession {
|
||||
editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces);
|
||||
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
||||
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
|
||||
editor.resolve_history_layout = config.resolve_history_layout;
|
||||
if let Err(error) =
|
||||
editor.set_chrome_config(config.top_bar, config.bottom_bar, config.labels)
|
||||
{
|
||||
@@ -4358,6 +4359,22 @@ end
|
||||
("config.leader = 7", "leader key must be a String"),
|
||||
("config.leader = nil", "leader key must be a String"),
|
||||
(r#"config.leader = """#, "leader key must not be empty"),
|
||||
(
|
||||
r#"config.resolve_history_layout = "invalid""#,
|
||||
"resolve history layout must be columns or base_below",
|
||||
),
|
||||
(
|
||||
"config.resolve_history_layout = 7",
|
||||
"resolve history layout must be a String",
|
||||
),
|
||||
(
|
||||
"config.resolve_history_layout = nil",
|
||||
"resolve history layout must be a String",
|
||||
),
|
||||
(
|
||||
r#"config.resolve_history_layout = """#,
|
||||
"resolve history layout must not be empty",
|
||||
),
|
||||
(
|
||||
r#"config.diff_view_mode = "invalid""#,
|
||||
"diff view mode must be split or unified",
|
||||
|
||||
+97
-5
@@ -45,8 +45,8 @@ use crate::{
|
||||
RowKind, ViewMode,
|
||||
},
|
||||
editor::{
|
||||
Completion, Cursor, Editor, Hover, Input, Mode, Palette, ResolverStatus, ResolverView,
|
||||
ResolverWorkspace, Selection, StartupDiagnostic, WhitespaceScope,
|
||||
Completion, Cursor, Editor, Hover, Input, Mode, Palette, ResolverLayout, ResolverStatus,
|
||||
ResolverView, ResolverWorkspace, Selection, StartupDiagnostic, WhitespaceScope,
|
||||
},
|
||||
files::{DockSide, FilePicker, FilePresentation, FileView},
|
||||
panes::{PaneRect, SplitAxis, split_rect},
|
||||
@@ -698,7 +698,12 @@ impl BufferViewports {
|
||||
editor.set_pane_bounds(pane_rect(workspace));
|
||||
self.rects.clear();
|
||||
if resolver_history {
|
||||
let layout = resolver_history_layout(workspace, editor.resolver().unwrap(), focused);
|
||||
let layout = resolver_history_layout(
|
||||
workspace,
|
||||
editor.resolver().unwrap(),
|
||||
focused,
|
||||
editor.resolve_history_layout,
|
||||
);
|
||||
draw_resolver_heading(frame, editor.resolver().unwrap(), layout.heading);
|
||||
self.draw_resolver_history(frame, editor, &layout);
|
||||
} else if resolver_resolution {
|
||||
@@ -963,7 +968,7 @@ impl BufferViewports {
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|(_, _, area)| usize::from(area.height))
|
||||
.max()
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
history.top.continuation = history
|
||||
.top
|
||||
@@ -2097,6 +2102,7 @@ fn resolver_history_layout(
|
||||
area: Rect,
|
||||
resolver: &ResolverWorkspace,
|
||||
focused: usize,
|
||||
mode: ResolverLayout,
|
||||
) -> ResolverHistoryLayout {
|
||||
let [heading, documents] = Layout::vertical([
|
||||
Constraint::Length(u16::from(area.height >= 4)),
|
||||
@@ -2126,7 +2132,8 @@ fn resolver_history_layout(
|
||||
sources.push(index);
|
||||
}
|
||||
}
|
||||
if documents.width < 60 {
|
||||
let base_below = mode == ResolverLayout::BaseBelow && sources.len() == 3;
|
||||
if documents.width < 60 || (base_below && documents.height < 4) {
|
||||
let selected = sources
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -2135,6 +2142,12 @@ fn resolver_history_layout(
|
||||
if let Some(selected) = selected {
|
||||
layout.history.push((selected, documents));
|
||||
}
|
||||
} else if base_below {
|
||||
let [inputs, base] = Layout::vertical([Constraint::Fill(1); 2]).areas(documents);
|
||||
let [left, right] = Layout::horizontal([Constraint::Fill(1); 2]).areas(inputs);
|
||||
layout
|
||||
.history
|
||||
.extend([(sources[0], left), (sources[2], right), (sources[1], base)]);
|
||||
} else {
|
||||
let columns = Layout::horizontal(vec![Constraint::Fill(1); sources.len()]).split(documents);
|
||||
layout
|
||||
@@ -8183,6 +8196,85 @@ mod tests {
|
||||
assert_eq!(session.editor().cursor(), draft_cursor);
|
||||
assert_eq!(views.panels.diff.as_ref().unwrap().viewport, draft_viewport);
|
||||
assert_eq!(restored_caret, searched_caret);
|
||||
{
|
||||
let config = directory.file(
|
||||
"base-below.rb",
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.resolve_history_layout = 'base_below'\nend\n",
|
||||
);
|
||||
let mut bottom = EditorSession::open(
|
||||
Some(directory.0.join("first.txt")),
|
||||
vec!["text".into()],
|
||||
Some(&config),
|
||||
)
|
||||
.unwrap();
|
||||
bottom.execute("resolve");
|
||||
ready(&mut bottom);
|
||||
let draft = bottom.editor().context().text;
|
||||
bottom.execute("resolve.history");
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
render(&mut terminal, &mut views, &mut bottom);
|
||||
let resolver = bottom.editor().resolver().unwrap();
|
||||
let left = resolver.history_panes[resolver.history_sources[0]];
|
||||
let right = resolver.history_panes[resolver.history_sources[1]];
|
||||
let base = resolver.history_panes[resolver.selected_base.unwrap()];
|
||||
assert_eq!(views.rects.len(), 3);
|
||||
assert_eq!(
|
||||
views
|
||||
.rects
|
||||
.iter()
|
||||
.map(|(pane, _)| *pane)
|
||||
.collect::<Vec<_>>(),
|
||||
[left, right, base]
|
||||
);
|
||||
let [left_area, right_area, base_area] =
|
||||
std::array::from_fn(|index| views.rects[index].1);
|
||||
assert_eq!(left_area.right(), right_area.x);
|
||||
assert_eq!(left_area.y, right_area.y);
|
||||
assert_eq!(base_area.x, left_area.x);
|
||||
assert_eq!(base_area.right(), right_area.right());
|
||||
assert!(base_area.y >= left_area.bottom());
|
||||
for (area, value) in [
|
||||
(left_area, "value = left;"),
|
||||
(right_area, "value = right;"),
|
||||
(base_area, "value = base;"),
|
||||
] {
|
||||
assert!(line_at(&terminal, area, area.y).contains(value));
|
||||
}
|
||||
bottom.handle(Input::Char('\t'));
|
||||
assert_eq!(bottom.editor().pane_id(), right);
|
||||
bottom.handle(Input::Char('\t'));
|
||||
assert_eq!(bottom.editor().pane_id(), base);
|
||||
bottom.handle(Input::BackTab);
|
||||
assert_eq!(bottom.editor().pane_id(), right);
|
||||
bottom.execute("pane.focus_down");
|
||||
let caret = render(&mut terminal, &mut views, &mut bottom);
|
||||
assert_eq!(bottom.editor().pane_id(), base);
|
||||
assert!(base_area.contains(caret));
|
||||
assert!(views.scroll(
|
||||
&mut bottom,
|
||||
document_wheel(MouseEventKind::ScrollDown, base_area.x, base_area.y)
|
||||
));
|
||||
render(&mut terminal, &mut views, &mut bottom);
|
||||
for area in [left_area, right_area, base_area] {
|
||||
assert!(line_at(&terminal, area, area.y).contains("context 02"));
|
||||
}
|
||||
let readonly = bottom.editor().context().text;
|
||||
bottom.handle(Input::Char('i'));
|
||||
bottom.handle(Input::Paste("must not edit base".into()));
|
||||
bottom.handle(Input::Escape);
|
||||
assert_eq!(bottom.editor().context().text, readonly);
|
||||
terminal.backend_mut().resize(44, 20);
|
||||
render(&mut terminal, &mut views, &mut bottom);
|
||||
assert_eq!(views.rects.len(), 1);
|
||||
assert_eq!(views.rects[0].0, base);
|
||||
terminal.backend_mut().resize(120, 6);
|
||||
render(&mut terminal, &mut views, &mut bottom);
|
||||
assert_eq!(views.rects.len(), 1);
|
||||
assert_eq!(views.rects[0].0, base);
|
||||
bottom.execute("resolve.draft");
|
||||
assert_eq!(bottom.editor().context().text, draft);
|
||||
}
|
||||
|
||||
for (key, action) in [
|
||||
("ctrl+e", "core.file_explorer"),
|
||||
|
||||
Reference in New Issue
Block a user