use super::*;
use palette::IntoColor;

#[gpui::test]
fn large_conflict_bootstrap_trace_records_stage_counts(cx: &mut gpui::TestAppContext) {
    use gitcomet_core::mergetool_trace::{self, MergetoolTraceStage};

    fn trace_line_count(text: &str) -> usize {
        if text.is_empty() {
            0
        } else {
            text.as_bytes()
                .iter()
                .filter(|&&byte| byte == b'\n')
                .count()
                + 1
        }
    }

    let _trace = mergetool_trace::capture();
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(161);
    let fixture = SyntheticLargeConflictFixture::new(
        "large_conflict_bootstrap_trace",
        "fixtures/large_conflict_trace.html",
        crate::view::conflict_resolver::LARGE_CONFLICT_BLOCK_DIFF_MAX_LINES + 100,
        1,
    );
    fixture.write();

    let expected_resolved = crate::view::conflict_resolver::generate_resolved_text(
        crate::view::conflict_resolver::parse_conflict_markers(&fixture.current_text).as_slice(),
    );
    let expected_resolved_line_count = trace_line_count(&expected_resolved);

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::ZERO,
                });
            });

            let next_state = app_state_with_repo(fixture.repo_state(repo_id), repo_id);

            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "large conflict bootstrap trace initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && pane.conflict_resolver.split_row_index().is_some()
        },
        |pane| {
            format!(
                "path={:?} split_rows={} visible_rows={} resolved_path={:?}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver
                    .split_row_index()
                    .map(|index| index.total_rows())
                    .unwrap_or_default(),
                pane.conflict_resolver.two_way_split_visible_len(),
                pane.conflict_resolved_preview_path,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.recompute_conflict_resolved_outline_for_tests(cx);
            });
        });
    });

    let trace = mergetool_trace::snapshot();
    let path_events: Vec<_> = trace
        .events
        .iter()
        .filter(|event| event.path.as_deref() == Some(fixture.file_rel.as_path()))
        .collect();
    assert!(
        !path_events.is_empty(),
        "expected mergetool trace events for the focused large conflict fixture"
    );

    // Giant mode skips BuildInlineRows since inline is not supported.
    let is_streamed = path_events.iter().any(|event| {
        event.rendering_mode
            == Some(gitcomet_core::mergetool_trace::MergetoolTraceRenderingMode::StreamedLargeFile)
    });
    for stage in [
        MergetoolTraceStage::ParseConflictMarkers,
        MergetoolTraceStage::GenerateResolvedText,
        MergetoolTraceStage::SideBySideRows,
        MergetoolTraceStage::BuildThreeWayConflictMaps,
        MergetoolTraceStage::ComputeThreeWayWordHighlights,
        MergetoolTraceStage::ComputeTwoWayWordHighlights,
        MergetoolTraceStage::ResolvedOutlineRecompute,
        MergetoolTraceStage::ConflictResolverBootstrapTotal,
    ] {
        assert!(
            path_events.iter().any(|event| event.stage == stage),
            "missing {stage:?} trace event for large conflict bootstrap"
        );
    }
    if !is_streamed {
        assert!(
            path_events
                .iter()
                .any(|event| event.stage == MergetoolTraceStage::ConflictResolverInputSetText),
            "missing ConflictResolverInputSetText trace event for non-streamed bootstrap"
        );
    }
    if !is_streamed {
        assert!(
            path_events
                .iter()
                .any(|event| event.stage == MergetoolTraceStage::BuildInlineRows),
            "missing BuildInlineRows trace event for non-streamed bootstrap"
        );
    }

    let bootstrap_event = path_events
        .iter()
        .find(|event| event.stage == MergetoolTraceStage::ConflictResolverBootstrapTotal)
        .copied()
        .expect("missing bootstrap-total trace event");
    // SyntheticLargeConflictFixture ensures base/ours/theirs all have fixture_line_count lines.
    assert_eq!(bootstrap_event.base.lines, Some(fixture.fixture_line_count));
    assert_eq!(bootstrap_event.ours.lines, Some(fixture.fixture_line_count));
    assert_eq!(
        bootstrap_event.theirs.lines,
        Some(fixture.fixture_line_count)
    );
    assert_eq!(
        bootstrap_event.conflict_block_count,
        Some(fixture.conflict_block_count)
    );
    assert_eq!(
        bootstrap_event.rendering_mode,
        Some(gitcomet_core::mergetool_trace::MergetoolTraceRenderingMode::StreamedLargeFile),
        "large fixture bootstrap should opt into the explicit large-file rendering mode",
    );
    assert_eq!(
        bootstrap_event.whole_block_diff_ran,
        Some(false),
        "large fixture bootstrap should keep whole-block two-way diffs disabled",
    );
    assert_eq!(
        bootstrap_event.full_output_generated,
        Some(false),
        "streamed bootstrap should keep the resolved output virtual until an explicit edit or save path needs the full text",
    );
    assert_eq!(
        bootstrap_event.full_syntax_parse_requested,
        Some(true),
        "large fixture bootstrap should still request prepared syntax for streamed conflict inputs",
    );
    // In giant mode the diff_row_count is the paged index total (large);
    // in eager mode it stays bounded by conflict block size + context.
    let diff_row_count = bootstrap_event.diff_row_count.unwrap_or_default();
    if is_streamed {
        assert!(
            diff_row_count > 0,
            "streamed mode should still report a non-zero diff row count, got {diff_row_count}",
        );
        let inline_row_count = bootstrap_event.inline_row_count.unwrap_or_default();
        assert_eq!(
            inline_row_count, 0,
            "streamed mode should not build inline rows, got {inline_row_count}",
        );
    } else {
        let max_rows_per_block =
            (crate::view::conflict_resolver::BLOCK_LOCAL_DIFF_CONTEXT_LINES * 2) + 2;
        assert!(
            diff_row_count > 0 && diff_row_count <= max_rows_per_block,
            "block-local diff should stay bounded by one conflict block plus context, got {diff_row_count}"
        );
        let inline_row_count = bootstrap_event.inline_row_count.unwrap_or_default();
        assert!(
            inline_row_count > 0 && inline_row_count <= max_rows_per_block + 1,
            "inline rows should stay bounded by the block-local diff rows, got {inline_row_count}"
        );
    }
    assert_eq!(
        bootstrap_event.resolved_output_line_count,
        Some(expected_resolved_line_count)
    );

    let outline_event = path_events
        .iter()
        .rev()
        .find(|event| event.stage == MergetoolTraceStage::ResolvedOutlineRecompute)
        .copied()
        .expect("missing resolved-outline trace event");
    assert_eq!(
        outline_event.resolved_output_line_count,
        Some(expected_resolved_line_count)
    );
    assert_eq!(
        outline_event.conflict_block_count,
        Some(fixture.conflict_block_count)
    );

    fixture.cleanup();
}

#[gpui::test]
fn focused_mergetool_bootstrap_reuses_shared_text_arcs(cx: &mut gpui::TestAppContext) {
    use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession};

    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(162);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_shared_conflict_arcs",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/shared_conflict_arcs.html");
    let abs_path = workdir.join(&file_rel);

    // `SharedString` is backed by `SmolStr`, which stores strings up to 23 bytes
    // inline (copied, not `Arc`-shared). Each fixture must exceed that inline
    // capacity so the zero-copy `Arc` path is actually exercised below.
    let base_text: Arc<str> = "<p>base content paragraph</p>\n".into();
    let ours_text: Arc<str> = "<p>ours content paragraph</p>\n".into();
    let theirs_text: Arc<str> = "<p>theirs content paragraph</p>\n".into();
    let current_text: Arc<str> =
        "<<<<<<< ours\n<p>ours content paragraph</p>\n=======\n<p>theirs content paragraph</p>\n>>>>>>> theirs\n".into();

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("shared conflict fixture parent"))
        .expect("create shared conflict fixture dir");
    std::fs::write(&abs_path, current_text.as_bytes()).expect("write shared conflict fixture");

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let mut repo = opening_repo_state(repo_id, &workdir);
            set_test_conflict_status(
                &mut repo,
                file_rel.clone(),
                gitcomet_core::domain::DiffArea::Unstaged,
            );
            // Must set conflict_file manually here: this test checks Arc<str> pointer
            // identity, which requires passing Arc<str> directly instead of converting
            // to String via set_test_conflict_file().
            repo.conflict_state.conflict_file_path = Some(file_rel.clone());
            repo.conflict_state.conflict_file =
                gitcomet_state::model::Loadable::Ready(Some(gitcomet_state::model::ConflictFile {
                    path: file_rel.clone().into(),
                    base_bytes: None,
                    ours_bytes: None,
                    theirs_bytes: None,
                    current_bytes: None,
                    base: Some(base_text.clone()),
                    ours: Some(ours_text.clone()),
                    theirs: Some(theirs_text.clone()),
                    current: Some(current_text.clone()),
                }));
            repo.conflict_state.conflict_session = Some(ConflictSession::from_merged_shared_text(
                file_rel.clone(),
                gitcomet_core::domain::FileConflictKind::BothModified,
                ConflictPayload::Text(base_text.clone()),
                ConflictPayload::Text(ours_text.clone()),
                ConflictPayload::Text(theirs_text.clone()),
                current_text.clone(),
            ));

            let next_state = app_state_with_repo(repo, repo_id);

            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "shared conflict arc bootstrap initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolver.current.as_deref() == Some(current_text.as_ref())
                && !pane
                    .conflict_resolver
                    .three_way_text
                    .base
                    .as_ref()
                    .is_empty()
        },
        |pane| {
            format!(
                "path={:?} current={} base_len={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.current.is_some(),
                pane.conflict_resolver.three_way_text.base.len(),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                let base_arc: Arc<str> = pane.conflict_resolver.three_way_text.base.clone().into();
                let ours_arc: Arc<str> = pane.conflict_resolver.three_way_text.ours.clone().into();
                let theirs_arc: Arc<str> =
                    pane.conflict_resolver.three_way_text.theirs.clone().into();
                let current_arc = pane
                    .conflict_resolver
                    .current
                    .as_ref()
                    .expect("current text should be cached")
                    .clone();

                assert!(
                    Arc::ptr_eq(&base_text, &base_arc),
                    "base text should be shared into SharedString without a new allocation",
                );
                assert!(
                    Arc::ptr_eq(&ours_text, &ours_arc),
                    "ours text should be shared into SharedString without a new allocation",
                );
                assert!(
                    Arc::ptr_eq(&theirs_text, &theirs_arc),
                    "theirs text should be shared into SharedString without a new allocation",
                );
                assert!(
                    Arc::ptr_eq(&current_text, &current_arc),
                    "current text should stay Arc-shared in resolver state",
                );
            });
        });
    });

    std::fs::remove_dir_all(&workdir).expect("cleanup shared conflict fixture");
}

#[gpui::test]
fn svg_conflict_preview_rasterizes_off_the_ui_thread(cx: &mut gpui::TestAppContext) {
    use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession};

    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(163);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_svg_conflict_preview",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/conflict_preview.svg");
    let abs_path = workdir.join(&file_rel);

    let base_svg: Arc<str> =
        r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#1d4ed8"/></svg>"##
            .into();
    let ours_svg: Arc<str> =
        r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="#dc2626"/></svg>"##
            .into();
    let theirs_svg: Arc<str> =
        r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 14L8 2L14 14Z" fill="#16a34a"/></svg>"##
            .into();
    let current_text: Arc<str> = format!(
        "<<<<<<< ours\n{}\n=======\n{}\n>>>>>>> theirs\n",
        ours_svg, theirs_svg
    )
    .into();

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("svg conflict preview parent"))
        .expect("create svg conflict preview dir");
    std::fs::write(&abs_path, current_text.as_bytes()).expect("write svg conflict preview");

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let mut repo = opening_repo_state(repo_id, &workdir);
            set_test_conflict_status(
                &mut repo,
                file_rel.clone(),
                gitcomet_core::domain::DiffArea::Unstaged,
            );
            repo.conflict_state.conflict_file_path = Some(file_rel.clone());
            repo.conflict_state.conflict_file =
                gitcomet_state::model::Loadable::Ready(Some(gitcomet_state::model::ConflictFile {
                    path: file_rel.clone().into(),
                    base_bytes: None,
                    ours_bytes: None,
                    theirs_bytes: None,
                    current_bytes: None,
                    base: Some(base_svg.clone()),
                    ours: Some(ours_svg.clone()),
                    theirs: Some(theirs_svg.clone()),
                    current: Some(current_text.clone()),
                }));
            repo.conflict_state.conflict_session = Some(ConflictSession::from_merged_text(
                file_rel.clone(),
                gitcomet_core::domain::FileConflictKind::BothModified,
                ConflictPayload::Text(base_svg.clone()),
                ConflictPayload::Text(ours_svg.clone()),
                ConflictPayload::Text(theirs_svg.clone()),
                &current_text,
            ));

            push_test_state(this, app_state_with_repo(repo, repo_id), cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "svg conflict resolver bootstrap initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolver.source_hash.is_some()
        },
        |pane| {
            format!(
                "path={:?} source_hash={:?} preview_path={:?}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.source_hash,
                pane.conflict_resolver.image_preview.path.clone(),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.ensure_conflict_image_preview_cache(cx);
            });
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "svg conflict preview cache rasterized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            matches!(
                pane.conflict_resolver.image_preview.image(ThreeWayColumn::Base),
                Loadable::Ready(Some(image)) if image.format() == gpui::ImageFormat::Png
            ) && matches!(
                pane.conflict_resolver.image_preview.image(ThreeWayColumn::Ours),
                Loadable::Ready(Some(image)) if image.format() == gpui::ImageFormat::Png
            ) && matches!(
                pane.conflict_resolver.image_preview.image(ThreeWayColumn::Theirs),
                Loadable::Ready(Some(image)) if image.format() == gpui::ImageFormat::Png
            )
        },
        |pane| {
            format!(
                "base={:?} ours={:?} theirs={:?}",
                pane.conflict_resolver
                    .image_preview
                    .image(ThreeWayColumn::Base),
                pane.conflict_resolver
                    .image_preview
                    .image(ThreeWayColumn::Ours),
                pane.conflict_resolver
                    .image_preview
                    .image(ThreeWayColumn::Theirs),
            )
        },
    );

    // The resolved output is now materialized into the editable buffer at
    // bootstrap (kdiff3-style free-text editing), which recomputes the output
    // outline/syntax and can leave background work pending. Drain it so no stray
    // task remains when the deterministic test scheduler ends.
    cx.run_until_parked();

    std::fs::remove_dir_all(&workdir).expect("cleanup svg conflict preview fixture");
}

#[gpui::test]
fn conflict_resolver_input_lists_measure_later_long_rows_for_horizontal_scroll(
    cx: &mut gpui::TestAppContext,
) {
    use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession};

    fn assert_horizontal_overflow(handle: &gpui::UniformListScrollHandle, label: &str) {
        let size = handle
            .0
            .borrow()
            .last_item_size
            .expect("expected rendered list item size");
        assert!(
            size.contents.width > size.item.width,
            "{label} should report horizontal overflow, got item={:?} contents={:?}",
            size.item,
            size.contents,
        );
    }

    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(163);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_hscroll_measure",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/conflict_resolver_hscroll_measure.txt");
    let abs_path = workdir.join(&file_rel);

    let long_base = format!("base {}", "X".repeat(320));
    let long_ours = format!("ours {}", "Y".repeat(320));
    let long_theirs = format!("theirs {}", "Z".repeat(320));
    let base_text = ["short", "context", long_base.as_str(), "tail"].join("\n");
    let ours_text = ["short", "context", long_ours.as_str(), "tail"].join("\n");
    let theirs_text = ["short", "context", long_theirs.as_str(), "tail"].join("\n");
    let current_text =
        format!("<<<<<<< ours\n{ours_text}\n=======\n{theirs_text}\n>>>>>>> theirs\n");

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create resolver hscroll fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write resolver hscroll fixture");

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let mut repo = opening_repo_state(repo_id, &workdir);
            set_test_conflict_status(
                &mut repo,
                file_rel.clone(),
                gitcomet_core::domain::DiffArea::Unstaged,
            );
            set_test_conflict_file(
                &mut repo,
                file_rel.clone(),
                base_text.clone(),
                ours_text.clone(),
                theirs_text.clone(),
                current_text.clone(),
            );
            repo.conflict_state.conflict_session = Some(ConflictSession::from_merged_text(
                file_rel.clone(),
                gitcomet_core::domain::FileConflictKind::BothModified,
                ConflictPayload::Text(base_text.clone().into()),
                ConflictPayload::Text(ours_text.clone().into()),
                ConflictPayload::Text(theirs_text.clone().into()),
                &current_text,
            ));

            push_test_state(this, app_state_with_repo(repo, repo_id), cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolver hscroll fixture initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolver.two_way_split_visible_len() >= 4
                && pane.conflict_resolver.three_way_visible_len() >= 4
        },
        |pane| {
            format!(
                "path={:?} two_way_visible={} three_way_visible={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.two_way_split_visible_len(),
                pane.conflict_resolver.three_way_visible_len(),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::TwoWayDiff, cx);
                assert!(
                    pane.conflict_resolver.two_way_horizontal_measure_row(
                        crate::view::conflict_resolver::ConflictPickSide::Ours,
                    ) > 0,
                    "two-way ours column should not measure only the first short row",
                );
                assert!(
                    pane.conflict_resolver.two_way_horizontal_measure_row(
                        crate::view::conflict_resolver::ConflictPickSide::Theirs,
                    ) > 0,
                    "two-way theirs column should not measure only the first short row",
                );
            });
        });
    });
    cx.update(|window, app| {
        let _ = window.draw(app);
    });
    cx.run_until_parked();

    cx.update(|window, app| {
        let _ = window.draw(app);
        let pane = view.read(app).main_pane.read(app);
        assert_horizontal_overflow(&pane.conflict_resolver_diff_scroll, "two-way ours list");
        assert_horizontal_overflow(&pane.conflict_preview_theirs_scroll, "two-way theirs list");
    });

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                assert!(
                    pane.conflict_resolver
                        .three_way_horizontal_measure_row(ThreeWayColumn::Base)
                        > 0,
                    "three-way base column should not measure only the first short row",
                );
                assert!(
                    pane.conflict_resolver
                        .three_way_horizontal_measure_row(ThreeWayColumn::Ours)
                        > 0,
                    "three-way ours column should not measure only the first short row",
                );
                assert!(
                    pane.conflict_resolver
                        .three_way_horizontal_measure_row(ThreeWayColumn::Theirs)
                        > 0,
                    "three-way theirs column should not measure only the first short row",
                );
            });
        });
    });
    cx.update(|window, app| {
        let _ = window.draw(app);
    });
    cx.run_until_parked();

    cx.update(|window, app| {
        let _ = window.draw(app);
        let pane = view.read(app).main_pane.read(app);
        assert_horizontal_overflow(&pane.conflict_resolver_diff_scroll, "three-way base list");
        assert_horizontal_overflow(&pane.conflict_preview_ours_scroll, "three-way ours list");
        assert_horizontal_overflow(
            &pane.conflict_preview_theirs_scroll,
            "three-way theirs list",
        );
    });

    std::fs::remove_dir_all(&workdir).expect("cleanup resolver hscroll fixture");
}

#[gpui::test]
fn conflict_resolver_three_way_remote_horizontal_overflow_with_divergent_context(
    cx: &mut gpui::TestAppContext,
) {
    use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession};

    fn assert_horizontal_overflow(handle: &gpui::UniformListScrollHandle, renderer: &str) {
        let size = handle
            .0
            .borrow()
            .last_item_size
            .expect("expected rendered Remote list item size");
        assert!(
            size.contents.width > size.item.width,
            "Remote should report horizontal overflow with {renderer} rows, got item={:?} contents={:?}",
            size.item,
            size.contents,
        );
    }

    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(174);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_remote_divergent_hscroll",
        std::process::id()
    ));
    let file_rel =
        std::path::PathBuf::from("fixtures/conflict_resolver_remote_divergent_hscroll.txt");
    let abs_path = workdir.join(&file_rel);

    // The merged file carries Local's longer pre-conflict context, while the
    // Remote stage reaches the conflict much earlier. This makes Remote's side
    // line index differ from the shared aligned row used by the three-way list.
    let base_prefix = ["shared", "base context one", "base context two"].join("\n");
    let ours_prefix = [
        "shared",
        "local context one",
        "local context two",
        "local context three",
        "local context four",
        "local context five",
        "local context six",
    ]
    .join("\n");
    let theirs_prefix = "shared";
    let base_conflict = "base value";
    let ours_conflict = "local value";
    let long_remote = format!("remote value {}", "R".repeat(420));
    let base_text = format!("{base_prefix}\n{base_conflict}\ntail\n");
    let ours_text = format!("{ours_prefix}\n{ours_conflict}\ntail\n");
    let theirs_text = format!("{theirs_prefix}\n{long_remote}\ntail\n");
    let current_text = format!(
        "{ours_prefix}\n<<<<<<< ours\n{ours_conflict}\n||||||| base\n{base_conflict}\n=======\n{long_remote}\n>>>>>>> theirs\ntail\n"
    );

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create divergent-context hscroll fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write divergent-context hscroll fixture");

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let mut repo = opening_repo_state(repo_id, &workdir);
            set_test_conflict_status(
                &mut repo,
                file_rel.clone(),
                gitcomet_core::domain::DiffArea::Unstaged,
            );
            set_test_conflict_file(
                &mut repo,
                file_rel.clone(),
                base_text.clone(),
                ours_text.clone(),
                theirs_text.clone(),
                current_text.clone(),
            );
            repo.conflict_state.conflict_session = Some(ConflictSession::from_merged_text(
                file_rel.clone(),
                gitcomet_core::domain::FileConflictKind::BothModified,
                ConflictPayload::Text(base_text.clone().into()),
                ConflictPayload::Text(ours_text.clone().into()),
                ConflictPayload::Text(theirs_text.clone().into()),
                &current_text,
            ));

            push_test_state(this, app_state_with_repo(repo, repo_id), cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "divergent-context Remote hscroll fixture initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolver.three_way_visible_len() >= 3
        },
        |pane| {
            format!(
                "path={:?} three_way_visible={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.three_way_visible_len(),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                let remote_line = theirs_text
                    .lines()
                    .position(|line| line == long_remote)
                    .expect("long Remote line should exist in the Remote stage");
                let expected_row = pane
                    .conflict_resolver
                    .three_way_row_for_side_line(ThreeWayColumn::Theirs, remote_line);
                assert_eq!(
                    pane.conflict_resolver
                        .three_way_horizontal_measure_row(ThreeWayColumn::Theirs),
                    expected_row,
                    "Remote width measurement must translate its stage line to the aligned row",
                );
            });
        });
    });

    for canvas_rows_enabled in [true, false] {
        cx.update(|_window, app| {
            view.update(app, |this, cx| {
                this.main_pane.update(cx, |pane, cx| {
                    pane.conflict_canvas_rows_enabled = canvas_rows_enabled;
                    cx.notify();
                });
            });
        });
        cx.update(|window, app| {
            let _ = window.draw(app);
        });
        cx.run_until_parked();
        cx.update(|window, app| {
            let _ = window.draw(app);
            let pane = view.read(app).main_pane.read(app);
            assert_horizontal_overflow(
                &pane.conflict_preview_theirs_scroll,
                if canvas_rows_enabled { "canvas" } else { "div" },
            );
        });
    }

    std::fs::remove_dir_all(&workdir).expect("cleanup divergent-context Remote hscroll fixture");
}

fn build_conflict_scroll_matrix_current_text(ours_text: &str, theirs_text: &str) -> String {
    format!("<<<<<<< ours\n{ours_text}\n=======\n{theirs_text}\n>>>>>>> theirs\n")
}

fn build_conflict_scroll_matrix_text(label: &str, fill: char) -> String {
    (0..160)
        .map(|ix| format!("{label} line {ix:03} {}", fill.to_string().repeat(240)))
        .collect::<Vec<_>>()
        .join("\n")
}

fn seed_conflict_scroll_matrix_state(
    cx: &mut gpui::VisualTestContext,
    view: &gpui::Entity<super::super::GitCometView>,
    repo_id: gitcomet_state::model::RepoId,
    workdir: &std::path::Path,
    file_rel: &std::path::Path,
    base_text: &str,
    ours_text: &str,
    theirs_text: &str,
    current_text: &str,
) {
    use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession};

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let mut repo = opening_repo_state(repo_id, workdir);
            set_test_conflict_status(
                &mut repo,
                file_rel.to_path_buf(),
                gitcomet_core::domain::DiffArea::Unstaged,
            );
            set_test_conflict_file(
                &mut repo,
                file_rel.to_path_buf(),
                base_text.to_string(),
                ours_text.to_string(),
                theirs_text.to_string(),
                current_text.to_string(),
            );
            let mut session = ConflictSession::from_merged_text(
                file_rel.to_path_buf(),
                gitcomet_core::domain::FileConflictKind::BothModified,
                ConflictPayload::Text(base_text.to_string().into()),
                ConflictPayload::Text(ours_text.to_string().into()),
                ConflictPayload::Text(theirs_text.to_string().into()),
                current_text,
            );
            for region in &mut session.regions {
                region.resolution =
                    gitcomet_core::conflict_session::ConflictRegionResolution::PickOurs;
            }
            repo.conflict_state.conflict_session = Some(session);

            push_test_state(this, app_state_with_repo(repo, repo_id), cx);
        });
    });
}

fn reset_conflict_scroll_matrix_offsets(pane: &mut MainPaneView) {
    reset_uniform_list_offsets(&[
        &pane.conflict_resolver_diff_scroll,
        &pane.conflict_preview_ours_scroll,
        &pane.conflict_preview_theirs_scroll,
        &pane.conflict_resolved_preview_scroll,
        &pane.conflict_resolved_preview_gutter_scroll,
    ]);
    // The editable resolved output couples via its own `ScrollHandle`.
    set_scroll_handle_offset(
        &pane.conflict_resolved_output_editor_scroll,
        point(px(0.0), px(0.0)),
    );
}

#[gpui::test]
fn conflict_resolver_output_gutter_tracks_output_scroll_when_diff_sync_is_disabled(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(163);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_output_gutter_scroll_sync",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/conflict_output_gutter_scroll_sync.txt");
    let abs_path = workdir.join(&file_rel);
    let base_text = build_conflict_scroll_matrix_text("base", 'B');
    let ours_text = build_conflict_scroll_matrix_text("ours", 'O');
    let theirs_text = build_conflict_scroll_matrix_text("theirs", 'T');
    let current_text = build_conflict_scroll_matrix_current_text(&ours_text, &theirs_text);

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create resolver output gutter fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write resolver output gutter fixture");

    seed_conflict_scroll_matrix_state(
        cx,
        &view,
        repo_id,
        &workdir,
        &file_rel,
        &base_text,
        &ours_text,
        &theirs_text,
        &current_text,
    );

    wait_for_main_pane_condition(
        cx,
        &view,
        "resolver output gutter fixture initialized",
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolved_preview_line_count >= 1
        },
        |pane| {
            format!(
                "path={:?} resolved_lines={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolved_preview_line_count,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolver output gutter overflow",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.view_mode == ConflictResolverViewMode::ThreeWay
                && scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll).width
                    > px(120.0)
                && scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll).height
                    > px(120.0)
        },
        |pane| {
            format!(
                "view_mode={:?} output_offset={:?} output_max={:?} gutter_offset={:?} gutter_max={:?}",
                pane.conflict_resolver.view_mode,
                scroll_handle_offset(&pane.conflict_resolved_output_editor_scroll),
                scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll),
                uniform_list_offset(&pane.conflict_resolved_preview_gutter_scroll),
                uniform_list_max_offset(&pane.conflict_resolved_preview_gutter_scroll),
            )
        },
    );

    set_diff_scroll_sync_for_test(cx, &view, DiffScrollSync::None);

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                reset_conflict_scroll_matrix_offsets(pane);
                set_scroll_handle_offset(
                    &pane.conflict_resolved_output_editor_scroll,
                    point(px(-72.0), px(-48.0)),
                );
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        assert_eq!(
            scroll_handle_offset(&pane.conflict_resolved_output_editor_scroll),
            point(px(-72.0), px(-48.0)),
            "resolved output should keep its own scroll offset when diff sync is disabled",
        );
        assert_eq!(
            uniform_list_offset(&pane.conflict_resolved_preview_gutter_scroll),
            point(px(0.0), px(-48.0)),
            "resolved output gutter should follow only the editor's vertical scroll",
        );
        assert_eq!(
            uniform_list_offset(&pane.conflict_resolver_diff_scroll),
            point(px(0.0), px(0.0)),
            "base pane should remain independent when diff sync is disabled",
        );
        assert_eq!(
            uniform_list_offset(&pane.conflict_preview_ours_scroll),
            point(px(0.0), px(0.0)),
            "ours pane should remain independent when diff sync is disabled",
        );
        assert_eq!(
            uniform_list_offset(&pane.conflict_preview_theirs_scroll),
            point(px(0.0), px(0.0)),
            "theirs pane should remain independent when diff sync is disabled",
        );
    });

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                let editor_max =
                    scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll).height;
                set_scroll_handle_offset(
                    &pane.conflict_resolved_output_editor_scroll,
                    point(px(0.0), -editor_max),
                );
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        let editor_offset = scroll_handle_offset(&pane.conflict_resolved_output_editor_scroll).y;
        let gutter_offset = uniform_list_offset(&pane.conflict_resolved_preview_gutter_scroll).y;
        assert_eq!(
            gutter_offset, editor_offset,
            "line-number gutter should stop at the editor's bottom boundary; editor_max={:?} gutter_max={:?}",
            scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll),
            uniform_list_max_offset(&pane.conflict_resolved_preview_gutter_scroll),
        );
    });

    std::fs::remove_dir_all(&workdir).expect("cleanup resolver output gutter fixture");
}

