Improve large diff and merge conflict workflows

This commit is contained in:
2026-08-28 21:08:55 -05:00
parent 88c1ef9716
commit e8166012f4
15 changed files with 908 additions and 76 deletions
@@ -9,7 +9,7 @@ use galaxy_core::features::FeatureFlag;
use galaxyui::elements::{
ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Flex,
MainAxisAlignment, MainAxisSize, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Shrinkable, Stack,
ParentOffsetBounds, Shrinkable, Stack, Text,
};
use galaxyui::{Element, ViewHandle};
use pathfinder_geometry::vector::vec2f;
@@ -40,6 +40,43 @@ impl CodeReviewHeader {
right_section.add_child(git_button);
}
if code_review_header_fields.conflict_count > 0 {
right_section.add_child(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
ChildView::new(&code_review_header_fields.conflict_filter_button)
.finish(),
)
.with_child(
Container::new(
Text::new(
format!(
"{} unresolved",
code_review_header_fields.conflict_count
),
appearance.ui_font_family(),
appearance.ui_font_size() * 0.85,
)
.with_color(
appearance
.theme()
.sub_text_color(appearance.theme().surface_2())
.into(),
)
.finish(),
)
.with_margin_left(6.)
.finish(),
)
.finish(),
)
.with_margin_right(4.)
.finish(),
);
}
if let Some(nav_button) = &code_review_header_fields.file_nav_button {
right_section.add_child(Self::render_file_nav_button(nav_button));
}
+72 -7
View File
@@ -5,7 +5,7 @@ use galaxyui::elements::{
Align, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Flex,
Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch,
Stack,
Stack, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
@@ -87,6 +87,7 @@ impl CodeReviewHeader {
code_review_header_fields: &CodeReviewHeaderFields,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let mut left_section_wide = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
@@ -113,8 +114,40 @@ impl CodeReviewHeader {
let mut right_section_wide = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(ChildView::new(&code_review_header_fields.diff_selector).finish());
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if code_review_header_fields.conflict_count > 0 {
right_section_wide.add_child(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
ChildView::new(&code_review_header_fields.conflict_filter_button)
.finish(),
)
.with_child(
Container::new(
Text::new(
format!(
"{} unresolved",
code_review_header_fields.conflict_count
),
appearance.ui_font_family(),
appearance.ui_font_size() * 0.85,
)
.with_color(theme.sub_text_color(theme.surface_2()).into())
.finish(),
)
.with_margin_left(6.)
.finish(),
)
.finish(),
)
.with_margin_right(4.)
.finish(),
);
}
right_section_wide
.add_child(ChildView::new(&code_review_header_fields.diff_selector).finish());
let has_no_changes = state.to_diff_stats().has_no_changes();
@@ -169,6 +202,7 @@ impl CodeReviewHeader {
code_review_header_fields: &CodeReviewHeaderFields,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let mut left_section_compact = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
@@ -228,12 +262,43 @@ impl CodeReviewHeader {
));
}
let right_section_compact = Flex::row()
let mut right_section_compact = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(ChildView::new(&code_review_header_fields.diff_selector).finish())
.with_child(Container::new(right_subsection_compact.finish()).finish());
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if code_review_header_fields.conflict_count > 0 {
right_section_compact.add_child(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
ChildView::new(&code_review_header_fields.conflict_filter_button)
.finish(),
)
.with_child(
Container::new(
Text::new(
format!(
"{} unresolved",
code_review_header_fields.conflict_count
),
appearance.ui_font_family(),
appearance.ui_font_size() * 0.85,
)
.with_color(theme.sub_text_color(theme.surface_2()).into())
.finish(),
)
.with_margin_left(6.)
.finish(),
)
.finish(),
)
.with_margin_right(4.)
.finish(),
);
}
right_section_compact.add_child(ChildView::new(&code_review_header_fields.diff_selector).finish());
right_section_compact.add_child(Container::new(right_subsection_compact.finish()).finish());
Clipped::new(
Shrinkable::new(
+252 -3
View File
@@ -20,7 +20,7 @@ use pathfinder_geometry::vector::{vec2f, Vector2F};
use rand::distributions::Alphanumeric;
use rand::Rng;
use string_offset::CharOffset;
use vec1::Vec1;
use vec1::{vec1, Vec1};
use warp_editor::content::buffer::{AutoScrollBehavior, InitialBufferState, SelectionOffsets};
use warp_editor::model::CoreEditorModel;
use warp_editor::render::element::VerticalExpansionBehavior;
@@ -104,6 +104,7 @@ use crate::code_review::find_model::CodeReviewFindModel;
use crate::code_review::git_repo_model::{GitRepoModels, GitRepoStatusEvent, GitRepoStatusModel};
use crate::code_review::github_repo_model::{GitHubRepoEvent, GitHubRepoModel};
use crate::code_review::hidden_lines::calculate_hidden_lines;
use crate::code_review::merge_conflicts::{parse_conflicts, resolve_conflict, ConflictResolution};
#[cfg(feature = "local_fs")]
use crate::code_review::telemetry_event::DiffSetContextScope;
use crate::code_review::telemetry_event::{
@@ -160,6 +161,8 @@ pub struct CodeReviewHeaderFields {
pub diff_state_model: ModelHandle<DiffStateModel>,
pub maximize_button: ViewHandle<ActionButton>,
pub diff_selector: ViewHandle<DiffSelector>,
pub conflict_filter_button: ViewHandle<ActionButton>,
pub conflict_count: usize,
pub header_menu: ViewHandle<Menu<CodeReviewAction>>,
pub header_menu_open: bool,
pub header_dropdown_button: ViewHandle<ActionButton>,
@@ -353,6 +356,12 @@ pub enum CodeReviewAction {
ViewPr(String),
PublishBranch,
SubmitReviewComments,
ToggleConflictsOnly,
ResolveConflict {
path: String,
conflict_index: usize,
resolution: ConflictResolution,
},
}
pub struct FileState {
@@ -366,6 +375,10 @@ pub struct FileState {
discard_button: ViewHandle<ActionButton>,
add_context_button: ViewHandle<ActionButton>,
copy_path_button: ViewHandle<ActionButton>,
/// Stable actions for the next unresolved conflict block. The live editor
/// is reparsed before each action, so edits remain safe after typing or
/// resolving another block.
conflict_action_buttons: Option<[ViewHandle<ActionButton>; 4]>,
}
pub(crate) struct LoadedState {
@@ -612,6 +625,11 @@ pub trait ReviewActionTargetProvider {
/// State shared among the entire code review view.
pub struct CodeReviewView {
active_repo: Option<RepositoryState>,
conflict_filter_button: ViewHandle<ActionButton>,
/// When enabled, the file navigator shows only files with unresolved
/// conflict markers. This keeps merge work front and center without
/// changing the selected diff set.
conflicts_only: bool,
focus_handle: Option<PaneFocusHandle>,
maximize_button: ViewHandle<ActionButton>,
@@ -1165,6 +1183,13 @@ impl CodeReviewView {
.on_click(|ctx| ctx.dispatch_typed_action(CodeReviewAction::ToggleFileSidebar))
});
let conflict_filter_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Conflicts only", SecondaryTheme)
.with_size(ButtonSize::Small)
.with_tooltip("Show only files with unresolved merge conflicts")
.on_click(|ctx| ctx.dispatch_typed_action(CodeReviewAction::ToggleConflictsOnly))
});
let git_primary_action_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Commit", SecondaryTheme)
.with_size(ButtonSize::Small)
@@ -1320,6 +1345,8 @@ impl CodeReviewView {
let mut view = Self {
active_repo,
conflict_filter_button,
conflicts_only: false,
ui_state_handles,
diff_state_model,
focus_handle: None,
@@ -2703,6 +2730,56 @@ impl CodeReviewView {
})
});
let conflict_action_buttons = if file.file_diff.status.is_conflicted()
&& file.file_diff.conflict_count > 0
&& FeatureFlag::CodeReviewSaveChanges.is_enabled()
{
let conflict_path = file.file_diff.file_path.clone();
Some(
[
("Left", ConflictResolution::Ours),
("Right", ConflictResolution::Theirs),
("Both", ConflictResolution::Both),
("Smart", ConflictResolution::Smart),
]
.map(|(label, resolution)| {
let path = conflict_path.clone();
ctx.add_typed_action_view(move |_ctx| {
ActionButton::new(label, NakedTheme)
.with_size(ButtonSize::InlineActionHeader)
.with_tooltip(match resolution {
ConflictResolution::Ours => {
"Take left / ours for the next conflict"
}
ConflictResolution::Theirs => {
"Take right / theirs for the next conflict"
}
ConflictResolution::Both => {
"Keep both sides for the next conflict"
}
ConflictResolution::Smart => {
"Smart merge the next conflict using the common base"
}
})
.on_click({
let path = path.clone();
move |ctx| {
ctx.dispatch_typed_action(
CodeReviewAction::ResolveConflict {
path: path.clone(),
conflict_index: 0,
resolution,
},
)
}
})
})
}),
)
} else {
None
};
file_states.push(FileState {
file_diff: file.file_diff.clone(),
editor_state,
@@ -2712,6 +2789,7 @@ impl CodeReviewView {
discard_button,
add_context_button,
copy_path_button,
conflict_action_buttons,
sidebar_mouse_state: MouseStateHandle::default(),
header_mouse_state: MouseStateHandle::default(),
})
@@ -2739,7 +2817,6 @@ impl CodeReviewView {
let Some((_, file_state)) = diff_state.get_index(index) else {
return Empty::new().finish();
};
self.render_file_diff(file_state, index, scroll_offset, appearance, app)
}
@@ -2876,6 +2953,20 @@ impl CodeReviewView {
Some(&mut self.active_repo.as_mut()?.state)
}
fn loaded_state(&self) -> Option<&LoadedState> {
match self.state() {
CodeReviewViewState::Loaded(state) => Some(state),
_ => None,
}
}
fn loaded_state_mut(&mut self) -> Option<&mut LoadedState> {
match self.state_mut()? {
CodeReviewViewState::Loaded(state) => Some(state),
_ => None,
}
}
#[cfg(not(target_family = "wasm"))]
fn session_env(&self, app: &AppContext) -> Option<GitSessionState> {
let terminal_view = self.focused_terminal(app)?;
@@ -4189,6 +4280,12 @@ impl CodeReviewView {
is_in_split_pane,
maximize_button: self.maximize_button.clone(),
diff_selector: self.diff_selector.clone(),
conflict_filter_button: self.conflict_filter_button.clone(),
conflict_count: state
.file_states
.values()
.map(|file| file.file_diff.conflict_count)
.sum(),
header_menu: self.header_menu.clone(),
header_menu_open: self.header_menu_open,
diff_state_model: self.diff_state_model.clone(),
@@ -4562,7 +4659,16 @@ impl CodeReviewView {
.with_main_axis_alignment(MainAxisAlignment::Start)
.with_cross_axis_alignment(CrossAxisAlignment::Start);
for (file_index, file_state) in state.file_states.values().enumerate() {
let visible_files = state
.file_states
.values()
.enumerate()
.filter(|(_, file_state)| {
!self.conflicts_only || file_state.file_diff.conflict_count > 0
})
.collect::<Vec<_>>();
for (file_index, file_state) in visible_files {
let file_row = self.render_file_sidebar_row(file_state, appearance);
column.add_child(
Hoverable::new(file_state.sidebar_mouse_state.clone(), |mouse_state| {
@@ -4985,6 +5091,36 @@ impl CodeReviewView {
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if file.file_diff.conflict_count > 0 {
if let Some(conflict_action_buttons) = &file.conflict_action_buttons {
right_row.add_child(
Container::new(
Text::new(
"Next conflict",
appearance.ui_font_family(),
appearance.ui_font_size() * 0.85,
)
.with_color(theme.sub_text_color(theme.surface_2()).into())
.finish(),
)
.with_margin_left(4.)
.finish(),
);
for button in conflict_action_buttons {
right_row.add_child(
EventHandler::new(
Container::new(ChildView::new(button).finish())
.with_margin_left(4.)
.finish(),
)
.on_left_mouse_up(|_, _, _| DispatchEventResult::StopPropagation)
.on_left_mouse_down(|_, _, _| DispatchEventResult::StopPropagation)
.finish(),
);
}
}
}
// Add file diff as context button (before remove button)
if FeatureFlag::DiffSetAsContext.is_enabled() {
right_row.add_child(
@@ -5666,6 +5802,7 @@ impl CodeReviewView {
);
}
CodeEditorEvent::ContentChanged { origin, .. } if origin.from_user() => {
self.update_conflict_count_for_file(&file_path, &editor, ctx);
if let Some((view_handle, content_version)) = self.last_revert.take() {
let same_content_version = content_version == editor.as_ref(ctx).version(ctx);
@@ -6248,6 +6385,100 @@ impl CodeReviewView {
diff_lines.join("\n")
}
fn update_conflict_count_for_file(
&mut self,
path: &str,
editor: &ViewHandle<CodeEditorView>,
ctx: &mut ViewContext<Self>,
) {
let should_track = self
.loaded_state()
.and_then(|state| state.file_states.get(path))
.is_some_and(|file| file.file_diff.status.is_conflicted());
if !should_track {
return;
}
let count = parse_conflicts(&editor.as_ref(ctx).text(ctx).into_string()).len();
if let Some(state) = self.loaded_state_mut() {
if let Some(file) = state.file_states.get_mut(path) {
if file.file_diff.conflict_count != count {
file.file_diff.conflict_count = count;
ctx.notify();
}
}
}
if self.loaded_state().is_some_and(|state| {
state
.file_states
.iter()
.all(|(_, file)| file.file_diff.conflict_count == 0)
}) && self.conflicts_only
{
self.conflicts_only = false;
self.conflict_filter_button.update(ctx, |button, ctx| {
button.set_active(false, ctx);
});
ctx.notify();
}
}
fn resolve_conflict_for_file(
&mut self,
path: &str,
conflict_index: usize,
resolution: ConflictResolution,
ctx: &mut ViewContext<Self>,
) {
let Some(editor) = self
.loaded_state()
.and_then(|state| state.file_states.get(path))
.and_then(|file| file.editor_state.as_ref())
.map(|editor_state| editor_state.editor.clone())
else {
return;
};
let current_text = editor
.as_ref(ctx)
.editor()
.as_ref(ctx)
.text(ctx)
.into_string();
let Some((byte_range, replacement)) =
resolve_conflict(&current_text, conflict_index, resolution)
else {
return;
};
let mut resolved_text = current_text.clone();
resolved_text.replace_range(byte_range.clone(), &replacement);
let remaining_conflicts = parse_conflicts(&resolved_text).len();
editor.update(ctx, |local_editor, ctx| {
let start = CharOffset::from(current_text[..byte_range.start].chars().count());
let end = CharOffset::from(current_text[..byte_range.end].chars().count());
local_editor.editor().update(ctx, |editor, ctx| {
editor.apply_edits(vec1![(replacement, start..end)], ctx);
});
});
if let Some(state) = self.loaded_state_mut() {
if let Some(file) = state.file_states.get_mut(path) {
file.file_diff.conflict_count = remaining_conflicts;
if remaining_conflicts == 0 {
file.conflict_action_buttons = None;
}
}
}
if let Some(index) = self
.loaded_state()
.and_then(|state| state.file_states.get_index_of(path))
{
self.viewported_list_state
.invalidate_height_for_index(index);
}
ctx.notify();
}
fn save_files(&mut self, paths: &[String], ctx: &mut ViewContext<Self>) {
for path in paths {
self.save_file(path, ctx);
@@ -7200,6 +7431,24 @@ impl TypedActionView for CodeReviewView {
CodeReviewAction::SetDiffMode(mode) => {
self.apply_diff_mode(mode.clone(), ctx);
}
CodeReviewAction::ToggleConflictsOnly => {
if !self.file_sidebar_expanded {
self.open_file_sidebar(ctx);
self.update_file_nav_button_tooltip(ctx);
}
self.conflicts_only = !self.conflicts_only;
self.conflict_filter_button.update(ctx, |button, ctx| {
button.set_active(self.conflicts_only, ctx);
});
ctx.notify();
}
CodeReviewAction::ResolveConflict {
path,
conflict_index,
resolution,
} => {
self.resolve_conflict_for_file(path, *conflict_index, *resolution, ctx);
}
CodeReviewAction::ToggleFileSidebar => {
if self.file_sidebar_expanded {
self.file_sidebar_expanded = false;
@@ -31,6 +31,7 @@ use crate::code_review::diff_size_limits::DiffSize;
use crate::code_review::diff_state::{DiffStateModel, FileDiff, GitFileStatus};
use crate::code_review::editor_state::CodeReviewEditorState;
use crate::code_review::git_repo_model::GitRepoModels;
use crate::code_review::merge_conflicts::{parse_conflicts, resolve_conflict, ConflictResolution};
use crate::code_review::GlobalCodeReviewModel;
use crate::pane_group::WorkingDirectoriesModel;
use crate::server::server_api::team::MockTeamClient;
@@ -321,6 +322,7 @@ fn create_loaded_state_with_editors(
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
},
editor_state: Some(CodeReviewEditorState::new_loaded(editor)),
@@ -332,6 +334,7 @@ fn create_loaded_state_with_editors(
discard_button,
add_context_button,
copy_path_button,
conflict_action_buttons: None,
};
(file_path, state)
})
@@ -345,6 +348,16 @@ fn create_loaded_state_with_editors(
}
}
#[test]
fn test_conflict_resolution_helpers_are_safe_for_review_edits() {
let text = "before\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> feature\nafter\n";
assert_eq!(parse_conflicts(text).len(), 1);
let (range, replacement) = resolve_conflict(text, 0, ConflictResolution::Both).unwrap();
let mut resolved = text.to_string();
resolved.replace_range(range, &replacement);
assert_eq!(resolved, "before\nours\ntheirs\nafter\n");
}
#[test]
fn test_relocate_comments_empty_input() {
App::test((), |mut app| async move {
+15 -5
View File
@@ -1,6 +1,7 @@
//! Overlay menu for the code review diff selector: pinned search input and
//! a filtered list of label-only rows with a left check slot.
use std::cmp;
use std::sync::Arc;
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
use galaxy_core::ui::theme::Fill;
@@ -85,6 +86,9 @@ pub struct CodeReviewDiffMenu {
/// current filter. Original target order is preserved; the match result
/// carries indices for bolding matched characters in the label.
filtered: Vec<(usize, Option<FuzzyMatchResult>)>,
/// Cached render rows. The Arc lets each virtualized list closure retain
/// stable data without cloning every target on every frame.
filtered_snapshot: Arc<Vec<(DiffTarget, Option<FuzzyMatchResult>)>>,
/// Index into `filtered` of the keyboard-focused row.
selected_index: Option<usize>,
search_input: ViewHandle<EditorView>,
@@ -135,6 +139,7 @@ impl CodeReviewDiffMenu {
Self {
targets: Vec::new(),
filtered: Vec::new(),
filtered_snapshot: Arc::new(Vec::new()),
selected_index: None,
search_input,
search_query: String::new(),
@@ -145,6 +150,9 @@ impl CodeReviewDiffMenu {
/// Replace the row set and reset filter/scroll to the top.
pub fn set_targets(&mut self, targets: Vec<DiffTarget>, ctx: &mut ViewContext<Self>) {
if self.targets == targets {
return;
}
self.targets = targets;
self.refresh_filtered();
self.scroll_list_to_top();
@@ -192,6 +200,12 @@ impl CodeReviewDiffMenu {
})
.collect();
}
self.filtered_snapshot = Arc::new(
self.filtered
.iter()
.filter_map(|(i, m)| self.targets.get(*i).cloned().map(|t| (t, m.clone())))
.collect(),
);
self.selected_index = if self.filtered.is_empty() {
None
} else {
@@ -302,11 +316,7 @@ impl CodeReviewDiffMenu {
let appearance = Appearance::as_ref(ctx);
let theme = appearance.theme();
let selected = self.selected_index;
let filtered_snapshot: Vec<(DiffTarget, Option<FuzzyMatchResult>)> = self
.filtered
.iter()
.filter_map(|(i, m)| self.targets.get(*i).cloned().map(|t| (t, m.clone())))
.collect();
let filtered_snapshot = self.filtered_snapshot.clone();
let filtered_len = filtered_snapshot.len();
let list = UniformList::new(
+1 -1
View File
@@ -24,7 +24,7 @@ use crate::code_review::diff_state::DiffMode;
use crate::ui_components::icons::Icon;
/// A single selectable target in the diff selector menu.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffTarget {
pub label: String,
pub mode: DiffMode,
+122 -59
View File
@@ -19,6 +19,7 @@ cfg_if::cfg_if! {
use std::fs;
}
}
use futures::stream::{self, StreamExt, TryStreamExt};
#[cfg(not(target_family = "wasm"))]
use galaxy_core::channel::ChannelState;
use galaxy_core::send_telemetry_from_ctx;
@@ -31,6 +32,7 @@ use galaxyui::AppContext;
use galaxyui::{ModelContext, SingletonEntity};
use crate::code_review::diff_size_limits::{compute_diff_size, DiffSize};
use crate::code_review::merge_conflicts::parse_conflicts;
use crate::code_review::{git_actions, is_file_autogenerated, CodeReviewTelemetryEvent};
use crate::features::FeatureFlag;
use crate::server::server_api::ServerApiProvider;
@@ -1783,34 +1785,14 @@ impl LocalDiffStateModel {
// Get binary file information using git diff --numstat
let binary_files = Self::get_binary_files(repo_path).await?;
// Then get the diff for each file
let mut files = Vec::new();
let mut total_additions = 0;
let mut total_deletions = 0;
for (file_path, status) in changed_files {
let is_binary = binary_files.contains(&file_path);
let mut file_diff =
Self::get_file_diff(repo_path, &file_path, &status, is_binary, None).await?;
// Never read or ship base content for binary files: it can't be
// inline-rendered and, after lossy UTF-8 decoding, can balloon ~3x.
let content_at_head = if is_binary {
None
} else {
Self::get_file_content_at_head(repo_path, &file_path, &status).await
};
file_diff.is_autogenerated =
is_file_autogenerated(&file_path, content_at_head.as_deref());
total_additions += file_diff.additions();
total_deletions += file_diff.deletions();
files.push(FileDiffAndContent {
file_diff,
content_at_head,
});
}
// Git operations are independent per file. Keep concurrency bounded so
// a large changeset gets parallel I/O without spawning an unbounded
// number of subprocesses, and collect in status order for stable UI.
let files =
Self::load_file_diffs_concurrently(repo_path, changed_files, binary_files, None)
.await?;
let total_additions = files.iter().map(|file| file.file_diff.additions()).sum();
let total_deletions = files.iter().map(|file| file.file_diff.deletions()).sum();
Ok(GitDiffWithBaseContent {
files_changed: files.len(),
@@ -1820,6 +1802,62 @@ impl LocalDiffStateModel {
})
}
async fn load_file_diffs_concurrently(
repo_path: &Path,
changed_files: Vec<(String, GitFileStatus)>,
binary_files: std::collections::HashSet<String>,
merge_base: Option<&str>,
) -> Result<Vec<FileDiffAndContent>> {
const MAX_CONCURRENT_FILE_DIFFS: usize = 8;
let repo_path = repo_path.to_path_buf();
let merge_base = merge_base.map(str::to_owned);
let files: Vec<Option<FileDiffAndContent>> =
stream::iter(changed_files.into_iter().map(|(file_path, status)| {
let repo_path = repo_path.clone();
let merge_base = merge_base.clone();
let is_binary = binary_files.contains(&file_path);
async move {
let file = match merge_base.as_deref() {
Some(base) => {
Self::file_diff_for_path(
is_binary,
&repo_path,
&file_path,
&status,
Some(base),
)
.await?
}
None => {
let mut file_diff = Self::get_file_diff(
&repo_path, &file_path, &status, is_binary, None,
)
.await?;
let content_at_head = if is_binary && !status.is_conflicted() {
None
} else {
Self::get_file_content_at_head(&repo_path, &file_path, &status)
.await
};
file_diff.is_autogenerated =
is_file_autogenerated(&file_path, content_at_head.as_deref());
Some(FileDiffAndContent {
file_diff,
content_at_head,
})
}
};
Ok::<_, anyhow::Error>(file)
}
}))
.buffered(MAX_CONCURRENT_FILE_DIFFS)
.try_collect()
.await?;
Ok(files.into_iter().flatten().collect())
}
async fn diff_state_against_base_branch(
repo_path: &Path,
should_fetch_base: bool,
@@ -1969,13 +2007,14 @@ impl LocalDiffStateModel {
&& (file_diff.hunks.is_empty() || file_diff.is_empty())
&& !status.is_renamed()
&& !status.is_new_file()
&& !status.is_conflicted()
{
return Ok(None);
}
// Never read or ship base content for binary files: it can't be
// inline-rendered and, after lossy UTF-8 decoding, can balloon ~3x.
let content_at_head = if is_binary {
let content_at_head = if file_diff.is_binary {
None
} else {
match &merge_base {
@@ -2037,9 +2076,20 @@ impl LocalDiffStateModel {
let status_files = Self::parse_git_status(&status_output)?;
// Add untracked files to the changed files list
// Add untracked files and preserve unmerged status in the branch diff.
// `git diff --name-status` can report a conflicted path as modified,
// while `git status` is authoritative for whether it needs resolving.
for (file_path, status) in status_files {
if matches!(status, GitFileStatus::Untracked) {
if status.is_conflicted() {
if let Some((_, existing_status)) = changed_files
.iter_mut()
.find(|(path, _)| path == &file_path)
{
*existing_status = status;
} else {
changed_files.push((file_path, status));
}
} else if matches!(status, GitFileStatus::Untracked) {
changed_files.push((file_path, status));
}
}
@@ -2087,29 +2137,15 @@ impl LocalDiffStateModel {
// Get binary file information using git diff --numstat against merge base
let binary_files = Self::get_binary_files_vs_commit(repo_path, &merge_base).await?;
// Get the diff for each file
let mut files = Vec::new();
let mut total_additions = 0;
let mut total_deletions = 0;
for (file_path, status) in changed_files {
let is_binary = binary_files.contains(&file_path);
let file_diff = Self::file_diff_for_path(
is_binary,
repo_path,
&file_path,
&status,
Some(&merge_base),
)
.await?;
if let Some(file_diff) = file_diff {
total_additions += file_diff.file_diff.additions();
total_deletions += file_diff.file_diff.deletions();
files.push(file_diff);
}
}
let files = Self::load_file_diffs_concurrently(
repo_path,
changed_files,
binary_files,
Some(&merge_base),
)
.await?;
let total_additions = files.iter().map(|file| file.file_diff.additions()).sum();
let total_deletions = files.iter().map(|file| file.file_diff.deletions()).sum();
Ok(GitDiffWithBaseContent {
files_changed: files.len(),
@@ -2376,9 +2412,16 @@ impl LocalDiffStateModel {
) -> Result<FileDiff> {
let mut hunks = Vec::new();
let mut max_line_number = 0;
let conflict_count = if status.is_conflicted() {
Self::conflict_count(repo_path, file_path).await
} else {
0
};
// If it's a binary file, don't fetch or parse the diff content
if is_binary {
// If it's a binary file, don't fetch or parse the diff content. A
// conflicted text file may be reported as binary by `git diff`, so
// only bypass this path when no conflict markers are present.
if is_binary && conflict_count == 0 {
return Ok(FileDiff {
file_path: file_path.to_owned(),
status: status.clone(),
@@ -2387,6 +2430,7 @@ impl LocalDiffStateModel {
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
});
}
@@ -2404,6 +2448,7 @@ impl LocalDiffStateModel {
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
});
}
@@ -2511,15 +2556,19 @@ impl LocalDiffStateModel {
"Failed to get file diff for {file_path}{}: {error}",
commit.map(|c| format!(" vs {c}")).unwrap_or_default()
);
// If diff fails, treat as binary or empty
// Unmerged files may not have a normal `git diff HEAD` patch,
// but their working-tree contents are still safe to render and
// resolve in the editor.
let is_conflicted = status.is_conflicted();
return Ok(FileDiff {
file_path: file_path.to_owned(),
status: status.clone(),
hunks: Arc::new(hunks),
is_binary: true,
is_binary: !is_conflicted,
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count,
size: DiffSize::Normal,
});
}
@@ -2530,6 +2579,7 @@ impl LocalDiffStateModel {
if diff_output
.lines()
.any(|line| line.starts_with("Binary files ") && line.contains(" differ"))
&& conflict_count == 0
{
return Ok(FileDiff {
file_path: file_path.to_owned(),
@@ -2539,6 +2589,7 @@ impl LocalDiffStateModel {
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
});
}
@@ -2566,14 +2617,26 @@ impl LocalDiffStateModel {
file_path: file_path.to_owned(),
status: status.clone(),
hunks: Arc::new(hunks),
is_binary,
is_binary: is_binary && conflict_count == 0,
is_autogenerated: false,
max_line_number,
has_hidden_bidi_chars,
conflict_count,
size,
})
}
async fn conflict_count(repo_path: &Path, file_path: &str) -> usize {
let path = repo_path.join(file_path);
if !path.is_file() {
return 0;
}
async_fs::read_to_string(path)
.await
.map(|content| parse_conflicts(&content).len())
.unwrap_or(0)
}
/// Parses diff hunks from git diff output
pub(crate) fn parse_diff_hunks(diff_output: &str) -> Result<Vec<DiffHunk>> {
let mut hunks = Vec::new();
+8
View File
@@ -109,6 +109,10 @@ impl GitFileStatus {
pub fn is_new_file(&self) -> bool {
matches!(self, Self::New | Self::Untracked)
}
pub fn is_conflicted(&self) -> bool {
matches!(self, Self::Conflicted)
}
}
#[derive(Clone, Debug)]
@@ -177,6 +181,10 @@ pub struct FileDiff {
pub is_autogenerated: bool,
pub max_line_number: usize,
pub has_hidden_bidi_chars: bool,
/// Number of complete conflict-marker blocks in the working-tree file.
/// This is metadata only; the live editor remains the source of truth for
/// applying resolutions.
pub conflict_count: usize,
pub size: DiffSize,
}
@@ -99,6 +99,7 @@ fn simple_file_with_content(path: &str, content_at_base: Option<&str>) -> FileDi
is_autogenerated: false,
max_line_number: 10,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
},
content_at_head: content_at_base.map(str::to_string),
@@ -691,6 +692,7 @@ fn apply_file_delta_none_removes_file() {
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
}],
total_additions: 0,
+323
View File
@@ -0,0 +1,323 @@
//! Parsing and resolution helpers for Git's conflict-marker format.
//!
//! This is deliberately separate from unified-diff parsing. A conflicted
//! working tree contains the current file with marker blocks, while a unified
//! diff describes two versions of a file. Keeping the representations separate
//! makes the merge actions safe to apply to the live editor buffer.
use std::ops::Range;
use similar::{DiffOp, TextDiff};
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ConflictBlock {
/// Byte range in the original text covering the complete marker block.
pub(crate) byte_range: Range<usize>,
/// One-indexed line containing the opening marker.
pub(crate) start_line: usize,
pub(crate) ours: String,
pub(crate) base: Option<String>,
pub(crate) theirs: String,
}
impl ConflictBlock {
/// Apply Git's common three-way merge rules. When both sides changed the
/// same base differently, preserving both sides is safer than silently
/// choosing one.
pub(crate) fn smart_merge(&self) -> String {
let Some(base) = self.base.as_deref() else {
return if self.ours == self.theirs {
self.ours.clone()
} else {
merge_both(&self.ours, &self.theirs)
};
};
if self.ours == self.theirs {
return self.ours.clone();
}
if self.ours == base {
return self.theirs.clone();
}
if self.theirs == base {
return self.ours.clone();
}
three_way_merge(base, &self.ours, &self.theirs)
.unwrap_or_else(|| merge_both(&self.ours, &self.theirs))
}
pub(crate) fn resolve(&self, resolution: ConflictResolution) -> String {
match resolution {
ConflictResolution::Ours => self.ours.clone(),
ConflictResolution::Theirs => self.theirs.clone(),
ConflictResolution::Both => merge_both(&self.ours, &self.theirs),
ConflictResolution::Smart => self.smart_merge(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ConflictResolution {
Ours,
Theirs,
Both,
Smart,
}
/// Return all complete conflict blocks in `text`, preserving each side's
/// original line endings. Malformed or incomplete marker sequences are ignored
/// so a partially written file is never destructively rewritten.
pub(crate) fn parse_conflicts(text: &str) -> Vec<ConflictBlock> {
let lines = text_lines(text);
let mut conflicts = Vec::new();
let mut index = 0;
while index < lines.len() {
let (line_start, _, line) = lines[index];
if !line_content(line).starts_with("<<<<<<<") {
index += 1;
continue;
}
let start_line = index + 1;
let mut cursor = index + 1;
let mut ours = String::new();
while cursor < lines.len() {
let content = line_content(lines[cursor].2);
if content.starts_with("|||||||") || content == "=======" {
break;
}
if content.starts_with("<<<<<<<") || content.starts_with(">>>>>>>") {
break;
}
ours.push_str(lines[cursor].2);
cursor += 1;
}
let mut base = None;
if cursor < lines.len() && line_content(lines[cursor].2).starts_with("|||||||") {
cursor += 1;
let mut base_content = String::new();
while cursor < lines.len() {
let content = line_content(lines[cursor].2);
if content == "=======" || content.starts_with(">>>>>>>") {
break;
}
base_content.push_str(lines[cursor].2);
cursor += 1;
}
base = Some(base_content);
}
if cursor >= lines.len() || line_content(lines[cursor].2) != "=======" {
index += 1;
continue;
}
cursor += 1;
let mut theirs = String::new();
while cursor < lines.len() {
let content = line_content(lines[cursor].2);
if content.starts_with(">>>>>>>") {
break;
}
if content.starts_with("<<<<<<<") {
break;
}
theirs.push_str(lines[cursor].2);
cursor += 1;
}
if cursor >= lines.len() || !line_content(lines[cursor].2).starts_with(">>>>>>>") {
index += 1;
continue;
}
conflicts.push(ConflictBlock {
byte_range: line_start..lines[cursor].1,
start_line,
ours,
base,
theirs,
});
index = cursor + 1;
}
conflicts
}
/// Resolve one block in `text`, returning the complete replacement text.
/// Callers should reparse after every edit because previous resolutions change
/// subsequent offsets.
pub(crate) fn resolve_conflict(
text: &str,
conflict_index: usize,
resolution: ConflictResolution,
) -> Option<(Range<usize>, String)> {
let conflict = parse_conflicts(text).into_iter().nth(conflict_index)?;
let replacement = conflict.resolve(resolution);
Some((conflict.byte_range, replacement))
}
#[derive(Debug)]
struct LineEdit {
base_range: Range<usize>,
replacement: String,
}
fn three_way_merge(base: &str, ours: &str, theirs: &str) -> Option<String> {
let base_lines = split_lines(base);
let ours_lines = split_lines(ours);
let theirs_lines = split_lines(theirs);
let ours_edits = line_edits(&base_lines, &ours_lines);
let theirs_edits = line_edits(&base_lines, &theirs_lines);
let mut merged = String::new();
let mut base_index = 0;
let mut ours_index = 0;
let mut theirs_index = 0;
while ours_index < ours_edits.len() || theirs_index < theirs_edits.len() {
let ours_edit = ours_edits.get(ours_index);
let theirs_edit = theirs_edits.get(theirs_index);
let next_start = match (ours_edit, theirs_edit) {
(Some(ours), Some(theirs)) => ours.base_range.start.min(theirs.base_range.start),
(Some(ours), None) => ours.base_range.start,
(None, Some(theirs)) => theirs.base_range.start,
(None, None) => break,
};
merged.push_str(&base_lines[base_index..next_start].concat());
match (ours_edit, theirs_edit) {
(Some(ours), Some(theirs)) if ranges_overlap(&ours.base_range, &theirs.base_range) => {
if ours.base_range == theirs.base_range && ours.replacement == theirs.replacement {
merged.push_str(&ours.replacement);
base_index = ours.base_range.end;
ours_index += 1;
theirs_index += 1;
} else {
return None;
}
}
(Some(ours), Some(theirs)) if ours.base_range.start <= theirs.base_range.start => {
merged.push_str(&ours.replacement);
base_index = ours.base_range.end;
ours_index += 1;
}
(Some(_), Some(theirs)) => {
merged.push_str(&theirs.replacement);
base_index = theirs.base_range.end;
theirs_index += 1;
}
(Some(ours), None) => {
merged.push_str(&ours.replacement);
base_index = ours.base_range.end;
ours_index += 1;
}
(None, Some(theirs)) => {
merged.push_str(&theirs.replacement);
base_index = theirs.base_range.end;
theirs_index += 1;
}
(None, None) => unreachable!(),
}
}
merged.push_str(&base_lines[base_index..].concat());
Some(merged)
}
fn line_edits(base: &[&str], changed: &[&str]) -> Vec<LineEdit> {
TextDiff::configure()
.algorithm(similar::Algorithm::Patience)
.diff_lines(&base.concat(), &changed.concat())
.ops()
.iter()
.filter_map(|op| match op {
DiffOp::Equal { .. } => None,
DiffOp::Delete {
old_index, old_len, ..
} => Some(LineEdit {
base_range: *old_index..*old_index + *old_len,
replacement: String::new(),
}),
DiffOp::Insert {
old_index,
new_index,
new_len,
} => Some(LineEdit {
base_range: *old_index..*old_index,
replacement: changed[*new_index..*new_index + *new_len].concat(),
}),
DiffOp::Replace {
old_index,
old_len,
new_index,
new_len,
} => Some(LineEdit {
base_range: *old_index..*old_index + *old_len,
replacement: changed[*new_index..*new_index + *new_len].concat(),
}),
})
.collect()
}
fn ranges_overlap(left: &Range<usize>, right: &Range<usize>) -> bool {
if left.start == left.end || right.start == right.end {
left.start == right.start
} else {
left.start < right.end && right.start < left.end
}
}
fn split_lines(text: &str) -> Vec<&str> {
let mut lines: Vec<_> = text.split_inclusive('\n').collect();
if lines.last().is_some_and(|line| !line.ends_with('\n')) {
return lines;
}
if text.is_empty() {
lines.clear();
}
lines
}
fn merge_both(ours: &str, theirs: &str) -> String {
if ours.is_empty() {
return theirs.to_string();
}
if theirs.is_empty() {
return ours.to_string();
}
if ours.ends_with('\n') || theirs.starts_with('\n') {
format!("{ours}{theirs}")
} else {
format!("{ours}\n{theirs}")
}
}
fn line_content(line: &str) -> &str {
line.strip_suffix('\n')
.unwrap_or(line)
.strip_suffix('\r')
.unwrap_or_else(|| line.strip_suffix('\n').unwrap_or(line))
}
fn text_lines(text: &str) -> Vec<(usize, usize, &str)> {
let mut lines = Vec::new();
let mut start = 0;
for line in text.split_inclusive('\n') {
let end = start + line.len();
lines.push((start, end, line));
start = end;
}
if start < text.len() || text.is_empty() {
lines.push((start, text.len(), &text[start..]));
}
lines
}
#[cfg(test)]
#[path = "merge_conflicts_tests.rs"]
mod tests;
@@ -0,0 +1,54 @@
use super::{parse_conflicts, resolve_conflict, ConflictResolution};
#[test]
fn parses_multiple_conflicts_and_optional_base() {
let text = "before\n<<<<<<< HEAD\nours\n||||||| base\nbase\n=======\ntheirs\n>>>>>>> feature\nafter\n<<<<<<< HEAD\nleft\n=======\nright\n>>>>>>> feature\n";
let conflicts = parse_conflicts(text);
assert_eq!(conflicts.len(), 2);
assert_eq!(conflicts[0].start_line, 2);
assert_eq!(conflicts[0].ours, "ours\n");
assert_eq!(conflicts[0].base.as_deref(), Some("base\n"));
assert_eq!(conflicts[0].theirs, "theirs\n");
assert_eq!(conflicts[1].base, None);
}
#[test]
fn ignores_incomplete_conflict_blocks() {
let text = "before\n<<<<<<< HEAD\nours\n=======\ntheirs\n";
assert!(parse_conflicts(text).is_empty());
}
#[test]
fn resolves_a_conflict_using_byte_ranges() {
let text = "prefix\n<<<<<<< HEAD\n\n=======\n\n>>>>>>> feature\nsuffix\n";
let (range, replacement) = resolve_conflict(text, 0, ConflictResolution::Theirs)
.expect("complete conflict should resolve");
assert_eq!(
&text[range],
"<<<<<<< HEAD\n\n=======\n\n>>>>>>> feature\n"
);
assert_eq!(replacement, "\n");
}
#[test]
fn smart_merge_prefers_the_changed_side_when_other_side_matches_base() {
let text = "<<<<<<< HEAD\nbase\n||||||| base\nbase\n=======\nchanged\n>>>>>>> feature\n";
let (_, replacement) = resolve_conflict(text, 0, ConflictResolution::Smart).unwrap();
assert_eq!(replacement, "changed\n");
}
#[test]
fn smart_merge_preserves_both_for_divergent_changes() {
let text = "<<<<<<< HEAD\nours\n||||||| base\nbase\n=======\ntheirs\n>>>>>>> feature\n";
let (_, replacement) = resolve_conflict(text, 0, ConflictResolution::Smart).unwrap();
assert_eq!(replacement, "ours\ntheirs\n");
}
#[test]
fn smart_merge_combines_non_overlapping_line_edits() {
let text = "<<<<<<< HEAD\none\nours-two\nthree\n||||||| base\none\ntwo\nthree\n=======\none\ntwo\ntheirs-three\n>>>>>>> feature\n";
let (_, replacement) = resolve_conflict(text, 0, ConflictResolution::Smart).unwrap();
assert_eq!(replacement, "one\nours-two\ntheirs-three\n");
}
+1
View File
@@ -23,6 +23,7 @@ pub(crate) mod diff_menu;
pub(crate) mod diff_selector;
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
pub(crate) mod file_invalidation_queue;
pub(crate) mod merge_conflicts;
use std::path::{Path, PathBuf};
@@ -263,6 +263,7 @@ impl TryFrom<&proto::FileDiff> for FileDiff {
is_autogenerated: file.is_autogenerated,
max_line_number: file.max_line_number as usize,
has_hidden_bidi_chars: file.has_hidden_bidi_chars,
conflict_count: file.conflict_count as usize,
size,
})
}
@@ -630,6 +631,7 @@ pub fn file_diff_to_proto(f: &FileDiff, content_at_base: Option<&str>) -> proto:
is_autogenerated: f.is_autogenerated,
max_line_number: f.max_line_number as u64,
has_hidden_bidi_chars: f.has_hidden_bidi_chars,
conflict_count: f.conflict_count as u64,
size: proto::DiffSize::from(&size).into(),
content_at_base: content_at_base.map(|s| s.to_string()),
}
@@ -119,6 +119,7 @@ fn file_diff_to_proto_preserves_repo_relative_path() {
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
};
@@ -138,6 +139,7 @@ fn build_diff_state_snapshot_preserves_repo_relative_file_paths() {
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
},
content_at_head: None,
@@ -187,6 +189,7 @@ fn text_file_diff(file_path: &str) -> FileDiff {
is_autogenerated: false,
max_line_number: 0,
has_hidden_bidi_chars: false,
conflict_count: 0,
size: DiffSize::Normal,
}
}