fix: clarify conflict pane roles and status indicators

Use Resolution and Other version borders, omit branch/path metadata,
and show unsaved output state only on the writable pane. Track native
explorer conflict C from authoritative jj state across draft/check/error
transitions and clear it only after confirmed resolution.

Focused resolver and save tests pass; live terminal labels and explorer
verified without saving the mock conflicts. Full library run: 795 pass,
one unrelated async plugin fixture_barrier startup failure.
This commit is contained in:
Valdemar Lorenzen
2026-10-15 10:50:22 +02:00
parent 168e12160a
commit 5396968b08
5 changed files with 265 additions and 66 deletions
+131 -51
View File
@@ -3942,6 +3942,31 @@ fn diff_pane_panel(
return area;
}
let focus = if before { Focus::Before } else { Focus::After };
if let Some(resolver) = resolver {
let file = resolver.files.get(resolver.current_file);
let title = if before {
" Resolution "
} else {
" Other version "
};
let state = if before {
format!(
" editable · {} ",
file.map_or("loading", |file| resolver_status(&file.status).0)
)
} else {
" read-only ".to_owned()
};
let source = if before {
String::new()
} else {
file.and_then(|file| file.sources.get(resolver.selected_source))
.map_or_else(String::new, |source| {
format!(" {} ", display_text(&source.label))
})
};
return diff_panel(frame, area, title, &state, &source, diff.focus == focus);
}
let label = diff.side_label(focus);
let title = file.map_or_else(
|| format!(" Diff · {} ", diff.comparison_mode.label()),
@@ -3964,60 +3989,33 @@ fn diff_pane_panel(
)
},
);
let revision = if let Some(resolver) = resolver {
let source = resolver
.files
.get(resolver.current_file)
.and_then(|file| {
file.sources.get(if before {
file.seed_source
} else {
resolver.selected_source
})
})
.map_or("", |source| source.label.as_str());
if before {
format!(" seeded from {} ", display_text(source))
} else {
format!(" {} ", display_text(source))
}
} else {
diff.comparison
.as_ref()
.map_or_else(String::new, |comparison| {
if diff.view_mode == ViewMode::Split && diff.pane_layout() == PaneLayout::Split {
format!(
" {} ",
display_text(if before {
&comparison.before
} else {
&comparison.after
})
)
} else {
format!(
" {} → {} ",
display_text(&comparison.before),
display_text(&comparison.after)
)
}
})
};
let revision = diff
.comparison
.as_ref()
.map_or_else(String::new, |comparison| {
if diff.view_mode == ViewMode::Split && diff.pane_layout() == PaneLayout::Split {
format!(
" {} ",
display_text(if before {
&comparison.before
} else {
&comparison.after
})
)
} else {
format!(
" {} → {} ",
display_text(&comparison.before),
display_text(&comparison.after)
)
}
});
let access = if diff.side_editable(focus) {
" editable "
} else {
" read-only "
};
let state = if let Some(resolver) = resolver {
let status = resolver
.files
.get(resolver.current_file)
.map_or("loading", |file| resolver_status(&file.status).0);
Cow::Owned(format!("{access}· {status} "))
} else {
Cow::Borrowed(access)
};
diff_panel(frame, area, &title, &state, &revision, diff.focus == focus)
diff_panel(frame, area, &title, access, &revision, diff.focus == focus)
}
#[allow(clippy::too_many_arguments)]
@@ -5465,7 +5463,10 @@ fn draw_file_list(
));
spans.push(Span::raw(" "));
}
if let Some(status) = status {
if picker.is_conflicted(entry.path) {
spans.push(Span::styled("C", Style::default().fg(Color::Yellow)));
spans.push(Span::raw(" "));
} else if let Some(status) = status {
let color = match status {
FileStatus::Added => Color::Green,
FileStatus::Removed => Color::Red,
@@ -6994,7 +6995,6 @@ mod tests {
assert!(draft.height > 20);
assert!(views.panels.files.is_none());
let screen = screen_text(&terminal);
assert!(screen.contains("Draft") && screen.contains("Alternative"));
assert!(screen.contains("unchanged lines hidden"));
assert!(!screen.contains("context 30"));
assert!(!screen.contains("<<<<<<<") && !screen.contains("%%%%%%%"));
@@ -7003,6 +7003,27 @@ mod tests {
.map(|x| terminal.backend().buffer()[(x, y)].symbol())
.collect::<String>()
};
let left_heading = line_at(&terminal, draft, draft.y);
let right_heading = line_at(&terminal, alternative, alternative.y);
assert!(left_heading.contains("Resolution") && left_heading.contains("editable"));
assert!(right_heading.contains("Other version") && right_heading.contains("read-only"));
for heading in [&left_heading, &right_heading] {
assert!(!heading.contains("Branch changes"));
assert!(
!heading.contains(resolver.files[current_file].path.to_string_lossy().as_ref())
);
assert!(!heading.split_whitespace().any(|word| word == "M"));
}
assert!(
line_at(&terminal, alternative, alternative.bottom() - 1)
.contains(&resolver.files[current_file].sources[alternative_index].label)
);
assert!(
line_at(&terminal, draft, draft.bottom() - 1)
.chars()
.all(|ch| !ch.is_alphanumeric()),
"The output must not repeat its seed source metadata"
);
for (side, area) in [draft, alternative].into_iter().enumerate() {
let content = diff_inner(area);
let x = content.right() - geometry.text_widths[side];
@@ -7233,6 +7254,11 @@ mod tests {
let geometry = views.panels.diff.as_ref().unwrap();
let left = diff_inner(geometry.regions[1].0);
let right = diff_inner(geometry.regions[2].0);
let left_heading = line_at(&terminal, geometry.regions[1].0, geometry.regions[1].0.y);
let right_heading = line_at(&terminal, geometry.regions[2].0, geometry.regions[2].0.y);
assert!(left_heading.contains("unsaved"));
assert!(!right_heading.contains("unsaved"));
assert!(right_heading.contains("read-only"));
assert!(line_at(&terminal, left, left.y + 1).contains("manual opening context 00"));
assert!(line_at(&terminal, right, right.y + 1).contains("opening context 00"));
assert!(!line_at(&terminal, right, right.y + 1).contains("manual"));
@@ -9175,6 +9201,60 @@ mod tests {
assert!(!text.contains('✓'));
}
#[test]
fn native_explorer_marks_only_confirmed_conflicts() {
let directory = Directory::new();
directory.file("file.txt", "");
let mut picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Floating,
FileView::List,
)
.unwrap();
picker.set_repository_changes(vec![crate::repository::WorkingFileChange {
path: "file.txt".into(),
status: FileStatus::Modified,
old_path: None,
}]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
for view in [FileView::List, FileView::Tree] {
picker.set_view(view);
for conflicted in [true, false] {
picker.set_conflicted(std::path::Path::new("file.txt"), conflicted);
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
frame.area(),
"",
&mut WheelPanels::default(),
None,
);
})
.unwrap();
let text = screen_text(&terminal);
let row = text.lines().find(|row| row.contains("file.txt")).unwrap();
assert!(row.contains(if conflicted {
"C file.txt"
} else {
"M file.txt"
}));
if conflicted {
let buffer = terminal.backend().buffer();
let (x, y) = (0..buffer.area.height)
.find_map(|y| {
(0..buffer.area.width)
.find(|&x| buffer[(x, y)].symbol() == "C")
.map(|x| (x, y))
})
.unwrap();
assert_eq!(buffer[(x, y)].fg, Color::Yellow);
}
}
}
}
#[test]
fn normal_file_names_stay_put_when_status_arrives_or_clears() {
let directory = Directory::new();