#[gpui::test]
fn conflict_resolver_three_way_scroll_sync_matrix_covers_all_modes_and_axes(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(164);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_three_way_scroll_sync_matrix",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/conflict_scroll_sync_matrix.txt");
    let abs_path = workdir.join(&file_rel);
    let base_text = build_conflict_scroll_matrix_text("base", 'B');
    let ours_text = build_conflict_scroll_matrix_text("ours", 'O');
    let theirs_text = build_conflict_scroll_matrix_text("theirs", 'T');
    let current_text = build_conflict_scroll_matrix_current_text(&ours_text, &theirs_text);

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create resolver three-way matrix fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write resolver three-way matrix fixture");

    seed_conflict_scroll_matrix_state(
        cx,
        &view,
        repo_id,
        &workdir,
        &file_rel,
        &base_text,
        &ours_text,
        &theirs_text,
        &current_text,
    );

    wait_for_main_pane_condition(
        cx,
        &view,
        "resolver three-way matrix fixture initialized",
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolver.three_way_visible_len() >= 4
                && pane.conflict_resolved_preview_line_count >= 1
        },
        |pane| {
            format!(
                "path={:?} three_way_visible={} resolved_lines={} base_max={:?} ours_max={:?} theirs_max={:?} output_max={:?}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.three_way_visible_len(),
                pane.conflict_resolved_preview_line_count,
                pane.conflict_resolver_diff_scroll
                    .0
                    .borrow()
                    .base_handle
                    .max_offset(),
                pane.conflict_preview_ours_scroll
                    .0
                    .borrow()
                    .base_handle
                    .max_offset(),
                pane.conflict_preview_theirs_scroll
                    .0
                    .borrow()
                    .base_handle
                    .max_offset(),
                pane.conflict_resolved_output_editor_scroll.max_offset(),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolver three-way matrix overflow",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.view_mode == ConflictResolverViewMode::ThreeWay
                && uniform_list_max_offset(&pane.conflict_resolver_diff_scroll).width > px(120.0)
                && uniform_list_max_offset(&pane.conflict_preview_ours_scroll).width > px(120.0)
                && uniform_list_max_offset(&pane.conflict_preview_theirs_scroll).width > px(120.0)
                && scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll).width
                    > px(120.0)
                && uniform_list_max_offset(&pane.conflict_resolver_diff_scroll).height > px(120.0)
                && uniform_list_max_offset(&pane.conflict_preview_ours_scroll).height > px(120.0)
                && uniform_list_max_offset(&pane.conflict_preview_theirs_scroll).height > px(120.0)
                && scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll).height
                    > px(120.0)
        },
        |pane| {
            format!(
                "view_mode={:?} base_offset={:?} ours_offset={:?} theirs_offset={:?} output_offset={:?} base_max={:?} ours_max={:?} theirs_max={:?} output_max={:?}",
                pane.conflict_resolver.view_mode,
                uniform_list_offset(&pane.conflict_resolver_diff_scroll),
                uniform_list_offset(&pane.conflict_preview_ours_scroll),
                uniform_list_offset(&pane.conflict_preview_theirs_scroll),
                scroll_handle_offset(&pane.conflict_resolved_output_editor_scroll),
                uniform_list_max_offset(&pane.conflict_resolver_diff_scroll),
                uniform_list_max_offset(&pane.conflict_preview_ours_scroll),
                uniform_list_max_offset(&pane.conflict_preview_theirs_scroll),
                scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll),
            )
        },
    );

    let reset_offsets = |cx: &mut gpui::VisualTestContext,
                         view: &gpui::Entity<super::super::GitCometView>| {
        cx.update(|_window, app| {
            view.update(app, |this, cx| {
                this.main_pane.update(cx, |pane, cx| {
                    reset_conflict_scroll_matrix_offsets(pane);
                    cx.notify();
                });
            });
        });
        draw_and_drain_test_window(cx);
    };

    for mode in ALL_DIFF_SCROLL_SYNC_MODES {
        set_diff_scroll_sync_for_test(cx, &view, mode);

        for axis in ScrollSyncAxis::ALL {
            let output_offset = axis.offset(px(72.0));
            reset_offsets(cx, &view);
            cx.update(|_window, app| {
                view.update(app, |this, cx| {
                    this.main_pane.update(cx, |pane, cx| {
                        set_scroll_handle_offset(
                            &pane.conflict_resolved_output_editor_scroll,
                            output_offset,
                        );
                        cx.notify();
                    });
                });
            });
            draw_and_drain_test_window(cx);

            cx.update(|_window, app| {
                let pane = view.read(app).main_pane.read(app);
                // The resolved output is a different document from the
                // aligned columns and is only coupled to them horizontally,
                // where the correspondence is exact (same pixel column).
                // Vertically it scrolls on its own; see
                // `sync_conflict_preview_axis`.
                let output_coupled =
                    axis.includes(mode) && matches!(axis, ScrollSyncAxis::Horizontal);
                let expected = if output_coupled {
                    axis.component(output_offset)
                } else {
                    px(0.0)
                };
                assert_eq!(
                    axis.component(scroll_handle_offset(
                        &pane.conflict_resolved_output_editor_scroll,
                    )),
                    axis.component(output_offset),
                    "resolved output should keep its {} offset in {:?} mode",
                    axis.label(),
                    mode,
                );
                assert_eq!(
                    axis.component(uniform_list_offset(&pane.conflict_resolver_diff_scroll)),
                    expected,
                    "three-way base pane should {} {} scrolling from resolved output in {:?} mode",
                    if output_coupled { "sync" } else { "not sync" },
                    axis.label(),
                    mode,
                );
                assert_eq!(
                    axis.component(uniform_list_offset(&pane.conflict_preview_ours_scroll)),
                    expected,
                    "three-way ours pane should {} {} scrolling from resolved output in {:?} mode",
                    if output_coupled { "sync" } else { "not sync" },
                    axis.label(),
                    mode,
                );
                assert_eq!(
                    axis.component(uniform_list_offset(&pane.conflict_preview_theirs_scroll)),
                    expected,
                    "three-way theirs pane should {} {} scrolling from resolved output in {:?} mode",
                    if output_coupled { "sync" } else { "not sync" },
                    axis.label(),
                    mode,
                );
            });

            let base_offset = axis.offset(px(96.0));
            reset_offsets(cx, &view);
            cx.update(|_window, app| {
                view.update(app, |this, cx| {
                    this.main_pane.update(cx, |pane, cx| {
                        set_uniform_list_offset(&pane.conflict_resolver_diff_scroll, base_offset);
                        cx.notify();
                    });
                });
            });
            draw_and_drain_test_window(cx);

            cx.update(|_window, app| {
                let pane = view.read(app).main_pane.read(app);
                let columns_expected = if axis.includes(mode) {
                    axis.component(base_offset)
                } else {
                    px(0.0)
                };
                let output_expected =
                    if axis.includes(mode) && matches!(axis, ScrollSyncAxis::Horizontal) {
                        axis.component(base_offset)
                    } else {
                        px(0.0)
                    };
                assert_eq!(
                    axis.component(uniform_list_offset(&pane.conflict_resolver_diff_scroll)),
                    axis.component(base_offset),
                    "three-way base pane should keep its {} offset in {:?} mode",
                    axis.label(),
                    mode,
                );
                assert_eq!(
                    axis.component(uniform_list_offset(&pane.conflict_preview_ours_scroll)),
                    columns_expected,
                    "three-way ours pane should {} {} scrolling from the base pane in {:?} mode",
                    if axis.includes(mode) {
                        "sync"
                    } else {
                        "not sync"
                    },
                    axis.label(),
                    mode,
                );
                assert_eq!(
                    axis.component(uniform_list_offset(&pane.conflict_preview_theirs_scroll)),
                    columns_expected,
                    "three-way theirs pane should {} {} scrolling from the base pane in {:?} mode",
                    if axis.includes(mode) {
                        "sync"
                    } else {
                        "not sync"
                    },
                    axis.label(),
                    mode,
                );
                assert_eq!(
                    axis.component(scroll_handle_offset(
                        &pane.conflict_resolved_output_editor_scroll,
                    )),
                    output_expected,
                    "resolved output should {} {} scrolling from the base pane in {:?} mode",
                    if axis.includes(mode) {
                        "sync"
                    } else {
                        "not sync"
                    },
                    axis.label(),
                    mode,
                );
            });
        }
    }

    std::fs::remove_dir_all(&workdir).expect("cleanup resolver three-way matrix fixture");
}

#[gpui::test]
fn conflict_resolver_two_way_scroll_sync_matrix_covers_all_modes_and_axes(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(165);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_two_way_scroll_sync_matrix",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/conflict_scroll_sync_two_way.txt");
    let abs_path = workdir.join(&file_rel);
    let base_text = build_conflict_scroll_matrix_text("base", 'B');
    let ours_text = build_conflict_scroll_matrix_text("ours", 'O');
    let theirs_text = build_conflict_scroll_matrix_text("theirs", 'T');
    let current_text = build_conflict_scroll_matrix_current_text(&ours_text, &theirs_text);

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create resolver two-way matrix fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write resolver two-way matrix fixture");

    seed_conflict_scroll_matrix_state(
        cx,
        &view,
        repo_id,
        &workdir,
        &file_rel,
        &base_text,
        &ours_text,
        &theirs_text,
        &current_text,
    );

    wait_for_main_pane_condition(
        cx,
        &view,
        "resolver two-way matrix fixture initialized",
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolver.two_way_split_visible_len() >= 4
                && pane.conflict_resolved_preview_line_count >= 1
        },
        |pane| {
            format!(
                "path={:?} two_way_visible={} resolved_lines={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.two_way_split_visible_len(),
                pane.conflict_resolved_preview_line_count,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::TwoWayDiff, cx);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolver two-way matrix overflow",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.view_mode == ConflictResolverViewMode::TwoWayDiff
                && uniform_list_max_offset(&pane.conflict_resolver_diff_scroll).width > px(120.0)
                && uniform_list_max_offset(&pane.conflict_preview_theirs_scroll).width > px(120.0)
                && scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll).width
                    > px(120.0)
                && uniform_list_max_offset(&pane.conflict_resolver_diff_scroll).height > px(120.0)
                && uniform_list_max_offset(&pane.conflict_preview_theirs_scroll).height > px(120.0)
                && scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll).height
                    > px(120.0)
        },
        |pane| {
            format!(
                "view_mode={:?} left_offset={:?} right_offset={:?} output_offset={:?} left_max={:?} right_max={:?} output_max={:?}",
                pane.conflict_resolver.view_mode,
                uniform_list_offset(&pane.conflict_resolver_diff_scroll),
                uniform_list_offset(&pane.conflict_preview_theirs_scroll),
                scroll_handle_offset(&pane.conflict_resolved_output_editor_scroll),
                uniform_list_max_offset(&pane.conflict_resolver_diff_scroll),
                uniform_list_max_offset(&pane.conflict_preview_theirs_scroll),
                scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll),
            )
        },
    );

    let reset_offsets = |cx: &mut gpui::VisualTestContext,
                         view: &gpui::Entity<super::super::GitCometView>| {
        cx.update(|_window, app| {
            view.update(app, |this, cx| {
                this.main_pane.update(cx, |pane, cx| {
                    reset_conflict_scroll_matrix_offsets(pane);
                    cx.notify();
                });
            });
        });
        draw_and_drain_test_window(cx);
    };

    // section 30 aligned two-way full mode (this fixture has a base, so ours/theirs
    // align onto the shared whole-file row space). The left/right columns
    // always couple as a pair; the resolved output couples with them only when
    // the merge-tool output-scroll-sync setting is on. Both are still gated by
    // the diff sync mode/axis.
    for output_sync_on in [true, false] {
        cx.update(|_window, app| {
            view.update(app, |this, cx| {
                this.main_pane.update(cx, |pane, cx| {
                    // Set the field directly: this test exercises the sync-group
                    // logic, not persistence, and the `_and_persist` variant
                    // re-enters the root view we're already updating.
                    pane.mergetool_output_scroll_sync = output_sync_on;
                    cx.notify();
                });
            });
        });
        draw_and_drain_test_window(cx);

        for mode in ALL_DIFF_SCROLL_SYNC_MODES {
            set_diff_scroll_sync_for_test(cx, &view, mode);

            for axis in ScrollSyncAxis::ALL {
                let coupled = axis.includes(mode);
                // The editable resolved output has a content-width horizontal
                // range, so it participates in both axes when output sync is on.
                // The resolved output is a different document from the
                // aligned columns and is only coupled to them horizontally,
                // where the correspondence is exact (same pixel column).
                // Vertically it scrolls on its own; see
                // `sync_conflict_preview_axis`.
                let output_coupled =
                    coupled && output_sync_on && matches!(axis, ScrollSyncAxis::Horizontal);

                let output_offset = axis.offset(px(72.0));
                reset_offsets(cx, &view);
                cx.update(|_window, app| {
                    view.update(app, |this, cx| {
                        this.main_pane.update(cx, |pane, cx| {
                            set_scroll_handle_offset(
                                &pane.conflict_resolved_output_editor_scroll,
                                output_offset,
                            );
                            cx.notify();
                        });
                    });
                });
                draw_and_drain_test_window(cx);

                cx.update(|_window, app| {
                    let pane = view.read(app).main_pane.read(app);
                    let expected = if output_coupled {
                        axis.component(output_offset)
                    } else {
                        px(0.0)
                    };
                    assert_eq!(
                        axis.component(scroll_handle_offset(
                        &pane.conflict_resolved_output_editor_scroll,
                    )),
                        axis.component(output_offset),
                        "two-way resolved output should keep its {} offset in {:?} mode (sync={output_sync_on})",
                        axis.label(),
                        mode,
                    );
                    assert_eq!(
                        axis.component(uniform_list_offset(&pane.conflict_resolver_diff_scroll)),
                        expected,
                        "two-way left pane should {} {} scrolling from resolved output in {:?} mode (sync={output_sync_on})",
                        if output_coupled { "sync" } else { "not sync" },
                        axis.label(),
                        mode,
                    );
                    assert_eq!(
                        axis.component(uniform_list_offset(&pane.conflict_preview_theirs_scroll)),
                        expected,
                        "two-way right pane should {} {} scrolling from resolved output in {:?} mode (sync={output_sync_on})",
                        if output_coupled { "sync" } else { "not sync" },
                        axis.label(),
                        mode,
                    );
                });

                let right_offset = axis.offset(px(96.0));
                reset_offsets(cx, &view);
                cx.update(|_window, app| {
                    view.update(app, |this, cx| {
                        this.main_pane.update(cx, |pane, cx| {
                            set_uniform_list_offset(
                                &pane.conflict_preview_theirs_scroll,
                                right_offset,
                            );
                            cx.notify();
                        });
                    });
                });
                draw_and_drain_test_window(cx);

                cx.update(|_window, app| {
                    let pane = view.read(app).main_pane.read(app);
                    let pair_expected = if coupled {
                        axis.component(right_offset)
                    } else {
                        px(0.0)
                    };
                    let output_expected = if output_coupled {
                        axis.component(right_offset)
                    } else {
                        px(0.0)
                    };
                    assert_eq!(
                        axis.component(uniform_list_offset(&pane.conflict_preview_theirs_scroll)),
                        axis.component(right_offset),
                        "two-way right pane should keep its {} offset in {:?} mode (sync={output_sync_on})",
                        axis.label(),
                        mode,
                    );
                    assert_eq!(
                        axis.component(uniform_list_offset(&pane.conflict_resolver_diff_scroll)),
                        pair_expected,
                        "two-way left pane should {} {} scrolling from the right pane in {:?} mode (sync={output_sync_on})",
                        if coupled { "sync" } else { "not sync" },
                        axis.label(),
                        mode,
                    );
                    assert_eq!(
                        axis.component(scroll_handle_offset(
                        &pane.conflict_resolved_output_editor_scroll,
                    )),
                        output_expected,
                        "two-way resolved output should {} {} scrolling from the right pane in {:?} mode (sync={output_sync_on})",
                        if output_coupled { "sync" } else { "not sync" },
                        axis.label(),
                        mode,
                    );
                });
            }
        }
    }

    // Exercise the real wheel path at EOF. The two-way source lists include
    // comfort overscroll while resolved output has a shorter maximum. Reaching
    // the source maximum must remain stable across subsequent render/sync
    // passes instead of letting the clamped output pull the sources backward.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.mergetool_output_scroll_sync = true;
                reset_conflict_scroll_matrix_offsets(pane);
                cx.notify();
            });
        });
    });
    set_diff_scroll_sync_for_test(cx, &view, DiffScrollSync::Vertical);
    draw_and_drain_test_window(cx);

    let (right_max, right_bounds) = cx.update(|window, app| {
        let _ = window.draw(app);
        let pane = view.read(app).main_pane.read(app);
        let handle = pane.conflict_preview_theirs_scroll.0.borrow();
        (
            handle.base_handle.max_offset().y.max(px(0.0)),
            handle.base_handle.bounds(),
        )
    });
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                set_uniform_list_offset(
                    &pane.conflict_preview_theirs_scroll,
                    point(px(0.0), -(right_max - px(40.0)).max(px(0.0))),
                );
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    cx.simulate_event(gpui::ScrollWheelEvent {
        position: right_bounds.center(),
        delta: gpui::ScrollDelta::Pixels(point(px(0.0), px(-160.0))),
        ..Default::default()
    });
    cx.run_until_parked();
    draw_and_drain_test_window(cx);

    let after_wheel = cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        (
            uniform_list_offset(&pane.conflict_resolver_diff_scroll).y,
            uniform_list_offset(&pane.conflict_preview_theirs_scroll).y,
            scroll_handle_offset(&pane.conflict_resolved_output_editor_scroll).y,
        )
    });
    draw_and_drain_test_window(cx);
    let after_idle_render = cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        (
            uniform_list_offset(&pane.conflict_resolver_diff_scroll).y,
            uniform_list_offset(&pane.conflict_preview_theirs_scroll).y,
            scroll_handle_offset(&pane.conflict_resolved_output_editor_scroll).y,
        )
    });
    assert_eq!(
        after_wheel, after_idle_render,
        "two-way EOF offsets must remain stable after output clamps; right_max={right_max:?}",
    );
    assert_eq!(
        after_wheel.1, -right_max,
        "two-way right pane should retain its comfort-overscroll EOF position",
    );

    std::fs::remove_dir_all(&workdir).expect("cleanup resolver two-way matrix fixture");
}

struct SyntheticLargeConflictFixture {
    workdir: std::path::PathBuf,
    file_rel: std::path::PathBuf,
    abs_path: std::path::PathBuf,
    fixture_line_count: usize,
    conflict_block_count: usize,
    first_conflict_line: u32,
    base_text: String,
    ours_text: String,
    theirs_text: String,
    current_text: String,
}

impl SyntheticLargeConflictFixture {
    fn new(
        workdir_label: &str,
        file_rel: &str,
        fixture_line_count: usize,
        conflict_block_count: usize,
    ) -> Self {
        assert!(
            fixture_line_count >= conflict_block_count.saturating_add(3),
            "fixture needs room for 3 header lines plus at least 1 line per conflict"
        );
        assert!(
            conflict_block_count > 0,
            "synthetic large conflict fixture requires at least one conflict block"
        );

        let workdir = std::env::temp_dir().join(format!(
            "gitcomet_ui_test_{}_{}",
            std::process::id(),
            workdir_label
        ));
        let file_rel = std::path::PathBuf::from(file_rel);
        let abs_path = workdir.join(&file_rel);

        let mut base_lines = vec![
            "<!doctype html>".to_string(),
            "<html lang=\"en\">".to_string(),
            "<body class=\"fixture-root\">".to_string(),
        ];
        let mut ours_lines = base_lines.clone();
        let mut theirs_lines = base_lines.clone();
        let mut current_lines = base_lines.clone();

        let remaining_context = fixture_line_count
            .saturating_sub(base_lines.len())
            .saturating_sub(conflict_block_count);
        let context_per_slot = remaining_context / conflict_block_count;
        let context_remainder = remaining_context % conflict_block_count;
        let mut next_context_row = 0usize;
        let mut first_conflict_line = None;

        for conflict_ix in 0..conflict_block_count {
            let base_line = format!(
                "<main id=\"choice-{conflict_ix}\" data-side=\"base\">base {conflict_ix}</main>"
            );
            let ours_line = format!(
                "<main id=\"choice-{conflict_ix}\" data-side=\"ours\">ours {conflict_ix}</main>"
            );
            let theirs_line = format!(
                "<main id=\"choice-{conflict_ix}\" data-side=\"theirs\">theirs {conflict_ix}</main>"
            );
            let conflict_line =
                u32::try_from(ours_lines.len().saturating_add(1)).unwrap_or(u32::MAX);
            first_conflict_line.get_or_insert(conflict_line);

            base_lines.push(base_line);
            ours_lines.push(ours_line.clone());
            theirs_lines.push(theirs_line.clone());
            current_lines.push("<<<<<<< ours".to_string());
            current_lines.push(ours_line);
            current_lines.push("=======".to_string());
            current_lines.push(theirs_line);
            current_lines.push(">>>>>>> theirs".to_string());

            let slot_lines = context_per_slot + usize::from(conflict_ix < context_remainder);
            append_synthetic_large_conflict_context(
                &mut base_lines,
                &mut ours_lines,
                &mut theirs_lines,
                &mut current_lines,
                &mut next_context_row,
                slot_lines,
            );
        }

        assert_eq!(base_lines.len(), fixture_line_count);
        assert_eq!(ours_lines.len(), fixture_line_count);
        assert_eq!(theirs_lines.len(), fixture_line_count);

        Self {
            workdir,
            file_rel,
            abs_path,
            fixture_line_count,
            conflict_block_count,
            first_conflict_line: first_conflict_line.unwrap_or(1),
            base_text: base_lines.join("\n"),
            ours_text: ours_lines.join("\n"),
            theirs_text: theirs_lines.join("\n"),
            current_text: current_lines.join("\n"),
        }
    }

    fn write(&self) {
        let _ = std::fs::remove_dir_all(&self.workdir);
        std::fs::create_dir_all(self.abs_path.parent().expect("fixture file parent"))
            .expect("create fixture dir");
        std::fs::write(&self.abs_path, &self.current_text).expect("write fixture");
    }

    fn repo_state(
        &self,
        repo_id: gitcomet_state::model::RepoId,
    ) -> gitcomet_state::model::RepoState {
        use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession};

        let mut repo = opening_repo_state(repo_id, &self.workdir);
        set_test_conflict_status(
            &mut repo,
            self.file_rel.clone(),
            gitcomet_core::domain::DiffArea::Unstaged,
        );
        set_test_conflict_file(
            &mut repo,
            self.file_rel.clone(),
            self.base_text.clone(),
            self.ours_text.clone(),
            self.theirs_text.clone(),
            self.current_text.clone(),
        );
        repo.conflict_state.conflict_session = Some(ConflictSession::from_merged_text(
            self.file_rel.clone(),
            gitcomet_core::domain::FileConflictKind::BothModified,
            ConflictPayload::Text(self.base_text.clone().into()),
            ConflictPayload::Text(self.ours_text.clone().into()),
            ConflictPayload::Text(self.theirs_text.clone().into()),
            &self.current_text,
        ));
        repo
    }

    fn cleanup(&self) {
        std::fs::remove_dir_all(&self.workdir).expect("cleanup fixture");
    }
}

fn append_synthetic_large_conflict_context(
    base_lines: &mut Vec<String>,
    ours_lines: &mut Vec<String>,
    theirs_lines: &mut Vec<String>,
    current_lines: &mut Vec<String>,
    next_context_row: &mut usize,
    count: usize,
) {
    for _ in 0..count {
        let row = *next_context_row;
        let line = format!(
            "<section id=\"panel-{row}\" data-row=\"{row}\"><div class=\"copy\">row {row}</div></section>"
        );
        base_lines.push(line.clone());
        ours_lines.push(line.clone());
        theirs_lines.push(line.clone());
        current_lines.push(line);
        *next_context_row = next_context_row.saturating_add(1);
    }
}

struct SyntheticWholeFileConflictFixture {
    workdir: std::path::PathBuf,
    file_rel: std::path::PathBuf,
    abs_path: std::path::PathBuf,
    line_count: usize,
    base_text: String,
    ours_text: String,
    theirs_text: String,
    current_text: String,
}

impl SyntheticWholeFileConflictFixture {
    fn new(workdir_label: &str, file_rel: &str, line_count: usize) -> Self {
        assert!(
            line_count >= 5,
            "whole-file conflict fixture needs room for html wrapper lines"
        );

        let workdir = std::env::temp_dir().join(format!(
            "gitcomet_ui_test_{}_{}",
            std::process::id(),
            workdir_label
        ));
        let file_rel = std::path::PathBuf::from(file_rel);
        let abs_path = workdir.join(&file_rel);

        let build_side = |side: &str| {
            let mut lines = vec![
                "<!doctype html>".to_string(),
                "<html lang=\"en\">".to_string(),
                format!("<body class=\"whole-file-{side}\">"),
            ];
            let middle_count = line_count.saturating_sub(5);
            for row in 0..middle_count {
                lines.push(format!(
                    "<section id=\"panel-{row}\" data-side=\"{side}\"><div>{side} {row}</div></section>"
                ));
            }
            lines.push("</body>".to_string());
            lines.push("</html>".to_string());
            lines
        };

        let base_lines = build_side("base");
        let ours_lines = build_side("ours");
        let theirs_lines = build_side("theirs");
        assert_eq!(base_lines.len(), line_count);
        assert_eq!(ours_lines.len(), line_count);
        assert_eq!(theirs_lines.len(), line_count);

        let base_text = base_lines.join("\n");
        let ours_text = ours_lines.join("\n");
        let theirs_text = theirs_lines.join("\n");
        let current_text =
            format!("<<<<<<< ours\n{ours_text}\n=======\n{theirs_text}\n>>>>>>> theirs\n");

        Self {
            workdir,
            file_rel,
            abs_path,
            line_count,
            base_text,
            ours_text,
            theirs_text,
            current_text,
        }
    }

    fn write(&self) {
        let _ = std::fs::remove_dir_all(&self.workdir);
        std::fs::create_dir_all(self.abs_path.parent().expect("fixture file parent"))
            .expect("create fixture dir");
        std::fs::write(&self.abs_path, &self.current_text).expect("write fixture");
    }

    fn repo_state(
        &self,
        repo_id: gitcomet_state::model::RepoId,
    ) -> gitcomet_state::model::RepoState {
        use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession};

        let mut repo = opening_repo_state(repo_id, &self.workdir);
        set_test_conflict_status(
            &mut repo,
            self.file_rel.clone(),
            gitcomet_core::domain::DiffArea::Unstaged,
        );
        set_test_conflict_file(
            &mut repo,
            self.file_rel.clone(),
            self.base_text.clone(),
            self.ours_text.clone(),
            self.theirs_text.clone(),
            self.current_text.clone(),
        );
        repo.conflict_state.conflict_session = Some(ConflictSession::from_merged_text(
            self.file_rel.clone(),
            gitcomet_core::domain::FileConflictKind::BothModified,
            ConflictPayload::Text(self.base_text.clone().into()),
            ConflictPayload::Text(self.ours_text.clone().into()),
            ConflictPayload::Text(self.theirs_text.clone().into()),
            &self.current_text,
        ));
        repo
    }

    fn cleanup(&self) {
        std::fs::remove_dir_all(&self.workdir).expect("cleanup fixture");
    }
}

fn load_synthetic_whole_file_conflict(
    cx: &mut gpui::VisualTestContext,
    view: &gpui::Entity<super::super::GitCometView>,
    repo_id: gitcomet_state::model::RepoId,
    fixture: &SyntheticWholeFileConflictFixture,
) {
    fixture.write();

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::ZERO,
                });
            });

            let next_state = app_state_with_repo(fixture.repo_state(repo_id), repo_id);

            push_test_state(this, next_state, cx);
        });
    });
}

fn assert_streamed_whole_file_two_way_state(pane: &MainPaneView, line_count: usize) -> usize {
    assert_eq!(
        pane.conflict_resolver.rendering_mode(),
        crate::view::conflict_resolver::ConflictRenderingMode::StreamedLargeFile,
        "whole-file conflicts past the large threshold should enter streamed mode",
    );
    assert_eq!(
        pane.conflict_resolver.three_way_len, line_count,
        "three-way line count should still reflect the full document",
    );
    let index = pane
        .conflict_resolver
        .split_row_index()
        .expect("streamed whole-file mode should build a paged split-row index");
    let projection = pane
        .conflict_resolver
        .two_way_split_projection()
        .expect("streamed whole-file mode should expose a split projection");
    assert_eq!(
        pane.conflict_resolver.two_way_row_counts(),
        (index.total_rows(), 0),
        "streamed whole-file mode should expose paged split rows without inline materialization",
    );
    assert_eq!(
        projection.visible_len(),
        pane.conflict_resolver.two_way_split_visible_len(),
        "streamed whole-file mode should expose a split projection",
    );
    assert!(
        index.total_rows() >= line_count,
        "paged split row index should expose at least the full line count, got {}",
        index.total_rows(),
    );

    let total = pane.conflict_resolver.two_way_split_visible_len();
    assert!(
        total >= line_count,
        "streamed two-way visible length should cover the full file, got {total}",
    );

    let deep_ix = total / 2;
    let crate::view::conflict_resolver::TwoWaySplitVisibleRow {
        source_row_ix: _source_ix,
        row,
        conflict_ix: _conflict_ix,
    } = pane
        .conflict_resolver
        .two_way_split_visible_row(deep_ix)
        .expect("deep streamed two-way row should resolve on demand");
    assert!(
        row.old.is_some() || row.new.is_some(),
        "deep streamed two-way row should expose real source text",
    );

    total
}

fn assert_streamed_whole_file_three_way_state(pane: &MainPaneView, line_count: usize) {
    assert_eq!(
        pane.conflict_resolver.rendering_mode(),
        crate::view::conflict_resolver::ConflictRenderingMode::StreamedLargeFile,
        "large whole-file conflicts should select the explicit large-file rendering mode",
    );
    assert_eq!(
        pane.conflict_resolver.three_way_len, line_count,
        "three-way mode should still preserve the full document line count",
    );
    assert_eq!(
        pane.conflict_resolver.three_way_visible_len(),
        line_count,
        "large whole-file three-way mode should expose every visible line",
    );
    assert!(
        pane.conflict_resolver.has_three_way_visible_state_ready(),
        "streamed large-file mode should rebuild the visible three-way projection",
    );
    assert!(
        !pane
            .conflict_resolver
            .three_way_conflict_ranges
            .ours
            .is_empty(),
        "streamed large-file mode should keep conflict ranges for three-way lookups",
    );

    let mid_visible_ix = line_count / 2;
    assert_eq!(
        pane.conflict_resolver
            .three_way_visible_item(mid_visible_ix),
        Some(crate::view::conflict_resolver::ThreeWayVisibleItem::Line(
            mid_visible_ix
        )),
        "deep rows in streamed large-file mode should resolve to real lines",
    );
    assert!(
        pane.conflict_resolver
            .three_way_word_highlights
            .base
            .is_empty()
            && pane
                .conflict_resolver
                .three_way_word_highlights
                .ours
                .is_empty()
            && pane
                .conflict_resolver
                .three_way_word_highlights
                .theirs
                .is_empty(),
        "giant whole-file three-way blocks should skip eager word highlights",
    );
}

