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
+4 -2
View File
@@ -27,8 +27,10 @@
- show unresolved comments by default; `--all` includes resolved comments.
- manually resolve/unresolve comments in Vamp, independently of staleness; persist status in SQLite and version 3 JSON.
- preserve filename arguments, configured storage, and incompatible data; no MCP, agent replies, or automatic dispatch.
- [ ] add an option for three pane lef/base/right view for conflict resolution.
- alternative pane layout as left/right with base underneath (configurable)
- [x] add an option for three pane left/base/right view for conflict resolution.
- alternative pane layout as left/right with base underneath (`config.resolve_history_layout = "base_below"`)
- [ ] pull out tests from implementation code, and place them in a dedicated test folder.
- maybe a test folder sibling to `src` could make sense? 🤔
- [ ] diff view: GitHub code review integration after the local review UX is usable.
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
+3
View File
@@ -30,6 +30,7 @@ Vamp.configure do |config|
config.diff_view_mode = "split" # or "unified"
config.diff_split_orientation = "vertical" # or "horizontal"
config.diff_follow_working_copy = true
config.resolve_history_layout = "columns" # or "base_below"
config.review_directory = nil # XDG data directory; override with an absolute or config-relative path
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
config.bind "ctrl+l", config.core.right, mode: config.modes.tree
@@ -104,6 +105,8 @@ Indicators compare the current editor text (including unsaved edits) against Git
`config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior.
`config.resolve_history_layout` controls the read-only conflict inputs opened by `:resolve.history`: `"columns"` (default) places left/base/right side by side; `"base_below"` places left/right above a full-width base. Both layouts share aligned rows and synchronized vertical scrolling. Tab/Shift+Tab follows layout order (left/base/right or left/right/base); directional pane actions follow the displayed geometry. Narrow terminals show the focused source only; the base-below layout does the same when there is insufficient height. This setting does not change the editable Resolution view or ordinary diffs. Invalid values reject the configuration.
```ruby
config.bind "ctrl+b", config.core.diff.mode.toggle
config.bind "leader v", config.core.diff.view.toggle
+2
View File
@@ -71,6 +71,8 @@ Conflicted-revision regressions preserve unchanged symlinks, defer oversized uns
Resolver checks use isolated real jj repositories: read-only discovery, marker-free all-side/base projections preserving outside edits and EOF, initial seed undo, hunk choice/undo, retained resolved files, jj-authoritative status, checkout/external-write refusal, cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover aligned gaps/highlights, context folding, native Draft focus/search/selection, separate History, hidden/on-demand explorer, viewport retention and narrow layouts. Live PTY smoke exercises frozen marker-free alternatives, History/Draft switching, hunk choice and guarded save confirmation without touching user repositories.
Conflict History layout checks load `resolve_history_layout` through the real Ruby configuration and render both left/base/right columns and left/right over base. They cover source contents, full-width base geometry, Tab/directional focus, synchronized wheel scrolling, read-only base protection, narrow/short fallbacks and preserved Draft text. Live PTY smoke opens both layouts, focuses/scrolls the base, returns to Resolution, takes a hunk and saves; jj confirms the checkout is no longer conflicted.
Shared-workspace parity checks compare ordinary and resolver diff geometry/content under identical chrome, focus and source cursors. Cover native picker selection/remaps, Tree/List and dock/floating actions, excerpt initialization, folded search, focus transfer on wheel, layout controls, loading/History/pane-rebuild transitions, and preserved per-file viewport state. Live PTY smoke uses Space e, Down/Enter file selection and guarded saving through the same explorer path.
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
+1 -1
View File
@@ -536,7 +536,7 @@ Pane borders label the editable output **Resolution** and its frozen reference *
| `resolve.history` / `resolve.view.toggle` | Show read-only input/base/input History / switch views |
| `resolve.close` | Close the resolver without discarding dirty drafts |
**History** is separate from editing: it compares the original inputs and their base, with all sides and bases selectable for multi-sided conflicts. Draft text, undo, caret and viewport survive view switches. Resolution uses the ordinary diff workspace, including its configured explorer, split/unified/orientation controls, source-line navigation, excerpts, focus and scrolling. Narrow History shows the focused input. Read-only Alternatives and History reject edits and saves.
**History** is separate from editing: it compares the original inputs and their base, with all sides and bases selectable for multi-sided conflicts. `config.resolve_history_layout = "columns"` (default) shows left/base/right; `"base_below"` shows left/right above a full-width base. Both layouts retain aligned changes and synchronized vertical scrolling. Tab/Shift+Tab cycles in layout order; directional pane actions follow the displayed geometry. Narrow History shows the focused source, as does base-below History when too short to display both rows of panes. Draft text, undo, caret and viewport survive view switches. Resolution uses the ordinary diff workspace, including its configured explorer, split/unified/orientation controls, source-line navigation, excerpts, focus and scrolling. Read-only Alternatives and History reject edits and saves.
Input indexes are one-based and include bases. Conflict-specific actions appear in the palette and through corresponding `config.core.resolve` methods. File navigation uses the existing picker/explorer actions: Space o opens floating search, Space e focuses the dock, Ctrl+t switches Tree/List, Ctrl+w closes it, and configured tree/selection bindings apply unchanged. Enter selects a relevant conflict file and focuses the editable Draft. Normal-mode Tab/Shift+Tab, native motions/search, context expansion and layout actions follow normal diff behavior. The complete seeded Draft can be saved even when intentional differences remain against the Alternative: those differences are not unresolved conflict markers. There is no automatic “accept both”.
+1
View File
@@ -18,6 +18,7 @@ Vamp.configure do |config|
config.diff_view_mode = "split" # or "unified"
config.diff_split_orientation = "vertical" # or "horizontal"
config.diff_follow_working_copy = true # false keeps the selected Working After endpoint
config.resolve_history_layout = "columns" # or "base_below" for left/right above the base
# config.popup.command = { anchor: :bottom_left }
# config.popup.search = { anchor: :center }
# Status above, tabs and live battery below; either bar accepts all segments.
+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"),