Skip to content

Commit d5a6184

Browse files
joskeclaude
andcommitted
Add conflict navigation for 3-way merge view
Scans middle buffer for <<<<<<< conflict markers and provides navigation buttons and Ctrl+J/K shortcuts to jump between them. Shows "Conflict X of Y" label in the toolbar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 106c366 commit d5a6184

1 file changed

Lines changed: 176 additions & 0 deletions

File tree

src/ui.rs

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3048,6 +3048,21 @@ fn merge_change_indices(
30483048
.collect()
30493049
}
30503050

3051+
/// Find line numbers of `<<<<<<<` conflict markers in a buffer.
3052+
fn find_conflict_markers(buf: &TextBuffer) -> Vec<usize> {
3053+
let text = buf.text(&buf.start_iter(), &buf.end_iter(), false);
3054+
text.lines()
3055+
.enumerate()
3056+
.filter_map(|(i, line)| {
3057+
if line.starts_with("<<<<<<<") {
3058+
Some(i)
3059+
} else {
3060+
None
3061+
}
3062+
})
3063+
.collect()
3064+
}
3065+
30513066
fn build_merge_view(
30523067
left_path: &Path,
30533068
middle_path: &Path,
@@ -3503,6 +3518,29 @@ fn build_merge_view(
35033518
nav_box.append(&prev_btn);
35043519
nav_box.append(&next_btn);
35053520

3521+
// Conflict navigation buttons
3522+
let prev_conflict_btn = Button::from_icon_name("go-up-symbolic");
3523+
prev_conflict_btn.set_tooltip_text(Some("Previous conflict (Ctrl+J)"));
3524+
let next_conflict_btn = Button::from_icon_name("go-down-symbolic");
3525+
next_conflict_btn.set_tooltip_text(Some("Next conflict (Ctrl+K)"));
3526+
let conflict_nav_box = GtkBox::new(Orientation::Horizontal, 0);
3527+
conflict_nav_box.add_css_class("linked");
3528+
conflict_nav_box.append(&prev_conflict_btn);
3529+
conflict_nav_box.append(&next_conflict_btn);
3530+
3531+
let conflict_label = Label::new(None);
3532+
conflict_label.add_css_class("chunk-label");
3533+
{
3534+
let n = find_conflict_markers(&middle_buf).len();
3535+
if n == 0 {
3536+
conflict_label.set_label("No conflicts");
3537+
} else {
3538+
conflict_label.set_label(&format!("{n} conflicts"));
3539+
}
3540+
}
3541+
3542+
let current_conflict: Rc<Cell<Option<usize>>> = Rc::new(Cell::new(None));
3543+
35063544
// Undo/Redo buttons
35073545
let undo_btn = Button::from_icon_name("edit-undo-symbolic");
35083546
undo_btn.set_tooltip_text(Some("Undo (Ctrl+Z)"));
@@ -3591,6 +3629,8 @@ fn build_merge_view(
35913629
toolbar.append(&undo_redo_box);
35923630
toolbar.append(&nav_box);
35933631
toolbar.append(&chunk_label);
3632+
toolbar.append(&conflict_nav_box);
3633+
toolbar.append(&conflict_label);
35943634
toolbar.append(&goto_entry);
35953635
toolbar.append(&filter_box);
35963636
toolbar.append(&merge_prefs_btn);
@@ -3703,6 +3743,101 @@ fn build_merge_view(
37033743
});
37043744
}
37053745

3746+
// Navigate conflict helper — jumps between `<<<<<<<` markers in middle buf.
3747+
let navigate_conflict = |cur: &Rc<Cell<Option<usize>>>,
3748+
direction: i32,
3749+
mtv: &TextView,
3750+
mb: &TextBuffer,
3751+
ms: &ScrolledWindow| {
3752+
let markers = find_conflict_markers(mb);
3753+
if markers.is_empty() {
3754+
return;
3755+
}
3756+
let next = match cur.get() {
3757+
Some(cur_line) => {
3758+
let pos = markers.iter().position(|&l| l == cur_line);
3759+
match pos {
3760+
Some(p) => {
3761+
if direction > 0 {
3762+
markers.get(p + 1).or(markers.first())
3763+
} else if p > 0 {
3764+
markers.get(p - 1)
3765+
} else {
3766+
markers.last()
3767+
}
3768+
}
3769+
None => {
3770+
if direction > 0 {
3771+
markers.first()
3772+
} else {
3773+
markers.last()
3774+
}
3775+
}
3776+
}
3777+
}
3778+
None => {
3779+
if direction > 0 {
3780+
markers.first()
3781+
} else {
3782+
markers.last()
3783+
}
3784+
}
3785+
};
3786+
if let Some(&line) = next {
3787+
cur.set(Some(line));
3788+
scroll_to_line(mtv, mb, line, ms);
3789+
}
3790+
};
3791+
3792+
let update_conflict_label = |lbl: &Label, mb: &TextBuffer, cur: Option<usize>| {
3793+
let markers = find_conflict_markers(mb);
3794+
let total = markers.len();
3795+
if total == 0 {
3796+
lbl.set_label("No conflicts");
3797+
return;
3798+
}
3799+
match cur {
3800+
Some(cur_line) => {
3801+
if let Some(pos) = markers.iter().position(|&l| l == cur_line) {
3802+
lbl.set_label(&format!("Conflict {} of {}", pos + 1, total));
3803+
} else {
3804+
lbl.set_label(&format!("{total} conflicts"));
3805+
}
3806+
}
3807+
None => lbl.set_label(&format!("{total} conflicts")),
3808+
}
3809+
};
3810+
3811+
// Prev conflict
3812+
{
3813+
let cur = current_conflict.clone();
3814+
let mtv = middle_pane.text_view.clone();
3815+
let mb = middle_buf.clone();
3816+
let ms = middle_pane.scroll.clone();
3817+
let lbl = conflict_label.clone();
3818+
let nav = navigate_conflict;
3819+
let upd = update_conflict_label;
3820+
prev_conflict_btn.connect_clicked(move |_| {
3821+
nav(&cur, -1, &mtv, &mb, &ms);
3822+
upd(&lbl, &mb, cur.get());
3823+
});
3824+
}
3825+
3826+
// Next conflict
3827+
{
3828+
let cur = current_conflict.clone();
3829+
let mtv = middle_pane.text_view.clone();
3830+
let mb = middle_buf.clone();
3831+
let ms = middle_pane.scroll.clone();
3832+
let lbl = conflict_label.clone();
3833+
let nav = navigate_conflict;
3834+
let upd = update_conflict_label;
3835+
next_conflict_btn.connect_clicked(move |_| {
3836+
nav(&cur, 1, &mtv, &mb, &ms);
3837+
upd(&lbl, &mb, cur.get());
3838+
});
3839+
}
3840+
37063841
// ── Chunk maps for merge view ────────────────────────────────
37073842
let left_chunk_map = DrawingArea::new();
37083843
left_chunk_map.set_content_width(12);
@@ -3785,9 +3920,12 @@ fn build_merge_view(
37853920
let lcm = left_chunk_map.clone();
37863921
let rcm = right_chunk_map.clone();
37873922
let lbl = chunk_label.clone();
3923+
let clbl = conflict_label.clone();
37883924
let lch = left_chunks.clone();
37893925
let rch = right_chunks.clone();
3926+
let mb = middle_buf.clone();
37903927
let cur = current_chunk.clone();
3928+
let ccur = current_conflict.clone();
37913929
let lf = left_pane.filler_overlay.clone();
37923930
let mf = middle_pane.filler_overlay.clone();
37933931
let rf = right_pane.filler_overlay.clone();
@@ -3797,9 +3935,12 @@ fn build_merge_view(
37973935
let lcm = lcm.clone();
37983936
let rcm = rcm.clone();
37993937
let lbl = lbl.clone();
3938+
let clbl = clbl.clone();
38003939
let lch = lch.clone();
38013940
let rch = rch.clone();
3941+
let mb = mb.clone();
38023942
let cur = cur.clone();
3943+
let ccur = ccur.clone();
38033944
let lf = lf.clone();
38043945
let mf = mf.clone();
38053946
let rf = rf.clone();
@@ -3813,6 +3954,13 @@ fn build_merge_view(
38133954
rf.queue_draw();
38143955
cur.set(None);
38153956
update_merge_label(&lbl, &lch.borrow(), &rch.borrow(), None);
3957+
ccur.set(None);
3958+
let n = find_conflict_markers(&mb).len();
3959+
if n == 0 {
3960+
clbl.set_label("No conflicts");
3961+
} else {
3962+
clbl.set_label(&format!("{n} conflicts"));
3963+
}
38163964
}
38173965
}
38183966
};
@@ -4214,6 +4362,32 @@ fn build_merge_view(
42144362
});
42154363
action_group.add_action(&action);
42164364
}
4365+
{
4366+
let action = gio::SimpleAction::new("prev-conflict", None);
4367+
let cur = current_conflict.clone();
4368+
let mtv = middle_pane.text_view.clone();
4369+
let mb = middle_buf.clone();
4370+
let ms = middle_pane.scroll.clone();
4371+
let lbl = conflict_label.clone();
4372+
action.connect_activate(move |_, _| {
4373+
navigate_conflict(&cur, -1, &mtv, &mb, &ms);
4374+
update_conflict_label(&lbl, &mb, cur.get());
4375+
});
4376+
action_group.add_action(&action);
4377+
}
4378+
{
4379+
let action = gio::SimpleAction::new("next-conflict", None);
4380+
let cur = current_conflict.clone();
4381+
let mtv = middle_pane.text_view.clone();
4382+
let mb = middle_buf.clone();
4383+
let ms = middle_pane.scroll.clone();
4384+
let lbl = conflict_label.clone();
4385+
action.connect_activate(move |_, _| {
4386+
navigate_conflict(&cur, 1, &mtv, &mb, &ms);
4387+
update_conflict_label(&lbl, &mb, cur.get());
4388+
});
4389+
action_group.add_action(&action);
4390+
}
42174391
// Find action (Ctrl+F)
42184392
{
42194393
let action = gio::SimpleAction::new("find", None);
@@ -4478,6 +4652,8 @@ fn build_merge_window(
44784652
if let Some(gtk_app) = window.application() {
44794653
gtk_app.set_accels_for_action("diff.prev-chunk", &["<Alt>Up", "<Ctrl>e"]);
44804654
gtk_app.set_accels_for_action("diff.next-chunk", &["<Alt>Down", "<Ctrl>d"]);
4655+
gtk_app.set_accels_for_action("diff.prev-conflict", &["<Ctrl>j"]);
4656+
gtk_app.set_accels_for_action("diff.next-conflict", &["<Ctrl>k"]);
44814657
gtk_app.set_accels_for_action("diff.find", &["<Ctrl>f"]);
44824658
gtk_app.set_accels_for_action("diff.find-replace", &["<Ctrl>h"]);
44834659
gtk_app.set_accels_for_action("diff.find-next", &["F3"]);

0 commit comments

Comments
 (0)