/// The input columns stream a whole-file conflict, but the resolved output is
/// editable at any size — the two gates are independent. `StreamedLargeFile`
/// describes how the A/B/C columns render; it never demotes the output pane to
/// a read-only projection.
#[gpui::test]
fn whole_file_conflict_bootstrap_streams_input_but_keeps_output_editable(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(169);
    let fixture = SyntheticWholeFileConflictFixture::new(
        "whole_file_conflict_streamed",
        "fixtures/whole_file_conflict.html",
        crate::view::conflict_resolver::LARGE_CONFLICT_BLOCK_DIFF_MAX_LINES + 1_000,
    );
    load_synthetic_whole_file_conflict(cx, &view, repo_id, &fixture);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "whole-file conflict streamed bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && crate::view::conflict_resolver::conflict_count(
                    &pane.conflict_resolver.marker_segments,
                ) == 1
                && pane.conflict_resolver.rendering_mode()
                    == crate::view::conflict_resolver::ConflictRenderingMode::StreamedLargeFile
                && pane.conflict_resolver.split_row_index().is_some()
                && pane.conflict_resolver.two_way_split_projection().is_some()
                && pane.conflict_resolved_output_projection.is_none()
        },
        |pane| {
            format!(
                "path={:?} conflicts={} rendering_mode={:?} split_row_index={} projection={} output_projection={} three_way_len={}",
                pane.conflict_resolver.path.clone(),
                crate::view::conflict_resolver::conflict_count(
                    &pane.conflict_resolver.marker_segments,
                ),
                pane.conflict_resolver.rendering_mode(),
                pane.conflict_resolver.split_row_index().is_some(),
                pane.conflict_resolver.two_way_split_projection().is_some(),
                pane.conflict_resolved_output_projection.is_some(),
                pane.conflict_resolver.three_way_len,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, _cx| {
            this.main_pane.update(_cx, |pane, _cx| {
                assert_streamed_whole_file_two_way_state(pane, fixture.line_count);
                assert!(
                    pane.conflict_resolved_output_projection.is_none(),
                    "whole-file bootstrap should materialize the resolved output, not stream it",
                );
                assert!(
                    !pane.conflict_resolved_output_is_streamed(),
                    "a materialized output must report itself editable so the edit \
                     affordances gated on this are enabled",
                );
            });
        });
    });

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        let expected = crate::view::conflict_resolver::generate_resolved_text(
            &pane.conflict_resolver.marker_segments,
        );
        assert_eq!(
            pane.conflict_resolver_input.read(app).text(),
            expected.as_str(),
            "the editable buffer should hold the full merged text of a whole-file conflict",
        );
    });

    fixture.cleanup();
}

/// There is no line-count ceiling on editing the resolved output.
///
/// An unresolved whole-file conflict collapses to a one-line placeholder, so the
/// size only materializes once a side is picked — which is precisely what the
/// old upper-bound guard refused to do. Pick a side to expand the output past
/// the old limit, then type into it. If a size gate is reintroduced anywhere on
/// the materialize path, the expanded output stays read-only and this fails.
#[gpui::test]
fn a_resolved_output_past_the_old_editable_ceiling_still_accepts_edits(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(173);
    let fixture = SyntheticWholeFileConflictFixture::new(
        "whole_file_conflict_editable_no_ceiling",
        "fixtures/whole_file_conflict_editable.html",
        crate::view::conflict_resolver::LARGE_CONFLICT_BLOCK_DIFF_MAX_LINES + 1_000,
    );
    load_synthetic_whole_file_conflict(cx, &view, repo_id, &fixture);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "large resolved output materialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && !pane.conflict_resolved_output_is_streamed()
                && pane.conflict_resolved_preview_line_count > 1
        },
        |pane| {
            format!(
                "path={:?} streamed={} preview_lines={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolved_output_is_streamed(),
                pane.conflict_resolved_preview_line_count,
            )
        },
    );

    let main_pane = cx.update(|_window, app| view.read(app).main_pane.clone());

    // Take "ours" for the single whole-file conflict. This is the step the old
    // ceiling refused: it expands a one-line placeholder into the full file.
    cx.update(|_window, app| {
        main_pane.update(app, |pane, cx| {
            pane.conflict_resolver_pick_at(
                0,
                crate::view::conflict_resolver::ConflictChoice::Ours,
                cx,
            );
        });
    });
    cx.run_until_parked();

    let before = cx.update(|_window, app| {
        main_pane
            .read(app)
            .conflict_resolver_input
            .read(app)
            .text()
            .to_string()
    });
    assert!(
        before.lines().count()
            > crate::view::conflict_resolver::LARGE_CONFLICT_BLOCK_DIFF_MAX_LINES,
        "picking a side should expand the output past the old ceiling, got {} lines",
        before.lines().count()
    );
    assert!(
        cx.update(|_window, app| !main_pane.read(app).conflict_resolved_output_is_streamed()),
        "an output expanded past the old ceiling must stay editable, not fall back to streamed",
    );

    // Append at the very end, which is never inside a protected marker range.
    let at = before.len();
    cx.update(|_window, app| {
        main_pane.update(app, |pane, cx| {
            pane.conflict_resolver_input.update(cx, |input, cx| {
                input.replace_utf8_range(at..at, "edited", cx);
            });
        });
    });
    cx.run_until_parked();

    let after = cx.update(|_window, app| {
        main_pane
            .read(app)
            .conflict_resolver_input
            .read(app)
            .text()
            .to_string()
    });
    assert_eq!(
        after,
        format!("{before}edited"),
        "a keystroke in a large resolved output should land and persist"
    );

    fixture.cleanup();
}

/// Stage-anyway on a whole-file conflict must serialize the merged text the user
/// is actually looking at. The output is materialized at this size now, so this
/// guards the buffer-backed save path rather than the projection one.
#[gpui::test]
fn whole_file_conflict_stage_anyway_serializes_the_materialized_output(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(172);
    let fixture = SyntheticWholeFileConflictFixture::new(
        "whole_file_conflict_stage_anyway_streamed",
        "fixtures/whole_file_conflict_stage_anyway.html",
        crate::view::conflict_resolver::LARGE_CONFLICT_BLOCK_DIFF_MAX_LINES + 1_000,
    );
    load_synthetic_whole_file_conflict(cx, &view, repo_id, &fixture);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "whole-file conflict streamed stage-anyway bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && pane.conflict_resolver.rendering_mode()
                    == crate::view::conflict_resolver::ConflictRenderingMode::StreamedLargeFile
                && pane.conflict_resolved_output_projection.is_none()
        },
        |pane| {
            format!(
                "path={:?} rendering_mode={:?} output_projection={} preview_lines={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.rendering_mode(),
                pane.conflict_resolved_output_projection.is_some(),
                pane.conflict_resolved_preview_line_count,
            )
        },
    );

    let (expected, actual, input_before, input_after, projection_after) =
        cx.update(|_window, app| {
            view.update(app, |this, cx| {
                this.main_pane.update(cx, |pane, cx| {
                    let expected = crate::view::conflict_resolver::generate_resolved_text(
                        &pane.conflict_resolver.marker_segments,
                    );
                    let input_before = pane.conflict_resolver_input.read(cx).text().to_string();
                    // Mirrors the production save path in conflict_resolver_view.
                    let output_text = pane.current_conflict_resolved_output_text(cx);
                    let actual = pane.conflict_resolver_save_contents_from_text(output_text);
                    let input_after = pane.conflict_resolver_input.read(cx).text().to_string();
                    (
                        expected,
                        actual,
                        input_before,
                        input_after,
                        pane.conflict_resolved_output_projection.is_some(),
                    )
                })
            })
        });

    assert_eq!(
        input_before, expected,
        "a whole-file conflict should already hold its merged text in the editable buffer"
    );
    assert_eq!(
        actual, expected,
        "stage confirmation should serialize the resolved output the user is editing"
    );
    assert!(
        !actual.is_empty(),
        "stage-confirm contents should contain the resolved output text"
    );
    assert_eq!(
        input_after, input_before,
        "stage confirmation should read the editor buffer, not rewrite it"
    );
    assert!(
        !projection_after,
        "stage confirmation should not push the output back into projection mode"
    );

    fixture.cleanup();
}

#[gpui::test]
fn whole_file_conflict_switch_to_three_way_stays_fully_reviewable(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(171);
    let fixture = SyntheticWholeFileConflictFixture::new(
        "whole_file_conflict_three_way_switch",
        "fixtures/whole_file_conflict_switch.html",
        crate::view::conflict_resolver::LARGE_CONFLICT_BLOCK_DIFF_MAX_LINES + 100,
    );
    load_synthetic_whole_file_conflict(cx, &view, repo_id, &fixture);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "whole-file conflict initialized for three-way switch",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel),
        |pane| format!("path={:?}", pane.conflict_resolver.path),
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::TwoWayDiff, cx);
                assert_eq!(
                    pane.conflict_resolver.view_mode,
                    ConflictResolverViewMode::TwoWayDiff,
                    "fixture should be in two-way mode before switching back to three-way",
                );
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                assert_eq!(
                    pane.conflict_resolver.view_mode,
                    ConflictResolverViewMode::ThreeWay,
                    "switching a large whole-file conflict into three-way mode should succeed",
                );
                assert_streamed_whole_file_three_way_state(pane, fixture.line_count);
            });
        });
    });

    cx.update(|window, app| {
        let _ = window.draw(app);
    });

    fixture.cleanup();
}

#[gpui::test]
fn whole_file_conflict_streamed_three_way_syntax_survives_view_mode_switch(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(172);
    let fixture = SyntheticWholeFileConflictFixture::new(
        "whole_file_conflict_three_way_streamed_syntax",
        "fixtures/whole_file_conflict_streamed_syntax.html",
        crate::view::conflict_resolver::LARGE_CONFLICT_BLOCK_DIFF_MAX_LINES + 100,
    );
    let ours_body_line = r#"<body class="whole-file-ours">"#;

    load_synthetic_whole_file_conflict(cx, &view, repo_id, &fixture);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "whole-file streamed syntax fixture initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel),
        |pane| format!("path={:?}", pane.conflict_resolver.path),
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                pane.conflict_resolver_scroll_all_columns(0, gpui::ScrollStrategy::Top);
                cx.notify();
            });
        });
    });

    cx.update(|window, app| {
        let _ = window.draw(app);
    });

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        let styled = pane
            .conflict_three_way_segments_cache
            .get(&(2, ThreeWayColumn::Ours))
            .expect("three-way draw should cache the visible streamed HTML body row");
        assert_eq!(
            styled.text.as_ref(),
            ours_body_line,
            "expected the streamed three-way cache to contain the visible ours HTML body row",
        );
        assert!(
            !styled.highlights.is_empty(),
            "streamed three-way rows above the old 20k line gate should still be syntax highlighted; got {:?}",
            styled_debug_info_with_styles(styled),
        );
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "whole-file streamed three-way background syntax completion",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_three_way_prepared_syntax_documents
                .base
                .is_some()
                && pane
                    .conflict_three_way_prepared_syntax_documents
                    .ours
                    .is_some()
                && pane
                    .conflict_three_way_prepared_syntax_documents
                    .theirs
                    .is_some()
        },
        |pane| {
            format!(
                "base={:?} ours={:?} theirs={:?} inflight={:?}",
                pane.conflict_three_way_prepared_syntax_documents.base,
                pane.conflict_three_way_prepared_syntax_documents.ours,
                pane.conflict_three_way_prepared_syntax_documents.theirs,
                pane.conflict_three_way_syntax_inflight,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::TwoWayDiff, cx);
                pane.conflict_resolver_scroll_all_columns(0, gpui::ScrollStrategy::Top);
                cx.notify();
            });
        });
    });

    cx.update(|window, app| {
        let _ = window.draw(app);
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "streamed two-way HTML row cache after three-way switch",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            conflict_split_cached_styled(
                pane,
                crate::view::conflict_resolver::ConflictPickSide::Ours,
                ours_body_line,
            )
            .is_some_and(|styled| !styled.highlights.is_empty())
        },
        |pane| {
            let split_cached = conflict_split_cached_styled(
                pane,
                crate::view::conflict_resolver::ConflictPickSide::Ours,
                ours_body_line,
            )
            .map(styled_debug_info_with_styles);
            format!(
                "split_cached={split_cached:?} split_cache_len={} three_way_cache_len={}",
                pane.conflict_diff_segments_cache_split.len(),
                pane.conflict_three_way_segments_cache.len(),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                pane.conflict_resolver_scroll_all_columns(0, gpui::ScrollStrategy::Top);
                cx.notify();
            });
        });
    });

    cx.update(|window, app| {
        let _ = window.draw(app);
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "streamed three-way HTML row cache after toggling back",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_three_way_segments_cache
                .get(&(2, ThreeWayColumn::Ours))
                .is_some_and(|styled| !styled.highlights.is_empty())
        },
        |pane| {
            let three_way_cached = pane
                .conflict_three_way_segments_cache
                .get(&(2, ThreeWayColumn::Ours))
                .map(styled_debug_info_with_styles);
            format!(
                "three_way_cached={three_way_cached:?} split_cache_len={} three_way_cache_len={}",
                pane.conflict_diff_segments_cache_split.len(),
                pane.conflict_three_way_segments_cache.len(),
            )
        },
    );

    fixture.cleanup();
}

#[gpui::test]
fn three_way_view_survives_incomplete_line_syntax_fragments(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(173);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_three_way_incomplete_fragments",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("src/three_way_incomplete_fragments.ts");
    let abs_path = workdir.join(&file_rel);

    let shared_prefix_line = "const element = document.querySelector(";
    let base_line = r#"  ".base""#;
    let ours_line = r#"  ".ours""#;
    let theirs_line = r#"  ".theirs""#;

    let base_text = [
        shared_prefix_line,
        base_line,
        ");",
        "type Example<T extends Record<string,",
        "  number>> = HTMLElement;",
    ]
    .join("\n");
    let ours_text = [
        shared_prefix_line,
        ours_line,
        ");",
        "type Example<T extends Record<string,",
        "  number>> = HTMLElement;",
    ]
    .join("\n");
    let theirs_text = [
        shared_prefix_line,
        theirs_line,
        ");",
        "type Example<T extends Record<string,",
        "  number>> = HTMLElement;",
    ]
    .join("\n");
    let current_text = [
        shared_prefix_line,
        "<<<<<<< ours",
        ours_line,
        "=======",
        theirs_line,
        ">>>>>>> theirs",
        ");",
        "type Example<T extends Record<string,",
        "  number>> = HTMLElement;",
    ]
    .join("\n");

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create three-way fragment fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write three-way fragment fixture");

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::ZERO,
                });
            });

            let mut repo = opening_repo_state(repo_id, &workdir);
            set_test_conflict_status(
                &mut repo,
                file_rel.clone(),
                gitcomet_core::domain::DiffArea::Unstaged,
            );
            set_test_conflict_file(
                &mut repo,
                file_rel.clone(),
                base_text.clone(),
                ours_text.clone(),
                theirs_text.clone(),
                current_text.clone(),
            );

            push_test_state(this, app_state_with_repo(repo, repo_id), cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "three-way incomplete-fragment fixture initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| pane.conflict_resolver.path.as_ref() == Some(&file_rel),
        |pane| format!("path={:?}", pane.conflict_resolver.path),
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                pane.conflict_resolver_scroll_all_columns(0, gpui::ScrollStrategy::Top);
                cx.notify();
            });
        });
    });

    cx.update(|window, app| {
        let _ = window.draw(app);
        let pane = view.read(app).main_pane.read(app);
        let styled = pane
            .conflict_three_way_segments_cache
            .get(&(0, ThreeWayColumn::Base))
            .expect("three-way draw should cache the visible incomplete base line");
        assert_eq!(
            styled.text.as_ref(),
            shared_prefix_line,
            "expected the cached base line to preserve the incomplete source fragment"
        );
    });

    std::fs::remove_dir_all(&workdir).expect("cleanup three-way fragment fixture");
}

/// Verifies huge conflicts stay on the streamed split path and avoid
/// bootstrap diff/highlight work.
#[gpui::test]
fn large_conflict_bootstrap_stays_streamed_for_huge_files(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(162);
    let fixture = SyntheticLargeConflictFixture::new(
        "large_conflict_block_local_sparse",
        "fixtures/huge_conflict.html",
        55_001,
        1,
    );
    fixture.write();

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::ZERO,
                });
            });

            let next_state = app_state_with_repo(fixture.repo_state(repo_id), repo_id);

            push_test_state(this, next_state, cx);
        });
    });

    // Wait for the conflict resolver to be populated with the streamed split
    // index used for giant files.
    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "large conflict streamed bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && pane.conflict_resolver.split_row_index().is_some()
        },
        |pane| {
            format!(
                "path={:?} split_rows={} split_row_index={} three_way_len={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver
                    .split_row_index()
                    .map(|index| index.total_rows())
                    .unwrap_or_default(),
                pane.conflict_resolver.split_row_index().is_some(),
                pane.conflict_resolver.three_way_len,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                let index = pane
                    .conflict_resolver
                    .split_row_index()
                    .expect("huge conflict should stay on streamed split index");
                assert!(
                    pane
                        .conflict_resolver
                        .three_way_word_highlights
                        .ours
                        .is_empty(),
                    "streamed huge-file bootstrap should skip three-way word diff computation",
                );
                assert!(
                    pane.conflict_resolver.two_way_split_word_highlight(0).is_none(),
                    "streamed huge-file bootstrap should keep two-way word highlights on-demand",
                );
                assert!(
                    index.total_rows() > 0,
                    "paged split row index should have rows",
                );
                assert!(
                    pane.conflict_resolver.two_way_split_projection().is_some(),
                    "giant mode should have a split projection",
                );

                // View mode should NOT be forced to ThreeWay — two-way now has data.
                // (Default for FullTextResolver with base is ThreeWay, but it's
                // not forced by the large-file path.)

                // Three-way data should still be populated correctly.
                assert!(
                    pane.conflict_resolver.three_way_len >= fixture.fixture_line_count,
                    "three_way_len should be at least fixture_line_count ({}), got {}",
                    fixture.fixture_line_count,
                    pane.conflict_resolver.three_way_len,
                );
                assert!(
                    !pane
                        .conflict_resolver
                        .three_way_text
                        .base
                        .as_ref()
                        .is_empty(),
                    "three-way base text should be populated",
                );

                // Conflict marker parsing should still work.
                assert_eq!(
                    crate::view::conflict_resolver::conflict_count(
                        &pane.conflict_resolver.marker_segments
                    ),
                    fixture.conflict_block_count,
                    "should have parsed {} conflict block(s)",
                    fixture.conflict_block_count,
                );
                let current = pane
                    .conflict_resolver
                    .current
                    .clone()
                    .expect("huge streamed bootstrap should retain current merged text");
                let first_block = pane
                    .conflict_resolver
                    .marker_segments
                    .iter()
                    .find_map(|segment| match segment {
                        crate::view::conflict_resolver::ConflictSegment::Block(block) => {
                            Some(block)
                        }
                        crate::view::conflict_resolver::ConflictSegment::Text(_) => None,
                    })
                    .expect("huge streamed bootstrap should keep a conflict block");
                assert!(
                    first_block.ours.shares_backing_with(&current)
                        && first_block.theirs.shares_backing_with(&current),
                    "huge streamed bootstrap should reuse current-text backing for marker block sides",
                );
                let first_row_ix = index
                    .first_row_for_conflict(0)
                    .expect("paged index should expose the first conflict row");
                let first_row = index
                    .row_at(&pane.conflict_resolver.marker_segments, first_row_ix)
                    .expect("paged index should serve the first conflict row");
                let expected_first_row_line = fixture.first_conflict_line;
                assert!(
                    first_row.old_line == Some(expected_first_row_line)
                        || first_row.new_line == Some(expected_first_row_line),
                    "first streamed conflict row should align to the first conflict line {}, got old={:?} new={:?}",
                    expected_first_row_line,
                    first_row.old_line,
                    first_row.new_line,
                );
                assert!(
                    pane.conflict_resolver
                        .two_way_visible_ix_for_conflict(0)
                        .is_some(),
                    "streamed projection should expose the first conflict in visible space",
                );

                let _ = cx;
            });
        });
    });

    fixture.cleanup();
}

#[gpui::test]
fn large_conflict_bootstrap_uses_streamed_split_index_for_dense_huge_files(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(163);
    let fixture = SyntheticLargeConflictFixture::new(
        "large_conflict_block_local_dense",
        "fixtures/huge_conflict_dense.html",
        60_000,
        256,
    );
    fixture.write();

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::ZERO,
                });
            });

            let next_state = app_state_with_repo(fixture.repo_state(repo_id), repo_id);

            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "dense large conflict streamed split bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && crate::view::conflict_resolver::conflict_count(
                    &pane.conflict_resolver.marker_segments,
                ) == fixture.conflict_block_count
                && pane.conflict_resolver.split_row_index().is_some()
        },
        |pane| {
            format!(
                "path={:?} split_rows={} split_row_index={} conflicts={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver
                    .split_row_index()
                    .map(|index| index.total_rows())
                    .unwrap_or_default(),
                pane.conflict_resolver.split_row_index().is_some(),
                crate::view::conflict_resolver::conflict_count(
                    &pane.conflict_resolver.marker_segments
                ),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, _cx| {
            this.main_pane.update(_cx, |pane, _cx| {
                assert_eq!(
                    crate::view::conflict_resolver::conflict_count(
                        &pane.conflict_resolver.marker_segments
                    ),
                    fixture.conflict_block_count,
                );
                let index = pane
                    .conflict_resolver
                    .split_row_index()
                    .expect("dense huge conflicts should now always use the streamed split index");
                assert!(
                    index.total_rows() >= fixture.conflict_block_count,
                    "paged index should have at least one row per conflict block, got {}",
                    index.total_rows(),
                );
                assert!(
                    pane.conflict_resolver.two_way_split_projection().is_some(),
                    "streamed dense conflicts should have a split projection",
                );
                assert_eq!(
                    pane.conflict_resolver.two_way_row_counts().1,
                    0,
                    "streamed dense conflicts should not materialize inline rows",
                );
                assert!(
                    pane.conflict_resolver
                        .two_way_split_word_highlight(0)
                        .is_none(),
                    "streamed dense conflicts should keep word highlights on-demand",
                );
            });
        });
    });

    fixture.cleanup();
}

/// Verifies that merge-input (three-way) sides get background syntax
/// preparation when the foreground parse budget is exhausted, and that
/// the visible-row fallback still uses `Auto` syntax above the old line gate
/// before the prepared documents become available for rendering.
#[gpui::test]
fn large_conflict_three_way_sides_get_background_syntax_documents(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(165);
    let fixture_line_count = rows::MAX_LINES_FOR_SYNTAX_HIGHLIGHTING + 101;
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_three_way_bg_syntax",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("src/three_way_syntax_bg.xml");
    let abs_path = workdir.join(&file_rel);
    let shared_root_line = r#"<root attr="shared">"#;
    let base_conflict_line = r#"<button class="base" disabled="true" />"#;
    let ours_conflict_line = r#"<button class="ours" disabled="true" />"#;
    let theirs_conflict_line = r#"<button class="theirs" disabled="true" />"#;
    let closing_root_line = "</root>";
    let tag_or_attr_before_quote_ix = shared_root_line
        .find('"')
        .expect("shared XML line should include a quoted attribute value");

    assert!(
        fixture_line_count > rows::MAX_LINES_FOR_SYNTAX_HIGHLIGHTING,
        "fixture should stay above the old conflict-resolver syntax gate"
    );

    let mut base_lines = vec![shared_root_line.to_string(), base_conflict_line.to_string()];
    base_lines.extend(
        (base_lines.len()..fixture_line_count.saturating_sub(1))
            .map(|ix| format!(r#"<item ix="{ix}" />"#)),
    );
    base_lines.push(closing_root_line.to_string());
    let base_text = base_lines.join("\n");

    let mut ours_lines = base_lines.clone();
    ours_lines[1] = ours_conflict_line.to_string();
    let ours_text = ours_lines.join("\n");

    let mut theirs_lines = base_lines.clone();
    theirs_lines[1] = theirs_conflict_line.to_string();
    let theirs_text = theirs_lines.join("\n");

    let mut current_lines = vec![
        shared_root_line.to_string(),
        "<<<<<<< ours".to_string(),
        ours_conflict_line.to_string(),
        "=======".to_string(),
        theirs_conflict_line.to_string(),
        ">>>>>>> theirs".to_string(),
    ];
    current_lines.extend(
        (current_lines.len()..fixture_line_count.saturating_sub(1))
            .map(|ix| format!(r#"<item ix="{ix}" />"#)),
    );
    current_lines.push(closing_root_line.to_string());
    let current_text = current_lines.join("\n");

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write fixture");

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            // Set foreground budget to zero so all sides go to background.
            this.main_pane.update(cx, |pane, _cx| {
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::ZERO,
                });
            });

            let mut repo = opening_repo_state(repo_id, &workdir);
            set_test_conflict_status(
                &mut repo,
                file_rel.clone(),
                gitcomet_core::domain::DiffArea::Unstaged,
            );
            set_test_conflict_file(
                &mut repo,
                file_rel.clone(),
                base_text.clone(),
                ours_text.clone(),
                theirs_text.clone(),
                current_text.clone(),
            );

            let next_state = app_state_with_repo(repo, repo_id);
            push_test_state(this, next_state, cx);
        });
    });

    // Wait for bootstrap to complete.
    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "three-way background syntax bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| pane.conflict_resolver.path.as_ref() == Some(&file_rel),
        |pane| format!("path={:?}", pane.conflict_resolver.path),
    );

    // Right after bootstrap with ZERO budget, the test may still observe either
    // the fallback path or an already-completed prepared document, depending on
    // how quickly the deterministic test scheduler drains the queued task.
    cx.update(|_window, app| {
        view.update(app, |this, _cx| {
            this.main_pane.update(_cx, |pane, _cx| {
                assert_eq!(
                    pane.conflict_resolver.conflict_syntax_language,
                    Some(rows::DiffSyntaxLanguage::Xml),
                    "syntax language should be XML for .xml file"
                );
            });
        });
    });

    cx.update(|window, app| {
        let _ = window.draw(app);
    });

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        if pane
            .conflict_three_way_prepared_syntax_documents
            .base
            .is_none()
        {
            let styled = pane
                .conflict_three_way_segments_cache
                .get(&(0, ThreeWayColumn::Base))
                .expect("initial draw should populate the visible three-way base-row cache");
            assert_eq!(
                styled.text.as_ref(),
                shared_root_line,
                "expected the cached three-way fallback row to match the shared XML root line"
            );
            assert!(
                styled
                    .highlights
                    .iter()
                    .any(|(range, _)| range.start < tag_or_attr_before_quote_ix),
                "three-way fallback should use Auto syntax and highlight XML tag/attribute ranges before the quoted string above the old line gate; got {:?}",
                styled_debug_info_with_styles(styled),
            );
        }
    });

    // Wait for background syntax parses to complete for all three sides.
    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "three-way background syntax completion",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_three_way_prepared_syntax_documents
                .base
                .is_some()
                && pane
                    .conflict_three_way_prepared_syntax_documents
                    .ours
                    .is_some()
                && pane
                    .conflict_three_way_prepared_syntax_documents
                    .theirs
                    .is_some()
        },
        |pane| {
            format!(
                "base={:?} ours={:?} theirs={:?}",
                pane.conflict_three_way_prepared_syntax_documents.base,
                pane.conflict_three_way_prepared_syntax_documents.ours,
                pane.conflict_three_way_prepared_syntax_documents.theirs,
            )
        },
    );

    // After background parses complete, inflight flags should be cleared
    // and documents should be available for rendering.
    cx.update(|_window, app| {
        view.update(app, |this, _cx| {
            this.main_pane.update(_cx, |pane, _cx| {
                assert!(!pane.conflict_three_way_syntax_inflight.base);
                assert!(!pane.conflict_three_way_syntax_inflight.ours);
                assert!(!pane.conflict_three_way_syntax_inflight.theirs);
            });
        });
    });

    std::fs::remove_dir_all(&workdir).expect("cleanup fixture");
}

#[gpui::test]
fn large_conflict_two_way_views_upgrade_to_prepared_document_syntax(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(166);
    let fixture_line_count = rows::MAX_LINES_FOR_SYNTAX_HIGHLIGHTING + 101;
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_two_way_bg_syntax",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("src/two_way_syntax_bg.rs");
    let abs_path = workdir.join(&file_rel);
    let opening_line = "fn main() {";
    let comment_open_line = "/* open comment";
    let base_comment_line = "still base comment */ let base_value = 0;";
    let ours_comment_line = "still ours comment */ let ours_value = 1;";
    let theirs_comment_line = "still theirs comment */ let theirs_value = 2;";
    let closing_line = "}";
    let comment_prefix_end = ours_comment_line
        .find("*/")
        .map(|ix| ix + 2)
        .expect("comment line should include a closing block comment delimiter");
    let ours_comment_line_ix = 2usize;

    let mut base_lines = vec![
        opening_line.to_string(),
        comment_open_line.to_string(),
        base_comment_line.to_string(),
    ];
    base_lines.extend(
        (base_lines.len()..fixture_line_count.saturating_sub(1))
            .map(|ix| format!("let filler_{ix} = {ix};")),
    );
    base_lines.push(closing_line.to_string());
    let base_text = base_lines.join("\n");

    let mut ours_lines = vec![
        opening_line.to_string(),
        comment_open_line.to_string(),
        ours_comment_line.to_string(),
    ];
    ours_lines.extend(
        (ours_lines.len()..fixture_line_count.saturating_sub(1))
            .map(|ix| format!("let filler_{ix} = {ix};")),
    );
    ours_lines.push(closing_line.to_string());
    let ours_text = ours_lines.join("\n");

    let mut theirs_lines = vec![
        opening_line.to_string(),
        comment_open_line.to_string(),
        theirs_comment_line.to_string(),
    ];
    theirs_lines.extend(
        (theirs_lines.len()..fixture_line_count.saturating_sub(1))
            .map(|ix| format!("let filler_{ix} = {ix};")),
    );
    theirs_lines.push(closing_line.to_string());
    let theirs_text = theirs_lines.join("\n");

    let mut current_lines = vec![
        opening_line.to_string(),
        comment_open_line.to_string(),
        "<<<<<<< ours".to_string(),
        ours_comment_line.to_string(),
        "=======".to_string(),
        theirs_comment_line.to_string(),
        ">>>>>>> theirs".to_string(),
    ];
    current_lines.extend(
        (current_lines.len()..fixture_line_count.saturating_sub(1))
            .map(|ix| format!("let filler_{ix} = {ix};")),
    );
    current_lines.push(closing_line.to_string());
    let current_text = current_lines.join("\n");

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write fixture");

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::ZERO,
                });
            });

            let mut repo = opening_repo_state(repo_id, &workdir);
            set_test_conflict_status(
                &mut repo,
                file_rel.clone(),
                gitcomet_core::domain::DiffArea::Unstaged,
            );
            set_test_conflict_file(
                &mut repo,
                file_rel.clone(),
                base_text.clone(),
                ours_text.clone(),
                theirs_text.clone(),
                current_text.clone(),
            );

            let next_state = app_state_with_repo(repo, repo_id);
            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "two-way background syntax bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| pane.conflict_resolver.path.as_ref() == Some(&file_rel),
        |pane| format!("path={:?}", pane.conflict_resolver.path),
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::TwoWayDiff, cx);
                pane.conflict_resolver_scroll_all_columns(0, gpui::ScrollStrategy::Top);
                cx.notify();
            });
        });
    });

    cx.update(|window, app| {
        let _ = window.draw(app);
    });

    let fallback_split_highlights_hash = cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        let styled = conflict_split_cached_styled(
            pane,
            crate::view::conflict_resolver::ConflictPickSide::Ours,
            ours_comment_line,
        )
        .expect("initial split draw should populate the visible conflict diff cache");
        assert_eq!(
            styled.text.as_ref(),
            ours_comment_line,
            "expected the cached two-way split row to match the multiline comment text"
        );
        let has_comment_highlight = styled_has_leading_color_highlight(
            styled,
            comment_prefix_end,
            pane.theme.syntax.comment.into_color(),
        );
        if has_comment_highlight {
            None
        } else {
            assert!(
                pane.conflict_three_way_prepared_syntax_documents
                    .ours
                    .is_none(),
                "if the first split draw is still using fallback syntax, the prepared ours document should not exist yet"
            );
            assert!(
                pane.conflict_three_way_prepared_syntax_documents
                    .theirs
                    .is_none(),
                "if the first split draw is still using fallback syntax, the prepared theirs document should not exist yet"
            );
            Some(styled.highlights_hash)
        }
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "two-way split syntax upgrade after background preparation",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_three_way_prepared_syntax_documents
                .ours
                .is_some()
                && pane
                    .conflict_three_way_prepared_syntax_documents
                    .theirs
                    .is_some()
                && conflict_split_cached_styled(
                    pane,
                    crate::view::conflict_resolver::ConflictPickSide::Ours,
                    ours_comment_line,
                )
                .is_some_and(|styled| {
                    fallback_split_highlights_hash
                        .map(|hash| styled.highlights_hash != hash)
                        .unwrap_or(true)
                        && styled_has_leading_color_highlight(
                            styled,
                            comment_prefix_end,
                            pane.theme.syntax.comment.into_color(),
                        )
                })
        },
        |pane| {
            let split_cached = conflict_split_cached_styled(
                pane,
                crate::view::conflict_resolver::ConflictPickSide::Ours,
                ours_comment_line,
            )
            .map(styled_debug_info_with_styles);
            format!(
                "ours_doc={:?} theirs_doc={:?} split_cached={split_cached:?}",
                pane.conflict_three_way_prepared_syntax_documents.ours,
                pane.conflict_three_way_prepared_syntax_documents.theirs,
            )
        },
    );

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        let styled = conflict_split_cached_styled(
            pane,
            crate::view::conflict_resolver::ConflictPickSide::Ours,
            ours_comment_line,
        )
        .expect("split cache should stay available after background syntax preparation");
        assert!(
            styled_has_leading_color_highlight(
                styled,
                comment_prefix_end,
                pane.theme.syntax.comment.into_color(),
            ),
            "prepared syntax should continue to drive split-row styling after background preparation",
        );
    });

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                assert!(
                    pane.conflict_diff_segments_cache_split.is_empty(),
                    "switching to three-way should invalidate stale split-row styling caches",
                );
                assert!(
                    pane.conflict_three_way_segments_cache.is_empty(),
                    "switching to three-way should invalidate stale three-way styling caches",
                );
            });
        });
    });

    cx.update(|window, app| {
        let _ = window.draw(app);
    });

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        let styled = pane
            .conflict_three_way_segments_cache
            .get(&(ours_comment_line_ix, ThreeWayColumn::Ours))
            .expect("three-way draw should restyle the visible ours row after toggling from two-way");
        assert_eq!(
            styled.text.as_ref(),
            ours_comment_line,
            "expected the cached three-way ours row to match the multiline comment text",
        );
        assert!(
            styled_has_leading_color_highlight(
                styled,
                comment_prefix_end,
                pane.theme.syntax.comment.into_color(),
            ),
            "prepared syntax should continue to drive three-way row styling after toggling from two-way",
        );
    });

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::TwoWayDiff, cx);
                assert!(
                    pane.conflict_diff_segments_cache_split.is_empty(),
                    "switching back to two-way should invalidate stale split-row styling caches",
                );
                assert!(
                    pane.conflict_three_way_segments_cache.is_empty(),
                    "switching back to two-way should invalidate stale three-way styling caches",
                );
            });
        });
    });

    cx.update(|window, app| {
        let _ = window.draw(app);
    });

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        let styled = conflict_split_cached_styled(
            pane,
            crate::view::conflict_resolver::ConflictPickSide::Ours,
            ours_comment_line,
        )
        .expect("split cache should rebuild after returning from three-way mode");
        assert!(
            styled_has_leading_color_highlight(
                styled,
                comment_prefix_end,
                pane.theme.syntax.comment.into_color(),
            ),
            "prepared syntax should continue to drive split-row styling after toggling back from three-way",
        );
    });

    std::fs::remove_dir_all(&workdir).expect("cleanup fixture");
}

