feat: resolve current jj working-copy conflicts
Add a dedicated native resolver workspace with all side/base inputs, shared editable Results, region choices, retained per-file status, responsive rendering, and explicit nontext file choices. Guard writes with jj working-copy locking, frozen checkout identity, exact disk baselines, native dirty-buffer protection, and asynchronous jj snapshot/status confirmation. Historical comparisons remain readonly. Verified all 916 tests and live isolated terminal workflows covering text regions, narrow layouts, binary bytes and symlink targets/types. Clippy completes with pre-existing warnings only. Size exception: 2614 implementation additions/deletions excluding tests and docs. Native workspace integration, explicit file choices and the safe save/check operation are retained as one complete behavioral slice.
This commit is contained in:
+694
-2
@@ -39,8 +39,8 @@ use crate::{
|
||||
ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind, ViewMode,
|
||||
},
|
||||
editor::{
|
||||
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
|
||||
WhitespaceScope,
|
||||
Completion, Cursor, Editor, Hover, Input, Mode, Palette, ResolverStatus, ResolverWorkspace,
|
||||
Selection, StartupDiagnostic, WhitespaceScope,
|
||||
},
|
||||
files::{DockSide, FilePicker, FilePresentation, FileView},
|
||||
panes::{PaneRect, SplitAxis, split_rect},
|
||||
@@ -450,6 +450,10 @@ impl BufferViewports {
|
||||
self.syntax.retain(|id, _| editor.has_buffer(*id));
|
||||
let syntax = self.syntax.entry(editor.buffer_id()).or_default();
|
||||
let full_workspace = draw_bars(frame, editor, frame.area(), syntax);
|
||||
if editor.resolver().is_some() {
|
||||
self.draw_resolver(frame, editor, full_workspace);
|
||||
return;
|
||||
}
|
||||
let multiple = editor.pane_layout().len() > 1;
|
||||
let (workspace, dock) = match editor.file_picker() {
|
||||
Some(picker) if picker.presentation == FilePresentation::Docked => dock_layout(
|
||||
@@ -585,6 +589,87 @@ impl BufferViewports {
|
||||
.any(|geometry| geometry.viewport.is_some());
|
||||
}
|
||||
|
||||
fn draw_resolver(&mut self, frame: &mut Frame, editor: &mut Editor, area: Rect) {
|
||||
let focused = editor.pane_id();
|
||||
let resolver = editor.resolver().expect("resolver workspace is active");
|
||||
let layout = resolver_layout(area, resolver, focused);
|
||||
frame.render_widget(Clear, area);
|
||||
draw_resolver_heading(frame, resolver, layout.heading, layout.selector);
|
||||
self.rects.clear();
|
||||
if let Some(file) = resolver.files.get(resolver.current_file) {
|
||||
for (index, panel) in &layout.sources {
|
||||
if let (Some(pane), Some(source)) =
|
||||
(resolver.source_panes.get(*index), file.sources.get(*index))
|
||||
{
|
||||
let content = resolver_document_panel(
|
||||
frame,
|
||||
*panel,
|
||||
&format!("{} · readonly", source.label),
|
||||
!resolver.files_focused && focused == *pane,
|
||||
);
|
||||
self.rects.push((*pane, content));
|
||||
}
|
||||
}
|
||||
let result = resolver_document_panel(
|
||||
frame,
|
||||
layout.result,
|
||||
&format!("Result · {}", resolver_status(&file.status).0),
|
||||
!resolver.files_focused && focused == resolver.result_pane,
|
||||
);
|
||||
if file.text {
|
||||
self.rects.push((resolver.result_pane, result));
|
||||
} else {
|
||||
draw_resolver_choices(frame, resolver, result);
|
||||
}
|
||||
}
|
||||
editor.set_pane_bounds(pane_rect(area));
|
||||
for active_pass in [false, true] {
|
||||
for index in 0..self.rects.len() {
|
||||
let (id, leaf) = self.rects[index];
|
||||
if (id == focused) != active_pass {
|
||||
continue;
|
||||
}
|
||||
self.activate(id, editor);
|
||||
self.panels.workspace_managed = true;
|
||||
editor.with_render_pane(id, |view| {
|
||||
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,
|
||||
leaf,
|
||||
area,
|
||||
top,
|
||||
left,
|
||||
syntax,
|
||||
&mut self.document,
|
||||
&mut self.panels,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
self.activate(focused, editor);
|
||||
self.panels.workspace_managed = false;
|
||||
if !self.rects.iter().any(|(id, _)| *id == focused) {
|
||||
self.panels.begin_frame();
|
||||
self.document.geometry = None;
|
||||
}
|
||||
let resolver = editor.resolver().expect("resolver workspace is active");
|
||||
self.panels.resolver_files = layout.files;
|
||||
if let Some(files) = layout.files {
|
||||
draw_resolver_files(
|
||||
frame,
|
||||
resolver,
|
||||
files,
|
||||
editor.file_navigation_hint(),
|
||||
&mut self.panels,
|
||||
);
|
||||
}
|
||||
draw_overlays(frame, editor, area, self.panels.caret, &mut self.panels);
|
||||
self.diff_visible = false;
|
||||
}
|
||||
|
||||
fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) {
|
||||
self.draw(frame, session.editor_mut());
|
||||
if let Some((comparison_id, file, top, left_before, left_after)) = self
|
||||
@@ -1493,6 +1578,333 @@ fn draw_bars(frame: &mut Frame, editor: &Editor, area: Rect, syntax: &mut Syntax
|
||||
workspace
|
||||
}
|
||||
|
||||
struct ResolverLayout {
|
||||
heading: Rect,
|
||||
selector: Rect,
|
||||
sources: Vec<(usize, Rect)>,
|
||||
result: Rect,
|
||||
files: Option<Rect>,
|
||||
}
|
||||
|
||||
fn resolver_layout(area: Rect, resolver: &ResolverWorkspace, focused: usize) -> ResolverLayout {
|
||||
let [heading, selector, workspace] = Layout::vertical([
|
||||
Constraint::Length(u16::from(area.height >= 4)),
|
||||
Constraint::Length(u16::from(area.height >= 7)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
let (workspace, files) = if area.width >= 76 {
|
||||
let [documents, files] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length((area.width / 4).clamp(24, 36)),
|
||||
])
|
||||
.areas(workspace);
|
||||
(documents, Some(files))
|
||||
} else if resolver.files_focused {
|
||||
(Rect::default(), Some(workspace))
|
||||
} else {
|
||||
(workspace, None)
|
||||
};
|
||||
let mut layout = ResolverLayout {
|
||||
heading,
|
||||
selector,
|
||||
sources: Vec::with_capacity(2),
|
||||
result: workspace,
|
||||
files,
|
||||
};
|
||||
let Some(file) = resolver.files.get(resolver.current_file) else {
|
||||
return layout;
|
||||
};
|
||||
if file.sources.is_empty() || workspace.is_empty() {
|
||||
return layout;
|
||||
}
|
||||
let selected = resolver.selected_source.min(file.sources.len() - 1);
|
||||
if workspace.height < 4 {
|
||||
if resolver.source_panes.get(selected) == Some(&focused) {
|
||||
layout.sources.push((selected, workspace));
|
||||
layout.result = Rect::default();
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
let [sources, result] =
|
||||
Layout::vertical([Constraint::Ratio(1, 2), Constraint::Fill(1)]).areas(workspace);
|
||||
layout.result = result;
|
||||
if workspace.width >= 88
|
||||
&& let Some(other) = file
|
||||
.sources
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(index, source)| *index != selected && !source.base)
|
||||
.or_else(|| {
|
||||
file.sources
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(index, _)| *index != selected)
|
||||
})
|
||||
.map(|(index, _)| index)
|
||||
{
|
||||
let [left, right] =
|
||||
Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Fill(1)]).areas(sources);
|
||||
layout
|
||||
.sources
|
||||
.extend([(selected.min(other), left), (selected.max(other), right)]);
|
||||
} else {
|
||||
layout.sources.push((selected, sources));
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
fn resolver_status(status: &ResolverStatus) -> (&'static str, Color) {
|
||||
match status {
|
||||
ResolverStatus::Unresolved => ("unresolved", Color::Yellow),
|
||||
ResolverStatus::Unsaved => ("edited · unsaved", Color::Yellow),
|
||||
ResolverStatus::Checking => ("saved · checking jj", Color::Cyan),
|
||||
ResolverStatus::Resolved => ("resolved", Color::Green),
|
||||
ResolverStatus::Unconfirmed => ("unconfirmed · jj check failed", Color::Red),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_resolver_heading(
|
||||
frame: &mut Frame,
|
||||
resolver: &ResolverWorkspace,
|
||||
heading: Rect,
|
||||
selector: Rect,
|
||||
) {
|
||||
let file = resolver.files.get(resolver.current_file);
|
||||
let status = if resolver.loading {
|
||||
("loading", Color::Cyan)
|
||||
} else {
|
||||
file.map_or(("no conflicted files", Color::Green), |file| {
|
||||
resolver_status(&file.status)
|
||||
})
|
||||
};
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
format!("Resolve · {} ", status.0),
|
||||
Style::default().fg(status.1),
|
||||
),
|
||||
Span::raw(file.map_or_else(|| Cow::Borrowed(""), |file| display_path(&file.path))),
|
||||
];
|
||||
if let Some(error) = resolver
|
||||
.error
|
||||
.as_deref()
|
||||
.or_else(|| file.and_then(|file| file.error.as_deref()))
|
||||
{
|
||||
spans.push(Span::styled(
|
||||
format!(" · {}", display_text(error)),
|
||||
Style::default().fg(Color::Red),
|
||||
));
|
||||
}
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), heading);
|
||||
if let Some(file) = file {
|
||||
let bases = file.sources.iter().filter(|source| source.base).count();
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"Input {}/{} · Base {bases} · :resolve.source N / :resolve.base N · Tab panes/files",
|
||||
resolver.selected_source + 1,
|
||||
file.sources.len(),
|
||||
))
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
selector,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolver_document_panel(frame: &mut Frame, panel: Rect, title: &str, focused: bool) -> Rect {
|
||||
let [heading, document] = Layout::vertical([
|
||||
Constraint::Length(u16::from(panel.height >= 2)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(panel);
|
||||
frame.render_widget(
|
||||
Paragraph::new(display_text(title)).style(
|
||||
Style::default()
|
||||
.fg(if focused {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::DarkGray
|
||||
})
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
heading,
|
||||
);
|
||||
document
|
||||
}
|
||||
|
||||
fn draw_resolver_choices(frame: &mut Frame, resolver: &ResolverWorkspace, area: Rect) {
|
||||
let Some(file) = resolver.files.get(resolver.current_file) else {
|
||||
return;
|
||||
};
|
||||
let mut lines = vec![
|
||||
Line::from("Whole-file conflict · text editing is unavailable")
|
||||
.style(Style::default().fg(Color::Yellow)),
|
||||
Line::from(
|
||||
file.whole_file_choice
|
||||
.and_then(|index| file.sources.get(index))
|
||||
.map_or_else(
|
||||
|| "No whole-file choice selected.".to_owned(),
|
||||
|source| format!("Selected: {}", display_text(&source.label)),
|
||||
),
|
||||
),
|
||||
Line::from("Choose an explicit input, then :w to save and check jj."),
|
||||
];
|
||||
lines.extend(
|
||||
file.sources
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, source)| !source.base)
|
||||
.map(|(index, source)| {
|
||||
Line::from(format!(
|
||||
"{} :resolve.use_source {} · {}",
|
||||
if file.whole_file_choice == Some(index) {
|
||||
">"
|
||||
} else {
|
||||
" "
|
||||
},
|
||||
index + 1,
|
||||
display_text(&source.label),
|
||||
))
|
||||
}),
|
||||
);
|
||||
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
|
||||
}
|
||||
|
||||
fn draw_resolver_files(
|
||||
frame: &mut Frame,
|
||||
resolver: &ResolverWorkspace,
|
||||
panel: Rect,
|
||||
navigation_hint: &str,
|
||||
panels: &mut WheelPanels,
|
||||
) {
|
||||
if panel.is_empty() {
|
||||
return;
|
||||
}
|
||||
let bordered = panel.width >= 4 && panel.height >= 4;
|
||||
let block = Block::default()
|
||||
.borders(if bordered {
|
||||
Borders::ALL
|
||||
} else {
|
||||
Borders::NONE
|
||||
})
|
||||
.border_style(Style::default().fg(if resolver.files_focused {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::DarkGray
|
||||
}))
|
||||
.title(" Conflicts ");
|
||||
let content = block.inner(panel);
|
||||
frame.render_widget(Clear, panel);
|
||||
frame.render_widget(block, panel);
|
||||
let [query, entries, help] = Layout::vertical([
|
||||
Constraint::Length(u16::from(content.height >= 2)),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(u16::from(content.height >= 5)),
|
||||
])
|
||||
.areas(content);
|
||||
let prompt = if resolver.filtering {
|
||||
format!("> {}", display_text(&resolver.filter))
|
||||
} else if resolver.filter.is_empty() {
|
||||
"/ filter".to_owned()
|
||||
} else {
|
||||
format!("filter: {}", display_text(&resolver.filter))
|
||||
};
|
||||
let prompt_width = display_column(&prompt, usize::MAX);
|
||||
let prompt_offset = if resolver.filtering {
|
||||
prompt_width.saturating_sub(usize::from(query.width.saturating_sub(1)))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(visible_line(
|
||||
&prompt,
|
||||
prompt_offset,
|
||||
usize::from(query.width),
|
||||
))
|
||||
.style(Style::default().fg(Color::Yellow)),
|
||||
query,
|
||||
);
|
||||
let filter = resolver.filter.to_lowercase();
|
||||
let files: Vec<_> = resolver
|
||||
.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, file)| file.path.to_string_lossy().to_lowercase().contains(&filter))
|
||||
.collect();
|
||||
let selected = files
|
||||
.iter()
|
||||
.position(|(index, _)| *index == resolver.selected_file)
|
||||
.unwrap_or(0);
|
||||
let top = PickerViewport::follow(
|
||||
&mut panels.resolver_scroll,
|
||||
0,
|
||||
selected,
|
||||
usize::from(entries.height),
|
||||
1,
|
||||
Some(0),
|
||||
);
|
||||
let lines: Vec<_> = files
|
||||
.iter()
|
||||
.skip(top)
|
||||
.take(usize::from(entries.height))
|
||||
.map(|(index, file)| {
|
||||
let (status, color) = resolver_status(&file.status);
|
||||
let status = match file.status {
|
||||
ResolverStatus::Unsaved => "unsaved",
|
||||
ResolverStatus::Checking => "checking",
|
||||
ResolverStatus::Unconfirmed => "unconfirmed",
|
||||
_ => status,
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{status} "), Style::default().fg(color)),
|
||||
Span::raw(display_path(&file.path)),
|
||||
])
|
||||
.style(if *index == resolver.selected_file {
|
||||
Style::default()
|
||||
.bg(Color::Blue)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(
|
||||
Paragraph::new(if files.is_empty() {
|
||||
vec![Line::from("No matching files")]
|
||||
} else {
|
||||
lines
|
||||
}),
|
||||
entries,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("{navigation_hint} · Enter open · Tab panes"))
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
help,
|
||||
);
|
||||
if resolver.files_focused {
|
||||
let position = if resolver.filtering && !query.is_empty() {
|
||||
Some(Position::new(
|
||||
query.x
|
||||
+ (prompt_width - prompt_offset).min(usize::from(query.width.saturating_sub(1)))
|
||||
as u16,
|
||||
query.y,
|
||||
))
|
||||
} else if !entries.is_empty()
|
||||
&& selected >= top
|
||||
&& selected - top < usize::from(entries.height)
|
||||
{
|
||||
Some(Position::new(
|
||||
entries.x,
|
||||
entries.y + (selected - top) as u16,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(position) = position {
|
||||
frame.set_cursor_position(position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn draw(
|
||||
frame: &mut Frame,
|
||||
@@ -4019,6 +4431,8 @@ struct WheelPanels {
|
||||
diff_syntax: Option<DiffSyntaxCache>,
|
||||
previous_diff: Option<DiffGeometry>,
|
||||
diff_wrap: Option<DiffWrapViewport>,
|
||||
resolver_files: Option<Rect>,
|
||||
resolver_scroll: Option<PickerViewport>,
|
||||
}
|
||||
|
||||
struct PickerScroll {
|
||||
@@ -4129,6 +4543,7 @@ impl WheelPanels {
|
||||
self.completion = None;
|
||||
self.files = None;
|
||||
self.previous_diff = self.diff.take();
|
||||
self.resolver_files = None;
|
||||
}
|
||||
|
||||
fn follow_files(&mut self) {
|
||||
@@ -4227,6 +4642,18 @@ impl WheelPanels {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if self
|
||||
.resolver_files
|
||||
.is_some_and(|area| area.contains(position))
|
||||
{
|
||||
if !horizontal {
|
||||
session.editor_mut().focus_resolver_files();
|
||||
for _ in 0..3 {
|
||||
session.handle(if down { Input::Down } else { Input::Up });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let pane = session.editor().pane_id();
|
||||
let floating_files = editor.file_picker().is_some_and(|picker| {
|
||||
picker.presentation == FilePresentation::Floating
|
||||
@@ -6083,6 +6510,271 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_renders_native_result_sources_explorer_and_narrow_focus() {
|
||||
let directory = Directory::new();
|
||||
let jj = |args: &[&str]| {
|
||||
let output = std::process::Command::new("jj")
|
||||
.current_dir(&directory.0)
|
||||
.env("JJ_CONFIG", "/dev/null")
|
||||
.args([
|
||||
"--config=user.name=Resolver",
|
||||
"--config=user.email=resolver@example.test",
|
||||
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
])
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
};
|
||||
let text = |side: &str| {
|
||||
format!(
|
||||
"{side}\n{}",
|
||||
(0..60)
|
||||
.map(|row| format!("context {row:02}\n"))
|
||||
.collect::<String>()
|
||||
)
|
||||
};
|
||||
jj(&["git", "init", "--no-colocate", "."]);
|
||||
jj(&["config", "set", "--repo", "user.name", "Resolver"]);
|
||||
jj(&[
|
||||
"config",
|
||||
"set",
|
||||
"--repo",
|
||||
"user.email",
|
||||
"resolver@example.test",
|
||||
]);
|
||||
for path in ["first.txt", "second.txt"] {
|
||||
directory.file(path, &text("base"));
|
||||
}
|
||||
fs::write(directory.0.join("binary.bin"), b"base\0").unwrap();
|
||||
jj(&["describe", "-m", "base"]);
|
||||
let base = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
jj(&["new", &base]);
|
||||
for path in ["first.txt", "second.txt"] {
|
||||
directory.file(path, &text("left"));
|
||||
}
|
||||
fs::write(directory.0.join("binary.bin"), b"left\0").unwrap();
|
||||
jj(&["describe", "-m", "left"]);
|
||||
let left = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
jj(&["new", &base]);
|
||||
for path in ["first.txt", "second.txt"] {
|
||||
directory.file(path, &text("right"));
|
||||
}
|
||||
fs::write(directory.0.join("binary.bin"), b"right\0").unwrap();
|
||||
jj(&["describe", "-m", "right"]);
|
||||
let right = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
jj(&["new", &left, &right, "-m", "conflicts"]);
|
||||
let operation = || {
|
||||
jj(&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
])
|
||||
};
|
||||
let before_browsing = operation();
|
||||
let config = directory.file("config.rb",
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'ctrl+w k', config.core.pane.focus_up\n config.bind 'ctrl+w j', config.core.pane.focus_down\nend\n");
|
||||
let mut session = EditorSession::open(
|
||||
Some(directory.0.join("first.txt")),
|
||||
vec!["text".into()],
|
||||
Some(&config),
|
||||
)
|
||||
.unwrap();
|
||||
let ready = |session: &mut EditorSession| {
|
||||
for _ in 0..15_000 {
|
||||
session.poll();
|
||||
if session.editor().resolver().is_some_and(|resolver| {
|
||||
!resolver.loading
|
||||
&& !resolver
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.status == ResolverStatus::Checking)
|
||||
}) {
|
||||
assert!(
|
||||
session.editor().resolver().unwrap().error.is_none(),
|
||||
"{}",
|
||||
session.editor().message()
|
||||
);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
panic!("Resolver did not settle: {}", session.editor().message());
|
||||
};
|
||||
session.execute("resolve");
|
||||
ready(&mut session);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
let render = |terminal: &mut Terminal<ratatui::backend::TestBackend>,
|
||||
views: &mut BufferViewports,
|
||||
session: &mut EditorSession| {
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, session))
|
||||
.unwrap();
|
||||
terminal.get_cursor_position().unwrap()
|
||||
};
|
||||
let caret = render(&mut terminal, &mut views, &mut session);
|
||||
let resolver = session.editor().resolver().unwrap();
|
||||
assert_eq!(resolver.files.len(), 3);
|
||||
let current_file = resolver.current_file;
|
||||
let result_pane = resolver.result_pane;
|
||||
let result = views
|
||||
.rects
|
||||
.iter()
|
||||
.find(|(pane, _)| *pane == result_pane)
|
||||
.unwrap()
|
||||
.1;
|
||||
assert!(result.contains(caret));
|
||||
assert_eq!(views.rects.len(), 3);
|
||||
assert!(
|
||||
views
|
||||
.rects
|
||||
.iter()
|
||||
.filter(|(pane, _)| *pane != result_pane)
|
||||
.all(|(_, source)| source.bottom() < result.y && source.width < result.width)
|
||||
);
|
||||
let screen = screen_text(&terminal);
|
||||
assert!(screen.contains("Result") && screen.contains("unresolved"));
|
||||
assert!(screen.contains("Base 1") && screen.contains("readonly"));
|
||||
assert!(views.panels.resolver_files.unwrap().x >= result.right());
|
||||
|
||||
session.handle(Input::Char('\t'));
|
||||
assert_ne!(session.editor().pane_id(), result_pane);
|
||||
session.handle(Input::BackTab);
|
||||
assert_eq!(session.editor().pane_id(), result_pane);
|
||||
session.handle(Input::Ctrl('w'));
|
||||
session.handle(Input::Char('k'));
|
||||
assert_ne!(session.editor().pane_id(), result_pane);
|
||||
session.handle(Input::Ctrl('w'));
|
||||
session.handle(Input::Char('j'));
|
||||
assert_eq!(session.editor().pane_id(), result_pane);
|
||||
session.execute("resolve.base");
|
||||
let resolver = session.editor().resolver().unwrap();
|
||||
assert!(resolver.files[current_file].sources[resolver.selected_source].base);
|
||||
let selected_pane = resolver.source_panes[resolver.selected_source];
|
||||
terminal.backend_mut().resize(44, 20);
|
||||
let caret = render(&mut terminal, &mut views, &mut session);
|
||||
assert_eq!(views.rects.len(), 2);
|
||||
let source = views
|
||||
.rects
|
||||
.iter()
|
||||
.find(|(pane, _)| *pane == selected_pane)
|
||||
.unwrap()
|
||||
.1;
|
||||
assert!(source.contains(caret));
|
||||
assert!(screen_text(&terminal).contains("base"));
|
||||
let original = session.editor().lines().to_vec();
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('X'));
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(session.editor().lines(), original);
|
||||
session.execute("resolve.result");
|
||||
let caret = render(&mut terminal, &mut views, &mut session);
|
||||
let cursor = session.editor().cursor();
|
||||
assert!(views.scroll(
|
||||
&mut session,
|
||||
document_wheel(MouseEventKind::ScrollDown, source.x, source.y),
|
||||
));
|
||||
assert_eq!(session.editor().pane_id(), result_pane);
|
||||
assert_eq!(session.editor().cursor(), cursor);
|
||||
assert!(
|
||||
views
|
||||
.rects
|
||||
.iter()
|
||||
.find(|(pane, _)| *pane == result_pane)
|
||||
.unwrap()
|
||||
.1
|
||||
.contains(caret)
|
||||
);
|
||||
session.execute("resolve.files");
|
||||
session.handle(Input::Down);
|
||||
render(&mut terminal, &mut views, &mut session);
|
||||
assert_eq!(
|
||||
session.editor().resolver().unwrap().current_file,
|
||||
current_file
|
||||
);
|
||||
assert!(screen_text(&terminal).contains("Conflicts"));
|
||||
session.handle(Input::Char('/'));
|
||||
for ch in "second".chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
render(&mut terminal, &mut views, &mut session);
|
||||
assert!(screen_text(&terminal).contains("second.txt"));
|
||||
assert!(!screen_text(&terminal).contains("binary.bin"));
|
||||
session.handle(Input::Escape);
|
||||
session.execute("resolve.result");
|
||||
assert_eq!(
|
||||
operation(),
|
||||
before_browsing,
|
||||
"Rendering/navigation must not snapshot jj"
|
||||
);
|
||||
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('X'));
|
||||
session.handle(Input::Escape);
|
||||
render(&mut terminal, &mut views, &mut session);
|
||||
assert!(screen_text(&terminal).contains("unsaved"));
|
||||
session.handle(Input::Char('u'));
|
||||
let resolver = session.editor().resolver().unwrap();
|
||||
let side = resolver.files[current_file]
|
||||
.sources
|
||||
.iter()
|
||||
.position(|source| !source.base)
|
||||
.unwrap()
|
||||
+ 1;
|
||||
session.execute(&format!("resolve.use_source {side}"));
|
||||
session.execute("w");
|
||||
render(&mut terminal, &mut views, &mut session);
|
||||
assert!(screen_text(&terminal).contains("checking"));
|
||||
ready(&mut session);
|
||||
render(&mut terminal, &mut views, &mut session);
|
||||
let resolver = session.editor().resolver().unwrap();
|
||||
assert_eq!(resolver.files.len(), 3);
|
||||
assert_eq!(
|
||||
resolver.files[current_file].status,
|
||||
ResolverStatus::Resolved
|
||||
);
|
||||
assert!(screen_text(&terminal).contains("resolved"));
|
||||
let binary = resolver
|
||||
.files
|
||||
.iter()
|
||||
.position(|file| file.path == std::path::Path::new("binary.bin"))
|
||||
.unwrap();
|
||||
session.editor_mut().select_resolver_file(binary);
|
||||
terminal.backend_mut().resize(120, 30);
|
||||
render(&mut terminal, &mut views, &mut session);
|
||||
assert!(screen_text(&terminal).contains("Whole-file conflict"));
|
||||
assert!(!views.rects.iter().any(|(pane, _)| *pane == result_pane));
|
||||
let resolver = session.editor().resolver().unwrap();
|
||||
let side = resolver.files[binary]
|
||||
.sources
|
||||
.iter()
|
||||
.position(|source| !source.base)
|
||||
.unwrap()
|
||||
+ 1;
|
||||
session.execute(&format!("resolve.use_source {side}"));
|
||||
render(&mut terminal, &mut views, &mut session);
|
||||
assert!(screen_text(&terminal).contains("Selected:"));
|
||||
for (width, height) in [(4, 4), (1, 1), (0, 0)] {
|
||||
terminal.backend_mut().resize(width, height);
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_catalog() -> crate::diff::Catalog {
|
||||
use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget};
|
||||
let mut entries = vec![Revision {
|
||||
|
||||
Reference in New Issue
Block a user