feat: add configurable three-pane conflict views

This commit is contained in:
Valdemar Lorenzen
2026-10-16 16:19:54 +02:00
parent a6310594c5
commit de6a57e67d
11 changed files with 176 additions and 20 deletions
+23 -8
View File
@@ -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)
{
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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"),