#[gpui::test]
fn conflict_compare_split_renderer_uses_streamed_visible_rows_for_large_conflicts(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(176);
    let fixture = SyntheticWholeFileConflictFixture::new(
        "conflict_compare_split_streamed",
        "fixtures/conflict_compare_split_streamed.html",
        crate::view::conflict_resolver::LARGE_CONFLICT_BLOCK_DIFF_MAX_LINES + 1,
    );
    fixture.write();

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let next_state = app_state_with_repo(
                conflict_compare_repo_state(
                    repo_id,
                    &fixture.workdir,
                    &fixture.file_rel,
                    &fixture.base_text,
                    &fixture.ours_text,
                    &fixture.theirs_text,
                    &fixture.current_text,
                ),
                repo_id,
            );
            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "streamed compare split bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && pane.conflict_resolver.rendering_mode()
                    == crate::view::conflict_resolver::ConflictRenderingMode::StreamedLargeFile
                && pane.conflict_resolver.split_row_index().is_some()
        },
        |pane| {
            format!(
                "path={:?} rendering_mode={:?} split_row_index={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.rendering_mode(),
                pane.conflict_resolver.split_row_index().is_some(),
            )
        },
    );

    cx.update(|window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.diff_view = DiffViewMode::Split;
                pane.conflict_diff_segments_cache_split.clear();
                pane.conflict_diff_query_segments_cache_split.clear();

                let visible_ix = pane.conflict_resolver.two_way_split_visible_len() / 2;
                let crate::view::conflict_resolver::TwoWaySplitVisibleRow {
                    source_row_ix: _source_ix,
                    row,
                    conflict_ix: _conflict_ix,
                } = pane
                    .conflict_resolver
                    .two_way_split_visible_row(visible_ix)
                    .expect("deep streamed compare row should resolve through the split provider");

                assert!(
                    pane.conflict_diff_segments_cache_split.is_empty(),
                    "compare split style cache should start empty for this focused render",
                );

                let elements = MainPaneView::render_conflict_compare_diff_rows(
                    pane,
                    visible_ix..visible_ix + 1,
                    window,
                    cx,
                );
                assert_eq!(elements.len(), 1);

                assert!(
                    pane.conflict_diff_segments_cache_split.is_empty(),
                    "large streamed compare render should skip per-row style caching and render plain text",
                );
                assert!(
                    row.old.is_some() || row.new.is_some(),
                    "deep streamed compare row should still expose real source text",
                );
            });
        });
    });

    fixture.cleanup();
}

#[gpui::test]
fn conflict_compare_split_renderer_uses_visible_projection_when_rows_are_hidden(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(177);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_conflict_compare_split_hidden",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("src/conflict_compare_split_hidden.rs");
    let abs_path = workdir.join(&file_rel);

    let base_text = [
        "fn main() {",
        "    let first = 0;",
        "    let between = 1;",
        "    let second = 2;",
        "}",
    ]
    .join("\n");
    let ours_text = [
        "fn main() {",
        "    let first = 10;",
        "    let between = 1;",
        "    let second = 20;",
        "}",
    ]
    .join("\n");
    let theirs_text = [
        "fn main() {",
        "    let first = 11;",
        "    let between = 1;",
        "    let second = 21;",
        "}",
    ]
    .join("\n");
    let current_text = [
        "fn main() {",
        "<<<<<<< ours",
        "    let first = 10;",
        "=======",
        "    let first = 11;",
        ">>>>>>> theirs",
        "    let between = 1;",
        "<<<<<<< ours",
        "    let second = 20;",
        "=======",
        "    let second = 21;",
        ">>>>>>> theirs",
        "}",
    ]
    .join("\n");

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write fixture");

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let next_state = app_state_with_repo(
                conflict_compare_repo_state(
                    repo_id,
                    &workdir,
                    &file_rel,
                    &base_text,
                    &ours_text,
                    &theirs_text,
                    &current_text,
                ),
                repo_id,
            );
            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "split compare streamed bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolver.rendering_mode()
                    == crate::view::conflict_resolver::ConflictRenderingMode::StreamedLargeFile
                && pane.conflict_resolver.split_row_index().is_some()
        },
        |pane| {
            format!(
                "path={:?} rendering_mode={:?} split_row_index={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.rendering_mode(),
                pane.conflict_resolver.split_row_index().is_some(),
            )
        },
    );

    cx.update(|window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                let first_block = pane
                    .conflict_resolver
                    .marker_segments
                    .iter_mut()
                    .find_map(|segment| match segment {
                        crate::view::conflict_resolver::ConflictSegment::Block(block) => {
                            Some(block)
                        }
                        crate::view::conflict_resolver::ConflictSegment::Text(_) => None,
                    })
                    .expect("fixture should contain a first conflict block");
                first_block.resolved = true;
                pane.conflict_resolver.hide_resolved = true;
                pane.conflict_resolver.rebuild_three_way_visible_state();
                pane.conflict_resolver.rebuild_two_way_visible_state();
                pane.diff_view = DiffViewMode::Split;
                pane.conflict_diff_segments_cache_split.clear();
                pane.conflict_diff_query_segments_cache_split.clear();

                let (visible_ix, source_ix, row) =
                    (0..pane.conflict_resolver.two_way_split_visible_len()).find_map(
                        |visible_ix| {
                        let crate::view::conflict_resolver::TwoWaySplitVisibleRow {
                            source_row_ix: source_ix,
                            row,
                            conflict_ix: _conflict_ix,
                        } = pane
                            .conflict_resolver
                            .two_way_split_visible_row(visible_ix)?;
                        (source_ix != visible_ix && (row.old.is_some() || row.new.is_some()))
                            .then_some((visible_ix, source_ix, row))
                    },
                    )
                    .expect("hide-resolved compare view should remap at least one split row");

                let elements = MainPaneView::render_conflict_compare_diff_rows(
                    pane,
                    visible_ix..visible_ix + 1,
                    window,
                    cx,
                );
                assert_eq!(elements.len(), 1);

                if let Some(expected_text) = row.old.as_deref() {
                    if let Some(styled) = pane.conflict_diff_segments_cache_split.get(&(
                        source_ix,
                        crate::view::conflict_resolver::ConflictPickSide::Ours,
                    )) {
                        assert_eq!(styled.text.as_ref(), expected_text);
                    }
                    assert!(
                        !pane.conflict_diff_segments_cache_split.contains_key(&(
                            visible_ix,
                            crate::view::conflict_resolver::ConflictPickSide::Ours,
                        )),
                        "compare split render should cache ours styling by source row index, not visible row index",
                    );
                }
                if let Some(expected_text) = row.new.as_deref() {
                    if let Some(styled) = pane.conflict_diff_segments_cache_split.get(&(
                        source_ix,
                        crate::view::conflict_resolver::ConflictPickSide::Theirs,
                    )) {
                        assert_eq!(styled.text.as_ref(), expected_text);
                    }
                    assert!(
                        !pane.conflict_diff_segments_cache_split.contains_key(&(
                            visible_ix,
                            crate::view::conflict_resolver::ConflictPickSide::Theirs,
                        )),
                        "compare split render should cache theirs styling by source row index, not visible row index",
                    );
                }
            });
        });
    });

    std::fs::remove_dir_all(&workdir).expect("cleanup fixture");
}

#[ignore = "manual stress: 500k-line whole-file conflict bootstrap"]
#[gpui::test]
fn very_large_whole_file_conflict_bootstrap_manual_regression_stays_streamed(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(170);
    let fixture = SyntheticWholeFileConflictFixture::new(
        "whole_file_conflict_manual_500k",
        "fixtures/very_large_whole_file_conflict.html",
        500_000,
    );
    load_synthetic_whole_file_conflict(cx, &view, repo_id, &fixture);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "very large whole-file conflict streamed bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && crate::view::conflict_resolver::conflict_count(
                    &pane.conflict_resolver.marker_segments,
                ) == 1
                && pane.conflict_resolver.rendering_mode()
                    == crate::view::conflict_resolver::ConflictRenderingMode::StreamedLargeFile
                && pane.conflict_resolver.split_row_index().is_some()
                && pane.conflict_resolved_output_projection.is_some()
        },
        |pane| {
            format!(
                "path={:?} rendering_mode={:?} split_rows={} split_row_index={} output_projection={} three_way_len={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.rendering_mode(),
                pane.conflict_resolver
                    .split_row_index()
                    .map(|index| index.total_rows())
                    .unwrap_or_default(),
                pane.conflict_resolver.split_row_index().is_some(),
                pane.conflict_resolved_output_projection.is_some(),
                pane.conflict_resolver.three_way_len,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, _cx| {
            this.main_pane.update(_cx, |pane, _cx| {
                assert_streamed_whole_file_two_way_state(pane, fixture.line_count);
                assert!(
                    pane.conflict_resolved_output_projection.is_some(),
                    "500k-line whole-file bootstrap should keep resolved output streamed",
                );
            });
        });
    });

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        assert_eq!(
            pane.conflict_resolver_input.read(app).text(),
            "",
            "500k-line whole-file bootstrap should not materialize the resolved output buffer",
        );
    });

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::TwoWayDiff, cx);
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                assert_eq!(
                    pane.conflict_resolver.view_mode,
                    ConflictResolverViewMode::ThreeWay,
                    "500k-line whole-file conflict should survive switching back to three-way mode",
                );
                assert_streamed_whole_file_three_way_state(pane, fixture.line_count);
            });
        });
    });

    fixture.cleanup();
}

#[ignore = "manual stress: 500k-line focused mergetool bootstrap"]
#[gpui::test]
fn very_large_conflict_bootstrap_manual_regression_stays_sparse(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(164);
    let fixture = SyntheticLargeConflictFixture::new(
        "large_conflict_block_local_manual_500k",
        "fixtures/very_large_conflict.html",
        500_001,
        12,
    );
    fixture.write();

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::ZERO,
                });
            });

            let next_state = app_state_with_repo(fixture.repo_state(repo_id), repo_id);

            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "very large conflict streamed bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && crate::view::conflict_resolver::conflict_count(
                    &pane.conflict_resolver.marker_segments,
                ) == fixture.conflict_block_count
                && pane.conflict_resolver.split_row_index().is_some()
        },
        |pane| {
            format!(
                "path={:?} split_rows={} split_row_index={} three_way_len={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver
                    .split_row_index()
                    .map(|index| index.total_rows())
                    .unwrap_or_default(),
                pane.conflict_resolver.split_row_index().is_some(),
                pane.conflict_resolver.three_way_len,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, _cx| {
            this.main_pane.update(_cx, |pane, _cx| {
                let index = pane
                    .conflict_resolver
                    .split_row_index()
                    .expect("500k-line manual fixture should use the streamed split index");
                assert!(
                    pane
                        .conflict_resolver
                        .three_way_word_highlights
                        .ours
                        .is_empty(),
                    "500k-line manual fixture should skip eager three-way word highlights",
                );
                assert!(
                    pane.conflict_resolver.two_way_split_word_highlight(0).is_none(),
                    "500k-line manual fixture should keep two-way word highlights on-demand",
                );
                assert!(
                    index.total_rows() > fixture.conflict_block_count,
                    "500k-line manual fixture should expose paged rows for the streamed split view",
                );
                let first_row = index
                    .first_row_for_conflict(0)
                    .expect("manual streamed fixture should expose a first conflict row");
                let row = index
                    .row_at(&pane.conflict_resolver.marker_segments, first_row)
                    .expect("manual streamed fixture should resolve rows on demand");
                assert!(
                    row.old.as_deref().is_some() || row.new.as_deref().is_some(),
                    "manual streamed fixture should still expose real diff content through the page index",
                );
            });
        });
    });

    fixture.cleanup();
}

#[gpui::test]
fn large_conflict_bootstrap_populates_resolved_outline_in_background(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(167);
    let fixture = SyntheticLargeConflictFixture::new(
        "large_conflict_resolved_outline_bg",
        "fixtures/resolved_outline_bg.html",
        20_000,
        4,
    );
    fixture.write();

    let expected_resolved_line_count = crate::view::conflict_resolver::generate_resolved_text(
        crate::view::conflict_resolver::parse_conflict_markers(&fixture.current_text).as_slice(),
    )
    .split('\n')
    .count();

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::ZERO,
                });
            });

            let next_state = app_state_with_repo(fixture.repo_state(repo_id), repo_id);

            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "background resolved outline bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && pane.conflict_resolved_preview_line_count == expected_resolved_line_count
                && pane.conflict_resolver.resolved_outline.meta.len()
                    == expected_resolved_line_count
                && pane.conflict_resolver.resolved_outline.markers.len()
                    == expected_resolved_line_count
        },
        |pane| {
            format!(
                "path={:?} preview_lines={} meta={} markers={} live_syntax={:?}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolved_preview_line_count,
                pane.conflict_resolver.resolved_outline.meta.len(),
                pane.conflict_resolver.resolved_outline.markers.len(),
                pane.conflict_resolved_output_live_syntax
                    .as_ref()
                    .map(|document| document.version()),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, _cx| {
            this.main_pane.update(_cx, |pane, _cx| {
                let start_markers = pane
                    .conflict_resolver
                    .resolved_outline
                    .markers
                    .iter()
                    .flatten()
                    .filter(|marker| marker.is_start)
                    .count();
                assert_eq!(
                    start_markers, fixture.conflict_block_count,
                    "background outline rebuild should materialize one start marker per conflict",
                );
                assert!(
                    pane.conflict_resolver
                        .resolved_outline
                        .markers
                        .iter()
                        .flatten()
                        .any(|marker| marker.unresolved),
                    "bootstrap outline markers should preserve unresolved conflict state",
                );
                assert!(
                    pane.conflict_resolver
                        .resolved_outline
                        .meta
                        .iter()
                        .any(|meta| meta.source
                            != crate::view::conflict_resolver::ResolvedLineSource::Manual),
                    "background provenance rebuild should classify source-backed output lines",
                );
            });
        });
    });

    fixture.cleanup();
}

#[gpui::test]
fn large_conflict_two_way_resolved_outline_uses_indexed_sources_in_streamed_mode(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(168);
    let fixture = SyntheticLargeConflictFixture::new(
        "large_conflict_two_way_resolved_outline_streamed",
        "fixtures/resolved_outline_two_way_streamed.html",
        20_001,
        4,
    );
    fixture.write();

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let mut repo = fixture.repo_state(repo_id);
            for region in &mut repo
                .conflict_state
                .conflict_session
                .as_mut()
                .expect("large conflict session")
                .regions
            {
                region.resolution =
                    gitcomet_core::conflict_session::ConflictRegionResolution::PickOurs;
            }
            let next_state = app_state_with_repo(repo, repo_id);

            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "two-way streamed resolved outline bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && pane.conflict_resolver.split_row_index().is_some()
        },
        |pane| {
            format!(
                "path={:?} split_rows={} split_row_index={} resolved_meta={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver
                    .split_row_index()
                    .map(|index| index.total_rows())
                    .unwrap_or_default(),
                pane.conflict_resolver.split_row_index().is_some(),
                pane.conflict_resolver.resolved_outline.meta.len(),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::TwoWayDiff, cx);
                pane.recompute_conflict_resolved_outline_for_tests(cx);
            });
        });
    });

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                let conflict_line_ix =
                    usize::try_from(fixture.first_conflict_line.saturating_sub(1)).unwrap_or(0);
                let conflict_meta = pane
                    .conflict_resolver
                    .resolved_outline
                    .meta
                    .get(conflict_line_ix)
                    .expect("conflict line metadata");
                assert_eq!(
                    pane.conflict_resolver.resolved_outline.meta.len(),
                    fixture.fixture_line_count,
                    "two-way streamed outline should populate one metadata row per output line",
                );
                assert_eq!(
                    conflict_meta.source,
                    crate::view::conflict_resolver::ResolvedLineSource::A,
                    "an explicit Local selection should map conflict lines to the ours side in two-way mode",
                );
                assert_eq!(
                    conflict_meta.input_line,
                    Some(fixture.first_conflict_line),
                    "two-way streamed outline should keep the original source line number for conflict rows",
                );
            });
        });
    });

    fixture.cleanup();
}

#[gpui::test]
fn structured_conflict_edit_reuses_stashed_outline_base_while_background_recompute_is_pending(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(168);
    let fixture = SyntheticLargeConflictFixture::new(
        "resolved_outline_pending_incremental",
        "fixtures/resolved_outline_pending.html",
        20_000,
        4,
    );
    fixture.write();

    let expected_resolved_line_count = crate::view::conflict_resolver::generate_resolved_text(
        crate::view::conflict_resolver::parse_conflict_markers(&fixture.current_text).as_slice(),
    )
    .split('\n')
    .count();

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let next_state = app_state_with_repo(fixture.repo_state(repo_id), repo_id);

            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolved outline pending incremental initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel),
        |pane| {
            format!(
                "path={:?} preview_lines={} meta={} markers={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolved_preview_line_count,
                pane.conflict_resolver.resolved_outline.meta.len(),
                pane.conflict_resolver.resolved_outline.markers.len(),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.ensure_conflict_resolved_output_materialized(cx);
            });
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolved outline pending incremental materialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && pane.conflict_resolved_output_projection.is_none()
                && pane.conflict_resolved_preview_line_count == expected_resolved_line_count
        },
        |pane| {
            format!(
                "path={:?} projection_present={} preview_lines={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolved_output_projection.is_some(),
                pane.conflict_resolved_preview_line_count,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.recompute_conflict_resolved_outline_for_tests(cx);
                pane.conflict_resolver.resolver_pending_recompute_seq = pane
                    .conflict_resolver
                    .resolver_pending_recompute_seq
                    .wrapping_add(1);
                pane.set_conflict_resolved_outline_background_delay_override_for_tests(
                    std::time::Duration::from_millis(1_000),
                );
                assert_eq!(
                    pane.conflict_resolver.resolved_outline.meta.len(),
                    expected_resolved_line_count,
                    "forced outline recompute should seed current metadata before the pending fallback test starts",
                );
            });
        });
    });

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let mut repo = fixture.repo_state(repo_id);
            repo.conflict_state.conflict_hide_resolved = true;
            repo.conflict_state.conflict_rev = repo.conflict_state.conflict_rev.wrapping_add(1);

            let next_state = app_state_with_repo(repo, repo_id);

            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolved outline state sync clears visible metadata while delayed background recompute is pending",
        std::time::Duration::from_millis(500),
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && pane.conflict_resolved_preview_line_count == expected_resolved_line_count
                && pane.conflict_resolver.resolved_outline.meta.is_empty()
                && pane.conflict_resolver.resolved_outline.markers.is_empty()
        },
        |pane| {
            format!(
                "hide_resolved={} preview_lines={} meta={} markers={} stash={} pending_seq={}",
                pane.conflict_resolver.hide_resolved,
                pane.conflict_resolved_preview_line_count,
                pane.conflict_resolver.resolved_outline.meta.len(),
                pane.conflict_resolver.resolved_outline.markers.len(),
                pane.conflict_resolved_outline_stash.is_some(),
                pane.conflict_resolver.resolver_pending_recompute_seq,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                let first_block = pane
                    .conflict_resolver
                    .marker_segments
                    .iter_mut()
                    .find_map(|segment| match segment {
                        crate::view::conflict_resolver::ConflictSegment::Block(block) => {
                            Some(block)
                        }
                        crate::view::conflict_resolver::ConflictSegment::Text(_) => None,
                    })
                    .expect("fixture should contain at least one conflict block");
                first_block.choice = crate::view::conflict_resolver::ConflictChoice::Theirs;
                first_block.resolved = true;

                let resolved = crate::view::conflict_resolver::generate_resolved_text(
                    &pane.conflict_resolver.marker_segments,
                );
                pane.conflict_resolver_set_output(resolved, cx);
            });
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "structured edit incrementally restores outline metadata from stashed base before delayed background fallback completes",
        std::time::Duration::from_millis(500),
        |pane| {
            pane.conflict_resolver.resolved_outline.meta.len() == expected_resolved_line_count
                && pane.conflict_resolver.resolved_outline.markers.len()
                    == expected_resolved_line_count
                && pane
                    .conflict_resolver
                    .resolved_outline
                    .markers
                    .iter()
                    .flatten()
                    .any(|marker| marker.conflict_ix == 0 && !marker.unresolved)
                && pane
                    .conflict_resolver
                    .resolved_outline
                    .markers
                    .iter()
                    .flatten()
                    .any(|marker| marker.conflict_ix == 1 && marker.unresolved)
        },
        |pane| {
            let first_markers: Vec<(usize, bool, bool)> = pane
                .conflict_resolver
                .resolved_outline
                .markers
                .iter()
                .flatten()
                .take(8)
                .map(|marker| (marker.conflict_ix, marker.unresolved, marker.is_start))
                .collect();
            format!(
                "meta={} markers={} stash={} first_markers={first_markers:?} preview_revision={:?}",
                pane.conflict_resolver.resolved_outline.meta.len(),
                pane.conflict_resolver.resolved_outline.markers.len(),
                pane.conflict_resolved_outline_stash.is_some(),
                pane.conflict_resolved_preview_source_revision,
            )
        },
    );

    fixture.cleanup();
}

/// Verifies that giant two-way split mode uses the paged provider to generate
/// rows on demand instead of building an eager `diff_rows` array. Deep rows
/// should be accessible without materializing rows for earlier indices.
#[gpui::test]
fn giant_two_way_paged_provider_generates_rows_on_demand(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(170);
    let fixture = SyntheticWholeFileConflictFixture::new(
        "giant_two_way_paged_on_demand",
        "fixtures/paged_on_demand.html",
        20_001,
    );
    load_synthetic_whole_file_conflict(cx, &view, repo_id, &fixture);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "giant two-way paged bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && pane.conflict_resolver.split_row_index().is_some()
        },
        |pane| {
            format!(
                "path={:?} split_row_index={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.split_row_index().is_some(),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::TwoWayDiff, cx);
                let total = assert_streamed_whole_file_two_way_state(pane, fixture.line_count);

                // Generate a deep row on demand without touching earlier rows.
                let deep_ix = total / 2;
                let crate::view::conflict_resolver::TwoWaySplitVisibleRow {
                    source_row_ix: source_ix,
                    row,
                    conflict_ix: _conflict_ix,
                } = pane
                    .conflict_resolver
                    .two_way_split_visible_row(deep_ix)
                    .expect("deep visible row should be accessible on demand");
                assert!(
                    row.old.is_some() || row.new.is_some(),
                    "on-demand row at visible index {deep_ix} (source {source_ix}) should have text",
                );

                // Verify the first and last visible rows are accessible too.
                assert!(
                    pane.conflict_resolver.two_way_split_visible_row(0).is_some(),
                    "first visible row should be accessible",
                );
                assert!(
                    pane.conflict_resolver
                        .two_way_split_visible_row(total - 1)
                        .is_some(),
                    "last visible row should be accessible",
                );

                // Out-of-bounds returns None.
                assert!(
                    pane.conflict_resolver
                        .two_way_split_visible_row(total)
                        .is_none(),
                    "out-of-bounds visible row should return None",
                );
            });
        });
    });

    fixture.cleanup();
}

/// Verifies that search in giant two-way mode works over source texts without
/// generating eager diff rows. The search should find text in the middle of a
/// large conflict block.
#[gpui::test]
fn giant_two_way_search_finds_text_in_middle_of_large_block(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(171);
    let fixture = SyntheticWholeFileConflictFixture::new(
        "giant_two_way_search_mid_block",
        "fixtures/search_mid_block.html",
        20_001,
    );
    load_synthetic_whole_file_conflict(cx, &view, repo_id, &fixture);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "giant two-way search bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && pane.conflict_resolver.split_row_index().is_some()
        },
        |pane| {
            format!(
                "path={:?} split_row_index={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.split_row_index().is_some(),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::TwoWayDiff, cx);
                assert_streamed_whole_file_two_way_state(pane, fixture.line_count);

                // The whole-file conflict fixture has lines like 'panel-25000'
                // in the middle of the block. Search for it via the paged index.
                let index = pane
                    .conflict_resolver
                    .split_row_index()
                    .expect("split row index should be present");
                index.clear_cached_pages();
                assert_eq!(
                    index.cached_page_count(),
                    0,
                    "search should start without materialized split pages"
                );

                let target = "panel-10000";
                let matches = index
                    .search_matching_rows(&pane.conflict_resolver.marker_segments, |line_text| {
                        line_text.contains(target)
                    });
                assert!(
                    !matches.is_empty(),
                    "search should find '{target}' in the middle of the large block",
                );
                assert_eq!(
                    index.cached_page_count(),
                    0,
                    "source-text search should not materialize split pages"
                );

                // Verify the matching row actually contains the search text.
                let matched_row_ix = matches[0];
                let row = index
                    .row_at(&pane.conflict_resolver.marker_segments, matched_row_ix)
                    .expect("matched row should be generatable");
                let row_has_target = row.old.as_ref().is_some_and(|t| t.contains(target))
                    || row.new.as_ref().is_some_and(|t| t.contains(target));
                assert!(
                    row_has_target,
                    "generated row at source index {matched_row_ix} should contain '{target}'",
                );
                assert_eq!(
                    index.cached_page_count(),
                    1,
                    "reading the matched row should materialize only the destination split page"
                );

                // The matching row should have a visible index via the projection.
                if let Some(proj) = pane.conflict_resolver.two_way_split_projection() {
                    let visible_ix = proj.source_to_visible(matched_row_ix);
                    assert!(
                        visible_ix.is_some(),
                        "source row {matched_row_ix} should map to a visible index",
                    );
                }
            });
        });
    });

    fixture.cleanup();
}

