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:
Valdemar Lorenzen
2026-10-14 15:49:26 +02:00
parent dcfa50319c
commit a3aef713e2
13 changed files with 3591 additions and 20 deletions
+694 -2
View File
@@ -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 {