#[gpui::test]
fn giant_two_way_resync_rebuilds_split_index_after_manual_session_edit(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(172);
    let fixture = SyntheticLargeConflictFixture::new(
        "giant_two_way_resync_manual_edit",
        "fixtures/resync_manual_edit.html",
        20_001,
        4,
    );
    fixture.write();

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::ZERO,
                });
            });

            let next_state = app_state_with_repo(fixture.repo_state(repo_id), repo_id);

            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "giant two-way resync bootstrap",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && pane.conflict_resolver.split_row_index().is_some()
        },
        |pane| {
            format!(
                "path={:?} split_row_index={} conflict_rev={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.split_row_index().is_some(),
                pane.conflict_resolver.conflict_rev,
            )
        },
    );

    let initial_visible_len = cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::TwoWayDiff, cx);
                pane.conflict_resolver.two_way_split_visible_len()
            })
        })
    });

    let manual_text = "<article id=\"manual-0\">manual block 0</article>\n<article id=\"manual-1\">manual block 1</article>\n";
    let (
        updated_repo,
        expected_rev,
        expected_conflict_count,
        expected_total_rows,
        expected_visible_len,
    ) = {
        let mut repo = fixture.repo_state(repo_id);
        let session = repo
            .conflict_state
            .conflict_session
            .as_mut()
            .expect("fixture should include a text conflict session");
        session.regions[0].resolution =
            gitcomet_core::conflict_session::ConflictRegionResolution::ManualEdit(
                manual_text.to_string(),
            );

        let mut expected_segments =
            crate::view::conflict_resolver::parse_conflict_markers(&fixture.current_text);
        crate::view::conflict_resolver::apply_session_region_resolutions_with_index_map(
            &mut expected_segments,
            &session.regions,
        );
        let expected_conflict_count =
            crate::view::conflict_resolver::conflict_count(&expected_segments);
        let expected_index = crate::view::conflict_resolver::ConflictSplitRowIndex::new(
            &expected_segments,
            crate::view::conflict_resolver::BLOCK_LOCAL_DIFF_CONTEXT_LINES,
        );
        let expected_projection = crate::view::conflict_resolver::TwoWaySplitProjection::new(
            &expected_index,
            &expected_segments,
            false,
        );
        repo.conflict_state.conflict_rev = repo.conflict_state.conflict_rev.wrapping_add(1);
        let expected_rev = repo.conflict_state.conflict_rev;
        (
            repo,
            expected_rev,
            expected_conflict_count,
            expected_index.total_rows(),
            expected_projection.visible_len(),
        )
    };

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let next_state = app_state_with_repo(updated_repo.clone(), repo_id);

            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "giant two-way resync applied manual session edit",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && pane.conflict_resolver.conflict_rev == expected_rev
                && crate::view::conflict_resolver::conflict_count(
                    &pane.conflict_resolver.marker_segments,
                ) == expected_conflict_count
        },
        |pane| {
            format!(
                "path={:?} conflict_rev={} conflicts={} visible_len={} split_rows={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.conflict_rev,
                crate::view::conflict_resolver::conflict_count(
                    &pane.conflict_resolver.marker_segments,
                ),
                pane.conflict_resolver.two_way_split_visible_len(),
                pane.conflict_resolver
                    .split_row_index()
                    .map(|index| index.total_rows())
                    .unwrap_or_default(),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::TwoWayDiff, cx);

                assert_eq!(
                    pane.conflict_resolver.rendering_mode(),
                    crate::view::conflict_resolver::ConflictRenderingMode::StreamedLargeFile,
                    "large fixture should remain in streamed large-file mode after re-sync",
                );
                assert_eq!(
                    crate::view::conflict_resolver::conflict_count(
                        &pane.conflict_resolver.marker_segments,
                    ),
                    expected_conflict_count,
                    "manual session edit should materialize one conflict block into text during re-sync",
                );
                assert_eq!(
                    pane.conflict_resolver.conflict_region_indices.len(),
                    expected_conflict_count,
                    "visible region indices should shrink with the remaining conflict blocks",
                );

                let index = pane
                    .conflict_resolver
                    .split_row_index()
                    .expect("re-sync should rebuild the giant split row index");
                assert_eq!(
                    index.total_rows(),
                    expected_total_rows,
                    "split row index should be rebuilt from the updated marker structure",
                );
                assert_eq!(
                    pane.conflict_resolver.two_way_split_visible_len(),
                    expected_visible_len,
                    "two-way projection should reflect the rebuilt split index",
                );
                assert_ne!(
                    pane.conflict_resolver.two_way_split_visible_len(),
                    initial_visible_len,
                    "manual materialization should change the visible giant split layout",
                );

                assert!(
                    index.first_row_for_conflict(expected_conflict_count).is_none(),
                    "rebuilt split index should drop the removed conflict block entirely",
                );
                let first_conflict_row_ix = index
                    .first_row_for_conflict(0)
                    .expect("remaining first conflict should still have rows after re-sync");
                let first_conflict_row = index
                    .row_at(
                        &pane.conflict_resolver.marker_segments,
                        first_conflict_row_ix,
                    )
                    .expect("remaining first conflict row should be generatable after re-sync");
                let row_has_shifted_conflict = first_conflict_row
                    .old
                    .as_deref()
                    .is_some_and(|text| text.contains("choice-1"))
                    || first_conflict_row
                        .new
                        .as_deref()
                        .is_some_and(|text| text.contains("choice-1"));
                assert!(
                    row_has_shifted_conflict,
                    "re-synced first remaining conflict row should now point at the old second block",
                );
                let first_conflict_visible_ix = pane
                    .conflict_resolver
                    .two_way_split_projection()
                    .and_then(|projection| projection.source_to_visible(first_conflict_row_ix));
                assert!(
                    first_conflict_visible_ix
                        .and_then(|visible_ix| {
                            pane.conflict_resolver.two_way_split_visible_row(visible_ix)
                        })
                        .is_some(),
                    "rebuilt projection should resolve the shifted first-conflict row as visible",
                );
            });
        });
    });

    fixture.cleanup();
}

#[gpui::test]
fn large_conflict_resolved_output_above_the_old_line_gate_is_highlighted(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(62);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_large_conflict_resolved_output_background_syntax",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("src/large_conflict_resolved_bg.rs");
    let abs_path = workdir.join(&file_rel);
    let comment_line = "still inside block comment";
    let fixture_line_count = 20_001usize;

    let mut base_lines = vec![
        "/* start block comment".to_string(),
        comment_line.to_string(),
        "end */".to_string(),
        "let chosen = 0;".to_string(),
    ];
    base_lines.extend(
        (base_lines.len()..fixture_line_count).map(|ix| format!("let base_bg_{ix}: usize = {ix};")),
    );
    // Carry classes the heuristic tokenizer cannot produce: a `type_identifier`,
    // a `field_identifier` and a method call. `syntax/heuristic.rs` colours
    // keywords, strings, numbers and comments and nothing else, so these are the
    // only probes that can tell a real tree-sitter parse from the fallback.
    let discriminating_lines = [
        "struct Stage { retries: usize }".to_string(),
        "fn bump(stage: &mut Stage) { stage.retries = stage.retries.wrapping_add(1); }".to_string(),
    ];
    base_lines.extend(discriminating_lines.iter().cloned());
    let base_text = base_lines.join("\n");

    let mut ours_lines = base_lines.clone();
    ours_lines[3] = "let chosen = 1;".to_string();
    let ours_text = ours_lines.join("\n");

    let mut theirs_lines = base_lines.clone();
    theirs_lines[3] = "let chosen = 2;".to_string();
    let theirs_text = theirs_lines.join("\n");

    let mut current_lines = vec![
        "/* start block comment".to_string(),
        comment_line.to_string(),
        "end */".to_string(),
        "<<<<<<< ours".to_string(),
        "let chosen = 1;".to_string(),
        "=======".to_string(),
        "let chosen = 2;".to_string(),
        ">>>>>>> theirs".to_string(),
    ];
    current_lines.extend(
        (current_lines.len()..fixture_line_count)
            .map(|ix| format!("let resolved_bg_{ix}: usize = {ix};")),
    );
    current_lines.extend(discriminating_lines.iter().cloned());
    let current_text = current_lines.join("\n");
    let resolved_output = crate::view::conflict_resolver::generate_resolved_text(
        crate::view::conflict_resolver::parse_conflict_markers(&current_text).as_slice(),
    );
    let line_count = resolved_output.lines().count();
    assert!(
        fixture_line_count > rows::MAX_LINES_FOR_SYNTAX_HIGHLIGHTING,
        "fixture should stay above the old syntax gate"
    );

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create conflict resolver fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write conflict resolver fixture");

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::from_secs(1),
                });
            });

            let mut repo = opening_repo_state(repo_id, &workdir);
            set_test_conflict_status(
                &mut repo,
                file_rel.clone(),
                gitcomet_core::domain::DiffArea::Unstaged,
            );
            set_test_conflict_file(
                &mut repo,
                file_rel.clone(),
                base_text.clone(),
                ours_text.clone(),
                theirs_text.clone(),
                current_text.clone(),
            );

            let next_state = app_state_with_repo(repo, repo_id);

            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "large conflict resolved output initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| pane.conflict_resolver.path.as_ref() == Some(&file_rel),
        |pane| {
            format!(
                "path={:?} line_count={} syntax_language={:?} live_syntax={} source_revision={:?}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolved_preview_line_count,
                pane.conflict_resolved_preview_syntax_language,
                pane.conflict_resolved_output_live_syntax.is_some(),
                pane.conflict_resolved_preview_source_revision,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.recompute_conflict_resolved_outline_for_tests(cx);
                pane.conflict_resolver.resolver_pending_recompute_seq = pane
                    .conflict_resolver
                    .resolver_pending_recompute_seq
                    .wrapping_add(1);
                assert_eq!(
                    pane.conflict_resolved_preview_line_count, line_count,
                    "forced recompute should materialize the expected resolved output line count"
                );
                assert_eq!(
                    pane.conflict_resolved_preview_syntax_language,
                    Some(rows::DiffSyntaxLanguage::Rust),
                    "resolved output should still use the file-derived Rust syntax language"
                );
                assert!(
                    pane.conflict_resolved_output_live_syntax.is_some(),
                    "a 20k-line output should get a live syntax document: the old 4000-line \
                     `MAX_LINES_FOR_SYNTAX_HIGHLIGHTING` gate no longer applies to this view"
                );
            });
        });
    });

    cx.update(|window, app| {
        let _ = window.draw(app);
    });

    let target_ix = 1usize;
    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        assert_eq!(
            pane.conflict_resolver_input.read_with(app, |input, _| {
                input.text().lines().nth(target_ix).map(ToOwned::to_owned)
            }),
            Some(comment_line.to_owned()),
            "the editable resolved-output buffer should expose the multiline comment text",
        );
        assert!(
            pane.conflict_resolved_output_projection.is_none(),
            "resolver bootstrap should materialize the editable output buffer"
        );
    });

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.ensure_conflict_resolved_output_materialized(cx);
                assert!(
                    pane.conflict_resolved_output_projection.is_none(),
                    "explicit materialization should be idempotent"
                );
                assert_eq!(
                    pane.conflict_resolved_preview_line_count, line_count,
                    "materialized preview should preserve the output line count"
                );
                assert_eq!(
                    pane.conflict_resolved_preview_syntax_language,
                    Some(rows::DiffSyntaxLanguage::Rust),
                    "materialized resolved output should keep the path-derived syntax language"
                );
            });
        });
    });

    cx.update(|window, app| {
        let _ = window.draw(app);
    });

    // The row is the *continuation* of a block comment opened on the line
    // above, so getting it right requires the whole-document tree — a per-line
    // parse would read it as bare identifiers.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                let comment_color = pane.theme.syntax.comment;
                let text = pane.conflict_resolver_input.read(cx).text().to_string();
                let line_start = text
                    .find(comment_line)
                    .expect("fixture should contain the comment continuation line");
                let line_end = line_start + comment_line.len();
                let highlights = pane.conflict_resolver_input.update(cx, |input, _| {
                    input.debug_effective_highlights_for_range(0..line_end + 1)
                });
                assert!(
                    highlights.iter().any(|(range, style)| {
                        range.start <= line_start
                            && range.end >= line_end
                            && style.color == Some(comment_color.into_color())
                    }),
                    "row {target_ix} continues a block comment and should be comment-coloured \
                     straight away: {highlights:?}"
                );

                // Do not assert on a keyword here. `syntax/heuristic.rs` colours
                // keywords too, so a `let`-shaped assertion passes in exactly the
                // broken state this test exists to catch -- the pane silently
                // falling back to the tokenizer because it never got a live
                // tree-sitter document. Only classes the tokenizer cannot
                // produce can tell the two engines apart.
                let all = pane.conflict_resolver_input.update(cx, |input, _| {
                    input.debug_effective_highlights_for_range(0..text.len())
                });
                assert_resolved_output_carries_treesitter_classes(&text, &all, pane.theme);
            });
        });
    });

    // The real regression this guards: a cold parse of a ~10KB output does not
    // fit the 1ms live foreground budget, so the first `LiveSyntaxDocument::new`
    // returns None. There is no tree to reparse incrementally, so unless the
    // build is finished off-thread the view stays on heuristic tokens forever --
    // which loses exactly the classes tree-sitter adds over a tokenizer: method
    // calls and field accesses.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                // Drop the document so the next refresh takes the *first parse*
                // path. An edit alone would not do: `sync` still has the old
                // tree to fall back on, so it recovers through the ordinary
                // deferred-reparse route and the bug stays hidden.
                pane.conflict_resolved_output_live_syntax = None;
                pane.conflict_resolved_output_live_syntax_source = None;
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::ZERO,
                });
                pane.conflict_resolver_input.update(cx, |input, cx| {
                    let at = input.text().len();
                    input.replace_utf8_range(at..at, "\n", cx);
                });
            });
        });
    });
    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolved output recovers a live document after a budget-exhausted first parse",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| pane.conflict_resolved_output_live_syntax.is_some(),
        |pane| {
            format!(
                "live_syntax={} building={:?}",
                pane.conflict_resolved_output_live_syntax.is_some(),
                pane.conflict_resolved_output_live_syntax_building,
            )
        },
    );
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::from_secs(1),
                });
                let snapshot = pane.conflict_resolver_input.read(cx).text_snapshot();
                let text: Arc<str> = snapshot.as_shared_string().into();
                let live = pane
                    .conflict_resolved_output_live_syntax
                    .as_ref()
                    .expect("recovered live document")
                    .snapshot(pane.theme)
                    .highlights_for_byte_range(0..text.len());
                let cold = rows::LiveSyntaxDocument::new(
                    rows::DiffSyntaxLanguage::Rust,
                    snapshot.rope(),
                    resolved_output_placeholder_protected_ranges_for_test(&text),
                    None,
                )
                .expect("cold parse")
                .snapshot(pane.theme)
                .highlights_for_byte_range(0..text.len());
                assert!(!live.is_empty(), "the recovered document must highlight");
                assert_eq!(
                    live, cold,
                    "the off-thread build must produce the same tree as an unbudgeted parse"
                );

                // And the recovered document must reach the *pane*, not just sit
                // in the field: the input is still showing whatever the earlier
                // fallback installed until the provider is rebound over it.
                let effective = pane.conflict_resolver_input.update(cx, |input, _| {
                    input.debug_effective_highlights_for_range(0..text.len())
                });
                assert_resolved_output_carries_treesitter_classes(&text, &effective, pane.theme);
            });
        });
    });

    // Switching theme must actually re-colour the output. The syntax palette is
    // baked into LiveSyntaxSnapshot at build time, and `set_highlight_provider_with_key`
    // early-returns on an unchanged key -- so if the key does not move on a theme
    // change, the old palette stays installed and the text keeps its old colours.
    let (dark_runs, light_runs) = cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                let len = pane.conflict_resolver_input.read(cx).text().len();
                let dark = pane.conflict_resolver_input.update(cx, |input, _| {
                    input.debug_effective_highlights_for_range(0..len.min(400))
                });
                // Deliberately another *dark* theme that differs only in its
                // syntax palette. A key built from sampled theme colours (or
                // from `is_dark`) would collide here and silently keep the old
                // palette; only a theme epoch catches it.
                pane.set_theme(other_dark_theme(), cx);
                let light = pane.conflict_resolver_input.update(cx, |input, _| {
                    input.debug_effective_highlights_for_range(0..len.min(400))
                });
                pane.set_theme(crate::theme::AppTheme::gitcomet_dark(), cx);
                (dark, light)
            })
        })
    });
    assert!(!dark_runs.is_empty() && !light_runs.is_empty());
    assert_ne!(
        dark_runs, light_runs,
        "a theme change must rebind the provider so the new syntax palette is used"
    );
    assert!(
        dark_runs
            .iter()
            .zip(light_runs.iter())
            .any(|((_, a), (_, b))| a.color != b.color),
        "the difference must be in the colours themselves, not just run boundaries"
    );

    // Settling must be idempotent. Installing a highlight provider notifies the
    // input, which re-enters the `cx.observe` that installed it; if a quiet
    // cycle still reparsed and rebound, that notify would trigger another, and
    // the pane would spin forever instead of ever finishing a frame.
    let settled_version = cx.update(|_window, app| {
        view.read(app)
            .main_pane
            .read(app)
            .conflict_resolved_output_live_syntax
            .as_ref()
            .map(|document| document.version())
            .expect("a materialized Rust output has a live syntax document")
    });
    cx.run_until_parked();
    cx.update(|window, app| {
        let _ = window.draw(app);
    });
    cx.run_until_parked();
    cx.update(|_window, app| {
        assert_eq!(
            view.read(app)
                .main_pane
                .read(app)
                .conflict_resolved_output_live_syntax
                .as_ref()
                .map(|document| document.version()),
            Some(settled_version),
            "an idle frame must not reparse or rebind the resolved output"
        );
    });

    std::fs::remove_dir_all(&workdir).expect("cleanup conflict resolver fixture");
}

#[gpui::test]
fn edited_conflict_resolved_output_highlights_multiline_comment_on_the_keystroke(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(63);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_edited_conflict_resolved_output_background_syntax",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("src/edited_conflict_resolved_bg.rs");
    let abs_path = workdir.join(&file_rel);
    let inserted_comment_line = "still inside block comment";
    let inserted_prefix = format!("/* start block comment\n{inserted_comment_line}\nend */\n");
    let fixture_line_count = 20_001usize;

    let mut base_lines = vec![
        "fn large_demo() {".to_string(),
        "    let chosen = 0;".to_string(),
        "    let tail = 9;".to_string(),
        "}".to_string(),
    ];
    base_lines.extend(
        (base_lines.len()..fixture_line_count).map(|ix| format!("let base_bg_{ix}: usize = {ix};")),
    );
    let base_text = base_lines.join("\n");

    let mut ours_lines = base_lines.clone();
    ours_lines[1] = "    let chosen = 1;".to_string();
    let ours_text = ours_lines.join("\n");

    let mut theirs_lines = base_lines.clone();
    theirs_lines[1] = "    let chosen = 2;".to_string();
    let theirs_text = theirs_lines.join("\n");

    let mut current_lines = vec![
        "fn large_demo() {".to_string(),
        "<<<<<<< ours".to_string(),
        "    let chosen = 1;".to_string(),
        "=======".to_string(),
        "    let chosen = 2;".to_string(),
        ">>>>>>> theirs".to_string(),
        "    let tail = 9;".to_string(),
        "}".to_string(),
    ];
    current_lines.extend(
        (current_lines.len()..fixture_line_count)
            .map(|ix| format!("let resolved_bg_{ix}: usize = {ix};")),
    );
    let current_text = current_lines.join("\n");

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create conflict resolver fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write conflict resolver fixture");

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                pane.set_full_document_syntax_budget_override_for_tests(rows::DiffSyntaxBudget {
                    foreground_parse: std::time::Duration::from_secs(1),
                });
            });

            let mut repo = opening_repo_state(repo_id, &workdir);
            set_test_conflict_status(
                &mut repo,
                file_rel.clone(),
                gitcomet_core::domain::DiffArea::Unstaged,
            );
            set_test_conflict_file(
                &mut repo,
                file_rel.clone(),
                base_text.clone(),
                ours_text.clone(),
                theirs_text.clone(),
                current_text.clone(),
            );

            let next_state = app_state_with_repo(repo, repo_id);

            push_test_state(this, next_state, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "edited conflict resolved output initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| pane.conflict_resolver.path.as_ref() == Some(&file_rel),
        |pane| {
            format!(
                "path={:?} line_count={} source_revision={:?}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolved_preview_line_count,
                pane.conflict_resolved_preview_source_revision,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.ensure_conflict_resolved_output_materialized(cx);
            });
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "edited conflict resolved output materialized for editing",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| pane.conflict_resolved_output_projection.is_none(),
        |pane| {
            format!(
                "projection_present={} line_count={} live_syntax={}",
                pane.conflict_resolved_output_projection.is_some(),
                pane.conflict_resolved_preview_line_count,
                pane.conflict_resolved_output_live_syntax.is_some(),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.recompute_conflict_resolved_outline_for_tests(cx);
                pane.conflict_resolver.resolver_pending_recompute_seq = pane
                    .conflict_resolver
                    .resolver_pending_recompute_seq
                    .wrapping_add(1);
            });
        });
    });

    // Under the live engine there is no plain-then-upgrade window to wait for:
    // the tree is parsed on materialization and edited in place afterwards.
    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        assert!(
            pane.conflict_resolved_output_live_syntax.is_some(),
            "materializing an editable Rust output should build a live syntax document"
        );
        assert_eq!(
            pane.conflict_resolved_preview_syntax_language,
            Some(rows::DiffSyntaxLanguage::Rust)
        );
    });

    // Insert a block comment whose body runs onto the next row. Getting that row
    // right needs the reparse to have happened — `tree.edit` alone only shifts
    // existing nodes, it cannot invent a comment node — so this is a test that
    // the keystroke path reparses synchronously within its budget. (The
    // budget-exhausted path is covered by `syntax::live`'s own tests, where the
    // deferred tree keeps painting until a background pass catches up.)
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_input.update(cx, |input, cx| {
                    input.replace_utf8_range(0..0, &inserted_prefix, cx);
                });
            });
        });
    });

    // No `wait_for_*`: the assertion is that this is already true, on the very
    // next look, with no background pass and no debounce elapsed.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                let comment_color = pane.theme.syntax.comment;
                let line_start = inserted_prefix
                    .find(inserted_comment_line)
                    .expect("fixture prefix should contain the continuation line");
                let line_end = line_start + inserted_comment_line.len();
                let highlights = pane.conflict_resolver_input.update(cx, |input, _| {
                    input.debug_effective_highlights_for_range(0..inserted_prefix.len())
                });
                assert!(
                    highlights.iter().any(|(range, style)| {
                        range.start <= line_start
                            && range.end >= line_end
                            && style.color == Some(comment_color.into_color())
                    }),
                    "the row inside the inserted block comment should be comment-coloured \
                     on the keystroke, not after a background upgrade: {highlights:?}"
                );
            });
        });
    });

    std::fs::remove_dir_all(&workdir).expect("cleanup conflict resolver fixture");
}

#[gpui::test]
fn conflict_resolver_fresh_open_uses_persisted_view_mode_and_toasts_once(
    cx: &mut gpui::TestAppContext,
) {
    use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession};

    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(171);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_view_mode_persist",
        std::process::id()
    ));

    let base_text = "base line\ncontext\ntail".to_string();
    let ours_text = "ours line\ncontext\ntail".to_string();
    let theirs_text = "theirs line\ncontext\ntail".to_string();
    let current_text =
        format!("<<<<<<< ours\n{ours_text}\n=======\n{theirs_text}\n>>>>>>> theirs\n");

    let file_a = std::path::PathBuf::from("fixtures/view_mode_persist_a.txt");
    let file_b = std::path::PathBuf::from("fixtures/view_mode_persist_b.txt");
    let _ = std::fs::remove_dir_all(&workdir);
    for file_rel in [&file_a, &file_b] {
        let abs_path = workdir.join(file_rel);
        std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
            .expect("create view-mode fixture dir");
        std::fs::write(&abs_path, &current_text).expect("write view-mode fixture");
    }

    let repo_with_conflict = |file_rel: &std::path::PathBuf,
                              base_text: &String,
                              ours_text: &String,
                              theirs_text: &String,
                              current_text: &String| {
        let mut repo = opening_repo_state(repo_id, &workdir);
        set_test_conflict_status(
            &mut repo,
            file_rel.clone(),
            gitcomet_core::domain::DiffArea::Unstaged,
        );
        set_test_conflict_file(
            &mut repo,
            file_rel.clone(),
            base_text.clone(),
            ours_text.clone(),
            theirs_text.clone(),
            current_text.clone(),
        );
        repo.conflict_state.conflict_session = Some(ConflictSession::from_merged_text(
            file_rel.clone(),
            gitcomet_core::domain::FileConflictKind::BothModified,
            ConflictPayload::Text(base_text.clone().into()),
            ConflictPayload::Text(ours_text.clone().into()),
            ConflictPayload::Text(theirs_text.clone().into()),
            current_text,
        ));
        repo
    };

    // Persisted preference says the user last used two-way mode.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                pane.mergetool_view_three_way = false;
            });
            let repo =
                repo_with_conflict(&file_a, &base_text, &ours_text, &theirs_text, &current_text);
            push_test_state(this, app_state_with_repo(repo, repo_id), cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "view-mode fixture A open summary announced",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_a)
                && pane.conflict_resolver.open_summary_announced
        },
        |pane| {
            format!(
                "path={:?} announced={} auto={:?}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.open_summary_announced,
                pane.conflict_resolver.open_summary_counts,
            )
        },
    );

    cx.update(|_window, app| {
        let this = view.read(app);
        let pane = this.main_pane.read(app);
        assert_eq!(
            pane.conflict_resolver.view_mode,
            ConflictResolverViewMode::TwoWayDiff,
            "base-present fresh open should honor the persisted two-way preference",
        );
        assert_eq!(
            this.toast_host.read(app).toast_count_for_tests(),
            1,
            "fresh open should push exactly one summary toast",
        );
    });

    // Re-syncing the same conflict must not announce again.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let repo =
                repo_with_conflict(&file_a, &base_text, &ours_text, &theirs_text, &current_text);
            push_test_state(this, app_state_with_repo(repo, repo_id), cx);
        });
    });
    cx.run_until_parked();
    cx.update(|_window, app| {
        let this = view.read(app);
        assert_eq!(
            this.toast_host.read(app).toast_count_for_tests(),
            1,
            "same-conflict re-sync must not push another summary toast",
        );
    });

    // Toggling to three-way persists the preference.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                assert!(
                    pane.mergetool_view_three_way,
                    "switching to three-way should persist the preference",
                );
            });
        });
    });

    // A different conflict file is a fresh open: it honors the new preference
    // and announces its own summary.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let repo =
                repo_with_conflict(&file_b, &base_text, &ours_text, &theirs_text, &current_text);
            push_test_state(this, app_state_with_repo(repo, repo_id), cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "view-mode fixture B open summary announced",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_b)
                && pane.conflict_resolver.open_summary_announced
        },
        |pane| {
            format!(
                "path={:?} announced={} auto={:?}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.open_summary_announced,
                pane.conflict_resolver.open_summary_counts,
            )
        },
    );

    cx.update(|_window, app| {
        let this = view.read(app);
        let pane = this.main_pane.read(app);
        assert_eq!(
            pane.conflict_resolver.view_mode,
            ConflictResolverViewMode::ThreeWay,
            "fresh open after toggling should default to the persisted three-way mode",
        );
        assert_eq!(
            this.toast_host.read(app).toast_count_for_tests(),
            2,
            "a different conflict file is a fresh open and gets its own toast",
        );
    });

    // Returning to the first conflict file in this window must not announce it
    // a second time.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let repo =
                repo_with_conflict(&file_a, &base_text, &ours_text, &theirs_text, &current_text);
            push_test_state(this, app_state_with_repo(repo, repo_id), cx);
        });
    });
    cx.run_until_parked();
    cx.update(|_window, app| {
        let this = view.read(app);
        let pane = this.main_pane.read(app);
        assert_eq!(pane.conflict_resolver.path.as_ref(), Some(&file_a));
        assert!(pane.conflict_resolver.open_summary_announced);
        assert_eq!(
            this.toast_host.read(app).toast_count_for_tests(),
            2,
            "reopening a previously announced conflict file must not push another toast",
        );
    });

    cx.run_until_parked();
    std::fs::remove_dir_all(&workdir).expect("cleanup view-mode persist fixture");
}

#[gpui::test]
fn conflict_resolver_split_selection_and_join_dispatch_and_rebuild_blocks(
    cx: &mut gpui::TestAppContext,
) {
    use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession};

    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let store_for_assert = store.clone();
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(172);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_split_selection",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/split_selection.txt");
    let base = "ctx\nb1\nb2\nb3\ntail\n".to_string();
    let ours = "ctx\no1\no2\no3\ntail\n".to_string();
    let theirs = "ctx\nt1\nt2\nt3\ntail\n".to_string();
    let current = concat!(
        "ctx\n",
        "<<<<<<< ours\n",
        "o1\n",
        "o2\n",
        "o3\n",
        "=======\n",
        "t1\n",
        "t2\n",
        "t3\n",
        ">>>>>>> theirs\n",
        "tail\n",
    )
    .to_string();

    let mut repo = opening_repo_state(repo_id, &workdir);
    set_test_conflict_status(
        &mut repo,
        file_rel.clone(),
        gitcomet_core::domain::DiffArea::Unstaged,
    );
    set_test_conflict_file(
        &mut repo,
        file_rel.clone(),
        base.clone(),
        ours.clone(),
        theirs.clone(),
        current.clone(),
    );
    repo.conflict_state.conflict_file_load_mode = gitcomet_state::model::ConflictFileLoadMode::Full;
    repo.conflict_state.conflict_session = Some(ConflictSession::from_merged_text(
        file_rel.clone(),
        gitcomet_core::domain::FileConflictKind::BothModified,
        ConflictPayload::Text(base.into()),
        ConflictPayload::Text(ours.into()),
        ConflictPayload::Text(theirs.into()),
        &current,
    ));
    let state = app_state_with_repo(repo, repo_id);
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.store.replace_snapshot_for_test(Arc::clone(&state));
            push_test_state(this, Arc::clone(&state), cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "split-ready conflict alignment",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolver.conflict_row_selection_enabled()
                && pane
                    .conflict_resolver
                    .three_way_block_aligned_range(0)
                    .is_some_and(|range| range.len() >= 3)
                && pane.conflict_resolver.conflict_region_indices == vec![0]
        },
        |pane| {
            format!(
                "path={:?} enabled={} range={:?} regions={:?}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.conflict_row_selection_enabled(),
                pane.conflict_resolver.three_way_block_aligned_range(0),
                pane.conflict_resolver.conflict_region_indices,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                let range = pane
                    .conflict_resolver
                    .three_way_block_aligned_range(0)
                    .expect("aligned block");
                pane.conflict_resolver_begin_row_selection(0, 0, cx);
                let selection = pane.conflict_resolver.row_selection.expect("selection");
                assert_eq!(selection.anchor_row, range.start);
                assert!(selection.selecting);
                pane.conflict_resolver_extend_row_selection(99, usize::MAX, cx);
                assert_eq!(
                    pane.conflict_resolver.row_selection.unwrap().head_row,
                    range.end - 1,
                    "dragging into another block clamps to the anchored block",
                );
                pane.conflict_resolver_extend_row_selection(99, 0, cx);
                assert_eq!(
                    pane.conflict_resolver.row_selection.unwrap().head_row,
                    range.start,
                );
                pane.conflict_resolver_end_row_selection(cx);
                assert!(!pane.conflict_resolver.row_selection.unwrap().selecting);

                let middle_row = range.start + 1;
                pane.conflict_resolver_begin_row_selection(0, middle_row, cx);
                pane.conflict_resolver_end_row_selection(cx);
                assert_eq!(pane.conflict_resolver_split_selection_row_count(0), Some(1),);

                pane.conflict_resolver_click_row_selection(
                    0,
                    range.end - 1,
                    gpui::Modifiers {
                        shift: true,
                        ..Default::default()
                    },
                    cx,
                );
                let selection = pane.conflict_resolver.row_selection.unwrap();
                assert_eq!(selection.anchor_row, middle_row);
                assert_eq!(selection.head_row, range.end - 1);
                assert!(!selection.selecting);
                assert_eq!(pane.conflict_resolver_split_selection_row_count(0), Some(2));

                pane.conflict_resolver_click_row_selection(
                    0,
                    range.start,
                    gpui::Modifiers {
                        control: true,
                        ..Default::default()
                    },
                    cx,
                );
                let selection = pane.conflict_resolver.row_selection.unwrap();
                assert_eq!(selection.anchor_row, middle_row);
                assert_eq!(selection.head_row, range.start);
                assert_eq!(pane.conflict_resolver_split_selection_row_count(0), Some(2));

                pane.conflict_resolver_begin_row_selection(0, middle_row, cx);
                pane.conflict_resolver_end_row_selection(cx);
                pane.conflict_resolver_split_selection(cx);
                assert!(
                    pane.conflict_resolver.row_selection.is_some(),
                    "selection stays available until the split is accepted"
                );
            });
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "split dispatch to reach the store",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |_pane| {
            store_for_assert
                .snapshot()
                .repos
                .first()
                .and_then(|repo| repo.conflict_state.conflict_session.as_ref())
                .is_some_and(|session| session.regions.len() == 3)
        },
        |_pane| {
            let snapshot = store_for_assert.snapshot();
            snapshot
                .repos
                .first()
                .map(|repo| {
                    (
                        repo.conflict_state.conflict_rev,
                        repo.conflict_state
                            .conflict_session
                            .as_ref()
                            .map(|session| session.regions.len()),
                    )
                })
                .unwrap_or_default()
        },
    );
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            crate::view::test_support::sync_store_snapshot(this, cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "split reducer round-trip",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            crate::view::conflict_resolver::conflict_count(&pane.conflict_resolver.marker_segments)
                == 3
                && pane.conflict_resolver.conflict_region_indices == vec![0, 1, 2]
                && pane.conflict_resolver.row_selection.is_none()
                && pane.conflict_resolver.active_conflict == Some(0)
                && pane.conflict_resolver.nav_anchor.is_some_and(|anchor| {
                    anchor.id == crate::view::conflict_resolver::ConflictNavTargetId::Region(0)
                })
        },
        |pane| {
            format!(
                "blocks={} regions={:?} rev={}",
                crate::view::conflict_resolver::conflict_count(
                    &pane.conflict_resolver.marker_segments,
                ),
                pane.conflict_resolver.conflict_region_indices,
                pane.conflict_resolver.conflict_rev,
            )
        },
    );

    let snapshot = store_for_assert.snapshot();
    let session = snapshot.repos[0]
        .conflict_state
        .conflict_session
        .as_ref()
        .expect("split session");
    assert_eq!(session.regions.len(), 3);

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                let target = ConflictResolverJoinTarget {
                    repo_id,
                    path: file_rel.clone().into(),
                    conflict_rev: pane.conflict_resolver.conflict_rev,
                    first_region_index: 0,
                };
                let mut stale = target.clone();
                stale.conflict_rev = stale.conflict_rev.wrapping_add(1);
                pane.conflict_resolver_join_regions(stale, cx);
                pane.conflict_resolver_join_regions(target, cx);
            });
        });
    });
    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "join dispatch to reach the store",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |_pane| {
            store_for_assert
                .snapshot()
                .repos
                .first()
                .and_then(|repo| repo.conflict_state.conflict_session.as_ref())
                .is_some_and(|session| session.regions.len() == 2)
        },
        |_pane| {
            store_for_assert
                .snapshot()
                .repos
                .first()
                .and_then(|repo| repo.conflict_state.conflict_session.as_ref())
                .map(|session| session.regions.len())
        },
    );
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            crate::view::test_support::sync_store_snapshot(this, cx);
        });
    });
    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "join reducer round-trip",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            crate::view::conflict_resolver::conflict_count(&pane.conflict_resolver.marker_segments)
                == 2
                && pane.conflict_resolver.conflict_region_indices == vec![0, 1]
                && pane.conflict_resolver.active_conflict == Some(0)
                && pane.conflict_resolver.nav_anchor.is_some_and(|anchor| {
                    anchor.id == crate::view::conflict_resolver::ConflictNavTargetId::Region(0)
                })
        },
        |pane| {
            format!(
                "blocks={} regions={:?} rev={}",
                crate::view::conflict_resolver::conflict_count(
                    &pane.conflict_resolver.marker_segments,
                ),
                pane.conflict_resolver.conflict_region_indices,
                pane.conflict_resolver.conflict_rev,
            )
        },
    );

    let before_reset = store_for_assert.snapshot();
    let before_reset_repo = &before_reset.repos[0];
    let before_reset_rev = before_reset_repo.conflict_state.conflict_rev;
    let session_current = before_reset_repo
        .conflict_state
        .conflict_session
        .as_ref()
        .and_then(|session| session.marker_projection_text())
        .expect("joined session marker projection")
        .to_string();
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                assert_eq!(
                    pane.conflict_resolver.current.as_deref(),
                    Some(session_current.as_str()),
                    "lightweight resync must retain the same authoritative marker snapshot",
                );
                pane.conflict_resolver_reset_output_from_markers(cx);
            });
        });
    });
    cx.run_until_parked();
    let after_reset = store_for_assert.snapshot();
    assert_eq!(
        after_reset.repos[0].conflict_state.conflict_rev, before_reset_rev,
        "Reset is a no-op in the reducer while every joined region is unresolved",
    );
    assert_eq!(
        after_reset.repos[0]
            .conflict_state
            .conflict_session
            .as_ref()
            .expect("session after no-op Reset")
            .regions
            .len(),
        2,
    );
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            crate::view::test_support::sync_store_snapshot(this, cx);
        });
    });
    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "no-op reset keeps joined geometry",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            crate::view::conflict_resolver::conflict_count(&pane.conflict_resolver.marker_segments)
                == 2
                && pane.conflict_resolver.conflict_region_indices == vec![0, 1]
        },
        |pane| {
            format!(
                "blocks={} regions={:?} current_markers={}",
                crate::view::conflict_resolver::conflict_count(
                    &pane.conflict_resolver.marker_segments,
                ),
                pane.conflict_resolver.conflict_region_indices,
                pane.conflict_resolver
                    .current
                    .as_deref()
                    .map_or(0, |text| text.matches("<<<<<<<").count()),
            )
        },
    );
}

#[gpui::test]
fn conflict_resolver_current_only_then_full_keeps_mode_and_edited_worktree_output(
    cx: &mut gpui::TestAppContext,
) {
    use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession};

    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });
    let repo_id = gitcomet_state::model::RepoId(173);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_current_only_mode",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/current_only_mode.txt");
    let base = "ctx\nbase\ntail\n".to_string();
    let ours = "ctx\nours\ntail\n".to_string();
    let theirs = "ctx\ntheirs\ntail\n".to_string();
    let current = "ctx\n<<<<<<< ours\nours\n=======\ntheirs\n>>>>>>> theirs\ntail\n";

    let mut current_only_repo = opening_repo_state(repo_id, &workdir);
    set_test_conflict_status(
        &mut current_only_repo,
        file_rel.clone(),
        gitcomet_core::domain::DiffArea::Unstaged,
    );
    current_only_repo.conflict_state.conflict_file_path = Some(file_rel.clone());
    current_only_repo.conflict_state.conflict_file_load_mode =
        gitcomet_state::model::ConflictFileLoadMode::CurrentOnly;
    current_only_repo.conflict_state.conflict_file =
        gitcomet_state::model::Loadable::Ready(Some(gitcomet_state::model::ConflictFile {
            path: file_rel.clone().into(),
            base_bytes: None,
            ours_bytes: None,
            theirs_bytes: None,
            current_bytes: None,
            base: None,
            ours: None,
            theirs: None,
            current: Some(current.to_string().into()),
        }));
    current_only_repo.conflict_state.conflict_session = Some(ConflictSession::from_merged_text(
        file_rel.clone(),
        gitcomet_core::domain::FileConflictKind::BothModified,
        ConflictPayload::Absent,
        ConflictPayload::Absent,
        ConflictPayload::Absent,
        current,
    ));

    let current_only_state = app_state_with_repo(current_only_repo, repo_id);
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, _cx| {
                pane.mergetool_view_three_way = true;
            });
            this.store
                .replace_snapshot_for_test(Arc::clone(&current_only_state));
            push_test_state(this, Arc::clone(&current_only_state), cx);
        });
    });
    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "current-only persisted three-way mode",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane
                    .conflict_resolver
                    .loaded_file
                    .as_ref()
                    .is_some_and(|file| file.base.is_none())
        },
        |pane| {
            format!(
                "path={:?} mode={:?} has_base={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.view_mode,
                pane.conflict_resolver
                    .loaded_file
                    .as_ref()
                    .is_some_and(|file| file.base.is_some()),
            )
        },
    );
    cx.update(|_window, app| {
        assert_eq!(
            view.read(app)
                .main_pane
                .read(app)
                .conflict_resolver
                .view_mode,
            ConflictResolverViewMode::ThreeWay,
            "a BothModified CurrentOnly first paint should honor persisted three-way mode",
        );
    });

    let full_current = "ctx\nmanually resolved during load\ntail\n".to_string();
    let mut full_repo = opening_repo_state(repo_id, &workdir);
    set_test_conflict_status(
        &mut full_repo,
        file_rel.clone(),
        gitcomet_core::domain::DiffArea::Unstaged,
    );
    set_test_conflict_file(
        &mut full_repo,
        file_rel.clone(),
        base.clone(),
        ours.clone(),
        theirs.clone(),
        full_current.clone(),
    );
    full_repo.conflict_state.conflict_file_load_mode =
        gitcomet_state::model::ConflictFileLoadMode::Full;
    // The reducer bumps this when the CurrentOnly request upgrades to Full;
    // mirror that notification boundary in this direct-state test fixture.
    full_repo.conflict_state.conflict_rev = current_only_state.repos[0]
        .conflict_state
        .conflict_rev
        .wrapping_add(1);
    full_repo.conflict_state.conflict_session =
        Some(ConflictSession::from_stage_inputs_with_current(
            file_rel.clone(),
            gitcomet_core::domain::FileConflictKind::BothModified,
            ConflictPayload::Text(base.clone().into()),
            ConflictPayload::Text(ours.into()),
            ConflictPayload::Text(theirs.into()),
            Some(ConflictPayload::Text(full_current.clone().into())),
        ));
    let full_state = app_state_with_repo(full_repo, repo_id);
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.store
                .replace_snapshot_for_test(Arc::clone(&full_state));
            crate::view::test_support::sync_store_snapshot(this, cx);
        });
    });
    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "full-side upgrade preserves three-way mode",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolver.three_way_text.base.as_ref() == base
                && !pane.conflict_resolver.three_way_aligned.is_identity()
                && pane.conflict_resolver.output_is_protected
                && pane.conflict_resolved_output_projection.is_none()
        },
        |pane| {
            format!(
                "path={:?} mode={:?} base_len={} identity={} protected={} projected={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.view_mode,
                pane.conflict_resolver.three_way_text.base.len(),
                pane.conflict_resolver.three_way_aligned.is_identity(),
                pane.conflict_resolver.output_is_protected,
                pane.conflict_resolved_output_projection.is_some(),
            )
        },
    );
    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        assert_eq!(
            pane.conflict_resolver.view_mode,
            ConflictResolverViewMode::ThreeWay
        );
        assert_eq!(
            pane.conflict_resolver_input.read(app).text(),
            full_current,
            "the Full upgrade must not replace a manual worktree result with stage markers",
        );
    });
}

/// Snapshot of every vertically synced conflict-resolver scroll offset.
#[derive(Clone, Copy, Debug, PartialEq)]
struct ConflictScrollSnapshot {
    base: Pixels,
    ours: Pixels,
    theirs: Pixels,
    output: Pixels,
    gutter: Pixels,
}

fn conflict_scroll_snapshot(pane: &MainPaneView) -> ConflictScrollSnapshot {
    ConflictScrollSnapshot {
        base: uniform_list_offset(&pane.conflict_resolver_diff_scroll).y,
        ours: uniform_list_offset(&pane.conflict_preview_ours_scroll).y,
        theirs: uniform_list_offset(&pane.conflict_preview_theirs_scroll).y,
        output: scroll_handle_offset(&pane.conflict_resolved_output_editor_scroll).y,
        gutter: uniform_list_offset(&pane.conflict_resolved_preview_gutter_scroll).y,
    }
}

fn read_conflict_scroll_snapshot(
    cx: &mut gpui::VisualTestContext,
    view: &gpui::Entity<super::super::GitCometView>,
) -> ConflictScrollSnapshot {
    cx.update(|_window, app| conflict_scroll_snapshot(view.read(app).main_pane.read(app)))
}

/// Once a scroll gesture has settled, further idle frames must not move any
/// pane. A jump here is the user-visible "the resolved output jumps after I
/// scroll" bug: some pane is treated as freshly changed on a frame with no
/// input, wins the master election, and drags the others onto it.
#[gpui::test]
fn conflict_resolver_scroll_positions_hold_across_idle_frames(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(191);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_idle_frame_scroll_hold",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/conflict_idle_frame_scroll_hold.txt");
    let abs_path = workdir.join(&file_rel);
    let base_text = build_conflict_scroll_matrix_text("base", 'B');
    let ours_text = build_conflict_scroll_matrix_text("ours", 'O');
    let theirs_text = build_conflict_scroll_matrix_text("theirs", 'T');
    let current_text = build_conflict_scroll_matrix_current_text(&ours_text, &theirs_text);

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create resolver idle-frame fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write resolver idle-frame fixture");

    seed_conflict_scroll_matrix_state(
        cx,
        &view,
        repo_id,
        &workdir,
        &file_rel,
        &base_text,
        &ours_text,
        &theirs_text,
        &current_text,
    );

    wait_for_main_pane_condition(
        cx,
        &view,
        "resolver idle-frame fixture initialized",
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolver.three_way_visible_len() >= 4
                && pane.conflict_resolved_preview_line_count >= 1
        },
        |pane| {
            format!(
                "path={:?} three_way_visible={} resolved_lines={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.three_way_visible_len(),
                pane.conflict_resolved_preview_line_count,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolver idle-frame vertical overflow",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.view_mode == ConflictResolverViewMode::ThreeWay
                && uniform_list_max_offset(&pane.conflict_resolver_diff_scroll).height > px(400.0)
                && scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll).height
                    > px(400.0)
        },
        |pane| {
            format!(
                "view_mode={:?} base_max={:?} output_max={:?}",
                pane.conflict_resolver.view_mode,
                uniform_list_max_offset(&pane.conflict_resolver_diff_scroll),
                scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll),
            )
        },
    );

    set_diff_scroll_sync_for_test(cx, &view, DiffScrollSync::Both);

    // A wheel over the resolved output: the editor handle moves natively and
    // the pane records the output as this gesture's master.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                reset_conflict_scroll_matrix_offsets(pane);
                set_scroll_handle_offset(
                    &pane.conflict_resolved_output_editor_scroll,
                    point(px(0.0), px(-240.0)),
                );
                pane.record_conflict_vertical_wheel_master(3);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    let settled = read_conflict_scroll_snapshot(cx, &view);
    for frame in 1..=3 {
        draw_and_drain_test_window(cx);
        let idle = read_conflict_scroll_snapshot(cx, &view);
        assert_eq!(
            idle, settled,
            "idle frame {frame} moved the resolver panes after an output wheel",
        );
    }

    // A minimap click/drag: every source column gets a deferred
    // `scroll_to_item_strict`, which lands during prepaint, after this frame's
    // synchronizer already ran.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                reset_conflict_scroll_matrix_offsets(pane);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_scroll_all_columns(90, gpui::ScrollStrategy::Center);
                cx.notify();
            });
        });
    });
    // Two frames: one for prepaint to consume the deferred scroll, one for the
    // synchronizer to observe it and remap the output.
    draw_and_drain_test_window(cx);
    draw_and_drain_test_window(cx);

    let settled = read_conflict_scroll_snapshot(cx, &view);
    assert!(
        settled.base < px(0.0),
        "the minimap jump should have scrolled the columns, got {settled:?}",
    );
    for frame in 1..=3 {
        draw_and_drain_test_window(cx);
        let idle = read_conflict_scroll_snapshot(cx, &view);
        assert_eq!(
            idle, settled,
            "idle frame {frame} moved the resolver panes after a minimap jump",
        );
    }

    // Scrolling a source column: the columns drive, and the output is remapped
    // through the conflict anchors rather than copied 1:1.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                reset_conflict_scroll_matrix_offsets(pane);
                set_uniform_list_offset(
                    &pane.conflict_resolver_diff_scroll,
                    point(px(0.0), px(-320.0)),
                );
                pane.record_conflict_vertical_wheel_master(0);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    let settled = read_conflict_scroll_snapshot(cx, &view);
    for frame in 1..=3 {
        draw_and_drain_test_window(cx);
        let idle = read_conflict_scroll_snapshot(cx, &view);
        assert_eq!(
            idle, settled,
            "idle frame {frame} moved the resolver panes after a column wheel",
        );
    }

    // The bottom boundary: the source columns carry comfort overscroll rows the
    // resolved output does not, so the output clamps while the columns keep
    // going. The clamped follower must not then be promoted to master.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                let deep = uniform_list_max_offset(&pane.conflict_resolver_diff_scroll).height;
                set_uniform_list_offset(&pane.conflict_resolver_diff_scroll, point(px(0.0), -deep));
                pane.record_conflict_vertical_wheel_master(0);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    let settled = read_conflict_scroll_snapshot(cx, &view);
    for frame in 1..=3 {
        draw_and_drain_test_window(cx);
        let idle = read_conflict_scroll_snapshot(cx, &view);
        assert_eq!(
            idle, settled,
            "idle frame {frame} moved the resolver panes at the bottom clamp boundary",
        );
    }

    // Collapsed context folds the line-number gutter's row space but not the
    // editor's text, so the two are no longer the same number of rows.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                // Pre-match the persisted default so the toggle does not
                // schedule a settings persist (which re-enters the view).
                pane.mergetool_collapse_unchanged = true;
                pane.conflict_resolver_toggle_collapse_context(cx);
                reset_conflict_scroll_matrix_offsets(pane);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);
    draw_and_drain_test_window(cx);

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                set_scroll_handle_offset(
                    &pane.conflict_resolved_output_editor_scroll,
                    point(px(0.0), px(-240.0)),
                );
                pane.record_conflict_vertical_wheel_master(3);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    let settled = read_conflict_scroll_snapshot(cx, &view);
    for frame in 1..=3 {
        draw_and_drain_test_window(cx);
        let idle = read_conflict_scroll_snapshot(cx, &view);
        assert_eq!(
            idle, settled,
            "idle frame {frame} moved the resolver panes with collapsed context",
        );
    }

    std::fs::remove_dir_all(&workdir).expect("cleanup resolver idle-frame fixture");
}

/// A multi-conflict fixture whose two sides have different line counts per
/// block, so the aligned column row space and the resolved output line space
/// genuinely diverge and the conflict-anchored remap has real work to do.
///
/// The divergence comes from the *settled* blocks: an unresolved block now
/// covers its full aligned span in the output too (one named placeholder row
/// plus blank rows), so leaving every block conflicted would make the two
/// spaces line up 1:1 and prove nothing. Every other block is therefore already
/// merged to ours in `current` — it occupies `ours_len` output lines against
/// `max(ours_len, theirs_len)` aligned rows — while the blocks in between stay
/// conflicted so the output still has markers to anchor on.
fn build_multi_conflict_sides() -> (String, String, String, String) {
    let mut base = Vec::new();
    let mut ours = Vec::new();
    let mut theirs = Vec::new();
    let mut current = Vec::new();
    for block in 0..8 {
        for ctx in 0..12 {
            let line = format!("context {block:02}/{ctx:02} shared text");
            base.push(line.clone());
            ours.push(line.clone());
            theirs.push(line.clone());
            current.push(line);
        }
        // Asymmetric block sizes: ours grows with the block index, theirs
        // shrinks, so no single global ratio maps the two row spaces.
        let ours_len = 2 + block;
        let theirs_len = 10 - block;
        let settled = block % 2 == 1;
        base.push(format!("base block {block:02}"));
        if !settled {
            current.push("<<<<<<< ours".to_string());
        }
        for line in 0..ours_len {
            let text = format!("ours {block:02}/{line:02}");
            ours.push(text.clone());
            current.push(text);
        }
        if !settled {
            current.push("=======".to_string());
        }
        for line in 0..theirs_len {
            let text = format!("theirs {block:02}/{line:02}");
            theirs.push(text.clone());
            if !settled {
                current.push(text);
            }
        }
        if !settled {
            current.push(">>>>>>> theirs".to_string());
        }
    }
    let join = |lines: Vec<String>| format!("{}\n", lines.join("\n"));
    (join(base), join(ours), join(theirs), join(current))
}

/// The resolved output scrolls entirely on its own: walking a source column
/// down must leave it exactly where it was, and vice versa.
///
/// This is the KDiff3 behaviour — its merge result window owns a scrollbar the
/// diff windows are not connected to. Offsets are never mapped between the two
/// documents, because on a real conflict, where a changed block can occur every
/// few rows, no continuous mapping between them exists. Navigation is what
/// brings the two panes onto the same block.
#[gpui::test]
fn conflict_resolver_output_scrolls_independently_of_the_columns(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(192);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_independent_output_scroll",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/conflict_independent_output_scroll.txt");
    let abs_path = workdir.join(&file_rel);
    let (base_text, ours_text, theirs_text, current_text) = build_multi_conflict_sides();

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create resolver independence fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write resolver independence fixture");

    seed_unresolved_conflict_state(
        cx,
        &view,
        repo_id,
        &workdir,
        &file_rel,
        &base_text,
        &ours_text,
        &theirs_text,
        &current_text,
    );

    wait_for_main_pane_condition(
        cx,
        &view,
        "resolver independence fixture initialized",
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolver.three_way_visible_len() >= 4
                && pane.conflict_resolved_preview_line_count >= 1
        },
        |pane| {
            format!(
                "path={:?} visible={} lines={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.three_way_visible_len(),
                pane.conflict_resolved_preview_line_count,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolver independence overflow",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.view_mode == ConflictResolverViewMode::ThreeWay
                && uniform_list_max_offset(&pane.conflict_resolver_diff_scroll).height > px(400.0)
                && scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll).height
                    > px(400.0)
        },
        |pane| {
            format!(
                "base_max={:?} output_max={:?}",
                uniform_list_max_offset(&pane.conflict_resolver_diff_scroll),
                scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll),
            )
        },
    );

    set_diff_scroll_sync_for_test(cx, &view, DiffScrollSync::Both);

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                // Output scroll sync on, which is the demanding case: even
                // then the vertical axis carries no relationship between the
                // resolved output and the columns.
                pane.mergetool_output_scroll_sync = true;
                reset_conflict_scroll_matrix_offsets(pane);
                // Park the output partway down so a stray coupling would show
                // up as movement in either direction.
                set_scroll_handle_offset(
                    &pane.conflict_resolved_output_editor_scroll,
                    point(px(0.0), px(-400.0)),
                );
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    let parked_output = cx.update(|_window, app| {
        scroll_handle_offset(
            &view
                .read(app)
                .main_pane
                .read(app)
                .conflict_resolved_output_editor_scroll,
        )
        .y
    });

    // Walk the base column the length of the file. The output must not budge,
    // and the other two columns must track the base exactly.
    let column_max = cx.update(|_window, app| {
        uniform_list_max_offset(
            &view
                .read(app)
                .main_pane
                .read(app)
                .conflict_resolver_diff_scroll,
        )
        .height
    });
    let mut row = 0.0f32;
    while px(row * 20.0) < column_max {
        let target = point(px(0.0), px(-row * 20.0));
        cx.update(|_window, app| {
            view.update(app, |this, cx| {
                this.main_pane.update(cx, |pane, cx| {
                    set_uniform_list_offset(&pane.conflict_resolver_diff_scroll, target);
                    pane.record_conflict_vertical_wheel_master(0);
                    cx.notify();
                });
            });
        });
        draw_and_drain_test_window(cx);

        let snapshot = read_conflict_scroll_snapshot(cx, &view);
        assert!(
            (f32::from(snapshot.output) - f32::from(parked_output)).abs() < 0.5,
            "scrolling the base column to row {row} moved the resolved output from \
             {parked_output:?} to {:?}",
            snapshot.output,
        );
        assert!(
            (f32::from(snapshot.ours) - f32::from(snapshot.base)).abs() < 0.5
                && (f32::from(snapshot.theirs) - f32::from(snapshot.base)).abs() < 0.5,
            "the aligned columns share one row space and must stay together: {snapshot:?}",
        );
        row += 1.0;
    }

    // And the reverse: scrolling the output leaves the columns alone.
    let parked_columns = read_conflict_scroll_snapshot(cx, &view).base;
    let output_max = cx.update(|_window, app| {
        scroll_handle_max_offset(
            &view
                .read(app)
                .main_pane
                .read(app)
                .conflict_resolved_output_editor_scroll,
        )
        .height
    });
    let mut row = 0.0f32;
    while px(row * 20.0) < output_max {
        let target = point(px(0.0), px(-row * 20.0));
        cx.update(|_window, app| {
            view.update(app, |this, cx| {
                this.main_pane.update(cx, |pane, cx| {
                    set_scroll_handle_offset(&pane.conflict_resolved_output_editor_scroll, target);
                    pane.record_conflict_vertical_wheel_master(3);
                    cx.notify();
                });
            });
        });
        draw_and_drain_test_window(cx);

        let snapshot = read_conflict_scroll_snapshot(cx, &view);
        assert!(
            (f32::from(snapshot.base) - f32::from(parked_columns)).abs() < 0.5,
            "scrolling the resolved output to row {row} moved the base column from \
             {parked_columns:?} to {:?}",
            snapshot.base,
        );
        row += 1.0;
    }

    std::fs::remove_dir_all(&workdir).expect("cleanup resolver independence fixture");
}

/// A freshly materialized resolved output must not leave the caret parked at
/// end-of-document: the pane opens at the top, so the first arrow key would
/// autoscroll the whole coupled group to the bottom of the file.
#[gpui::test]
fn conflict_resolver_materialized_output_parks_caret_at_the_start(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(193);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_caret_park",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/conflict_caret_park.txt");
    let abs_path = workdir.join(&file_rel);
    let (base_text, ours_text, theirs_text, current_text) = build_multi_conflict_sides();

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create resolver caret-park fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write resolver caret-park fixture");

    seed_unresolved_conflict_state(
        cx,
        &view,
        repo_id,
        &workdir,
        &file_rel,
        &base_text,
        &ours_text,
        &theirs_text,
        &current_text,
    );

    wait_for_main_pane_condition(
        cx,
        &view,
        "resolver caret-park fixture initialized",
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolved_preview_line_count >= 1
        },
        |pane| {
            format!(
                "path={:?} resolved_lines={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolved_preview_line_count,
            )
        },
    );
    draw_and_drain_test_window(cx);

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        let input = pane.conflict_resolver_input.read(app);
        assert!(
            input.text().len() > 100,
            "fixture should have materialized a multi-line output",
        );
        assert_eq!(
            input.selected_range(),
            0..0,
            "a freshly materialized resolved output should park the caret at the start",
        );
    });

    std::fs::remove_dir_all(&workdir).expect("cleanup resolver caret-park fixture");
}

/// Seed a conflict session with every region left unresolved, so the resolved
/// output still renders conflict markers and the column/output anchor list is
/// non-trivial.
fn seed_unresolved_conflict_state(
    cx: &mut gpui::VisualTestContext,
    view: &gpui::Entity<super::super::GitCometView>,
    repo_id: gitcomet_state::model::RepoId,
    workdir: &std::path::Path,
    file_rel: &std::path::Path,
    base_text: &str,
    ours_text: &str,
    theirs_text: &str,
    current_text: &str,
) {
    use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession};

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let mut repo = opening_repo_state(repo_id, workdir);
            set_test_conflict_status(
                &mut repo,
                file_rel.to_path_buf(),
                gitcomet_core::domain::DiffArea::Unstaged,
            );
            set_test_conflict_file(
                &mut repo,
                file_rel.to_path_buf(),
                base_text.to_string(),
                ours_text.to_string(),
                theirs_text.to_string(),
                current_text.to_string(),
            );
            // Plan-backed, the way the app builds a full-text conflict:
            // `from_merged_text` derives geometry from whatever markers happen
            // to be in the worktree and leaves `merge_plan` empty, which would
            // silently exercise only the marker-only anchor fallback.
            repo.conflict_state.conflict_session =
                Some(ConflictSession::from_stage_inputs_with_current(
                    file_rel.to_path_buf(),
                    gitcomet_core::domain::FileConflictKind::BothModified,
                    ConflictPayload::Text(base_text.to_string().into()),
                    ConflictPayload::Text(ours_text.to_string().into()),
                    ConflictPayload::Text(theirs_text.to_string().into()),
                    Some(ConflictPayload::Text(current_text.to_string().into())),
                ));

            push_test_state(this, app_state_with_repo(repo, repo_id), cx);
        });
    });
}

/// Conflict navigation centers the aligned row in the source columns and the
/// output line in the resolved output, independently. Those two panes are the
/// halves of the vsplit and therefore have different heights, while the
/// column/output scroll sync aligns their *top* rows. Two centerings cannot
/// both survive that, so the sync drags one onto the other and the loser jumps.
#[gpui::test]
fn conflict_navigation_settles_without_a_second_jump(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(194);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_nav_center_jump",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/conflict_nav_center_jump.txt");
    let abs_path = workdir.join(&file_rel);
    let (base_text, ours_text, theirs_text, current_text) = build_multi_conflict_sides();

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create resolver nav-center fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write resolver nav-center fixture");

    seed_unresolved_conflict_state(
        cx,
        &view,
        repo_id,
        &workdir,
        &file_rel,
        &base_text,
        &ours_text,
        &theirs_text,
        &current_text,
    );

    wait_for_main_pane_condition(
        cx,
        &view,
        "resolver nav-center fixture initialized",
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolver.three_way_visible_len() >= 4
                && pane.conflict_resolved_preview_line_count >= 1
        },
        |pane| {
            format!(
                "path={:?} visible={} lines={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.three_way_visible_len(),
                pane.conflict_resolved_preview_line_count,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolver nav-center overflow",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.view_mode == ConflictResolverViewMode::ThreeWay
                && uniform_list_max_offset(&pane.conflict_resolver_diff_scroll).height > px(400.0)
                && scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll).height
                    > px(400.0)
        },
        |pane| {
            format!(
                "base_max={:?} output_max={:?}",
                uniform_list_max_offset(&pane.conflict_resolver_diff_scroll),
                scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll),
            )
        },
    );

    set_diff_scroll_sync_for_test(cx, &view, DiffScrollSync::Both);

    for target in 1..5usize {
        cx.update(|_window, app| {
            view.update(app, |this, cx| {
                this.main_pane.update(cx, |pane, cx| {
                    reset_conflict_scroll_matrix_offsets(pane);
                    cx.notify();
                });
            });
        });
        draw_and_drain_test_window(cx);

        cx.update(|_window, app| {
            view.update(app, |this, cx| {
                this.main_pane.update(cx, |pane, cx| {
                    pane.conflict_jump_to_nav_target(target, cx);
                });
            });
        });
        // Let the deferred item scrolls land and the synchronizer observe them.
        draw_and_drain_test_window(cx);
        draw_and_drain_test_window(cx);
        let settled = read_conflict_scroll_snapshot(cx, &view);

        for frame in 1..=3 {
            draw_and_drain_test_window(cx);
            let idle = read_conflict_scroll_snapshot(cx, &view);
            let output_jump = f32::from(idle.output) - f32::from(settled.output);
            let column_jump = f32::from(idle.base) - f32::from(settled.base);
            assert!(
                output_jump.abs() < 1.0 && column_jump.abs() < 1.0,
                "target {target}, idle frame {frame}: navigation did not settle — output \
                 moved {output_jump}px ({:.1} lines), columns moved {column_jump}px \
                 ({:.1} lines); settled={settled:?} idle={idle:?}",
                output_jump / 20.0,
                column_jump / 20.0,
            );
        }
    }

    std::fs::remove_dir_all(&workdir).expect("cleanup resolver nav-center fixture");
}

/// The resolved output washes the conflict being resolved in yellow, and the
/// wash has to follow conflict navigation.
///
/// Navigating moves no text and touches no tree, so none of the paths that
/// normally reinstall the output's highlights fire — the pane only reassigns
/// `active_conflict`. Without the render pass noticing that, the wash stays
/// parked on whichever conflict the file opened on, which is worse than no wash
/// at all: it points at the wrong row.
#[gpui::test]
fn the_resolved_output_wash_follows_conflict_navigation(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(197);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_active_conflict_wash",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/active_conflict_wash.txt");
    let abs_path = workdir.join(&file_rel);
    let base = "head\nbase one\nmiddle\nbase two\ntail\n";
    let ours = "head\nours one\nmiddle\nours two\ntail\n";
    let theirs = "head\ntheirs one\nmiddle\ntheirs two\ntail\n";
    let current = "head\n\
                   <<<<<<< ours\nours one\n=======\ntheirs one\n>>>>>>> theirs\n\
                   middle\n\
                   <<<<<<< ours\nours two\n=======\ntheirs two\n>>>>>>> theirs\n\
                   tail\n";

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create active-conflict wash fixture dir");
    std::fs::write(&abs_path, current).expect("write active-conflict wash fixture");

    seed_unresolved_conflict_state(
        cx, &view, repo_id, &workdir, &file_rel, base, ours, theirs, current,
    );

    wait_for_main_pane_condition(
        cx,
        &view,
        "two-conflict wash fixture initialized",
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && crate::view::conflict_resolver::conflict_count(
                    &pane.conflict_resolver.marker_segments,
                ) == 2
                && !pane.conflict_resolved_output_is_streamed()
        },
        |pane| {
            format!(
                "path={:?} blocks={} streamed={}",
                pane.conflict_resolver.path.clone(),
                crate::view::conflict_resolver::conflict_count(
                    &pane.conflict_resolver.marker_segments,
                ),
                pane.conflict_resolved_output_is_streamed(),
            )
        },
    );

    // Both placeholder rows read `<Merge Conflict>`, so only their offsets can
    // say which one is washed.
    let placeholder = crate::view::conflict_resolver::UNRESOLVED_MERGE_CONFLICT_PLACEHOLDER;
    let output = cx.update(|_window, app| {
        view.read(app)
            .main_pane
            .read(app)
            .conflict_resolver_input
            .read(app)
            .text()
            .to_string()
    });
    let first = output.find(placeholder).expect("first placeholder row");
    let second = output[first + placeholder.len()..]
        .find(placeholder)
        .expect("second placeholder row")
        + first
        + placeholder.len();

    let washed_ranges = |cx: &mut gpui::VisualTestContext| -> Vec<std::ops::Range<usize>> {
        cx.update(|_window, app| {
            view.update(app, |this, cx| {
                this.main_pane.update(cx, |pane, cx| {
                    let wash = crate::view::panes::main::resolved_output_active_conflict_background(
                        pane.theme,
                    );
                    let len = pane.conflict_resolver_input.read(cx).text().len();
                    pane.conflict_resolver_input
                        .update(cx, |input, _| {
                            input.debug_effective_highlights_for_range(0..len)
                        })
                        .into_iter()
                        .filter(|(_, style)| style.background_color == Some(wash.into_color()))
                        .map(|(range, _)| range)
                        .collect()
                })
            })
        })
    };

    for (conflict_ix, expected_start) in [(0usize, first), (1, second), (0, first)] {
        cx.update(|_window, app| {
            view.update(app, |this, cx| {
                this.main_pane.update(cx, |pane, cx| {
                    pane.conflict_resolver_select_conflict(conflict_ix, cx);
                });
            });
        });
        draw_and_drain_test_window(cx);

        assert_eq!(
            washed_ranges(cx),
            vec![expected_start..expected_start + placeholder.len()],
            "selecting conflict {conflict_ix} must wash its row and only its row"
        );
    }

    // A pick can settle on a block that renders no marker, leaving nothing
    // selected. The wash has to come off then too, rather than staying on the
    // row the last selection put it on.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver.active_conflict = None;
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);
    assert!(
        washed_ranges(cx).is_empty(),
        "with no conflict selected there is nothing for the wash to point at"
    );

    std::fs::remove_dir_all(&workdir).expect("cleanup active-conflict wash fixture");
}

/// An HTML-shaped fixture with the structure that exposed the bug: a repeated
/// card, most of which the planner settles on its own, and a handful of real
/// conflicts spread through the file.
///
/// The repetition matters — it is what makes the marker-projection estimate
/// drift, because the same text appears in every card.
fn build_repetitive_card_conflict_sides() -> (String, String, String, String) {
    let mut base = Vec::new();
    let mut ours = Vec::new();
    let mut theirs = Vec::new();

    base.push("<html>".to_string());
    ours.push("<html>".to_string());
    theirs.push("<html>".to_string());
    for card in 0..24 {
        let head = [
            format!("  <article class=\"card\" id=\"card-{card:02}\">"),
            "    <header>".to_string(),
            format!("      <h2>Card {card:02}</h2>"),
        ];
        for line in &head {
            base.push(line.clone());
            ours.push(line.clone());
            theirs.push(line.clone());
        }

        // Every third card is a real conflict; the ones between it are edits
        // both sides made the same way, which the planner resolves by itself.
        match card % 3 {
            0 => {
                base.push("      <span>Healthy</span>".to_string());
                ours.push("      <span>Local override</span>".to_string());
                theirs.push("      <span>Remote canary</span>".to_string());
            }
            1 => {
                base.push("      <span>Healthy</span>".to_string());
                ours.push("      <span>Shared rollout</span>".to_string());
                theirs.push("      <span>Shared rollout</span>".to_string());
            }
            _ => {
                for side in [&mut base, &mut ours, &mut theirs] {
                    side.push("      <span>Healthy</span>".to_string());
                }
            }
        }

        let tail = [
            "    </header>".to_string(),
            "    <div class=\"body\">".to_string(),
            "      <p>Nominal traffic across all production cells.</p>".to_string(),
            "    </div>".to_string(),
            "  </article>".to_string(),
        ];
        for line in &tail {
            base.push(line.clone());
            ours.push(line.clone());
            theirs.push(line.clone());
        }
    }
    base.push("</html>".to_string());
    ours.push("</html>".to_string());
    theirs.push("</html>".to_string());

    let join = |lines: Vec<String>| format!("{}\n", lines.join("\n"));
    let (base, ours, theirs) = (join(base), join(ours), join(theirs));
    let current = gitcomet_core::merge::merge_file_with_optional_base(
        Some(base.as_str()),
        &ours,
        &theirs,
        &gitcomet_core::merge::MergeOptions::default(),
    )
    .output;
    (base, ours, theirs, current)
}

/// Every conflict highlight must stay inside the block it belongs to.
///
/// The highlight is driven by `three_way_conflict_ranges` (the chunk bar and
/// the active-conflict tint) and by the nav targets' `aligned_rows`. Both are
/// exact when the merge plan describes them; the marker-projection estimate
/// used to be able to hand a block a range running to the end of the file,
/// which painted the whole tail as one enormous selected conflict.
fn assert_conflict_highlight_ranges_are_bounded(
    cx: &mut gpui::VisualTestContext,
    view: &gpui::Entity<super::super::GitCometView>,
    stage: &str,
) {
    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        let aligned_len = pane.conflict_resolver.three_way_len;
        let ranges =
            &pane.conflict_resolver.three_way_conflict_ranges[crate::view::ThreeWayColumn::Ours];
        assert!(
            !ranges.is_empty(),
            "{stage}: the fixture must still have conflicts to highlight",
        );

        // No block may claim more than a modest slice of the file. The fixture's
        // conflicts are a line or two; anything spanning a quarter of the aligned
        // rows is the runaway range this guards against.
        let budget = (aligned_len / 4).max(8);
        for (ix, range) in ranges.iter().enumerate() {
            assert!(
                range.end <= aligned_len,
                "{stage}: conflict {ix} range {range:?} leaves the aligned space \
                 (len {aligned_len})",
            );
            assert!(
                range.len() <= budget,
                "{stage}: conflict {ix} spans {} of {aligned_len} aligned rows ({range:?})",
                range.len(),
            );
        }
        for pair in ranges.windows(2) {
            assert!(
                pair[0].end <= pair[1].start,
                "{stage}: conflict ranges overlap or go backwards: {pair:?}",
            );
        }

        for (ix, target) in pane.conflict_resolver.nav_targets.iter().enumerate() {
            let Some(rows) = target.aligned_rows.as_ref() else {
                continue;
            };
            assert!(
                rows.end <= aligned_len && rows.len() <= budget,
                "{stage}: nav target {ix} spans {rows:?} of {aligned_len} aligned rows",
            );
        }

        // The painted highlight itself: every aligned row the source columns
        // mark as the active conflict has to belong to a conflict.
        let highlighted = (0..aligned_len)
            .filter(|row| {
                let conflict_ix = pane
                    .conflict_resolver
                    .conflict_index_for_side_line(crate::view::ThreeWayColumn::Ours, *row);
                pane.conflict_resolver.conflict_is_active(conflict_ix)
                    || pane
                        .conflict_resolver
                        .selected_nav_target_contains_aligned_row(*row)
            })
            .count();
        assert!(
            highlighted <= budget,
            "{stage}: {highlighted} of {aligned_len} aligned rows are painted as the \
             active conflict (active={:?})",
            pane.conflict_resolver.active_conflict,
        );
    });
}

/// The active-conflict highlight must never cover more than the conflict it
/// belongs to — not on open, not after a pick, and above all not when nothing
/// is selected, which is where it used to swallow the rest of the file.
///
/// It guards the two range sources (the aligned conflict ranges behind the
/// chunk bar, and the nav targets' `aligned_rows`) as well as the predicate the
/// source columns actually paint with.
#[gpui::test]
fn the_conflict_highlight_stays_inside_the_conflict_it_belongs_to(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(193);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_conflict_highlight_bounds",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/conflict_highlight_bounds.html");
    let abs_path = workdir.join(&file_rel);
    let (base_text, ours_text, theirs_text, current_text) = build_repetitive_card_conflict_sides();

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create conflict highlight fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write conflict highlight fixture");

    seed_unresolved_conflict_state(
        cx,
        &view,
        repo_id,
        &workdir,
        &file_rel,
        &base_text,
        &ours_text,
        &theirs_text,
        &current_text,
    );

    wait_for_main_pane_condition(
        cx,
        &view,
        "conflict highlight fixture initialized",
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && !pane.conflict_resolver.three_way_aligned.is_identity()
                && pane.conflict_resolver.three_way_conflict_ranges
                    [crate::view::ThreeWayColumn::Ours]
                    .len()
                    >= 4
        },
        |pane| {
            format!(
                "path={:?} identity={} ranges={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.three_way_aligned.is_identity(),
                pane.conflict_resolver.three_way_conflict_ranges[crate::view::ThreeWayColumn::Ours]
                    .len(),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
            });
        });
    });
    draw_and_drain_test_window(cx);
    assert_conflict_highlight_ranges_are_bounded(cx, &view, "on open");

    // Pick a source on a conflict partway down the file — the case the user hit.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_select_conflict(3, cx);
                pane.conflict_resolver_pick_active_conflict(
                    crate::view::conflict_resolver::ConflictChoice::Theirs,
                    cx,
                );
            });
        });
    });
    draw_and_drain_test_window(cx);
    assert_conflict_highlight_ranges_are_bounded(cx, &view, "after picking a source");

    // Nothing selected is the state a pick can settle into: the anchor lands on
    // a block that renders no marker, so there is no displayed conflict index.
    // Rows outside every conflict must stay unmarked — comparing the row's
    // `Option` conflict index against the equally-`None` selection is what used
    // to light up the whole file below the conflict that was just resolved.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver.active_conflict = None;
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);
    assert_conflict_highlight_ranges_are_bounded(cx, &view, "with nothing selected");

    std::fs::remove_dir_all(&workdir).expect("cleanup conflict highlight fixture");
}

#[gpui::test]
fn measure_resolved_output_typing_rerenders(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(917);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_typing_rerender",
        std::process::id()
    ));
    // A real source file, so the syntax-highlighting path a user actually hits
    // is in the measurement.
    let file_rel = std::path::PathBuf::from("fixtures/conflict_typing_rerender.rs");
    let abs_path = workdir.join(&file_rel);
    let side_lines: usize = std::env::var("GITCOMET_MEASURE_LINES")
        .ok()
        .and_then(|value| value.parse().ok())
        .unwrap_or(2000);
    let big = |label: &str, fill: char| -> String {
        (0..side_lines)
            .map(|ix| {
                format!(
                    "fn {label}_{ix:05}(value: usize) -> String {{ format!(\"{}{ix}\", value) }}",
                    fill.to_string().repeat(20)
                )
            })
            .collect::<Vec<_>>()
            .join("\n")
    };
    let base_text = big("base", 'B');
    let ours_text = big("ours", 'O');
    let theirs_text = big("theirs", 'T');
    let current_text = build_conflict_scroll_matrix_current_text(&ours_text, &theirs_text);

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create typing fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write typing fixture");

    seed_conflict_scroll_matrix_state(
        cx,
        &view,
        repo_id,
        &workdir,
        &file_rel,
        &base_text,
        &ours_text,
        &theirs_text,
        &current_text,
    );

    wait_for_main_pane_condition(
        cx,
        &view,
        "typing fixture initialized",
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolved_preview_line_count >= 1
        },
        |pane| format!("path={:?}", pane.conflict_resolver.path.clone()),
    );
    let main_pane = cx.update(|_window, app| view.read(app).main_pane.clone());
    cx.update(|_window, app| {
        main_pane.update(app, |pane, cx| {
            pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
            cx.notify();
        });
    });
    draw_and_drain_test_window(cx);
    draw_and_drain_test_window(cx);

    // Confirm the resolver columns really render in this window before trusting
    // any of the numbers below.
    crate::view::perf::reset();
    cx.update(|window, app| {
        main_pane.update(app, |_pane, cx| cx.notify());
        let _ = window.draw(app);
    });
    eprintln!(
        "MEASURE cold full draw perf: {:?}",
        crate::view::perf::snapshot()
    );
    cx.run_until_parked();

    let main_notifies = Arc::new(AtomicUsize::new(0));
    let _main_notify_sub = cx.update(|_window, app| {
        let main_notifies = Arc::clone(&main_notifies);
        main_pane.update(app, |_pane, cx| {
            cx.observe_self(move |_pane, _cx| {
                main_notifies.fetch_add(1, Ordering::Relaxed);
            })
        })
    });

    let streamed =
        cx.update(|_window, app| main_pane.read(app).conflict_resolved_output_is_streamed());
    eprintln!("MEASURE streamed={streamed}");
    eprintln!(
        "MEASURE size_of::<ShapedLine>()={} lines={}",
        std::mem::size_of::<gpui::ShapedLine>(),
        cx.update(|_window, app| main_pane
            .read(app)
            .conflict_resolver_input
            .read(app)
            .text()
            .lines()
            .count()),
    );

    cx.update(|_window, app| {
        main_pane.update(app, |pane, _cx| {
            pane.set_conflict_resolved_outline_background_delay_override_for_tests(
                std::time::Duration::from_millis(500),
            );
        });
    });

    // Idle baseline: draws with no edits at all.
    main_notifies.store(0, Ordering::Relaxed);
    let idle_started = std::time::Instant::now();
    for _ in 0..5 {
        draw_and_drain_test_window(cx);
    }
    eprintln!(
        "MEASURE idle: 5 draws in {:?}, main notifies={}",
        idle_started.elapsed(),
        main_notifies.load(Ordering::Relaxed)
    );

    // Cost of a bare main-pane re-render (notify, no edit).
    main_notifies.store(0, Ordering::Relaxed);
    for ix in 0..5usize {
        cx.update(|_window, app| {
            main_pane.update(app, |_pane, cx| cx.notify());
        });
        let draw_started = std::time::Instant::now();
        cx.update(|window, app| {
            let _ = window.draw(app);
        });
        eprintln!(
            "MEASURE notify-only draw {ix}: {:?}",
            draw_started.elapsed()
        );
        cx.run_until_parked();
    }

    // Typing: one character at a time, each followed by a frame.
    main_notifies.store(0, Ordering::Relaxed);
    let typing_started = std::time::Instant::now();
    for ix in 0..5usize {
        let before = main_notifies.load(Ordering::Relaxed);
        crate::view::perf::reset();
        let keystroke_started = std::time::Instant::now();
        let buffer_elapsed = cx.update(|_window, app| {
            main_pane.update(app, |pane, cx| {
                pane.conflict_resolver_input.update(cx, |input, cx| {
                    let at = input.text().len().min(40);
                    let started = std::time::Instant::now();
                    input.replace_utf8_range(at..at, "x", cx);
                    started.elapsed()
                })
            })
        });
        // Everything after the buffer edit but before the frame: the
        // `cx.observe(conflict_resolver_input)` closure and any other flushed
        // effects.
        let effects_elapsed = keystroke_started.elapsed() - buffer_elapsed;
        // `flush_effects` auto-draws dirty windows in test builds, so the frame
        // the keystroke causes is already inside `effects_elapsed`.
        let effects_perf = crate::view::perf::snapshot();
        let after_edit = main_notifies.load(Ordering::Relaxed);
        crate::view::perf::reset();
        let draw_started = std::time::Instant::now();
        cx.update(|window, app| {
            let _ = window.draw(app);
        });
        let draw_elapsed = draw_started.elapsed();
        let perf = crate::view::perf::snapshot();
        // Debounced follow-up work (outline recompute, syntax refresh) plus the
        // frame it schedules.
        crate::view::perf::reset();
        let settle_started = std::time::Instant::now();
        cx.executor()
            .advance_clock(std::time::Duration::from_millis(600));
        cx.run_until_parked();
        cx.update(|window, app| {
            let _ = window.draw(app);
        });
        let settle_elapsed = settle_started.elapsed();
        let settle_perf = crate::view::perf::snapshot();
        cx.run_until_parked();
        eprintln!(
            "MEASURE keystroke {ix}: buffer={buffer_elapsed:?} effects={effects_elapsed:?} (notifies {}) draw={draw_elapsed:?} settle={settle_elapsed:?} (notifies {})",
            after_edit - before,
            main_notifies.load(Ordering::Relaxed) - before,
        );
        eprintln!("MEASURE keystroke {ix} effects perf: {effects_perf:?}");
        eprintln!("MEASURE keystroke {ix} draw perf: {perf:?}");
        eprintln!("MEASURE keystroke {ix} settle perf: {settle_perf:?}");
    }
    eprintln!(
        "MEASURE typing: 5 keystrokes in {:?}, main notifies={}",
        typing_started.elapsed(),
        main_notifies.load(Ordering::Relaxed)
    );

    std::fs::remove_dir_all(&workdir).expect("cleanup typing fixture");
}

/// Assert the resolved output is coloured by tree-sitter rather than by the
/// heuristic tokenizer.
///
/// The two engines agree on keywords, strings, numbers and comments, so an
/// assertion built from those classes cannot see the difference. These four
/// probes can: `syntax/heuristic.rs` has no notion of a `primitive_type`, a
/// `type_identifier`, a `field_identifier` or a method call, and leaves all of
/// them plain. If any comes back uncoloured, the pane is on the fallback --
/// which is what the diff panes above it are *not* on, hence the mismatch.
fn assert_resolved_output_carries_treesitter_classes(
    text: &str,
    highlights: &[(std::ops::Range<usize>, gpui::HighlightStyle)],
    theme: crate::theme::AppTheme,
) {
    for (needle, class, expected) in [
        ("usize", "primitive_type", theme.syntax.type_builtin),
        ("Stage {", "type_identifier", theme.syntax.type_name),
        ("retries: usize", "field_identifier", theme.syntax.property),
        ("wrapping_add", "method call", theme.syntax.function_method),
    ] {
        let at = text
            .find(needle)
            .unwrap_or_else(|| panic!("fixture should contain {needle:?}"));
        let found = highlights
            .iter()
            .find(|(range, _)| range.start <= at && range.end > at)
            .and_then(|(_, style)| style.color);
        assert_eq!(
            found,
            Some(expected.into_color()),
            "{needle:?} at {at} is a {class} and must carry its tree-sitter colour; \
             the heuristic tokenizer leaves it plain, so a mismatch here means the \
             resolved output never got a live document"
        );
    }
}

/// A dark theme that differs from `gitcomet_dark` only in its syntax palette.
fn other_dark_theme() -> crate::theme::AppTheme {
    crate::theme::AppTheme::from_json_str(&crate::theme::test_theme_json_with_syntax(
        "gitcomet_dark",
        r##"{
            "keyword": "#112233ff",
            "comment": "#445566ff"
        }"##,
    ))
    .expect("fixture theme JSON should parse")
}
/// The placeholder mask for a resolved-output text, matching what the pane
/// derives: the placeholder rows minus their line terminator.
fn resolved_output_placeholder_protected_ranges_for_test(
    text: &str,
) -> Arc<[std::ops::Range<usize>]> {
    let mut mask = Vec::new();
    let mut offset = 0usize;
    for line in text.split_inclusive('\n') {
        let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
        if conflict_resolver::line_is_unresolved_conflict_placeholder(trimmed) {
            mask.push(offset..offset + trimmed.len());
        }
        offset += line.len();
    }
    mask.into()
}

/// Conflict navigation must move the editable output in the frame it happens,
/// not leave it parked until some unrelated event repaints the pane.
///
/// The columns and the gutter are lists with their own deferred scroll; the
/// output is a `TextInput` that used to be dragged along only by a prepaint
/// mirror. The assertion is made *inside* the update that navigates — before
/// any draw — so it can only pass if navigation placed the editor itself.
#[gpui::test]
fn conflict_navigation_places_the_editable_output_without_waiting_for_a_frame(
    cx: &mut gpui::TestAppContext,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(953);
    let fixture = SyntheticLargeConflictFixture::new(
        "resolver_nav_places_output",
        "fixtures/resolver_nav_places_output.rs",
        900,
        12,
    );
    fixture.write();

    seed_unresolved_conflict_state(
        cx,
        &view,
        repo_id,
        &fixture.workdir,
        &fixture.file_rel,
        &fixture.base_text,
        &fixture.ours_text,
        &fixture.theirs_text,
        &fixture.current_text,
    );

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "nav placement fixture initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && !pane.conflict_resolver.nav_targets.is_empty()
                && !pane.conflict_resolved_output_is_streamed()
        },
        |pane| {
            format!(
                "targets={} streamed={}",
                pane.conflict_resolver.nav_targets.len(),
                pane.conflict_resolved_output_is_streamed(),
            )
        },
    );
    // Two draws: the first gives the gutter and the editor their bounds, which
    // is what the offset arithmetic reads.
    draw_and_drain_test_window(cx);
    draw_and_drain_test_window(cx);

    let main_pane = cx.update(|_window, app| view.read(app).main_pane.clone());
    let before = cx.update(|_window, app| {
        main_pane
            .read(app)
            .conflict_resolved_output_editor_scroll
            .offset()
            .y
    });

    // Jump far enough down that the target cannot already be on screen.
    let after = cx.update(|_window, app| {
        main_pane.update(app, |pane, cx| {
            for _ in 0..6 {
                pane.conflict_jump_next(cx);
            }
            pane.conflict_resolved_output_editor_scroll.offset().y
        })
    });

    assert!(
        after < before,
        "navigating six conflicts down must scroll the editable output immediately \
         (before={before:?} after={after:?})"
    );

    // And the placement has to be the one the gutter lands on, or the mirror
    // that runs on the next prepaint would jerk the view a second time.
    draw_and_drain_test_window(cx);
    let settled = cx.update(|_window, app| {
        main_pane
            .read(app)
            .conflict_resolved_output_editor_scroll
            .offset()
            .y
    });
    assert_eq!(
        settled, after,
        "the drawn frame must agree with the offset navigation placed"
    );

    fixture.cleanup();
}

/// Conflict navigation must not re-run the resolved output's edit pipeline.
///
/// Moving the yellow wash rebinds the highlight provider, which notifies the
/// input, which re-enters the observe that refreshes syntax. Without an
/// early-out that refresh rescans the whole document — two line walks and a
/// materialization — on every F3. Materialization is the observable half, so
/// that is what this pins.
#[gpui::test]
fn conflict_navigation_does_not_rescan_the_resolved_output(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(983);
    let fixture = SyntheticLargeConflictFixture::new(
        "nav_no_rescan",
        "fixtures/nav_no_rescan.html",
        4_000,
        24,
    );
    fixture.write();

    seed_unresolved_conflict_state(
        cx,
        &view,
        repo_id,
        &fixture.workdir,
        &fixture.file_rel,
        &fixture.base_text,
        &fixture.ours_text,
        &fixture.theirs_text,
        &fixture.current_text,
    );

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "nav rescan fixture initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&fixture.file_rel)
                && !pane.conflict_resolver.nav_targets.is_empty()
                && !pane.conflict_resolved_output_is_streamed()
        },
        |pane| format!("targets={}", pane.conflict_resolver.nav_targets.len()),
    );
    draw_and_drain_test_window(cx);

    let main_pane = cx.update(|_window, app| view.read(app).main_pane.clone());

    // An edit legitimately rescans. Navigation must not add any.
    cx.update(|_window, app| {
        main_pane.update(app, |pane, cx| {
            let at = pane.conflict_resolver_input.read(cx).text().len();
            pane.conflict_resolver_input.update(cx, |input, cx| {
                input.replace_utf8_range(at..at, "\n", cx);
            });
        });
    });
    draw_and_drain_test_window(cx);

    let before = cx.update(|_window, app| main_pane.read(app).conflict_resolved_output_full_scans);

    for _ in 0..4 {
        cx.update(|_window, app| {
            main_pane.update(app, |pane, cx| {
                pane.conflict_jump_next(cx);
            });
        });
        draw_and_drain_test_window(cx);
    }

    let after = cx.update(|_window, app| main_pane.read(app).conflict_resolved_output_full_scans);
    assert_eq!(
        after, before,
        "four conflict jumps changed no text, so none of them may rescan the document"
    );

    fixture.cleanup();
}

/// Shift+F2/F3 step between *unresolved* conflicts, in both focus states.
///
/// The chord replaces Ctrl+PgUp/PgDn, which collided with repository-tab
/// switching. Two things have to hold that plain F2/F3 does not give you: the
/// jump *skips over* conflicts already resolved, and it still fires while the
/// resolved-output editor has focus — resolving a merge means typing in that
/// editor, so a shortcut that dies there is the one you need most.
///
/// The resolved conflict is deliberately placed *between* the starting point
/// and the expected destination. Resolving the conflict you are standing on
/// and then stepping forward proves nothing: unfiltered navigation leaves it
/// too, simply by moving.
#[gpui::test]
fn shift_f2_and_f3_step_over_resolved_conflicts(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(991);
    let fixture =
        SyntheticLargeConflictFixture::new("shift_f3_nav", "fixtures/shift_f3_nav.html", 400, 6);
    fixture.write();
    seed_unresolved_conflict_state(
        cx,
        &view,
        repo_id,
        &fixture.workdir,
        &fixture.file_rel,
        &fixture.base_text,
        &fixture.ours_text,
        &fixture.theirs_text,
        &fixture.current_text,
    );
    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "shift-f3 nav fixture initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver
                .nav_targets
                .iter()
                .filter(|target| target.unresolved)
                .count()
                >= 3
        },
        |pane| format!("targets={}", pane.conflict_resolver.nav_targets.len()),
    );
    draw_and_drain_test_window(cx);

    let main_pane = cx.update(|_window, app| view.read(app).main_pane.clone());

    // Nav-target positions of the first three still-open conflicts.
    let open: Vec<usize> = cx.update(|_window, app| {
        main_pane
            .read(app)
            .conflict_resolver
            .nav_targets
            .iter()
            .enumerate()
            .filter(|(_, target)| target.unresolved)
            .map(|(ix, _)| ix)
            .collect()
    });
    let (start, middle, beyond) = (open[0], open[1], open[2]);

    // Resolve the middle one, so it sits between the caret and the next open
    // conflict. This is the conflict the chord must step over.
    let middle_display = cx.update(|_window, app| {
        main_pane.read(app).conflict_resolver.nav_targets[middle]
            .display_conflict_index
            .expect("an unresolved conflict target has a display index")
    });
    // Resolve the middle conflict for real — select it and pick a side, the way
    // a user does — so the chord is exercised against genuine resolution state.
    cx.update(|_window, app| {
        main_pane.update(app, |pane, cx| {
            pane.conflict_resolver_select_conflict(middle_display, cx);
            pane.conflict_resolver_pick_active_conflict(
                crate::view::conflict_resolver::ConflictChoice::Ours,
                cx,
            );
        });
    });
    draw_and_drain_test_window(cx);
    cx.update(|_window, app| {
        main_pane.update(app, |pane, cx| {
            pane.conflict_jump_to_nav_target(start, cx);
        });
    });
    draw_and_drain_test_window(cx);

    let order_of = |cx: &mut gpui::VisualTestContext, target_ix: usize| -> usize {
        cx.update(|_window, app| main_pane.read(app).conflict_resolver.nav_targets[target_ix].order)
    };
    let anchor = |cx: &mut gpui::VisualTestContext| -> Option<usize> {
        cx.update(|_window, app| {
            main_pane
                .read(app)
                .conflict_resolver
                .nav_anchor
                .map(|anchor| anchor.order_hint)
        })
    };
    let press = |cx: &mut gpui::VisualTestContext, chord: &str| -> bool {
        let keystroke = gpui::Keystroke::parse(chord).expect("valid chord");
        cx.update(|window, app| {
            main_pane.update(app, |pane, cx| {
                pane.handle_diff_shortcut(&keystroke, window, cx)
            })
        })
    };

    let (middle_order, beyond_order) = (order_of(cx, middle), order_of(cx, beyond));
    assert!(
        cx.update(|_window, app| {
            !main_pane.read(app).conflict_resolver.nav_targets[middle].unresolved
        }),
        "the middle conflict must be marked resolved for this test to mean anything"
    );
    assert_eq!(
        anchor(cx),
        Some(order_of(cx, start)),
        "should start at the first open conflict"
    );

    assert!(press(cx, "shift-f3"), "shift-f3 should be handled");
    draw_and_drain_test_window(cx);
    assert_ne!(
        anchor(cx),
        Some(middle_order),
        "shift-f3 landed on the conflict that was just resolved: it is navigating \
         conflicts, not unresolved conflicts"
    );
    assert_eq!(
        anchor(cx),
        Some(beyond_order),
        "shift-f3 should skip the resolved conflict and land on the next open one"
    );

    // Shift+F2 comes back the same way, skipping the same resolved conflict.
    assert!(press(cx, "shift-f2"), "shift-f2 should be handled");
    draw_and_drain_test_window(cx);
    assert_ne!(
        anchor(cx),
        Some(middle_order),
        "shift-f2 must skip the resolved conflict too"
    );

    // The chord must survive focus being in the resolved-output editor, which
    // is where a merge is actually resolved.
    cx.update(|window, app| {
        main_pane.update(app, |pane, cx| {
            let handle = pane.conflict_resolver_input.read(cx).focus_handle();
            handle.focus(window, cx);
        });
    });
    draw_and_drain_test_window(cx);
    let before_focused = anchor(cx);
    assert!(
        press(cx, "shift-f3"),
        "shift-f3 must still be handled while the resolved-output editor has focus"
    );
    draw_and_drain_test_window(cx);
    assert_ne!(
        anchor(cx),
        before_focused,
        "shift-f3 should navigate while the editor has focus"
    );
    assert_ne!(
        anchor(cx),
        Some(middle_order),
        "the focused-editor path must filter by resolution state as well"
    );

    fixture.cleanup();
}

/// The last conflict standing must still be reachable from itself.
///
/// Once everything else is decided there is nothing strictly past the anchor in
/// either direction, so both chords went dead and both toolbar arrows greyed
/// out — at exactly the point where the user has scrolled off somewhere else and
/// wants the one remaining decision back on screen.
#[gpui::test]
fn shift_f2_and_f3_still_reach_the_last_unresolved_conflict(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(992);
    let fixture =
        SyntheticLargeConflictFixture::new("last_open_nav", "fixtures/last_open_nav.html", 400, 6);
    fixture.write();
    seed_unresolved_conflict_state(
        cx,
        &view,
        repo_id,
        &fixture.workdir,
        &fixture.file_rel,
        &fixture.base_text,
        &fixture.ours_text,
        &fixture.theirs_text,
        &fixture.current_text,
    );
    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "last-open nav fixture initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver
                .nav_targets
                .iter()
                .filter(|target| target.unresolved)
                .count()
                >= 3
        },
        |pane| format!("targets={}", pane.conflict_resolver.nav_targets.len()),
    );
    draw_and_drain_test_window(cx);

    let main_pane = cx.update(|_window, app| view.read(app).main_pane.clone());
    let open_display_indices = |cx: &mut gpui::VisualTestContext| -> Vec<usize> {
        cx.update(|_window, app| {
            main_pane
                .read(app)
                .conflict_resolver
                .nav_targets
                .iter()
                .filter(|target| target.unresolved)
                .filter_map(|target| target.display_conflict_index)
                .collect()
        })
    };

    // Resolve every conflict but the last, the way a user does, so the one left
    // is genuinely the only unresolved target.
    let keep_open = *open_display_indices(cx).last().expect("an open conflict");
    while let Some(display) = open_display_indices(cx)
        .into_iter()
        .find(|display| *display != keep_open)
    {
        cx.update(|_window, app| {
            main_pane.update(app, |pane, cx| {
                pane.conflict_resolver_select_conflict(display, cx);
                pane.conflict_resolver_pick_active_conflict(
                    crate::view::conflict_resolver::ConflictChoice::Ours,
                    cx,
                );
            });
        });
        draw_and_drain_test_window(cx);
    }
    assert_eq!(
        open_display_indices(cx),
        vec![keep_open],
        "exactly one conflict must be left open for this test to mean anything"
    );

    let sole_target = cx.update(|_window, app| {
        main_pane
            .read(app)
            .conflict_resolver
            .nav_targets
            .iter()
            .position(|target| target.unresolved)
            .expect("the remaining open conflict has a nav target")
    });
    cx.update(|_window, app| {
        main_pane.update(app, |pane, cx| {
            pane.conflict_jump_to_nav_target(sole_target, cx);
        });
    });
    draw_and_drain_test_window(cx);

    let anchor = |cx: &mut gpui::VisualTestContext| -> Option<usize> {
        cx.update(|_window, app| {
            main_pane
                .read(app)
                .conflict_resolver
                .nav_anchor
                .map(|anchor| anchor.order_hint)
        })
    };
    let press = |cx: &mut gpui::VisualTestContext, chord: &str| -> bool {
        let keystroke = gpui::Keystroke::parse(chord).expect("valid chord");
        cx.update(|window, app| {
            main_pane.update(app, |pane, cx| {
                pane.handle_diff_shortcut(&keystroke, window, cx)
            })
        })
    };

    let sole_order = cx.update(|_window, app| {
        main_pane.read(app).conflict_resolver.nav_targets[sole_target].order
    });
    assert_eq!(anchor(cx), Some(sole_order));

    // The toolbar reads the same predicates the chords do, so both arrows have
    // to come back with them.
    cx.update(|_window, app| {
        let pane = main_pane.read(app);
        assert!(
            pane.conflict_has_next_unresolved(),
            "next-unresolved must be offered while a conflict is still open"
        );
        assert!(
            pane.conflict_has_prev_unresolved(),
            "previous-unresolved must be offered too"
        );
    });

    for chord in ["shift-f3", "shift-f2"] {
        assert!(press(cx, chord), "{chord} should be handled");
        draw_and_drain_test_window(cx);
        assert_eq!(
            anchor(cx),
            Some(sole_order),
            "{chord} must keep the last open conflict selected rather than going dead"
        );
    }

    fixture.cleanup();
}

/// *Reset conflict markers* has to outlive the round-trip it starts.
///
/// The button clears protection and then dispatches, and the resync that comes
/// back re-derived protection from the same unchanged worktree payload — so the
/// flag went straight back on and every pick and Unresolve greyed out again.
/// From the user's side the button did nothing.
///
/// The payload here is one git left conflicted but that no longer carries
/// markers, which is what an editor-side resolution looks like: protection is
/// right to fire on it, and the reset is the user overriding that.
#[gpui::test]
fn resetting_the_markers_survives_the_resync_it_triggers(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });
    let repo_id = gitcomet_state::model::RepoId(996);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_reset_sticks",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/reset_sticks.txt");
    let base = "head\nB\ntail\n";
    let ours = "head\nB1\ntail\n";
    let theirs = "head\nB2\ntail\n";
    let resolved_by_hand = "head\nB1\ntail\n";

    seed_unresolved_conflict_state(
        cx,
        &view,
        repo_id,
        &workdir,
        &file_rel,
        base,
        ours,
        theirs,
        resolved_by_hand,
    );
    draw_and_drain_test_window(cx);
    let main_pane = cx.update(|_window, app| view.read(app).main_pane.clone());
    let protected = |cx: &mut gpui::VisualTestContext| -> bool {
        cx.update(|_window, app| main_pane.read(app).conflict_resolver.output_is_protected)
    };

    assert!(
        protected(cx),
        "a payload with no conflict block left reads as resolved by hand"
    );

    cx.update(|_window, app| {
        main_pane.update(app, |pane, cx| {
            pane.conflict_resolver_reset_output_from_markers(cx);
        });
    });
    draw_and_drain_test_window(cx);
    assert!(!protected(cx), "the reset must clear protection");

    // Now resolve something. That dispatches, which bumps `conflict_rev`, which
    // is what drives the resync — and the resync re-derives protection from the
    // same unchanged worktree payload that still reads as hand-resolved. Only
    // the waiver keeps it off.
    cx.update(|_window, app| {
        main_pane.update(app, |pane, cx| {
            pane.conflict_resolver_select_conflict(0, cx);
            pane.conflict_resolver_pick_active_conflict(
                crate::view::conflict_resolver::ConflictChoice::Ours,
                cx,
            );
        });
    });
    draw_and_drain_test_window(cx);
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            crate::view::test_support::sync_store_snapshot(this, cx);
        });
    });
    draw_and_drain_test_window(cx);
    assert!(
        !protected(cx),
        "protection came back after the first pick, so the reset did nothing"
    );
    assert!(
        cx.update(|_window, app| main_pane
            .read(app)
            .conflict_resolver_active_pick_state()
            .is_some()),
        "the pick controls must be usable after the reset"
    );
}

/// The resolver's rows are shaped from `window.rem_size()`, which UI scale moves, so
/// the row boxes have to move with it. A flat 20px row holds a ~31px line box at 150%
/// and the text spills into the row below -- the "lines break" symptom.
#[gpui::test]
fn conflict_resolver_row_geometry_follows_ui_scale(cx: &mut gpui::TestAppContext) {
    use gitcomet_core::conflict_session::{ConflictPayload, ConflictSession};

    let _visual_guard = lock_visual_test();
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(191);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_ui_scale",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/conflict_resolver_ui_scale.txt");
    let abs_path = workdir.join(&file_rel);

    // Enough lines that the lists virtualize and the output gutter has room to drift.
    let context = (0..40)
        .map(|ix| format!("context line {ix}"))
        .collect::<Vec<_>>();
    let base_text = context.join("\n");
    let ours_text = context
        .iter()
        .enumerate()
        .map(|(ix, line)| {
            if ix == 20 {
                "ours change".to_string()
            } else {
                line.clone()
            }
        })
        .collect::<Vec<_>>()
        .join("\n");
    let theirs_text = context
        .iter()
        .enumerate()
        .map(|(ix, line)| {
            if ix == 20 {
                "theirs change".to_string()
            } else {
                line.clone()
            }
        })
        .collect::<Vec<_>>()
        .join("\n");
    let head = context[..20].join("\n");
    let tail = context[21..].join("\n");
    let current_text = format!(
        "{head}\n<<<<<<< ours\nours change\n=======\ntheirs change\n>>>>>>> theirs\n{tail}\n"
    );

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create resolver ui-scale fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write resolver ui-scale fixture");

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            let mut repo = opening_repo_state(repo_id, &workdir);
            set_test_conflict_status(
                &mut repo,
                file_rel.clone(),
                gitcomet_core::domain::DiffArea::Unstaged,
            );
            set_test_conflict_file(
                &mut repo,
                file_rel.clone(),
                base_text.clone(),
                ours_text.clone(),
                theirs_text.clone(),
                current_text.clone(),
            );
            repo.conflict_state.conflict_session = Some(ConflictSession::from_merged_text(
                file_rel.clone(),
                gitcomet_core::domain::FileConflictKind::BothModified,
                ConflictPayload::Text(base_text.clone().into()),
                ConflictPayload::Text(ours_text.clone().into()),
                ConflictPayload::Text(theirs_text.clone().into()),
                &current_text,
            ));

            push_test_state(this, app_state_with_repo(repo, repo_id), cx);
        });
    });

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolver ui-scale fixture initialized",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolver.three_way_visible_len() >= 40
        },
        |pane| {
            format!(
                "path={:?} three_way_visible={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolver.three_way_visible_len(),
            )
        },
    );

    cx.simulate_resize(gpui::size(px(1280.0), px(720.0)));
    draw_and_drain_test_window(cx);

    // Source rows have a canvas path and a div path, and which one runs is an env
    // toggle -- pin it rather than inheriting the ambient default.
    let set_canvas_rows = |cx: &mut gpui::VisualTestContext, enabled: bool| {
        cx.update(|_window, app| {
            view.update(app, |this, cx| {
                this.main_pane.update(cx, |pane, cx| {
                    pane.conflict_canvas_rows_enabled = enabled;
                    cx.notify();
                });
            });
        });
        draw_and_drain_test_window(cx);
    };

    /// Total laid-out height of every row in a virtualized list. `item` is the
    /// viewport, `contents` is the full row stack, so this is `row_height * rows`.
    fn measured_content_height(handle: &gpui::UniformListScrollHandle, label: &str) -> f32 {
        handle
            .0
            .borrow()
            .last_item_size
            .unwrap_or_else(|| panic!("expected rendered item size for {label}"))
            .contents
            .height
            .into()
    }

    struct Sample {
        base_contents: f32,
        gutter_contents: f32,
        gutter_rows: usize,
        gutter_row_height: Pixels,
        editor_line_height: Pixels,
    }

    let sample = |cx: &mut gpui::VisualTestContext| {
        cx.update(|_window, app| {
            let pane = view.read(app).main_pane.read(app);
            Sample {
                base_contents: measured_content_height(
                    &pane.conflict_resolver_diff_scroll,
                    "three-way base column",
                ),
                gutter_contents: measured_content_height(
                    &pane.conflict_resolved_preview_gutter_scroll,
                    "resolved output gutter",
                ),
                gutter_rows: pane.resolved_output_visible_len(),
                gutter_row_height: pane.conflict_resolved_gutter_row_height,
                editor_line_height: pane
                    .conflict_resolver_input
                    .read(app)
                    .line_height_override()
                    .expect("resolved output editor should carry an explicit line height"),
            }
        })
    };

    for canvas_rows in [true, false] {
        let path = if canvas_rows { "canvas" } else { "div" };
        set_canvas_rows(cx, canvas_rows);
        set_ui_scale_percent_for_test(cx, &view, 100);
        draw_and_drain_test_window(cx);
        let at_100 = sample(cx);

        set_ui_scale_percent_for_test(cx, &view, 200);
        draw_and_drain_test_window(cx);
        let at_200 = sample(cx);

        let base_ratio = at_200.base_contents / at_100.base_contents;
        assert!(
            (base_ratio - 2.0).abs() < 0.05,
            "{path} source column rows should double at 200% (100%={} 200%={})",
            at_100.base_contents,
            at_200.base_contents,
        );
    }

    set_canvas_rows(cx, true);
    set_ui_scale_percent_for_test(cx, &view, 100);
    draw_and_drain_test_window(cx);

    let at_100 = sample(cx);

    // The gutter list and the editable buffer it labels must advance at the same
    // rate, or the numbers walk off their lines as you scroll down the file.
    assert_eq!(
        at_100.gutter_row_height, at_100.editor_line_height,
        "at 100% the output gutter row height must match the editor line height"
    );
    // ...and the height the render pass recorded has to be the one the list really
    // laid out, since navigation centres the editor on a row using it.
    assert!(at_100.gutter_rows > 0);
    let laid_out_100 = at_100.gutter_contents / at_100.gutter_rows as f32;
    assert!(
        (laid_out_100 - f32::from(at_100.gutter_row_height)).abs() < 0.01,
        "recorded gutter row height {:?} disagrees with the {laid_out_100} the list laid out",
        at_100.gutter_row_height,
    );

    set_ui_scale_percent_for_test(cx, &view, 200);
    draw_and_drain_test_window(cx);

    let at_200 = sample(cx);

    assert_eq!(
        at_200.gutter_row_height, at_200.editor_line_height,
        "at 200% the output gutter row height must match the editor line height \
         (gutter={:?} editor={:?})",
        at_200.gutter_row_height, at_200.editor_line_height,
    );
    assert_eq!(at_200.gutter_rows, at_100.gutter_rows);
    let laid_out_200 = at_200.gutter_contents / at_200.gutter_rows as f32;
    assert!(
        (laid_out_200 - f32::from(at_200.gutter_row_height)).abs() < 0.01,
        "recorded gutter row height {:?} disagrees with the {laid_out_200} the list laid out",
        at_200.gutter_row_height,
    );

    let base_ratio = at_200.base_contents / at_100.base_contents;
    assert!(
        (base_ratio - 2.0).abs() < 0.05,
        "source column rows should double at 200% (100%={} 200%={})",
        at_100.base_contents,
        at_200.base_contents,
    );
    let gutter_ratio = at_200.gutter_contents / at_100.gutter_contents;
    assert!(
        (gutter_ratio - 2.0).abs() < 0.05,
        "output gutter rows should double at 200% (100%={} 200%={})",
        at_100.gutter_contents,
        at_200.gutter_contents,
    );

    std::fs::remove_dir_all(&workdir).expect("cleanup resolver ui-scale fixture");
}

/// Ctrl+F in the merge tool must bring the hit into view — in the input
/// columns *and* in the resolved output.
///
/// The scroll dispatch (`diff_search_scroll_to_visible_ix`) hands conflict
/// targets to `conflict_resolver_scroll_all_columns`, which knows only the
/// three column lists. The resolved output rides handles of its own, so a
/// match far down the file left it parked at the top.
fn assert_conflict_search_reveals_match(
    cx: &mut gpui::TestAppContext,
    view_mode: ConflictResolverViewMode,
    repo_id: gitcomet_state::model::RepoId,
    fixture_name: &str,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_{fixture_name}",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from(format!("fixtures/{fixture_name}.txt"));
    let abs_path = workdir.join(&file_rel);
    let base_text = build_conflict_scroll_matrix_text("base", 'B');
    let ours_text = build_conflict_scroll_matrix_text("ours", 'O');
    let theirs_text = build_conflict_scroll_matrix_text("theirs", 'T');
    let current_text = build_conflict_scroll_matrix_current_text(&ours_text, &theirs_text);

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create resolver search fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write resolver search fixture");

    seed_conflict_scroll_matrix_state(
        cx,
        &view,
        repo_id,
        &workdir,
        &file_rel,
        &base_text,
        &ours_text,
        &theirs_text,
        &current_text,
    );

    wait_for_main_pane_condition(
        cx,
        &view,
        "resolver search fixture initialized",
        |pane| {
            pane.conflict_resolver.path.as_ref() == Some(&file_rel)
                && pane.conflict_resolved_preview_line_count >= 1
        },
        |pane| {
            format!(
                "path={:?} resolved_lines={}",
                pane.conflict_resolver.path.clone(),
                pane.conflict_resolved_preview_line_count,
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(view_mode, cx);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolver search vertical overflow",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.view_mode == view_mode
                && uniform_list_max_offset(&pane.conflict_resolver_diff_scroll).height > px(120.0)
                && scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll).height
                    > px(120.0)
        },
        |pane| {
            format!(
                "view_mode={:?} left_max={:?} output_max={:?}",
                pane.conflict_resolver.view_mode,
                uniform_list_max_offset(&pane.conflict_resolver_diff_scroll),
                scroll_handle_max_offset(&pane.conflict_resolved_output_editor_scroll),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                reset_conflict_scroll_matrix_offsets(pane);
                pane.diff_search_active = true;
                pane.diff_search_query = "line 150".into();
                pane.diff_search_input
                    .update(cx, |input, cx| input.set_text("line 150", cx));
                pane.diff_search_recompute_matches_and_scroll_to_first();
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);
    draw_and_drain_test_window(cx);

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        assert!(
            !pane.diff_search_matches.is_empty(),
            "expected the merge tool search to find `line 150`"
        );

        // The two-way view renders only left/right, so `ours` stays untracked
        // there and must not be asserted on.
        let mut columns = vec![
            (
                "left/base",
                uniform_list_offset(&pane.conflict_resolver_diff_scroll).y,
            ),
            (
                "right/theirs",
                uniform_list_offset(&pane.conflict_preview_theirs_scroll).y,
            ),
        ];
        if view_mode == ConflictResolverViewMode::ThreeWay {
            columns.push((
                "ours",
                uniform_list_offset(&pane.conflict_preview_ours_scroll).y,
            ));
        }
        for (label, offset) in &columns {
            assert!(
                *offset < px(0.0),
                "expected the {label} column to scroll to the match, got {offset:?} \
                 (columns={columns:?} matches={:?} current={:?})",
                pane.diff_search_matches,
                pane.diff_search_match_ix,
            );
        }

        // Proves the output moved because search resolved the hit's row to an
        // output line, not because a scroll-sync pass happened to drag it.
        let current_row = pane
            .diff_search_current_match_row()
            .expect("expected a current search match row");
        let output_line = pane
            .conflict_resolver
            .output_line_for_visible_row(current_row)
            .expect("expected the matched column row to map to an output line");
        assert!(
            output_line > 100,
            "expected the mapped output line to be far down the file, got {output_line}"
        );

        let output_y = scroll_handle_offset(&pane.conflict_resolved_output_editor_scroll).y;
        let gutter_y = uniform_list_offset(&pane.conflict_resolved_preview_gutter_scroll).y;
        assert!(
            output_y < px(0.0) && gutter_y < px(0.0),
            "expected the resolved output and its gutter to scroll to the match, got \
             output={output_y:?} gutter={gutter_y:?} matches={:?} current={:?}",
            pane.diff_search_matches,
            pane.diff_search_match_ix,
        );
    });

    let _ = std::fs::remove_dir_all(&workdir);
}

#[gpui::test]
fn conflict_resolver_three_way_search_reveals_match_in_columns_and_output(
    cx: &mut gpui::TestAppContext,
) {
    assert_conflict_search_reveals_match(
        cx,
        ConflictResolverViewMode::ThreeWay,
        gitcomet_state::model::RepoId(1631),
        "resolver_search_reveal_three_way",
    );
}

#[gpui::test]
fn conflict_resolver_two_way_search_reveals_match_in_columns_and_output(
    cx: &mut gpui::TestAppContext,
) {
    assert_conflict_search_reveals_match(
        cx,
        ConflictResolverViewMode::TwoWayDiff,
        gitcomet_state::model::RepoId(1632),
        "resolver_search_reveal_two_way",
    );
}

/// The three-way merge tool columns had no search wash at all — only the
/// two-way split columns built a query overlay — so a Ctrl+F hit scrolled into
/// view with nothing marking it.
///
/// Also pins the mechanism that keeps the *current* match distinguishable: its
/// row is built per frame with `DiffSearchMatchEmphasis::Current` and
/// deliberately kept out of the cache, so the wash follows the search cursor
/// instead of being left behind on the row it stepped off.
#[gpui::test]
fn conflict_resolver_three_way_columns_paint_the_search_wash(cx: &mut gpui::TestAppContext) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let repo_id = gitcomet_state::model::RepoId(1633);
    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_resolver_search_wash",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from("fixtures/resolver_search_wash.txt");
    let abs_path = workdir.join(&file_rel);
    let base_text = build_conflict_scroll_matrix_text("base", 'B');
    let ours_text = build_conflict_scroll_matrix_text("ours", 'O');
    let theirs_text = build_conflict_scroll_matrix_text("theirs", 'T');
    let current_text = build_conflict_scroll_matrix_current_text(&ours_text, &theirs_text);

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create resolver search wash fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write resolver search wash fixture");

    seed_conflict_scroll_matrix_state(
        cx,
        &view,
        repo_id,
        &workdir,
        &file_rel,
        &base_text,
        &ours_text,
        &theirs_text,
        &current_text,
    );

    wait_for_main_pane_condition(
        cx,
        &view,
        "resolver search wash fixture initialized",
        |pane| pane.conflict_resolver.path.as_ref() == Some(&file_rel),
        |pane| format!("path={:?}", pane.conflict_resolver.path.clone()),
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(ConflictResolverViewMode::ThreeWay, cx);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    // `line 00` matches the first ten rows, so the top of the file holds both a
    // current match and several others without any scrolling.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.diff_search_active = true;
                pane.diff_search_query = "line 00".into();
                pane.diff_search_input
                    .update(cx, |input, cx| input.set_text("line 00", cx));
                pane.diff_search_recompute_matches_and_scroll_to_first();
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);
    draw_and_drain_test_window(cx);

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        assert!(
            pane.diff_search_matches.len() > 1,
            "expected several matches, got {:?}",
            pane.diff_search_matches
        );
        let current_row = pane
            .diff_search_current_match_row()
            .expect("expected a current search match row");
        let other_row = pane
            .diff_search_matches
            .iter()
            .copied()
            .find(|row| *row != current_row)
            .expect("expected a non-current match row");

        let side_line = |row: usize| {
            pane.conflict_resolver
                .three_way_side_line_for_row(ThreeWayColumn::Ours, row)
                .expect("expected the matched row to have an ours-side line")
        };

        let other = pane
            .conflict_three_way_query_segments_cache
            .get(&(side_line(other_row), ThreeWayColumn::Ours))
            .unwrap_or_else(|| {
                panic!(
                    "expected a search overlay for the non-current match row {other_row}, \
                     cache holds {} entries",
                    pane.conflict_three_way_query_segments_cache.len()
                )
            });
        assert!(
            !other.highlights.is_empty(),
            "expected the non-current match row to carry highlight ranges"
        );

        assert!(
            !pane
                .conflict_three_way_query_segments_cache
                .contains_key(&(side_line(current_row), ThreeWayColumn::Ours)),
            "expected the current match row {current_row} to be built per frame, not cached"
        );
    });

    // A new query throws the old wash away rather than leaving stale ranges
    // behind: entries are only ever inserted for rows the *current* query
    // matches, so a surviving `line 00` row would mean the cache was not cleared.
    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.diff_search_query = "line 09".into();
                pane.diff_search_input
                    .update(cx, |input, cx| input.set_text("line 09", cx));
                pane.diff_search_recompute_matches_and_scroll_to_first();
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);
    draw_and_drain_test_window(cx);

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        assert!(
            !pane.conflict_three_way_query_segments_cache.is_empty(),
            "expected the new query to paint its own wash"
        );
        for ((line, column), styled) in &pane.conflict_three_way_query_segments_cache {
            assert!(
                styled.text.contains("line 09"),
                "stale wash left over from the previous query on {column:?} line {line}: {:?}",
                styled.text
            );
        }
    });

    let _ = std::fs::remove_dir_all(&workdir);
}

/// The merge tool columns scroll sideways to a match too.
///
/// They are their own canvases and register nothing in the diff hitbox map, so
/// they record where they painted their text separately; the columns share a
/// horizontal scroll sync, so moving the one that matched carries the rest.
fn assert_conflict_search_scrolls_sideways(
    cx: &mut gpui::TestAppContext,
    view_mode: ConflictResolverViewMode,
    repo_id: gitcomet_state::model::RepoId,
    fixture_name: &str,
    reveal_whitespace: bool,
) {
    let (store, events) = AppStore::new(Arc::new(TestBackend));
    let (view, cx) = cx.add_window_view(|window, cx| {
        super::super::GitCometView::new(store, events, None, window, cx)
    });

    let workdir = std::env::temp_dir().join(format!(
        "gitcomet_ui_test_{}_{fixture_name}",
        std::process::id()
    ));
    let file_rel = std::path::PathBuf::from(format!("fixtures/{fixture_name}.txt"));
    let abs_path = workdir.join(&file_rel);
    let base_text = build_conflict_scroll_matrix_text("base", 'B');
    // Only `ours` carries the needle, and it sits past the fold on a long line.
    let ours_text = format!(
        "{}\n{}needle tail",
        build_conflict_scroll_matrix_text("ours", 'O'),
        "pad ".repeat(200)
    );
    let theirs_text = build_conflict_scroll_matrix_text("theirs", 'T');
    let current_text = build_conflict_scroll_matrix_current_text(&ours_text, &theirs_text);

    let _ = std::fs::remove_dir_all(&workdir);
    std::fs::create_dir_all(abs_path.parent().expect("fixture file parent"))
        .expect("create resolver hscroll fixture dir");
    std::fs::write(&abs_path, &current_text).expect("write resolver hscroll fixture");

    seed_conflict_scroll_matrix_state(
        cx,
        &view,
        repo_id,
        &workdir,
        &file_rel,
        &base_text,
        &ours_text,
        &theirs_text,
        &current_text,
    );

    wait_for_main_pane_condition(
        cx,
        &view,
        "resolver hscroll fixture initialized",
        |pane| pane.conflict_resolver.path.as_ref() == Some(&file_rel),
        |pane| format!("path={:?}", pane.conflict_resolver.path.clone()),
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                pane.conflict_resolver_set_view_mode(view_mode, cx);
                cx.notify();
            });
        });
    });
    draw_and_drain_test_window(cx);

    // Two-way reuses the three-way handles for a two-column layout: its left
    // (Ours) list is tracked by `conflict_resolver_diff_scroll`, and the ours
    // handle is never laid out there.
    fn ours_list(
        pane: &MainPaneView,
        view_mode: ConflictResolverViewMode,
    ) -> &gpui::UniformListScrollHandle {
        match view_mode {
            ConflictResolverViewMode::ThreeWay => &pane.conflict_preview_ours_scroll,
            ConflictResolverViewMode::TwoWayDiff => &pane.conflict_resolver_diff_scroll,
        }
    }

    wait_for_main_pane_condition_with_timeout(
        cx,
        &view,
        "resolver hscroll horizontal overflow",
        BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
        |pane| {
            pane.conflict_resolver.view_mode == view_mode
                && uniform_list_max_offset(ours_list(pane, view_mode)).width > px(120.0)
        },
        |pane| {
            format!(
                "view_mode={:?} ours_max={:?}",
                pane.conflict_resolver.view_mode,
                uniform_list_max_offset(ours_list(pane, view_mode)),
            )
        },
    );

    cx.update(|_window, app| {
        view.update(app, |this, cx| {
            this.main_pane.update(cx, |pane, cx| {
                reset_conflict_scroll_matrix_offsets(pane);
                pane.reveal_whitespace_chars = reveal_whitespace;
                pane.diff_search_active = true;
                // The space is the point of the whitespace variant: with
                // whitespace revealed every space in the painted text is `·`,
                // so the query has to be looked for in its painted form.
                pane.diff_search_query = "needle tail".into();
                pane.diff_search_input
                    .update(cx, |input, cx| input.set_text("needle tail", cx));
                pane.diff_search_recompute_matches_and_scroll_to_first();
                cx.notify();
            });
        });
    });
    // The vertical jump lands and the row paints, then the sideways reveal reads
    // what that paint recorded.
    draw_and_drain_test_window(cx);
    draw_and_drain_test_window(cx);
    draw_and_drain_test_window(cx);

    cx.update(|_window, app| {
        let pane = view.read(app).main_pane.read(app);
        assert!(
            !pane.diff_search_matches.is_empty(),
            "expected the merge tool to find the needle"
        );
        assert!(
            uniform_list_offset(ours_list(pane, view_mode)).x < px(0.0),
            "expected the ours column to scroll right to the match in {view_mode:?}, \
             x stayed at {:?}",
            uniform_list_offset(ours_list(pane, view_mode)),
        );
    });

    let _ = std::fs::remove_dir_all(&workdir);
}

#[gpui::test]
fn conflict_resolver_three_way_search_scrolls_columns_sideways_to_a_match(
    cx: &mut gpui::TestAppContext,
) {
    assert_conflict_search_scrolls_sideways(
        cx,
        ConflictResolverViewMode::ThreeWay,
        gitcomet_state::model::RepoId(1634),
        "resolver_search_hscroll_three_way",
        false,
    );
}

/// Two-way renders Ours in the list tracked by `conflict_resolver_diff_scroll`,
/// not by `conflict_preview_ours_scroll` — writing to the latter scrolls a
/// handle that mode never lays out.
#[gpui::test]
fn conflict_resolver_two_way_search_scrolls_columns_sideways_to_a_match(
    cx: &mut gpui::TestAppContext,
) {
    assert_conflict_search_scrolls_sideways(
        cx,
        ConflictResolverViewMode::TwoWayDiff,
        gitcomet_state::model::RepoId(1635),
        "resolver_search_hscroll_two_way",
        false,
    );
}

/// With whitespace revealed, the merge tool paints `·` for every space, so a
/// query containing one is only findable again in its painted form — otherwise
/// the sideways reveal silently gives up.
#[gpui::test]
fn conflict_resolver_search_scrolls_sideways_with_whitespace_revealed(
    cx: &mut gpui::TestAppContext,
) {
    assert_conflict_search_scrolls_sideways(
        cx,
        ConflictResolverViewMode::ThreeWay,
        gitcomet_state::model::RepoId(1636),
        "resolver_search_hscroll_ws",
        true,
    );
}
