diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..960bcaa3 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,54 @@ +name: Fuzz + +on: + workflow_dispatch: + pull_request: + paths: + - "crates/carbon/**" + - "fuzz/**" + - ".github/workflows/fuzz.yml" + +permissions: + contents: read + +concurrency: + group: fuzz-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + fuzz-smoke: + name: Carbon fuzz smoke + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + # cargo-fuzz needs nightly for -Z sanitizer flags; rust-toolchain.toml + # already pins nightly, so rustup resolves it from the checkout. + - name: Install Rust toolchain + run: rustup show active-toolchain + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked + + - name: Build fuzz targets + run: cargo fuzz build + + - name: Run each fuzz target briefly + run: | + for target in $(cargo fuzz list); do + echo "::group::fuzz $target" + cargo fuzz run "$target" -- -max_total_time=60 -rss_limit_mb=4096 + echo "::endgroup::" + done + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-artifacts + path: fuzz/artifacts/ + if-no-files-found: ignore diff --git a/Cargo.lock b/Cargo.lock index cf63e13e..4ebfefaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2848,6 +2848,7 @@ dependencies = [ name = "halogen-macros" version = "0.1.11" dependencies = [ + "halogen", "proc-macro2", "quote", "syn", diff --git a/crates/carbon/src/patch.rs b/crates/carbon/src/patch.rs index a8e56f4f..744782ed 100644 --- a/crates/carbon/src/patch.rs +++ b/crates/carbon/src/patch.rs @@ -178,10 +178,20 @@ impl FileBuilder { fn start_hunk(&mut self, line: &str) -> Result<(), PatchError> { self.finish_hunk(); let (old_start, old_count, new_start, new_count) = parse_hunk_header(line)?; - self.old_text - .reserve(u32_to_usize_saturating(old_count).saturating_mul(32)); - self.new_text - .reserve(u32_to_usize_saturating(new_count).saturating_mul(32)); + // Header counts are untrusted input; the reservation is only a + // warm-up, so cap it to keep a hostile count from forcing a huge + // allocation. Real content still grows the buffers as it is pushed. + const MAX_HUNK_RESERVE_BYTES: usize = 1 << 20; + self.old_text.reserve( + u32_to_usize_saturating(old_count) + .saturating_mul(32) + .min(MAX_HUNK_RESERVE_BYTES), + ); + self.new_text.reserve( + u32_to_usize_saturating(new_count) + .saturating_mul(32) + .min(MAX_HUNK_RESERVE_BYTES), + ); self.file.hunks.reserve(1); self.file.blocks.reserve(3); self.hunk = Some(HunkBuilder::new( @@ -203,15 +213,20 @@ impl FileBuilder { return; }; - if line == r"\ No newline at end of file" { + // Any `\`-prefixed hunk line is a "no newline at end of file" marker; + // the message text is localized by diff/git, so only the prefix is + // structural. + if line.starts_with('\\') { match hunk.last_side { Some(DiffSide::Old) => { - hunk.mark_old_no_newline(); - trim_trailing_newline(&mut self.old_text); + if trim_trailing_newline(&mut self.old_text) { + hunk.mark_old_no_newline(); + } } Some(DiffSide::New) => { - hunk.mark_new_no_newline(); - trim_trailing_newline(&mut self.new_text); + if trim_trailing_newline(&mut self.new_text) { + hunk.mark_new_no_newline(); + } } None => {} } @@ -483,13 +498,27 @@ fn strip_patch_path(path: &str) -> Option<&str> { } fn push_text_line(text: &mut String, content: &str) { + // Malformed input can append lines after a no-newline marker already + // trimmed the trailing separator; restore it so stored line counts stay + // in sync with the block ranges counted by the hunk builder. + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } text.push_str(content); text.push('\n'); } -fn trim_trailing_newline(text: &mut String) { - if text.ends_with('\n') { +/// Drops the trailing separator so the final line is stored without a +/// newline. Leaves the text untouched and returns false when the final line +/// is empty: popping its separator would erase the line entirely and desync +/// stored line counts from the hunk's block ranges. +fn trim_trailing_newline(text: &mut String) -> bool { + let bytes = text.as_bytes(); + if bytes.len() >= 2 && bytes[bytes.len() - 1] == b'\n' && bytes[bytes.len() - 2] != b'\n' { text.pop(); + true + } else { + false } } @@ -557,4 +586,86 @@ diff --git a/a.txt b/a.txt assert!(file.old_text.as_ref().unwrap().no_newline_at_eof()); assert!(file.new_text.as_ref().unwrap().no_newline_at_eof()); } + + #[test] + fn parses_localized_no_newline_marker() { + let patch = "\ +diff --git a/a.txt b/a.txt +--- a/a.txt ++++ b/a.txt +@@ -1 +1 @@ +-old +\\ Pas de fin de ligne a la fin du fichier ++new +\\ Kein Zeilenumbruch am Dateiende +"; + let document = parse_unified_patch(patch).unwrap(); + let file = &document.files[0]; + let block = &file.blocks[0]; + + assert!(block.old_no_newline_at_end); + assert!(block.new_no_newline_at_end); + assert!(file.old_text.as_ref().unwrap().no_newline_at_eof()); + assert!(file.new_text.as_ref().unwrap().no_newline_at_eof()); + } + + // Regression for a fuzz-found inconsistency: a malformed `\` line after a + // no-newline marker was pushed as a context line into a store whose + // trailing separator had been trimmed, so block ranges pointed one line + // past the stored text. + #[test] + fn block_ranges_stay_within_text_stores_for_malformed_marker() { + let patch = "\ +diff --git a/a.txt b/a.txt +index 3333333..4444444 100644 +--- a/a.txt ++++ b/a.txt +@@ -1,2 +1,2 @@ + first +-old end +\\ No newline at end of file ++new end +\\ N[ newline at end of file +"; + let document = parse_unified_patch(patch).unwrap(); + let file = &document.files[0]; + for block in &file.blocks { + if block.old.len > 0 { + let text = file.old_text.as_ref().unwrap(); + assert!(block.old.end() <= text.line_count()); + } + if block.new.len > 0 { + let text = file.new_text.as_ref().unwrap(); + assert!(block.new.end() <= text.line_count()); + } + } + } + + // Regression for a fuzz-found inconsistency: a no-newline marker after an + // empty line trimmed the separator and erased the line from the text + // store, leaving block ranges one line past the stored text. + #[test] + fn no_newline_marker_after_empty_line_keeps_counts_in_sync() { + let patch = "\ +diff --git a/a.txt b/a.txt +--- a/a.txt ++++ b/a.txt +@@ -1,2 +1,2 @@ + first + +\\ No newline at end of file +"; + let document = parse_unified_patch(patch).unwrap(); + let file = &document.files[0]; + for block in &file.blocks { + if block.old.len > 0 { + let text = file.old_text.as_ref().unwrap(); + assert!(block.old.end() <= text.line_count()); + } + if block.new.len > 0 { + let text = file.new_text.as_ref().unwrap(); + assert!(block.new.end() <= text.line_count()); + } + } + } } diff --git a/crates/halogen-macros/Cargo.toml b/crates/halogen-macros/Cargo.toml index 93b67c27..4cd95f7f 100644 --- a/crates/halogen-macros/Cargo.toml +++ b/crates/halogen-macros/Cargo.toml @@ -10,3 +10,8 @@ proc-macro = true proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full", "extra-traits"] } + +[dev-dependencies] +# Dev-only cycle (halogen depends on this crate) is allowed by cargo and gives +# `{@sig}` integration coverage against the real `SignalStore`. +halogen = { path = "../halogen" } diff --git a/crates/halogen-macros/tests/view_macro.rs b/crates/halogen-macros/tests/view_macro.rs new file mode 100644 index 00000000..af9c364f --- /dev/null +++ b/crates/halogen-macros/tests/view_macro.rs @@ -0,0 +1,524 @@ +//! Compile-and-run integration tests for the `view!` macro. +//! +//! `view!` is duck-typed: it emits calls to whatever `div()`, `text()`, +//! `spacer()`, component constructors, and builder methods are in scope at +//! the call site (in Diffy those live in `src/ui/element.rs`). The `dsl` +//! module below provides a minimal recording implementation of that contract +//! so each lowering rule can be asserted at runtime instead of only as token +//! snapshots (those live in `src/lib.rs` unit tests). + +use std::cell::Cell; +use std::rc::Rc; + +use halogen::reactive::{Signal, SignalStore}; +use halogen_macros::view; + +use dsl::*; + +#[allow(dead_code)] +mod dsl { + use std::rc::Rc; + + /// Built element tree node; records the tag, builder calls in order, + /// the slot it was assigned to (for component child slots), and children. + pub struct AnyElement { + pub tag: &'static str, + pub value: Option, + pub calls: Vec, + pub slot: Option<&'static str>, + pub children: Vec, + pub on_click: Option>, + } + + impl AnyElement { + fn new(tag: &'static str) -> Self { + AnyElement { + tag, + value: None, + calls: Vec::new(), + slot: None, + children: Vec::new(), + on_click: None, + } + } + } + + pub struct Div { + el: AnyElement, + } + + pub fn div() -> Div { + Div { + el: AnyElement::new("div"), + } + } + + impl Div { + pub fn child(mut self, child: AnyElement) -> Self { + self.el.children.push(child); + self + } + + pub fn optional_child(mut self, child: Option) -> Self { + if let Some(child) = child { + self.el.children.push(child); + } + self + } + + pub fn children(mut self, children: impl IntoIterator) -> Self { + self.el.children.extend(children); + self + } + + pub fn gap(mut self, value: f32) -> Self { + self.el.calls.push(format!("gap({value})")); + self + } + + pub fn flex_row(mut self) -> Self { + self.el.calls.push("flex_row".into()); + self + } + + pub fn flex_grow(mut self) -> Self { + self.el.calls.push("flex_grow".into()); + self + } + + pub fn flex_shrink_0(mut self) -> Self { + self.el.calls.push("flex_shrink_0".into()); + self + } + + pub fn px_2(mut self) -> Self { + self.el.calls.push("px_2".into()); + self + } + + pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self { + self.el.calls.push("on_click".into()); + self.el.on_click = Some(Rc::new(handler)); + self + } + + pub fn into_any(self) -> AnyElement { + self.el + } + } + + pub struct Text { + el: AnyElement, + } + + pub fn text(content: impl ToString) -> Text { + let mut el = AnyElement::new("text"); + el.value = Some(content.to_string()); + Text { el } + } + + impl Text { + pub fn color(mut self, value: impl ToString) -> Self { + self.el.calls.push(format!("color({})", value.to_string())); + self + } + + pub fn bold(mut self) -> Self { + self.el.calls.push("bold".into()); + self + } + + pub fn mono(mut self) -> Self { + self.el.calls.push("mono".into()); + self + } + + pub fn into_any(self) -> AnyElement { + self.el + } + } + + pub struct Spacer { + el: AnyElement, + } + + pub fn spacer() -> Spacer { + Spacer { + el: AnyElement::new("spacer"), + } + } + + impl Spacer { + pub fn into_any(self) -> AnyElement { + self.el + } + } + + /// Component with one constructor arg (`action`, per + /// `constructor_arg_order`) plus `Icon`/`Label` value slots. + pub struct Button { + el: AnyElement, + } + + impl Button { + pub fn new(action: &'static str) -> Self { + let mut el = AnyElement::new("Button"); + el.calls.push(format!("action({action})")); + Button { el } + } + + pub fn tooltip(mut self, value: &'static str) -> Self { + self.el.calls.push(format!("tooltip({value})")); + self + } + + pub fn icon(mut self, value: &'static str) -> Self { + self.el.calls.push(format!("icon({value})")); + self + } + + pub fn label(mut self, value: impl ToString) -> Self { + self.el.calls.push(format!("label({})", value.to_string())); + self + } + + pub fn flex_grow(mut self) -> Self { + self.el.calls.push("flex_grow".into()); + self + } + + pub fn into_any(self) -> AnyElement { + self.el + } + } + + /// Component with `Left`/`Right` child slots. + pub struct Toolbar { + el: AnyElement, + } + + impl Toolbar { + pub fn new() -> Self { + Toolbar { + el: AnyElement::new("Toolbar"), + } + } + + pub fn compact(mut self) -> Self { + self.el.calls.push("compact".into()); + self + } + + pub fn left_child(mut self, mut child: AnyElement) -> Self { + child.slot = Some("left"); + self.el.children.push(child); + self + } + + pub fn right_child(mut self, mut child: AnyElement) -> Self { + child.slot = Some("right"); + self.el.children.push(child); + self + } + + pub fn into_any(self) -> AnyElement { + self.el + } + } +} + +/// `cx` contract required by `{@sig}` attributes: any type with a +/// `read(Signal) -> T` method, named `cx` at the call site. +struct Cx<'a> { + store: &'a SignalStore, +} + +impl Cx<'_> { + fn read(&self, signal: Signal) -> T { + self.store.read(signal) + } +} + +#[test] +fn basic_element_emit() { + let el = view! { +
+ "hello" + +
+ }; + + assert_eq!(el.tag, "div"); + assert_eq!(el.calls, ["flex_row", "gap(4)"]); + assert_eq!(el.children.len(), 2); + assert_eq!(el.children[0].tag, "text"); + assert_eq!(el.children[0].value.as_deref(), Some("hello")); + assert_eq!(el.children[0].calls, ["color(red)"]); + assert_eq!(el.children[1].tag, "spacer"); +} + +#[test] +fn nested_children_expression_forms_and_fragment() { + fn badge(n: usize) -> AnyElement { + text(format!("badge-{n}")).into_any() + } + + let extras = vec![badge(1), badge(2)]; + let present: Option = Some(badge(3)); + let absent: Option = None; + + let el = view! { +
+
+ {badge(0)} +
+ {?present} + {?absent} + {...extras} + + "a" + "b" + +
+ }; + + // nested div + present optional + 2 spread + 2 fragment children, with the + // fragment flattened into the parent (no wrapper node). + let kinds: Vec<&str> = el + .children + .iter() + .map(|c| c.value.as_deref().unwrap_or(c.tag)) + .collect(); + assert_eq!(kinds, ["div", "badge-3", "badge-1", "badge-2", "a", "b"]); + assert_eq!(el.children[0].children[0].value.as_deref(), Some("badge-0")); +} + +#[test] +fn if_without_else_is_optional_child() { + let make = |cond: bool| { + view! { +
+ if cond { "shown" } +
+ } + }; + + assert_eq!(make(true).children.len(), 1); + assert_eq!(make(true).children[0].value.as_deref(), Some("shown")); + assert!(make(false).children.is_empty()); +} + +#[test] +fn if_else_and_else_if_chain_pick_one_branch() { + let pick = |a: bool, b: bool| { + view! { +
+ if a { "a" } + else if b { "b" } + else { "c" } +
+ } + }; + + assert_eq!(pick(true, false).children[0].value.as_deref(), Some("a")); + assert_eq!(pick(false, true).children[0].value.as_deref(), Some("b")); + assert_eq!(pick(false, false).children[0].value.as_deref(), Some("c")); +} + +#[test] +fn else_if_without_final_else_falls_back_to_spacer() { + let pick = |a: bool, b: bool| { + view! { +
+ if a { "a" } + else if b { "b" } +
+ } + }; + + assert_eq!(pick(false, true).children[0].value.as_deref(), Some("b")); + assert_eq!(pick(false, false).children[0].tag, "spacer"); +} + +#[test] +fn multi_child_if_spreads_into_parent_without_wrapper() { + let make = |cond: bool| { + view! { +
+ "lead" + if cond { + "x" + "y" + } + "tail" +
+ } + }; + + let on = make(true); + let values: Vec<&str> = on + .children + .iter() + .map(|c| c.value.as_deref().unwrap_or(c.tag)) + .collect(); + // Branch children flow inline into the parent — no wrapper div node. + assert_eq!(values, ["lead", "x", "y", "tail"]); + assert!(on.children.iter().all(|c| c.tag == "text")); + + let off = make(false); + let values: Vec<&str> = off + .children + .iter() + .map(|c| c.value.as_deref().unwrap_or(c.tag)) + .collect(); + assert_eq!(values, ["lead", "tail"]); +} + +#[test] +fn for_loop_flattens_each_iteration() { + let items = vec!["a", "b", "c"]; + + let el = view! { +
+ for (i, item) in items.iter().enumerate() { + {format!("{i}:{item}")} + } +
+ }; + + let values: Vec<&str> = el + .children + .iter() + .map(|c| c.value.as_deref().unwrap_or_default()) + .collect(); + assert_eq!(values, ["0:a", "1:b", "2:c"]); +} + +#[test] +fn match_arms_emit_child() { + let render = |n: u8| { + view! { +
+ match n { + 0 => view! { "zero" }, + 1 => text("one").into_any(), + _ => spacer().into_any(), + } +
+ } + }; + + assert_eq!(render(0).children[0].value.as_deref(), Some("zero")); + assert_eq!(render(1).children[0].value.as_deref(), Some("one")); + assert_eq!(render(7).children[0].tag, "spacer"); +} + +#[test] +fn component_value_slots_and_constructor_args() { + let el = view! { + + }; + + assert_eq!(el.tag, "Button"); + // Constructor arg first, then builder calls in attribute order (with the + // class lowered to its mapped builder method), then slot calls. + assert_eq!( + el.calls, + [ + "action(save)", + "tooltip(Save file)", + "flex_grow", + "icon(disk)", + "label(Save)", + ] + ); +} + +#[test] +fn component_child_slots_map_to_repeated_builder_calls() { + let el = view! { + + + + + + "r1" + "r2" + + + }; + + assert_eq!(el.tag, "Toolbar"); + assert_eq!(el.calls, ["compact"]); + let slots: Vec<(&str, &str)> = el + .children + .iter() + .map(|c| { + ( + c.slot.unwrap_or("none"), + c.value.as_deref().unwrap_or(c.tag), + ) + }) + .collect(); + assert_eq!( + slots, + [("left", "spacer"), ("right", "r1"), ("right", "r2")] + ); +} + +#[test] +fn class_attribute_lowers_to_builder_methods() { + let el = view! {
}; + assert_eq!(el.calls, ["flex_row", "flex_grow", "flex_shrink_0", "px_2"]); + + let el = view! { "x" }; + assert_eq!(el.calls, ["bold", "mono"]); +} + +#[test] +fn event_handler_attribute_binds_closure() { + let clicks = Rc::new(Cell::new(0u32)); + let sink = clicks.clone(); + + let el = view! { +
+ "button" +
+ }; + + assert!(el.calls.iter().any(|call| call == "on_click")); + let handler = el.on_click.as_ref().unwrap(); + handler(); + handler(); + assert_eq!(clicks.get(), 2); +} + +#[test] +fn reactive_attribute_reads_through_cx() { + let store = SignalStore::default(); + let gap = store.create(4.0f32); + let label = store.create(String::from("alpha")); + let cx = Cx { store: &store }; + + let build = || { + view! { +
+ "t" +
+ } + }; + + let el = build(); + assert_eq!(el.calls, ["gap(4)"]); + assert_eq!(el.children[0].calls, ["color(alpha)"]); + + store.write(gap, 12.0); + store.write(label, String::from("beta")); + + let el = build(); + assert_eq!(el.calls, ["gap(12)"]); + assert_eq!(el.children[0].calls, ["color(beta)"]); +} diff --git a/fuzz/.gitattributes b/fuzz/.gitattributes new file mode 100644 index 00000000..74d338b3 --- /dev/null +++ b/fuzz/.gitattributes @@ -0,0 +1 @@ +corpus/** -text diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 00000000..5208f22a --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,3 @@ +artifacts/ +coverage/ +target/ diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index b9465c08..df5d8207 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -10,7 +10,7 @@ checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "carbon" -version = "0.1.4" +version = "0.1.11" [[package]] name = "carbon-fuzz" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 19bc2957..097bba34 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -33,3 +33,17 @@ path = "fuzz_targets/inline_diff.rs" test = false doc = false bench = false + +[[bin]] +name = "patch_parse" +path = "fuzz_targets/patch_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "review_anchor" +path = "fuzz_targets/review_anchor.rs" +test = false +doc = false +bench = false diff --git a/fuzz/corpus/inline_diff/punct.txt b/fuzz/corpus/inline_diff/punct.txt new file mode 100644 index 00000000..374fddef --- /dev/null +++ b/fuzz/corpus/inline_diff/punct.txt @@ -0,0 +1,2 @@ +a,b;c.d(e)f[g]h +a,B;c.D(e)F[g]H diff --git a/fuzz/corpus/inline_diff/unicode.txt b/fuzz/corpus/inline_diff/unicode.txt new file mode 100644 index 00000000..ba751320 --- /dev/null +++ b/fuzz/corpus/inline_diff/unicode.txt @@ -0,0 +1,2 @@ +naive cafe resume +naïve café résumé ☃ diff --git a/fuzz/corpus/inline_diff/words.txt b/fuzz/corpus/inline_diff/words.txt new file mode 100644 index 00000000..629d8910 --- /dev/null +++ b/fuzz/corpus/inline_diff/words.txt @@ -0,0 +1,2 @@ +fn render(frame: &mut Frame) -> Result<(), Error> +fn render(scene: &mut Scene) -> Result<(), RenderError> diff --git a/fuzz/corpus/patch_parse/basic.diff b/fuzz/corpus/patch_parse/basic.diff new file mode 100644 index 00000000..c1b5dbf0 --- /dev/null +++ b/fuzz/corpus/patch_parse/basic.diff @@ -0,0 +1,10 @@ +diff --git a/src/lib.rs b/src/lib.rs +index 1111111..2222222 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,4 +1,4 @@ + fn main() { +- println!("old"); ++ println!("new"); + } + diff --git a/fuzz/corpus/patch_parse/binary.diff b/fuzz/corpus/patch_parse/binary.diff new file mode 100644 index 00000000..1b9d3acf --- /dev/null +++ b/fuzz/corpus/patch_parse/binary.diff @@ -0,0 +1,3 @@ +diff --git a/icon.png b/icon.png +index 7777777..8888888 100644 +Binary files a/icon.png and b/icon.png differ diff --git a/fuzz/corpus/patch_parse/crash-huge-hunk-count b/fuzz/corpus/patch_parse/crash-huge-hunk-count new file mode 100644 index 00000000..41d62387 Binary files /dev/null and b/fuzz/corpus/patch_parse/crash-huge-hunk-count differ diff --git a/fuzz/corpus/patch_parse/crash-marker-after-empty-line b/fuzz/corpus/patch_parse/crash-marker-after-empty-line new file mode 100644 index 00000000..1f359f22 --- /dev/null +++ b/fuzz/corpus/patch_parse/crash-marker-after-empty-line @@ -0,0 +1,11 @@ +diff txt ++++ b/a.txt +@@ -1,2 +1,2 @@ + first +-o1,2 @@ + first + +\ N at en a/a.ÿÿÿÿ rst +-old end +\ No newline at end of --nd +\ No newlf file diff --git a/fuzz/corpus/patch_parse/crash-no-newline-marker-mutation b/fuzz/corpus/patch_parse/crash-no-newline-marker-mutation new file mode 100644 index 00000000..3f60cbf0 --- /dev/null +++ b/fuzz/corpus/patch_parse/crash-no-newline-marker-mutation @@ -0,0 +1,10 @@ +diff --git a/a.txt b/a.txt +index 3333333..4444444 100644 +--- a/a.txt ++++ b/a.txt +@@ -1,2 +1,2 @@ + first +-old end +\ No newline at end of file ++new end +\ N[ newline at end of file diff --git a/fuzz/corpus/patch_parse/deleted_file.diff b/fuzz/corpus/patch_parse/deleted_file.diff new file mode 100644 index 00000000..845f7cc8 --- /dev/null +++ b/fuzz/corpus/patch_parse/deleted_file.diff @@ -0,0 +1,8 @@ +diff --git a/gone.txt b/gone.txt +deleted file mode 100644 +index aaaaaaa..0000000 +--- a/gone.txt ++++ /dev/null +@@ -1,2 +0,0 @@ +-hello +-world diff --git a/fuzz/corpus/patch_parse/empty_hunk.diff b/fuzz/corpus/patch_parse/empty_hunk.diff new file mode 100644 index 00000000..1be6f266 --- /dev/null +++ b/fuzz/corpus/patch_parse/empty_hunk.diff @@ -0,0 +1,8 @@ +diff --git a/x b/x +index 1234567..89abcde 100644 +--- a/x ++++ b/x +@@ -3,0 +4,0 @@ empty counts +@@ -5 +6 @@ implicit counts +-five ++six diff --git a/fuzz/corpus/patch_parse/invalid_hunk_header.diff b/fuzz/corpus/patch_parse/invalid_hunk_header.diff new file mode 100644 index 00000000..991892e6 --- /dev/null +++ b/fuzz/corpus/patch_parse/invalid_hunk_header.diff @@ -0,0 +1,6 @@ +diff --git a/x b/x +--- a/x ++++ b/x +@@ -1,oops +1 @@ +-bad ++good diff --git a/fuzz/corpus/patch_parse/multi_hunk.diff b/fuzz/corpus/patch_parse/multi_hunk.diff new file mode 100644 index 00000000..997b5f58 --- /dev/null +++ b/fuzz/corpus/patch_parse/multi_hunk.diff @@ -0,0 +1,21 @@ +diff --git a/multi.txt b/multi.txt +index bbbbbbb..ccccccc 100644 +--- a/multi.txt ++++ b/multi.txt +@@ -1,3 +1,3 @@ fn top() + a +-b ++B + c +@@ -10,3 +10,4 @@ fn bottom() + x + y ++y2 + z +diff --git a/other.txt b/other.txt +index ddddddd..eeeeeee 100644 +--- a/other.txt ++++ b/other.txt +@@ -1 +1 @@ +-one ++uno diff --git a/fuzz/corpus/patch_parse/new_file.diff b/fuzz/corpus/patch_parse/new_file.diff new file mode 100644 index 00000000..3e38bc3e --- /dev/null +++ b/fuzz/corpus/patch_parse/new_file.diff @@ -0,0 +1,8 @@ +diff --git a/added.txt b/added.txt +new file mode 100644 +index 0000000..9999999 +--- /dev/null ++++ b/added.txt +@@ -0,0 +1,2 @@ ++hello ++world diff --git a/fuzz/corpus/patch_parse/no_newline.diff b/fuzz/corpus/patch_parse/no_newline.diff new file mode 100644 index 00000000..fa641069 --- /dev/null +++ b/fuzz/corpus/patch_parse/no_newline.diff @@ -0,0 +1,10 @@ +diff --git a/a.txt b/a.txt +index 3333333..4444444 100644 +--- a/a.txt ++++ b/a.txt +@@ -1,2 +1,2 @@ + first +-old end +\ No newline at end of file ++new end +\ No newline at end of file diff --git a/fuzz/corpus/patch_parse/rename.diff b/fuzz/corpus/patch_parse/rename.diff new file mode 100644 index 00000000..fb4457be --- /dev/null +++ b/fuzz/corpus/patch_parse/rename.diff @@ -0,0 +1,12 @@ +diff --git a/old_name.rs b/new_name.rs +similarity index 95% +rename from old_name.rs +rename to new_name.rs +index 5555555..6666666 100644 +--- a/old_name.rs ++++ b/new_name.rs +@@ -2,3 +2,3 @@ + line one +-line two ++line 2 + line three diff --git a/fuzz/corpus/patch_projection/basic.diff b/fuzz/corpus/patch_projection/basic.diff new file mode 100644 index 00000000..c1b5dbf0 --- /dev/null +++ b/fuzz/corpus/patch_projection/basic.diff @@ -0,0 +1,10 @@ +diff --git a/src/lib.rs b/src/lib.rs +index 1111111..2222222 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,4 +1,4 @@ + fn main() { +- println!("old"); ++ println!("new"); + } + diff --git a/fuzz/corpus/patch_projection/binary.diff b/fuzz/corpus/patch_projection/binary.diff new file mode 100644 index 00000000..1b9d3acf --- /dev/null +++ b/fuzz/corpus/patch_projection/binary.diff @@ -0,0 +1,3 @@ +diff --git a/icon.png b/icon.png +index 7777777..8888888 100644 +Binary files a/icon.png and b/icon.png differ diff --git a/fuzz/corpus/patch_projection/deleted_file.diff b/fuzz/corpus/patch_projection/deleted_file.diff new file mode 100644 index 00000000..845f7cc8 --- /dev/null +++ b/fuzz/corpus/patch_projection/deleted_file.diff @@ -0,0 +1,8 @@ +diff --git a/gone.txt b/gone.txt +deleted file mode 100644 +index aaaaaaa..0000000 +--- a/gone.txt ++++ /dev/null +@@ -1,2 +0,0 @@ +-hello +-world diff --git a/fuzz/corpus/patch_projection/multi_hunk.diff b/fuzz/corpus/patch_projection/multi_hunk.diff new file mode 100644 index 00000000..997b5f58 --- /dev/null +++ b/fuzz/corpus/patch_projection/multi_hunk.diff @@ -0,0 +1,21 @@ +diff --git a/multi.txt b/multi.txt +index bbbbbbb..ccccccc 100644 +--- a/multi.txt ++++ b/multi.txt +@@ -1,3 +1,3 @@ fn top() + a +-b ++B + c +@@ -10,3 +10,4 @@ fn bottom() + x + y ++y2 + z +diff --git a/other.txt b/other.txt +index ddddddd..eeeeeee 100644 +--- a/other.txt ++++ b/other.txt +@@ -1 +1 @@ +-one ++uno diff --git a/fuzz/corpus/patch_projection/new_file.diff b/fuzz/corpus/patch_projection/new_file.diff new file mode 100644 index 00000000..3e38bc3e --- /dev/null +++ b/fuzz/corpus/patch_projection/new_file.diff @@ -0,0 +1,8 @@ +diff --git a/added.txt b/added.txt +new file mode 100644 +index 0000000..9999999 +--- /dev/null ++++ b/added.txt +@@ -0,0 +1,2 @@ ++hello ++world diff --git a/fuzz/corpus/patch_projection/no_newline.diff b/fuzz/corpus/patch_projection/no_newline.diff new file mode 100644 index 00000000..fa641069 --- /dev/null +++ b/fuzz/corpus/patch_projection/no_newline.diff @@ -0,0 +1,10 @@ +diff --git a/a.txt b/a.txt +index 3333333..4444444 100644 +--- a/a.txt ++++ b/a.txt +@@ -1,2 +1,2 @@ + first +-old end +\ No newline at end of file ++new end +\ No newline at end of file diff --git a/fuzz/corpus/patch_projection/rename.diff b/fuzz/corpus/patch_projection/rename.diff new file mode 100644 index 00000000..fb4457be --- /dev/null +++ b/fuzz/corpus/patch_projection/rename.diff @@ -0,0 +1,12 @@ +diff --git a/old_name.rs b/new_name.rs +similarity index 95% +rename from old_name.rs +rename to new_name.rs +index 5555555..6666666 100644 +--- a/old_name.rs ++++ b/new_name.rs +@@ -2,3 +2,3 @@ + line one +-line two ++line 2 + line three diff --git a/fuzz/corpus/review_anchor/basic.diff b/fuzz/corpus/review_anchor/basic.diff new file mode 100644 index 00000000..c1b5dbf0 --- /dev/null +++ b/fuzz/corpus/review_anchor/basic.diff @@ -0,0 +1,10 @@ +diff --git a/src/lib.rs b/src/lib.rs +index 1111111..2222222 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,4 +1,4 @@ + fn main() { +- println!("old"); ++ println!("new"); + } + diff --git a/fuzz/corpus/review_anchor/multi_hunk.diff b/fuzz/corpus/review_anchor/multi_hunk.diff new file mode 100644 index 00000000..997b5f58 --- /dev/null +++ b/fuzz/corpus/review_anchor/multi_hunk.diff @@ -0,0 +1,21 @@ +diff --git a/multi.txt b/multi.txt +index bbbbbbb..ccccccc 100644 +--- a/multi.txt ++++ b/multi.txt +@@ -1,3 +1,3 @@ fn top() + a +-b ++B + c +@@ -10,3 +10,4 @@ fn bottom() + x + y ++y2 + z +diff --git a/other.txt b/other.txt +index ddddddd..eeeeeee 100644 +--- a/other.txt ++++ b/other.txt +@@ -1 +1 @@ +-one ++uno diff --git a/fuzz/corpus/text_store/binary_bytes.bin b/fuzz/corpus/text_store/binary_bytes.bin new file mode 100644 index 00000000..1adcef98 Binary files /dev/null and b/fuzz/corpus/text_store/binary_bytes.bin differ diff --git a/fuzz/corpus/text_store/blank_lines.txt b/fuzz/corpus/text_store/blank_lines.txt new file mode 100644 index 00000000..b28b04f6 --- /dev/null +++ b/fuzz/corpus/text_store/blank_lines.txt @@ -0,0 +1,3 @@ + + + diff --git a/fuzz/corpus/text_store/crlf_no_trailing.txt b/fuzz/corpus/text_store/crlf_no_trailing.txt new file mode 100644 index 00000000..69cea06d --- /dev/null +++ b/fuzz/corpus/text_store/crlf_no_trailing.txt @@ -0,0 +1,3 @@ +a +b +no trailing newline \ No newline at end of file diff --git a/fuzz/fuzz_targets/patch_parse.rs b/fuzz/fuzz_targets/patch_parse.rs new file mode 100644 index 00000000..2f92a73e --- /dev/null +++ b/fuzz/fuzz_targets/patch_parse.rs @@ -0,0 +1,38 @@ +#![no_main] + +use carbon::{BlockId, FileId, HunkId, parse_unified_patch, usize_to_u32_saturating}; +use libfuzzer_sys::fuzz_target; + +// Exercises the parser's error paths on arbitrary (possibly non-UTF-8) bytes +// and checks structural invariants of any document it accepts. +fuzz_target!(|bytes: &[u8]| { + let input = String::from_utf8_lossy(bytes); + let Ok(document) = parse_unified_patch(&input) else { + return; + }; + + assert!(!document.files.is_empty()); + for (file_index, file) in document.files.iter().enumerate() { + assert_eq!(file.id, FileId(usize_to_u32_saturating(file_index))); + for (hunk_index, hunk) in file.hunks.iter().enumerate() { + assert_eq!(hunk.id, HunkId(usize_to_u32_saturating(hunk_index))); + assert!(file.hunk(hunk.id).is_some()); + assert!(hunk.blocks.end() as usize <= file.blocks.len()); + assert_eq!(file.hunk_blocks(hunk).len() as u32, hunk.blocks.len); + assert!(hunk.old_start_index() <= hunk.old_end_index()); + assert!(hunk.new_start_index() <= hunk.new_end_index()); + } + for (block_index, block) in file.blocks.iter().enumerate() { + assert_eq!(block.id, BlockId(usize_to_u32_saturating(block_index))); + assert!(file.block(block.id).is_some()); + if block.old.len > 0 { + let text = file.old_text.as_ref(); + assert!(text.is_some_and(|text| block.old.end() <= text.line_count())); + } + if block.new.len > 0 { + let text = file.new_text.as_ref(); + assert!(text.is_some_and(|text| block.new.end() <= text.line_count())); + } + } + } +}); diff --git a/fuzz/fuzz_targets/review_anchor.rs b/fuzz/fuzz_targets/review_anchor.rs new file mode 100644 index 00000000..0c198312 --- /dev/null +++ b/fuzz/fuzz_targets/review_anchor.rs @@ -0,0 +1,56 @@ +#![no_main] + +use carbon::{ + Anchor, DiffSide, ExpansionState, LineRange, ProjectionOptions, map_anchor_to_projection, + parse_unified_patch, project_file, projected_row_byte_range, +}; +use libfuzzer_sys::fuzz_target; + +// Round-trips arbitrary review anchors against projected rows and checks that +// row-to-text byte ranges stay inside the side text stores. +fuzz_target!(|data: (&str, u8, u32, u32)| { + let (input, side_byte, start, len) = data; + let Ok(document) = parse_unified_patch(input) else { + return; + }; + + let side = match side_byte % 3 { + 0 => Some(DiffSide::Old), + 1 => Some(DiffSide::New), + _ => None, + }; + + for file in &document.files { + let mut rows = Vec::new(); + project_file( + file, + ProjectionOptions::default(), + &ExpansionState::default(), + |row| rows.push(row), + ); + + let anchor = Anchor { + side, + line_range: LineRange::new(start, len), + ..Anchor::file(file.id) + }; + let touched = map_anchor_to_projection(&anchor, &rows); + let expected = rows.iter().filter(|row| anchor.touches_row(row)).count(); + assert_eq!(touched.len(), expected); + for row in &touched { + assert!(anchor.touches_row(row)); + } + + for row in &rows { + for side in [DiffSide::Old, DiffSide::New] { + let Some(range) = projected_row_byte_range(file, row, side) else { + continue; + }; + let text = file + .side_text(side) + .expect("a projected byte range implies side text exists"); + assert!(range.start.saturating_add(range.len) <= text.len()); + } + } + } +}); diff --git a/src/apprt/compare.rs b/src/apprt/compare.rs index a835f09d..c84a63b1 100644 --- a/src/apprt/compare.rs +++ b/src/apprt/compare.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::thread; @@ -18,6 +19,10 @@ pub(crate) struct CompareScheduler { struct CompareQueue { state: Mutex, ready: Condvar, + /// Highest compare generation observed. Jobs and results stamped with an + /// older generation are stale (a newer compare superseded them) and are + /// dropped instead of being run or emitted, mirroring `SyntaxScheduler`. + current_epoch: AtomicU64, } #[derive(Default)] @@ -60,6 +65,7 @@ impl CompareScheduler { let queue = Arc::new(CompareQueue { state: Mutex::new(CompareQueueState::default()), ready: Condvar::new(), + current_epoch: AtomicU64::new(0), }); let worker_count = std::thread::available_parallelism() .map(usize::from) @@ -86,10 +92,31 @@ impl CompareScheduler { self.enqueue(CompareJob::FileStats(task)); } + /// Mark every compare job older than `epoch` stale. Called when a new + /// compare request starts so rapid file/rev switching cannot leave older + /// queued work racing newer state. + pub(crate) fn set_epoch(&self, epoch: u64) { + let previous = self.queue.current_epoch.fetch_max(epoch, Ordering::AcqRel); + if epoch < previous { + return; + } + let mut state = self.queue.state.lock().expect("compare queue poisoned"); + let current = self.queue.current_epoch.load(Ordering::Acquire); + state.jobs.retain(|job| job.job.generation() >= current); + } + fn enqueue(&self, job: CompareJob) { + let epoch = job.generation(); + let previous = self.queue.current_epoch.fetch_max(epoch, Ordering::AcqRel); + let key = job.key(); let priority = job.priority(); let mut state = self.queue.state.lock().expect("compare queue poisoned"); + let current = self.queue.current_epoch.load(Ordering::Acquire); + state.jobs.retain(|job| job.job.generation() >= current); + if epoch < previous || epoch < current { + return; + } state.jobs.retain(|job| job.key != key); let sequence = state.next_sequence; state.next_sequence = state.next_sequence.saturating_add(1); @@ -105,6 +132,14 @@ impl CompareScheduler { } impl CompareJob { + fn generation(&self) -> u64 { + match self { + CompareJob::Stats(task) => task.generation, + CompareJob::File(task) => task.generation, + CompareJob::FileStats(task) => task.generation, + } + } + fn priority(&self) -> CompareWorkPriority { match self { CompareJob::Stats(task) => task.request.priority, @@ -140,13 +175,20 @@ fn compare_worker_loop( let job = { let mut state = queue.state.lock().expect("compare queue poisoned"); loop { + let current_epoch = queue.current_epoch.load(Ordering::Acquire); + state + .jobs + .retain(|job| job.job.generation() >= current_epoch); if let Some(index) = next_job_index(&state.jobs) { break state.jobs.swap_remove(index); } state = queue.ready.wait(state).expect("compare queue poisoned"); } }; - run_job(job, &services, &event_sender); + if job.job.generation() < queue.current_epoch.load(Ordering::Acquire) { + continue; + } + run_job(job, &services, &event_sender, &queue.current_epoch); } } @@ -157,18 +199,30 @@ fn next_job_index(jobs: &[QueuedCompareJob]) -> Option { .map(|(index, _)| index) } -fn run_job(job: QueuedCompareJob, services: &AppServices, event_sender: &RuntimeEventSender) { +fn run_job( + job: QueuedCompareJob, + services: &AppServices, + event_sender: &RuntimeEventSender, + current_epoch: &AtomicU64, +) { match job.job { - CompareJob::Stats(task) => run_load_stats(task, services, event_sender), - CompareJob::File(task) => run_load_file(task, services, event_sender), - CompareJob::FileStats(task) => run_load_file_stats(task, services, event_sender), + CompareJob::Stats(task) => run_load_stats(task, services, event_sender, current_epoch), + CompareJob::File(task) => run_load_file(task, services, event_sender, current_epoch), + CompareJob::FileStats(task) => { + run_load_file_stats(task, services, event_sender, current_epoch) + } } } +fn is_stale(generation: u64, current_epoch: &AtomicU64) -> bool { + generation < current_epoch.load(Ordering::Acquire) +} + fn run_load_stats( task: Task, services: &AppServices, event_sender: &RuntimeEventSender, + current_epoch: &AtomicU64, ) { let generation = task.generation; let request = task.request; @@ -179,6 +233,10 @@ fn run_load_stats( message: error.to_string(), }, }; + if is_stale(generation, current_epoch) { + tracing::debug!(generation, "stale compare stats result dropped"); + return; + } event_sender.send(event); } @@ -186,6 +244,7 @@ fn run_load_file( task: Task, services: &AppServices, event_sender: &RuntimeEventSender, + current_epoch: &AtomicU64, ) { let generation = task.generation; let request = task.request; @@ -194,10 +253,14 @@ fn run_load_file( Ok(payload) => CompareEvent::CompareFileFinished(payload), Err(error) => CompareEvent::CompareFileFailed { generation, - path, + path: path.clone(), message: error.to_string(), }, }; + if is_stale(generation, current_epoch) { + tracing::debug!(generation, path = %path, "stale compare file result dropped"); + return; + } event_sender.send(event); } @@ -205,12 +268,16 @@ fn run_load_file_stats( task: Task, services: &AppServices, event_sender: &RuntimeEventSender, + current_epoch: &AtomicU64, ) { let generation = task.generation; let request = task.request; let payload = match services.load_compare_file_stats(generation, request) { Ok(payload) => payload, Err(error) => { + if is_stale(generation, current_epoch) { + return; + } event_sender.send(CompareEvent::CompareFileStatsFailed { generation, message: error.to_string(), @@ -218,13 +285,18 @@ fn run_load_file_stats( return; } }; - send_file_stats_payload(generation, payload, event_sender); + if is_stale(generation, current_epoch) { + tracing::debug!(generation, "stale compare file stats result dropped"); + return; + } + send_file_stats_payload(generation, payload, event_sender, current_epoch); } fn send_file_stats_payload( generation: u64, payload: CompareFileStatsReady, event_sender: &RuntimeEventSender, + current_epoch: &AtomicU64, ) { if payload.stats.len() <= FILE_STATS_STREAM_CHUNK_SIZE { event_sender.send(CompareEvent::CompareFileStatsReady(payload)); @@ -233,6 +305,12 @@ fn send_file_stats_payload( let mut stats = payload.stats.into_iter(); loop { + // A newer compare invalidates the remainder of the stream; the + // state-side generation guard ignores any chunks already sent. + if is_stale(generation, current_epoch) { + tracing::debug!(generation, "stale compare file stats stream dropped"); + return; + } let chunk = stats .by_ref() .take(FILE_STATS_STREAM_CHUNK_SIZE) @@ -255,8 +333,83 @@ fn send_file_stats_payload( #[cfg(test)] mod tests { + use std::path::PathBuf; + + use super::*; + use crate::core::compare::{LayoutMode, RendererKind}; + use crate::core::vcs::model::{VcsCompareRequest, VcsCompareSpec}; use crate::effects::CompareWorkPriority; + fn scheduler_for_test() -> CompareScheduler { + CompareScheduler { + queue: Arc::new(CompareQueue { + state: Mutex::new(CompareQueueState::default()), + ready: Condvar::new(), + current_epoch: AtomicU64::new(0), + }), + } + } + + fn file_task(generation: u64, index: usize) -> Task { + Task { + generation, + request: CompareFileRequest { + repo_path: PathBuf::from("/repo"), + request: VcsCompareRequest { + spec: VcsCompareSpec::WorkingCopy, + layout: LayoutMode::Unified, + renderer: RendererKind::Builtin, + }, + path: format!("src/file-{index}.rs"), + index, + deferred_file: None, + priority: CompareWorkPriority::InteractiveSelectedFile, + }, + } + } + + #[test] + fn newer_compare_generation_drops_queued_older_jobs() { + let scheduler = scheduler_for_test(); + scheduler.dispatch_load_file(file_task(1, 1)); + scheduler.dispatch_load_file(file_task(1, 2)); + + scheduler.dispatch_load_file(file_task(2, 3)); + + let state = scheduler + .queue + .state + .lock() + .expect("compare queue poisoned"); + assert_eq!(scheduler.queue.current_epoch.load(Ordering::Acquire), 2); + assert_eq!(state.jobs.len(), 1); + assert_eq!(state.jobs[0].job.generation(), 2); + } + + #[test] + fn explicit_compare_epoch_drops_queued_older_jobs() { + let scheduler = scheduler_for_test(); + scheduler.dispatch_load_file(file_task(1, 1)); + scheduler.dispatch_load_file(file_task(1, 2)); + + scheduler.set_epoch(2); + scheduler.dispatch_load_file(file_task(1, 3)); + scheduler.dispatch_load_file(file_task(2, 4)); + + let state = scheduler + .queue + .state + .lock() + .expect("compare queue poisoned"); + assert_eq!(scheduler.queue.current_epoch.load(Ordering::Acquire), 2); + assert_eq!(state.jobs.len(), 1); + assert_eq!(state.jobs[0].job.generation(), 2); + match &state.jobs[0].job { + CompareJob::File(task) => assert_eq!(task.request.index, 4), + other => panic!("unexpected job kind: {:?}", other.key()), + } + } + #[test] fn visible_diff_work_outprioritizes_stats_work() { assert!( diff --git a/src/apprt/runtime.rs b/src/apprt/runtime.rs index 10f99deb..19e5412c 100644 --- a/src/apprt/runtime.rs +++ b/src/apprt/runtime.rs @@ -207,6 +207,10 @@ impl EffectRunner { } Effect::Compare(CompareEffect::Run(task)) => { let generation = task.generation; + // A new compare supersedes any queued/in-flight scheduler work + // from older generations; drop it instead of racing the new + // results. + self.compare_scheduler.set_epoch(generation); let request = task.request; let services = self.services.clone(); let event_sender = self.event_sender.clone(); @@ -217,7 +221,7 @@ impl EffectRunner { Ok(payload) => CompareEvent::CompareFinished(payload), Err(error) => CompareEvent::CompareFailed { generation, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -225,6 +229,7 @@ impl EffectRunner { } Effect::Compare(CompareEffect::RunText(task)) => { let generation = task.generation; + self.compare_scheduler.set_epoch(generation); let request = task.request; let services = self.services.clone(); let event_sender = self.event_sender.clone(); @@ -233,7 +238,7 @@ impl EffectRunner { Ok(payload) => CompareEvent::TextCompareFinished(payload), Err(error) => CompareEvent::TextCompareFailed { generation, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -252,7 +257,7 @@ impl EffectRunner { Ok(payload) => CompareEvent::CompareHistoryReady(payload), Err(error) => CompareEvent::CompareHistoryFailed { generation, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -275,7 +280,7 @@ impl EffectRunner { Err(error) => CompareEvent::StatusDiffFailed { generation, index, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -314,7 +319,7 @@ impl EffectRunner { }, Err(error) => GitHubEvent::PullRequestLoadFailed { url, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -327,7 +332,7 @@ impl EffectRunner { let event = match services.start_device_flow(&client_id) { Ok(state) => GitHubEvent::DeviceFlowStarted(state), Err(error) => GitHubEvent::DeviceFlowStartFailed { - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -346,7 +351,7 @@ impl EffectRunner { { Ok(token) => GitHubEvent::DeviceFlowCompleted { token }, Err(error) => GitHubEvent::DeviceFlowFailed { - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -370,7 +375,7 @@ impl EffectRunner { let event = match result { Ok(token) => GitHubEvent::GitHubTokenLoaded { token }, Err(error) => GitHubEvent::GitHubTokenLoadFailed { - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -391,7 +396,7 @@ impl EffectRunner { }; if let Err(error) = result { event_sender.send(GitHubEvent::GitHubTokenSaveFailed { - message: error.to_string(), + message: error.user_message(), }); } }); @@ -420,7 +425,7 @@ impl EffectRunner { let event = match services.fetch_github_user(&token) { Ok(user) => GitHubEvent::GitHubUserFetched { user }, Err(error) => GitHubEvent::GitHubUserFetchFailed { - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -447,7 +452,7 @@ impl EffectRunner { owner, repo, number, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -478,7 +483,7 @@ impl EffectRunner { owner, repo, number, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -509,7 +514,7 @@ impl EffectRunner { owner, repo, number, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -542,7 +547,7 @@ impl EffectRunner { owner, repo, number, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -577,7 +582,7 @@ impl EffectRunner { owner, repo, number, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -612,7 +617,7 @@ impl EffectRunner { repo, number, comment_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -645,7 +650,7 @@ impl EffectRunner { repo, number, comment_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -678,7 +683,7 @@ impl EffectRunner { owner, repo, number, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -718,7 +723,7 @@ impl EffectRunner { repo, number, draft_ids, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -754,7 +759,7 @@ impl EffectRunner { repo, number, thread_node_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -787,7 +792,7 @@ impl EffectRunner { repo, number, comment_node_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -818,7 +823,7 @@ impl EffectRunner { repo, number, comment_node_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -851,7 +856,7 @@ impl EffectRunner { repo, number, thread_node_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -887,7 +892,7 @@ impl EffectRunner { repo, number, review_id, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -904,7 +909,7 @@ impl EffectRunner { Ok(session) => GitHubEvent::ReviewSessionLoaded { target, session }, Err(error) => GitHubEvent::ReviewSessionLoadFailed { target, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -919,7 +924,7 @@ impl EffectRunner { Ok(key) => GitHubEvent::ReviewSessionSaved { key }, Err(error) => GitHubEvent::ReviewSessionSaveFailed { key, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -938,7 +943,7 @@ impl EffectRunner { }, Err(error) => GitHubEvent::AvatarFetchFailed { url, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -979,7 +984,7 @@ impl EffectRunner { UpdateEvent::UpdateNotAvailable { silent } } Err(error) => UpdateEvent::UpdateCheckFailed { - message: error.to_string(), + message: error.user_message(), silent, }, }; @@ -992,7 +997,7 @@ impl EffectRunner { thread::spawn(move || match services.stage_update(&update) { Ok(staged) => event_sender.send(UpdateEvent::UpdateStaged { staged, silent }), Err(error) => event_sender.send(UpdateEvent::UpdateInstallFailed { - message: error.to_string(), + message: error.user_message(), silent, }), }); @@ -1003,7 +1008,7 @@ impl EffectRunner { thread::spawn(move || { if let Err(error) = services.apply_staged_update(&staged) { event_sender.send(UpdateEvent::UpdateInstallFailed { - message: error.to_string(), + message: error.user_message(), silent: false, }); } @@ -1015,7 +1020,7 @@ impl EffectRunner { thread::spawn(move || { if let Err(error) = services.open_browser(&url) { event_sender.send(UiEvent::BrowserOpenFailed { - message: error.to_string(), + message: error.user_message(), }); } }); @@ -1047,7 +1052,7 @@ impl EffectRunner { Err(error) => RepositoryEvent::ContextLinesFailed { generation: request.generation, file_index: request.file_index, - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -1192,7 +1197,7 @@ impl EffectRunner { let event = match crate::apprt::services::load_ai_keys() { Ok((openai, anthropic)) => AiEvent::AiKeysLoaded { openai, anthropic }, Err(error) => AiEvent::AiKeysLoadFailed { - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); @@ -1206,7 +1211,7 @@ impl EffectRunner { thread::spawn(move || { if let Err(error) = crate::platform::secrets::save_ai_key(kind, &value) { event_sender.send(AiEvent::AiKeySaveFailed { - message: error.to_string(), + message: error.user_message(), }); } }); @@ -1282,7 +1287,7 @@ fn persist_settings( SettingsEvent::SettingsSaved } Err(error) => SettingsEvent::SettingsSaveFailed { - message: error.to_string(), + message: error.user_message(), }, }; event_sender.send(event); diff --git a/src/apprt/services.rs b/src/apprt/services.rs index e15966c9..de149791 100644 --- a/src/apprt/services.rs +++ b/src/apprt/services.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -18,11 +19,14 @@ use crate::core::forge::github::{ }; use crate::core::http; use crate::core::review::{ReviewDecision, ReviewSession, ReviewSessionKey, ReviewTarget}; -use crate::core::syntax::annotator::FullFileSyntax; +use crate::core::syntax::annotator::{ + FullFileSyntax, SourceLineWindow, carbon_window_source_line_bounds, +}; +use crate::core::vcs::backend::VcsRepository; use crate::core::vcs::discovery; -use crate::core::vcs::git::pr_ref_path; +use crate::core::vcs::git::{is_pr_ref, pr_ref_path}; use crate::core::vcs::model::RevisionId; -use crate::core::vcs::model::{VcsCompareRequest, VcsCompareSpec}; +use crate::core::vcs::model::{VcsCompareRequest, VcsCompareSpec, VcsKind}; use crate::effects::{ CompareFileRequest, CompareFileStatsRequest, CompareHistoryRequest, CompareRequest, CompareStatsRequest, GenerateCommitMessageRequest, LoadFileSyntaxRequest, StatusDiffRequest, @@ -39,45 +43,140 @@ use crate::ui::state::prepare_active_file; const DEV_GITHUB_TOKEN_FILE_NAME: &str = "github-token.dev"; +/// Files at or below this size are highlighted in full and cached once; above +/// it, span extraction is windowed to the requested source-line bucket so +/// huge files never pay (or cache) full-file query extraction. +const SYNTAX_FULL_HIGHLIGHT_BYTE_LIMIT: usize = 512 * 1024; +/// Quantization for windowed cache entries, in source lines. Buckets are +/// large relative to viewport tiles so scrolling reuses cached windows. +const SYNTAX_WINDOW_BUCKET_LINES: usize = 2048; + +/// PR comparison refs live in the git ref namespace (`refs/diffy/pr/...`), +/// which jj revsets cannot resolve. Route those comparisons through the git +/// backend, which in colocated jj checkouts operates on the same `.git` store. +fn open_compare_repository<'a>( + repo_path: &Path, + refs: impl IntoIterator, +) -> Result> { + if refs.into_iter().any(is_pr_ref) { + discovery::open_git_repository(repo_path) + } else { + discovery::open_repository(repo_path) + } +} + #[derive(Debug, Clone)] pub struct AppServices { settings_store: SettingsStore, review_store: ReviewStore, syntax_cache: Arc>, syntax_cache_ready: Arc, + syntax_cache_stats: Arc, +} + +/// Lock-free hit/miss counters for the file syntax cache, readable for +/// debugging without taking the cache mutex. +#[derive(Debug, Default)] +pub struct FileSyntaxCacheStats { + hits: AtomicU64, + misses: AtomicU64, +} + +impl FileSyntaxCacheStats { + pub fn hits(&self) -> u64 { + self.hits.load(Ordering::Relaxed) + } + + pub fn misses(&self) -> u64 { + self.misses.load(Ordering::Relaxed) + } + + fn record_hit(&self) { + self.hits.fetch_add(1, Ordering::Relaxed); + } + + fn record_miss(&self) { + self.misses.fetch_add(1, Ordering::Relaxed); + } } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] struct FileSyntaxCacheKey { repo_path: String, reference: String, path: String, generation: u64, epoch: u64, + /// Quantized source-line window for large files; `None` means the entry + /// covers the whole file. + line_window: Option<(u32, u32)>, } -#[derive(Debug, Default)] +const LRU_NIL: usize = usize::MAX; + +/// LRU cache of highlighted file syntax. Recency is tracked with an intrusive +/// doubly-linked list over slot indices, so lookups, inserts, and evictions +/// are all O(1) instead of scanning every entry on insert. +#[derive(Debug)] struct FileSyntaxCache { - entries: HashMap, + map: HashMap, + slots: Vec, + free_slots: Vec, + /// Most recently used slot index, or `LRU_NIL` when empty. + head: usize, + /// Least recently used slot index, or `LRU_NIL` when empty. + tail: usize, inflight: HashSet, bytes: usize, - tick: u64, epoch: u64, } #[derive(Debug)] -struct FileSyntaxCacheEntry { - syntax: Arc, +struct FileSyntaxCacheSlot { + key: FileSyntaxCacheKey, + syntax: Option>, bytes: usize, - last_used: u64, + prev: usize, + next: usize, +} + +impl Default for FileSyntaxCache { + fn default() -> Self { + Self { + map: HashMap::new(), + slots: Vec::new(), + free_slots: Vec::new(), + head: LRU_NIL, + tail: LRU_NIL, + inflight: HashSet::new(), + bytes: 0, + epoch: 0, + } + } +} + +/// Expands a requested source-line window to stable bucket boundaries so +/// nearby viewport requests share one cached windowed entry. +fn bucketed_line_window(lines: SourceLineWindow) -> (u32, u32) { + let bucket = SYNTAX_WINDOW_BUCKET_LINES.max(1); + let start = (lines.start / bucket).saturating_mul(bucket); + let end = lines + .end + .max(lines.start.saturating_add(1)) + .div_ceil(bucket) + .saturating_mul(bucket); + ( + carbon::usize_to_u32_saturating(start), + carbon::usize_to_u32_saturating(end), + ) } impl FileSyntaxCache { fn get(&mut self, key: &FileSyntaxCacheKey) -> Option> { - let tick = self.next_tick(); - let entry = self.entries.get_mut(key)?; - entry.last_used = tick; - Some(entry.syntax.clone()) + let index = self.map.get(key).copied()?; + self.detach(index); + self.attach_front(index); + self.slots.get(index).and_then(|slot| slot.syntax.clone()) } fn insert(&mut self, key: FileSyntaxCacheKey, syntax: Arc) { @@ -85,43 +184,108 @@ impl FileSyntaxCache { const BYTE_BUDGET: usize = 48 * 1024 * 1024; let bytes = syntax.estimated_bytes().max(1); - let tick = self.next_tick(); - if let Some(previous) = self.entries.insert( - key, - FileSyntaxCacheEntry { - syntax, + if let Some(index) = self.map.get(&key).copied() { + if let Some(slot) = self.slots.get_mut(index) { + let previous = std::mem::replace(&mut slot.bytes, bytes); + slot.syntax = Some(syntax); + self.bytes = self.bytes.saturating_sub(previous).saturating_add(bytes); + } + self.detach(index); + self.attach_front(index); + } else { + let slot = FileSyntaxCacheSlot { + key: key.clone(), + syntax: Some(syntax), bytes, - last_used: tick, - }, - ) { - self.bytes = self.bytes.saturating_sub(previous.bytes); + prev: LRU_NIL, + next: LRU_NIL, + }; + let index = match self.free_slots.pop() { + Some(free) if free < self.slots.len() => { + self.slots[free] = slot; + free + } + _ => { + self.slots.push(slot); + self.slots.len().saturating_sub(1) + } + }; + self.map.insert(key, index); + self.attach_front(index); + self.bytes = self.bytes.saturating_add(bytes); } - self.bytes = self.bytes.saturating_add(bytes); - - while self.entries.len() > MAX_ENTRIES - || (self.entries.len() > 1 && self.bytes > BYTE_BUDGET) - { - let Some(victim) = self - .entries - .iter() - .min_by_key(|(_, entry)| entry.last_used) - .map(|(key, _)| key.clone()) - else { + + while self.map.len() > MAX_ENTRIES || (self.map.len() > 1 && self.bytes > BYTE_BUDGET) { + if !self.evict_lru() { break; - }; - if let Some(entry) = self.entries.remove(&victim) { - self.bytes = self.bytes.saturating_sub(entry.bytes); } } } - fn next_tick(&mut self) -> u64 { - self.tick = self.tick.saturating_add(1); - self.tick + fn evict_lru(&mut self) -> bool { + let index = self.tail; + if index == LRU_NIL { + return false; + } + self.detach(index); + let Some(slot) = self.slots.get_mut(index) else { + return false; + }; + let key = std::mem::take(&mut slot.key); + slot.syntax = None; + let freed = std::mem::take(&mut slot.bytes); + self.bytes = self.bytes.saturating_sub(freed); + self.map.remove(&key); + self.free_slots.push(index); + true + } + + fn detach(&mut self, index: usize) { + let (prev, next) = match self.slots.get(index) { + Some(slot) => (slot.prev, slot.next), + None => return, + }; + if prev == LRU_NIL { + if self.head == index { + self.head = next; + } + } else if let Some(prev_slot) = self.slots.get_mut(prev) { + prev_slot.next = next; + } + if next == LRU_NIL { + if self.tail == index { + self.tail = prev; + } + } else if let Some(next_slot) = self.slots.get_mut(next) { + next_slot.prev = prev; + } + if let Some(slot) = self.slots.get_mut(index) { + slot.prev = LRU_NIL; + slot.next = LRU_NIL; + } + } + + fn attach_front(&mut self, index: usize) { + let old_head = self.head; + let Some(slot) = self.slots.get_mut(index) else { + return; + }; + slot.prev = LRU_NIL; + slot.next = old_head; + if old_head == LRU_NIL { + self.tail = index; + } else if let Some(head_slot) = self.slots.get_mut(old_head) { + head_slot.prev = index; + } + self.head = index; } fn clear(&mut self) { - self.entries.clear(); + self.map.clear(); + self.slots.clear(); + self.free_slots.clear(); + self.head = LRU_NIL; + self.tail = LRU_NIL; self.inflight.clear(); self.bytes = 0; self.epoch = self.epoch.saturating_add(1); @@ -136,9 +300,14 @@ impl AppServices { review_store, syntax_cache: Arc::new(Mutex::new(FileSyntaxCache::default())), syntax_cache_ready: Arc::new(Condvar::new()), + syntax_cache_stats: Arc::new(FileSyntaxCacheStats::default()), } } + pub fn file_syntax_cache_stats(&self) -> &FileSyntaxCacheStats { + &self.syntax_cache_stats + } + pub fn run_compare( &self, generation: u64, @@ -150,7 +319,7 @@ impl AppServices { r.phase(ComparePhase::OpeningRepo); } let stage_started = Instant::now(); - let mut repo = discovery::open_repository(&request.repo_path)?; + let mut repo = open_compare_repository(&request.repo_path, request.request.spec.refs())?; tracing::info!( generation, elapsed_ms = stage_started.elapsed().as_millis(), @@ -235,7 +404,7 @@ impl AppServices { generation: u64, request: CompareFileRequest, ) -> Result { - let mut repo = discovery::open_repository(&request.repo_path)?; + let mut repo = open_compare_repository(&request.repo_path, request.request.spec.refs())?; let mut output = repo.compare_path( &request.request, &request.path, @@ -259,7 +428,7 @@ impl AppServices { generation: u64, request: CompareStatsRequest, ) -> Result { - let mut repo = discovery::open_repository(&request.repo_path)?; + let mut repo = open_compare_repository(&request.repo_path, request.request.spec.refs())?; let (additions, deletions) = repo.compare_stats(&request.request)?; Ok(CompareStatsReady { @@ -274,7 +443,10 @@ impl AppServices { generation: u64, request: CompareHistoryRequest, ) -> Result { - let mut repo = discovery::open_repository(&request.repo_path)?; + let mut repo = open_compare_repository( + &request.repo_path, + [request.left_ref.as_str(), request.right_ref.as_str()], + )?; let range_commits = repo.compare_history(&request.left_ref, &request.right_ref, 500)?; Ok(CompareHistoryReady { @@ -288,7 +460,7 @@ impl AppServices { generation: u64, request: CompareFileStatsRequest, ) -> Result { - let mut repo = discovery::open_repository(&request.repo_path)?; + let mut repo = open_compare_repository(&request.repo_path, request.request.spec.refs())?; let files = request .files .iter() @@ -324,11 +496,19 @@ impl AppServices { if !is_current() { return Vec::new(); } - let Ok(mut repo) = discovery::open_repository(&request.repo_path) else { + let Ok(mut repo) = open_compare_repository( + &request.repo_path, + [request.left_ref.as_str(), request.right_ref.as_str()], + ) else { return Vec::new(); }; let annotator = crate::core::syntax::DiffSyntaxAnnotator::new(); + let (old_lines, new_lines) = carbon_window_source_line_bounds( + &request.carbon_file, + &request.carbon_expansion, + request.window, + ); let old_syntax = request .carbon_file .old_path @@ -339,6 +519,7 @@ impl AppServices { request, &request.left_ref, old_path, + old_lines, &annotator, is_current, ) @@ -356,6 +537,7 @@ impl AppServices { request, &request.right_ref, new_path, + new_lines, &annotator, is_current, ) @@ -374,12 +556,14 @@ impl AppServices { ) } + #[allow(clippy::too_many_arguments)] fn cached_file_syntax( &self, repo: &mut dyn crate::core::vcs::backend::VcsRepository, request: &LoadFileSyntaxRequest, reference: &str, source_path: &str, + lines: Option, annotator: &crate::core::syntax::DiffSyntaxAnnotator, is_current: &F, ) -> Option> @@ -389,22 +573,38 @@ impl AppServices { if reference.is_empty() || !is_current() { return None; } + // No rows from this side fall inside the requested window, so no + // tokens are needed for it. + let lines = lines?; + let bucket = bucketed_line_window(lines); let mut cache = self.syntax_cache.lock().ok()?; - let key = FileSyntaxCacheKey { + let whole_key = FileSyntaxCacheKey { repo_path: request.repo_path.to_string_lossy().into_owned(), reference: reference.to_owned(), path: source_path.to_owned(), generation: request.cache_generation, epoch: cache.epoch, + line_window: None, + }; + let window_key = FileSyntaxCacheKey { + line_window: Some(bucket), + ..whole_key.clone() }; loop { - if cache.epoch != key.epoch { + if cache.epoch != whole_key.epoch { return None; } - if let Some(cached) = cache.get(&key) { + // A whole-file entry covers every window, so prefer it. + if let Some(cached) = cache.get(&whole_key).or_else(|| cache.get(&window_key)) { + self.syntax_cache_stats.record_hit(); return Some(cached); } - if cache.inflight.insert(key.clone()) { + // Mark both keys in flight: until the file size is known we do + // not know whether the result will be whole-file or windowed, and + // this also serializes overlapping work on the same file. + if !cache.inflight.contains(&whole_key) && !cache.inflight.contains(&window_key) { + cache.inflight.insert(whole_key.clone()); + cache.inflight.insert(window_key.clone()); break; } cache = self @@ -412,16 +612,18 @@ impl AppServices { .wait_timeout(cache, Duration::from_millis(25)) .ok()? .0; - if cache.epoch != key.epoch || !is_current() { + if cache.epoch != whole_key.epoch || !is_current() { return None; } } - if cache.epoch != key.epoch || !is_current() { - cache.inflight.remove(&key); + if cache.epoch != whole_key.epoch || !is_current() { + cache.inflight.remove(&whole_key); + cache.inflight.remove(&window_key); self.syntax_cache_ready.notify_all(); return None; } drop(cache); + self.syntax_cache_stats.record_miss(); let revision = RevisionId { backend: repo.location().kind, @@ -430,29 +632,36 @@ impl AppServices { let text = match repo.read_file_text(&revision, source_path) { Ok(text) => text, Err(_) => { - if let Ok(mut cache) = self.syntax_cache.lock() { - cache.inflight.remove(&key); - self.syntax_cache_ready.notify_all(); - } + self.release_syntax_inflight(&whole_key, &window_key); return None; } }; if !is_current() { - if let Ok(mut cache) = self.syntax_cache.lock() { - cache.inflight.remove(&key); - self.syntax_cache_ready.notify_all(); - } + self.release_syntax_inflight(&whole_key, &window_key); return None; } - let syntax = Arc::new(annotator.highlight_full_text_store(source_path, &text)); + let windowed = + carbon::u32_to_usize_saturating(text.len()) > SYNTAX_FULL_HIGHLIGHT_BYTE_LIMIT; + let (store_key, syntax) = if windowed { + let window = SourceLineWindow { + start: carbon::u32_to_usize_saturating(bucket.0), + end: carbon::u32_to_usize_saturating(bucket.1), + }; + let syntax = annotator.highlight_window_text_store(source_path, &text, window); + (window_key.clone(), Arc::new(syntax)) + } else { + let syntax = annotator.highlight_full_text_store(source_path, &text); + (whole_key.clone(), Arc::new(syntax)) + }; match self.syntax_cache.lock() { Ok(mut cache) => { - cache.inflight.remove(&key); - if cache.epoch != key.epoch || !is_current() { + cache.inflight.remove(&whole_key); + cache.inflight.remove(&window_key); + if cache.epoch != whole_key.epoch || !is_current() { self.syntax_cache_ready.notify_all(); return None; } - cache.insert(key, syntax.clone()); + cache.insert(store_key, syntax.clone()); self.syntax_cache_ready.notify_all(); } Err(_) => self.syntax_cache_ready.notify_all(), @@ -460,11 +669,28 @@ impl AppServices { Some(syntax) } + fn release_syntax_inflight( + &self, + whole_key: &FileSyntaxCacheKey, + window_key: &FileSyntaxCacheKey, + ) { + if let Ok(mut cache) = self.syntax_cache.lock() { + cache.inflight.remove(whole_key); + cache.inflight.remove(window_key); + } + self.syntax_cache_ready.notify_all(); + } + pub fn clear_file_syntax_cache(&self) { if let Ok(mut cache) = self.syntax_cache.lock() { cache.clear(); self.syntax_cache_ready.notify_all(); } + tracing::debug!( + hits = self.syntax_cache_stats.hits(), + misses = self.syntax_cache_stats.misses(), + "syntax: file syntax cache cleared" + ); } pub fn load_pull_request( @@ -493,6 +719,9 @@ impl AppServices { .to_owned(), )); } + if repo.location().kind != VcsKind::GIT { + repo = discovery::open_git_repository(repo_path)?; + } let stage_started = Instant::now(); let (info, left_ref, right_ref) = repo.resolve_pull_request_comparison(url, &token)?; tracing::info!( @@ -524,6 +753,12 @@ impl AppServices { if !repo.capabilities().github_pull_requests { return Ok(None); } + if repo.location().kind != VcsKind::GIT { + let Ok(git_repo) = discovery::open_git_repository(repo_path) else { + return Ok(None); + }; + repo = git_repo; + } for session in sessions.into_iter().rev() { let info = session.pull_request; @@ -841,7 +1076,7 @@ impl AppServices { .header("User-Agent", "diffy/0.1") .send() .await - .map_err(|error| DiffyError::Http(format!("avatar fetch failed: {error}")))?; + .map_err(|error| DiffyError::network(format!("avatar fetch failed: {error}")))?; http::response_bytes(response, "avatar fetch").await })?; let img = image::load_from_memory(&bytes) diff --git a/src/apprt/vcs_worker.rs b/src/apprt/vcs_worker.rs index d28be5d3..8e0b8aff 100644 --- a/src/apprt/vcs_worker.rs +++ b/src/apprt/vcs_worker.rs @@ -22,13 +22,28 @@ const VCS_DIRTY_DEBOUNCE: Duration = Duration::from_millis(150); pub struct VcsWorker { sender: Sender, + event_sender: RuntimeEventSender, } impl VcsWorker { pub fn new(event_sender: RuntimeEventSender) -> Self { let (sender, receiver) = mpsc::channel(); - thread::spawn(move || vcs_worker_loop(event_sender, receiver)); - Self { sender } + let worker_events = event_sender.clone(); + thread::spawn(move || vcs_worker_loop(worker_events, receiver)); + Self { + sender, + event_sender, + } + } + + /// Sends a command to the worker thread. If the thread died, every repo + /// operation would otherwise silently evaporate, so report it through the + /// runtime event loop where state turns it into a visible error. + fn send(&self, command: VcsWorkerCommand) { + if self.sender.send(command).is_err() { + tracing::error!("VCS worker thread stopped; dropping repository command"); + self.event_sender.send(RepositoryEvent::WorkerStopped); + } } pub fn dispatch_sync( @@ -37,7 +52,7 @@ impl VcsWorker { reason: RepositorySyncReason, reporter_generation: Option, ) { - let _ = self.sender.send(VcsWorkerCommand::Sync { + self.send(VcsWorkerCommand::Sync { path, reason, reporter_generation, @@ -50,7 +65,7 @@ impl VcsWorker { file_change: FileChange, operation: FileOperation, ) { - let _ = self.sender.send(VcsWorkerCommand::ApplyOperation { + self.send(VcsWorkerCommand::ApplyOperation { path, file_change, operation, @@ -63,7 +78,7 @@ impl VcsWorker { file_changes: Vec, operation: FileOperation, ) { - let _ = self.sender.send(VcsWorkerCommand::ApplyBatchOperation { + self.send(VcsWorkerCommand::ApplyBatchOperation { path, file_changes, operation, @@ -77,7 +92,7 @@ impl VcsWorker { bucket: ChangeBucket, operation: FileOperation, ) { - let _ = self.sender.send(VcsWorkerCommand::ApplyPatch { + self.send(VcsWorkerCommand::ApplyPatch { path, patch, bucket, @@ -86,7 +101,7 @@ impl VcsWorker { } pub fn dispatch_commit(&self, path: PathBuf, message: String) { - let _ = self.sender.send(VcsWorkerCommand::Commit { path, message }); + self.send(VcsWorkerCommand::Commit { path, message }); } pub fn dispatch_operation_command( @@ -95,7 +110,7 @@ impl VcsWorker { operation: VcsOperation, toast_id: u64, ) { - let _ = self.sender.send(VcsWorkerCommand::RunOperation { + self.send(VcsWorkerCommand::RunOperation { path, operation, toast_id, @@ -103,7 +118,7 @@ impl VcsWorker { } pub fn dispatch_fetch(&self, path: PathBuf, remote: String, toast_id: u64) { - let _ = self.sender.send(VcsWorkerCommand::Fetch { + self.send(VcsWorkerCommand::Fetch { path, remote, toast_id, @@ -118,7 +133,7 @@ impl VcsWorker { force_with_lease: bool, toast_id: u64, ) { - let _ = self.sender.send(VcsWorkerCommand::Push { + self.send(VcsWorkerCommand::Push { path, remote, refspec, @@ -128,7 +143,7 @@ impl VcsWorker { } pub fn dispatch_publish(&self, path: PathBuf, action: Option, toast_id: u64) { - let _ = self.sender.send(VcsWorkerCommand::Publish { + self.send(VcsWorkerCommand::Publish { path, action, toast_id, @@ -136,13 +151,11 @@ impl VcsWorker { } pub fn dispatch_publish_plan(&self, path: PathBuf, toast_id: Option) { - let _ = self - .sender - .send(VcsWorkerCommand::PublishPlan { path, toast_id }); + self.send(VcsWorkerCommand::PublishPlan { path, toast_id }); } pub fn dispatch_pull_ff(&self, path: PathBuf, remote: String, branch: String, toast_id: u64) { - let _ = self.sender.send(VcsWorkerCommand::PullFf { + self.send(VcsWorkerCommand::PullFf { path, remote, branch, @@ -391,7 +404,7 @@ fn apply_status_operation( if let Err(error) = result { event_sender.send(RepositoryEvent::FileOperationFailed { path: path.clone(), - message: error.to_string(), + message: error.user_message(), }); return; } @@ -411,7 +424,7 @@ fn apply_batch_status_operation( if let Err(error) = result { event_sender.send(RepositoryEvent::FileOperationFailed { path: path.clone(), - message: error.to_string(), + message: error.user_message(), }); return; } @@ -458,7 +471,7 @@ fn apply_commit( { event_sender.send(RepositoryEvent::CommitFailed { path, - message: error.to_string(), + message: error.user_message(), }); return; } @@ -508,7 +521,7 @@ fn apply_vcs_operation( event_sender.send(RepositoryEvent::VcsOperationFailed { toast_id, operation, - message: error.to_string(), + message: error.user_message(), }); } } @@ -539,7 +552,7 @@ fn apply_fetch( event_sender.send(RepositoryEvent::FetchFailed { toast_id, remote, - message: error.to_string(), + message: error.user_message(), }); } } @@ -590,7 +603,7 @@ fn apply_push( event_sender.send(RepositoryEvent::PushFailed { toast_id, remote, - message: error.to_string(), + message: error.user_message(), }); } } @@ -626,7 +639,7 @@ fn apply_publish( tracing::warn!(path = %path.display(), %error, "vcs: publish failed"); event_sender.send(RepositoryEvent::PublishFailed { toast_id, - message: error.to_string(), + message: error.user_message(), }); } } @@ -649,7 +662,7 @@ fn apply_publish_plan(event_sender: &RuntimeEventSender, path: PathBuf, toast_id tracing::warn!(path = %path.display(), %error, "vcs: publish-plan failed"); event_sender.send(RepositoryEvent::PublishPlanFailed { toast_id, - message: error.to_string(), + message: error.user_message(), }); } } @@ -709,7 +722,7 @@ fn apply_pull_ff( toast_id, remote, branch, - message: error.to_string(), + message: error.user_message(), }); } } @@ -773,7 +786,7 @@ fn sync_repository_inner( event_sender.send(RepositoryEvent::RepositorySnapshotFailed { path, reason, - message: error.to_string(), + message: error.user_message(), }); return; } @@ -816,7 +829,7 @@ fn sync_vcs_repository( event_sender.send(RepositoryEvent::RepositorySnapshotFailed { path, reason, - message: error.to_string(), + message: error.user_message(), }); return; } @@ -836,7 +849,7 @@ fn sync_vcs_repository( event_sender.send(RepositoryEvent::RepositorySnapshotFailed { path, reason, - message: error.to_string(), + message: error.user_message(), }); return; } @@ -864,9 +877,14 @@ fn sync_vcs_repository( None }) }; - event_sender.send(RepositoryEvent::RepositorySnapshotReady( - RepositorySnapshot::from_vcs_snapshot(snapshot.clone()), - )); + let mut payload = RepositorySnapshot::from_vcs_snapshot(snapshot.clone()); + payload.publish_plan = repo + .publish_plan() + .map_err(|error| { + tracing::debug!(path = %path.display(), %error, "vcs: no publish plan for snapshot"); + }) + .ok(); + event_sender.send(RepositoryEvent::RepositorySnapshotReady(payload)); } state.last_snapshot = Some(snapshot); } diff --git a/src/core/error.rs b/src/core/error.rs index 13e9e1d9..b3bae96f 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -1,19 +1,141 @@ +use std::fmt; +use std::path::PathBuf; + use thiserror::Error; +/// VCS backend that produced a [`DiffyError::Vcs`] failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VcsBackendKind { + Git, + Jj, +} + +impl fmt::Display for VcsBackendKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Git => "git", + Self::Jj => "jj", + }) + } +} + #[derive(Error, Debug)] pub enum DiffyError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), - #[error("HTTP error: {0}")] - Http(String), + /// A VCS operation failed. `recoverable` is true when retrying after + /// fixing repository state can succeed, false for operations the backend + /// cannot perform at all (or invariant violations). + #[error("{backend} {op} failed: {details}")] + Vcs { + backend: VcsBackendKind, + op: String, + details: String, + recoverable: bool, + }, + /// A network request failed. `retryable` marks transient transport or + /// server failures where retrying the same request can succeed. + #[error("{details}")] + Network { details: String, retryable: bool }, + /// An operation required authentication that is missing or was rejected. + #[error("{details}")] + Auth { details: String }, + /// The OS denied access to a path. + #[error("permission denied: cannot {op} {}", path.display())] + Permission { path: PathBuf, op: String }, #[error("Parse error: {0}")] Parse(String), #[error("Syntax error: {0}")] Syntax(String), + /// Fallback for failures that have not been classified yet. #[error("{0}")] General(String), } +impl DiffyError { + /// VCS failure that retrying after fixing repository state may resolve. + pub fn vcs(backend: VcsBackendKind, op: impl Into, details: impl Into) -> Self { + Self::Vcs { + backend, + op: op.into(), + details: details.into(), + recoverable: true, + } + } + + /// VCS failure that retrying will not fix (unsupported operation or + /// broken invariant). + pub fn vcs_fatal( + backend: VcsBackendKind, + op: impl Into, + details: impl Into, + ) -> Self { + Self::Vcs { + backend, + op: op.into(), + details: details.into(), + recoverable: false, + } + } + + /// Transient network failure worth retrying (connection, timeout, 5xx). + pub fn network(details: impl Into) -> Self { + Self::Network { + details: details.into(), + retryable: true, + } + } + + /// Network failure that retrying alone will not fix (4xx, protocol). + pub fn network_fatal(details: impl Into) -> Self { + Self::Network { + details: details.into(), + retryable: false, + } + } + + pub fn auth(details: impl Into) -> Self { + Self::Auth { + details: details.into(), + } + } + + /// Classify an IO error against the path/op it touched so permission + /// failures surface distinctly from generic IO failures. + pub fn io(path: impl Into, op: &str, error: std::io::Error) -> Self { + if error.kind() == std::io::ErrorKind::PermissionDenied { + Self::Permission { + path: path.into(), + op: op.to_owned(), + } + } else { + Self::Io(error) + } + } + + /// True when retrying the same operation unchanged can plausibly succeed. + pub fn is_retryable(&self) -> bool { + matches!( + self, + Self::Network { + retryable: true, + .. + } + ) + } + + /// Message for user-facing surfaces (toasts, error states). Appends a + /// retry hint for transient failures. + pub fn user_message(&self) -> String { + let base = self.to_string(); + if self.is_retryable() { + format!("{base} Check your connection and retry.") + } else { + base + } + } +} + pub type Result = std::result::Result; diff --git a/src/core/forge/github/api.rs b/src/core/forge/github/api.rs index 7a71dc98..aa965697 100644 --- a/src/core/forge/github/api.rs +++ b/src/core/forge/github/api.rs @@ -326,10 +326,9 @@ impl GitHubApi { if !self.token.is_empty() { request = request.header("Authorization", &format!("Bearer {}", self.token)); } - let response = request - .send() - .await - .map_err(|error| DiffyError::Http(format!("GitHub user fetch failed: {error}")))?; + let response = request.send().await.map_err(|error| { + DiffyError::network(format!("GitHub user fetch failed: {error}")) + })?; http::response_text(response, "GitHub user fetch").await })?; let json: Value = serde_json::from_str(&body)?; @@ -367,7 +366,7 @@ impl GitHubApi { request = request.header("Authorization", &format!("Bearer {}", self.token)); } let response = request.send().await.map_err(|error| { - DiffyError::Http(format!("GitHub pull request fetch failed: {error}")) + DiffyError::network(format!("GitHub pull request fetch failed: {error}")) })?; http::response_text(response, "GitHub pull request fetch").await })?; @@ -427,7 +426,7 @@ impl GitHubApi { request = request.header("Authorization", &format!("Bearer {}", self.token)); } let response = request.send().await.map_err(|error| { - DiffyError::Http(format!("GitHub review comments fetch failed: {error}")) + DiffyError::network(format!("GitHub review comments fetch failed: {error}")) })?; let body = http::response_text(response, "GitHub review comments fetch").await?; let mut page_comments: Vec = serde_json::from_str(&body)?; @@ -450,7 +449,7 @@ impl GitHubApi { number: i32, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to fetch pull request review data".to_owned(), )); } @@ -510,7 +509,7 @@ impl GitHubApi { comment: &CreatePullRequestReviewComment, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to add review comments".to_owned(), )); } @@ -528,7 +527,7 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub review comment create failed: {error}")) + DiffyError::network(format!("GitHub review comment create failed: {error}")) })?; http::response_text(response, "GitHub review comment create").await })?; @@ -544,7 +543,7 @@ impl GitHubApi { reply: &CreatePullRequestReviewReply, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to reply to review comments".to_owned(), )); } @@ -564,7 +563,7 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub review comment reply failed: {error}")) + DiffyError::network(format!("GitHub review comment reply failed: {error}")) })?; http::response_text(response, "GitHub review comment reply").await })?; @@ -579,7 +578,7 @@ impl GitHubApi { update: &UpdatePullRequestReviewComment, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to update review comments".to_owned(), )); } @@ -598,7 +597,7 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub review comment update failed: {error}")) + DiffyError::network(format!("GitHub review comment update failed: {error}")) })?; http::response_text(response, "GitHub review comment update").await })?; @@ -612,7 +611,7 @@ impl GitHubApi { comment_id: i64, ) -> Result<()> { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to delete review comments".to_owned(), )); } @@ -628,7 +627,7 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub review comment delete failed: {error}")) + DiffyError::network(format!("GitHub review comment delete failed: {error}")) })?; http::response_text(response, "GitHub review comment delete").await })?; @@ -643,7 +642,7 @@ impl GitHubApi { review: &CreatePullRequestReview, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to create pull request reviews".to_owned(), )); } @@ -661,7 +660,9 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub pull request review create failed: {error}")) + DiffyError::network(format!( + "GitHub pull request review create failed: {error}" + )) })?; http::response_text(response, "GitHub pull request review create").await })?; @@ -677,7 +678,7 @@ impl GitHubApi { submit: &SubmitPullRequestReview, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to submit pull request reviews".to_owned(), )); } @@ -697,7 +698,9 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub pull request review submit failed: {error}")) + DiffyError::network(format!( + "GitHub pull request review submit failed: {error}" + )) })?; http::response_text(response, "GitHub pull request review submit").await })?; @@ -711,7 +714,7 @@ impl GitHubApi { body: &str, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to reply to review threads".to_owned(), )); } @@ -737,7 +740,7 @@ impl GitHubApi { body: &str, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to update review comments".to_owned(), )); } @@ -761,7 +764,7 @@ impl GitHubApi { comment_node_id: &str, ) -> Result> { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to delete review comments".to_owned(), )); } @@ -783,7 +786,7 @@ impl GitHubApi { resolved: bool, ) -> Result { if self.token.trim().is_empty() { - return Err(DiffyError::General( + return Err(DiffyError::auth( "GitHub authentication is required to update review thread resolution".to_owned(), )); } @@ -831,7 +834,7 @@ impl GitHubApi { .send() .await .map_err(|error| { - DiffyError::Http(format!("GitHub GraphQL request failed: {error}")) + DiffyError::network(format!("GitHub GraphQL request failed: {error}")) })?; http::response_text(response, "GitHub GraphQL request").await })?; @@ -839,7 +842,7 @@ impl GitHubApi { if let Some(errors) = value.get("errors").and_then(Value::as_array) && !errors.is_empty() { - return Err(DiffyError::Http(format!( + return Err(DiffyError::network_fatal(format!( "GitHub GraphQL request failed: {}", graphql_error_message(errors) ))); diff --git a/src/core/forge/github/device_flow.rs b/src/core/forge/github/device_flow.rs index 7f4321ff..6ce1e6ca 100644 --- a/src/core/forge/github/device_flow.rs +++ b/src/core/forge/github/device_flow.rs @@ -20,7 +20,7 @@ pub fn start_device_flow(client_id: &str) -> Result { .form(&[("client_id", client_id), ("scope", "repo")]) .send() .await - .map_err(|error| DiffyError::Http(format!("GitHub device flow failed: {error}")))?; + .map_err(|error| DiffyError::network(format!("GitHub device flow failed: {error}")))?; http::response_text(response, "GitHub device flow").await })?; @@ -62,19 +62,19 @@ pub fn poll_for_token(client_id: &str, device_code: &str) -> Result Ok(None), - Some("expired_token") => Err(DiffyError::Http("device code expired".to_owned())), + Some("expired_token") => Err(DiffyError::auth("device code expired")), Some(other) => { let description = form_value(&body, "error_description") .map(decode_form_value) .filter(|value| !value.is_empty()) .unwrap_or_else(|| other.to_owned()); - Err(DiffyError::Http(description)) + Err(DiffyError::auth(description)) } None => { let token = form_value(&body, "access_token").unwrap_or_default(); diff --git a/src/core/http.rs b/src/core/http.rs index b34986ed..d9a1a3e0 100644 --- a/src/core/http.rs +++ b/src/core/http.rs @@ -6,22 +6,30 @@ pub(crate) fn block_on(future: impl Future>) -> Result let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|error| DiffyError::Http(format!("failed to start HTTP runtime: {error}")))?; + .map_err(|error| DiffyError::General(format!("failed to start HTTP runtime: {error}")))?; runtime.block_on(future) } +/// Whether a failed HTTP status is worth retrying unchanged. +fn status_is_retryable(status: reqwest::StatusCode) -> bool { + status.is_server_error() + || status == reqwest::StatusCode::TOO_MANY_REQUESTS + || status == reqwest::StatusCode::REQUEST_TIMEOUT +} + pub(crate) async fn response_text(response: reqwest::Response, context: &str) -> Result { let status = response.status(); let body = response .text() .await - .map_err(|error| DiffyError::Http(format!("{context} read failed: {error}")))?; + .map_err(|error| DiffyError::network(format!("{context} read failed: {error}")))?; if status.is_success() { Ok(body) } else { - Err(DiffyError::Http(format!( - "{context} returned {status}: {body}" - ))) + Err(DiffyError::Network { + details: format!("{context} returned {status}: {body}"), + retryable: status_is_retryable(status), + }) } } @@ -30,13 +38,14 @@ pub(crate) async fn response_bytes(response: reqwest::Response, context: &str) - let body = response .bytes() .await - .map_err(|error| DiffyError::Http(format!("{context} read failed: {error}")))?; + .map_err(|error| DiffyError::network(format!("{context} read failed: {error}")))?; if status.is_success() { Ok(body.to_vec()) } else { let body = String::from_utf8_lossy(&body); - Err(DiffyError::Http(format!( - "{context} returned {status}: {body}" - ))) + Err(DiffyError::Network { + details: format!("{context} returned {status}: {body}"), + retryable: status_is_retryable(status), + }) } } diff --git a/src/core/syntax/annotator.rs b/src/core/syntax/annotator.rs index d1800b4d..32e4d67e 100644 --- a/src/core/syntax/annotator.rs +++ b/src/core/syntax/annotator.rs @@ -1,6 +1,6 @@ use crate::core::syntax::Highlighter; use crate::core::text::DiffTokenSpan; -use carbon::{LineId, TextStore}; +use carbon::{LineId, TextByteRange, TextStore}; #[derive(Debug, Clone, Copy)] struct LineRef { @@ -24,6 +24,13 @@ impl SyntaxRowWindow { } } +/// Half-open window of 0-based source lines on one diff side. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SourceLineWindow { + pub start: usize, + pub end: usize, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SyntaxLineTokens { pub hunk_index: usize, @@ -111,6 +118,58 @@ impl DiffSyntaxAnnotator { } } + /// Highlights only the given source-line window of `text`. Tree-sitter + /// still parses the full source (windowed parsing would lose syntactic + /// context), but query/span extraction — the expensive part on very large + /// files — is restricted to the window's byte range. The returned value + /// keeps full-file line tables so byte mapping works for any line, while + /// tokens only cover the window. + pub fn highlight_window_text_store( + &self, + path: &str, + text: &TextStore, + lines: SourceLineWindow, + ) -> FullFileSyntax { + let (line_offsets, line_lengths) = line_ranges_from_text_store(text); + let line_count = line_lengths.len(); + let start_line = lines.start.min(line_count); + let end_line = lines.end.clamp(start_line, line_count); + let text_len = carbon::u32_to_usize_saturating(text.len()); + let start_byte = line_offsets.get(start_line).copied().unwrap_or(text_len); + // `line_offsets` carries a trailing entry at `text.len()`, so the + // exclusive end line maps to the byte just past the window. + let end_byte = line_offsets.get(end_line).copied().unwrap_or(text_len); + + let mut tokens = Vec::new(); + if end_byte > start_byte { + let language = self.highlighter.resolve_language(path); + let range = TextByteRange { + start: usize_to_u32_saturating(start_byte), + len: usize_to_u32_saturating(end_byte - start_byte), + }; + match self + .highlighter + .highlight_text_store_resolved_ranges(language, text, &[range]) + { + Ok(spans) => tokens = spans, + Err(error) => { + tracing::warn!( + path = %path, + ?language, + %error, + "windowed syntax highlight failed" + ); + } + } + } + + FullFileSyntax { + line_offsets, + line_lengths, + tokens, + } + } + pub fn annotate_carbon_full_file_window_from_cache( &self, file: &carbon::FileDiff, @@ -138,6 +197,31 @@ impl DiffSyntaxAnnotator { } } +/// Returns the (old, new) source-line bounds touched by a projected-row +/// window, so callers can request windowed highlighting per side. `None` +/// means the side has no content rows inside the window. +pub fn carbon_window_source_line_bounds( + file: &carbon::FileDiff, + expansion: &carbon::ExpansionState, + window: SyntaxRowWindow, +) -> (Option, Option) { + if file.is_binary || window.end <= window.start { + return (None, None); + } + let (old_refs, new_refs) = build_carbon_full_file_refs(file, expansion, 0, window); + (source_line_bounds(&old_refs), source_line_bounds(&new_refs)) +} + +fn source_line_bounds(refs: &[LineRef]) -> Option { + // At this stage `content_offset` holds the 0-based source line index. + let min = refs.iter().map(|r| r.content_offset).min()?; + let max = refs.iter().map(|r| r.content_offset).max()?; + Some(SourceLineWindow { + start: min, + end: max.saturating_add(1), + }) +} + fn build_carbon_full_file_refs( file: &carbon::FileDiff, expansion: &carbon::ExpansionState, diff --git a/src/core/syntax/highlighter.rs b/src/core/syntax/highlighter.rs index 761d73c4..5c84de7a 100644 --- a/src/core/syntax/highlighter.rs +++ b/src/core/syntax/highlighter.rs @@ -65,6 +65,20 @@ impl Highlighter { self.highlight_resolved(language, source) } + pub fn highlight_text_store_resolved_ranges( + &self, + language: Option, + text: &TextStore, + byte_ranges: &[TextByteRange], + ) -> Result> { + let Some(source) = text.as_str() else { + return Err(DiffyError::Syntax( + "syntax source is not valid UTF-8".to_owned(), + )); + }; + self.highlight_resolved_ranges(language, source, byte_ranges) + } + pub fn highlight_resolved_ranges( &self, language: Option, diff --git a/src/core/update.rs b/src/core/update.rs index 4cb9adb9..3c419619 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -135,7 +135,7 @@ fn fetch_manifest() -> Result { let url = manifest_url(); let text = http::block_on(async { let response = reqwest::get(&url).await.map_err(|error| { - DiffyError::Http(format!("update manifest request failed: {error}")) + DiffyError::network(format!("update manifest request failed: {error}")) })?; http::response_text(response, "update manifest").await })?; @@ -146,10 +146,10 @@ fn download_file(url: &str, path: &Path) -> Result<()> { let bytes = http::block_on(async { let response = reqwest::get(url) .await - .map_err(|error| DiffyError::Http(format!("update download failed: {error}")))?; + .map_err(|error| DiffyError::network(format!("update download failed: {error}")))?; http::response_bytes(response, "update download").await })?; - fs::write(path, bytes)?; + fs::write(path, bytes).map_err(|error| DiffyError::io(path, "write", error))?; Ok(()) } @@ -228,9 +228,9 @@ fn persist_update_artifact(path: &Path) -> Result { .file_name() .ok_or_else(|| DiffyError::General("update artifact has no file name".to_owned()))?; let dir = env::temp_dir().join(format!("diffy-update-{}", std::process::id())); - fs::create_dir_all(&dir)?; + fs::create_dir_all(&dir).map_err(|error| DiffyError::io(&dir, "create", error))?; let dest = dir.join(file_name); - fs::copy(path, &dest)?; + fs::copy(path, &dest).map_err(|error| DiffyError::io(&dest, "write", error))?; Ok(dest) } diff --git a/src/core/vcs/backend.rs b/src/core/vcs/backend.rs index 6c7216d2..4b6ae7e6 100644 --- a/src/core/vcs/backend.rs +++ b/src/core/vcs/backend.rs @@ -5,7 +5,7 @@ use carbon::TextStore; use crate::core::compare::{ CompareFileStatsTarget, CompareFileSummary, CompareOutput, ProgressSink, RendererKind, }; -use crate::core::error::Result; +use crate::core::error::{DiffyError, Result, VcsBackendKind}; use crate::core::forge::github::PullRequestInfo; use crate::core::vcs::model::{ FileChange, FileOperation, PublishAction, PublishOutcome, PublishPlan, PullFastForwardOutcome, @@ -14,6 +14,15 @@ use crate::core::vcs::model::{ }; use crate::events::RepositorySyncReason; +fn unsupported_operation(location: &RepoLocation, op: &str) -> DiffyError { + let backend = if location.kind == VcsKind::JJ { + VcsBackendKind::Jj + } else { + VcsBackendKind::Git + }; + DiffyError::vcs_fatal(backend, op, "not supported by this backend") +} + pub trait VcsBackend: Send + Sync { fn kind(&self) -> VcsKind; fn owns_location(&self, location: &RepoLocation) -> bool { @@ -77,23 +86,17 @@ pub trait VcsRepository: Send { _change: &FileChange, _renderer: RendererKind, ) -> Result { - Err(crate::core::error::DiffyError::General( - "file-change diff unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "file-change diff")) } fn commit_diff(&mut self, _has_staged: bool) -> Result { - Err(crate::core::error::DiffyError::General( - "commit diff unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "commit diff")) } fn apply_file_operation( &mut self, _change: &FileChange, _operation: FileOperation, ) -> Result<()> { - Err(crate::core::error::DiffyError::General( - "file operation unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "file operation")) } fn apply_batch_file_operation( &mut self, @@ -106,56 +109,41 @@ pub trait VcsRepository: Send { Ok(()) } fn apply_patch_operation(&mut self, _patch: &str, _operation: FileOperation) -> Result<()> { - Err(crate::core::error::DiffyError::General( - "patch operation unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "patch operation")) } fn create_commit(&mut self, _message: &str) -> Result<()> { - Err(crate::core::error::DiffyError::General( - "commit unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "commit")) } fn run_operation(&mut self, _operation: &VcsOperation) -> Result { - Err(crate::core::error::DiffyError::General( - "operation unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "operation")) } fn fetch_remote(&mut self, _remote: &str) -> Result<()> { - Err(crate::core::error::DiffyError::General( - "fetch unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "fetch")) } fn push(&mut self, _remote: &str, _refspec: &str, _force_with_lease: bool) -> Result<()> { - Err(crate::core::error::DiffyError::General( - "push unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "push")) } fn publish_plan(&mut self) -> Result { - Err(crate::core::error::DiffyError::General( - "publish unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "publish")) } fn publish(&mut self, _action: &PublishAction) -> Result { - Err(crate::core::error::DiffyError::General( - "publish unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "publish")) } fn pull_fast_forward( &mut self, _remote: &str, _branch: &str, ) -> Result { - Err(crate::core::error::DiffyError::General( - "fast-forward pull unsupported by this backend".to_owned(), - )) + Err(unsupported_operation(self.location(), "fast-forward pull")) } fn resolve_pull_request_comparison( &mut self, _pull_request_url: &str, _github_token: &str, ) -> Result<(PullRequestInfo, String, String)> { - Err(crate::core::error::DiffyError::General( - "GitHub pull request comparison unsupported by this backend".to_owned(), + Err(unsupported_operation( + self.location(), + "GitHub pull request comparison", )) } fn compare_working_file(&mut self, path: &str) -> Result; diff --git a/src/core/vcs/cache.rs b/src/core/vcs/cache.rs new file mode 100644 index 00000000..6e70c68b --- /dev/null +++ b/src/core/vcs/cache.rs @@ -0,0 +1,307 @@ +//! Shared epoch-keyed read caches for VCS backend services. +//! +//! Backends that re-run expensive reads (whole-compare diffs, per-path +//! diffs, diff stats, file text at a revision) can memoize them here, keyed on an opaque +//! read epoch. The epoch identifies the repository state the read was +//! produced under — for jj this is the operation id — so cache hits require +//! an exact epoch match and a `None` epoch only matches entries inserted +//! with a `None` epoch. Callers are responsible for calling [`clear`] after +//! writes or whenever the epoch changes; the caches never invalidate +//! entries on their own. Eviction is FIFO with small fixed caps so memory +//! stays bounded without tracking recency. +//! +//! [`clear`]: VcsReadCache::clear + +use carbon::TextStore; + +use crate::core::compare::CompareOutput; +use crate::core::vcs::model::{RevisionId, VcsCompareRequest}; + +const MAX_DIFF_CACHE_ENTRIES: usize = 8; +const MAX_FILE_TEXT_CACHE_ENTRIES: usize = 16; +const MAX_STATS_CACHE_ENTRIES: usize = 16; + +#[derive(Clone)] +struct DiffCacheEntry { + epoch: Option, + request: VcsCompareRequest, + path: Option, + output: CompareOutput, +} + +#[derive(Clone)] +struct StatsCacheEntry { + epoch: Option, + request: VcsCompareRequest, + stats: (i32, i32), +} + +#[derive(Clone)] +struct FileTextCacheEntry { + epoch: Option, + revision: RevisionId, + path: String, + text: TextStore, +} + +/// Bounded diff, diff-stat, and file-text caches for a VCS repository +/// service. +#[derive(Default)] +pub struct VcsReadCache { + diffs: Vec, + file_texts: Vec, + stats: Vec, +} + +impl VcsReadCache { + pub fn new() -> Self { + Self::default() + } + + pub fn cached_diff( + &self, + epoch: Option<&str>, + request: &VcsCompareRequest, + path: Option<&str>, + ) -> Option { + self.diffs + .iter() + .find(|entry| { + entry.epoch.as_deref() == epoch + && entry.request == *request + && entry.path.as_deref() == path + }) + .map(|entry| entry.output.clone()) + } + + pub fn insert_diff( + &mut self, + epoch: Option, + request: VcsCompareRequest, + path: Option, + output: CompareOutput, + ) { + if self.diffs.len() >= MAX_DIFF_CACHE_ENTRIES { + self.diffs.remove(0); + } + self.diffs.push(DiffCacheEntry { + epoch, + request, + path, + output, + }); + } + + pub fn cached_stats( + &self, + epoch: Option<&str>, + request: &VcsCompareRequest, + ) -> Option<(i32, i32)> { + self.stats + .iter() + .find(|entry| entry.epoch.as_deref() == epoch && entry.request == *request) + .map(|entry| entry.stats) + } + + pub fn insert_stats( + &mut self, + epoch: Option, + request: VcsCompareRequest, + stats: (i32, i32), + ) { + if self.stats.len() >= MAX_STATS_CACHE_ENTRIES { + self.stats.remove(0); + } + self.stats.push(StatsCacheEntry { + epoch, + request, + stats, + }); + } + + pub fn cached_file_text( + &self, + epoch: Option<&str>, + revision: &RevisionId, + path: &str, + ) -> Option { + self.file_texts + .iter() + .find(|entry| { + entry.epoch.as_deref() == epoch && entry.revision == *revision && entry.path == path + }) + .map(|entry| entry.text.clone()) + } + + pub fn insert_file_text( + &mut self, + epoch: Option, + revision: RevisionId, + path: String, + text: TextStore, + ) { + if self.file_texts.len() >= MAX_FILE_TEXT_CACHE_ENTRIES { + self.file_texts.remove(0); + } + self.file_texts.push(FileTextCacheEntry { + epoch, + revision, + path, + text, + }); + } + + pub fn clear(&mut self) { + self.diffs.clear(); + self.file_texts.clear(); + self.stats.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::compare::{LayoutMode, RendererKind}; + use crate::core::vcs::model::{VcsCompareSpec, VcsKind}; + + fn request(revision: &str) -> VcsCompareRequest { + VcsCompareRequest { + spec: VcsCompareSpec::Change { + revision: revision.to_owned(), + }, + layout: LayoutMode::Unified, + renderer: RendererKind::Builtin, + } + } + + fn revision(id: &str) -> RevisionId { + RevisionId { + backend: VcsKind::JJ, + id: id.to_owned(), + } + } + + #[test] + fn diff_hits_require_matching_epoch_request_and_path() { + let mut cache = VcsReadCache::new(); + cache.insert_diff( + Some("op-1".to_owned()), + request("abc"), + Some("src/lib.rs".to_owned()), + CompareOutput::default(), + ); + + assert!( + cache + .cached_diff(Some("op-1"), &request("abc"), Some("src/lib.rs")) + .is_some() + ); + assert!( + cache + .cached_diff(Some("op-2"), &request("abc"), Some("src/lib.rs")) + .is_none() + ); + assert!( + cache + .cached_diff(None, &request("abc"), Some("src/lib.rs")) + .is_none() + ); + assert!( + cache + .cached_diff(Some("op-1"), &request("def"), Some("src/lib.rs")) + .is_none() + ); + assert!( + cache + .cached_diff(Some("op-1"), &request("abc"), None) + .is_none() + ); + } + + #[test] + fn file_text_hits_require_matching_epoch_revision_and_path() { + let mut cache = VcsReadCache::new(); + cache.insert_file_text( + Some("op-1".to_owned()), + revision("abc"), + "src/lib.rs".to_owned(), + TextStore::from_text("hello\n".to_owned()), + ); + + assert!( + cache + .cached_file_text(Some("op-1"), &revision("abc"), "src/lib.rs") + .is_some() + ); + assert!( + cache + .cached_file_text(Some("op-2"), &revision("abc"), "src/lib.rs") + .is_none() + ); + assert!( + cache + .cached_file_text(Some("op-1"), &revision("def"), "src/lib.rs") + .is_none() + ); + } + + #[test] + fn diff_cache_evicts_oldest_entry_at_capacity() { + let mut cache = VcsReadCache::new(); + for index in 0..=MAX_DIFF_CACHE_ENTRIES { + cache.insert_diff( + Some("op-1".to_owned()), + request(&format!("rev-{index}")), + None, + CompareOutput::default(), + ); + } + + assert!( + cache + .cached_diff(Some("op-1"), &request("rev-0"), None) + .is_none() + ); + assert!( + cache + .cached_diff(Some("op-1"), &request("rev-1"), None) + .is_some() + ); + assert_eq!(cache.diffs.len(), MAX_DIFF_CACHE_ENTRIES); + } + + #[test] + fn stats_hits_require_matching_epoch_and_request() { + let mut cache = VcsReadCache::new(); + cache.insert_stats(Some("op-1".to_owned()), request("abc"), (3, 1)); + + assert_eq!( + cache.cached_stats(Some("op-1"), &request("abc")), + Some((3, 1)) + ); + assert!(cache.cached_stats(Some("op-2"), &request("abc")).is_none()); + assert!(cache.cached_stats(None, &request("abc")).is_none()); + assert!(cache.cached_stats(Some("op-1"), &request("def")).is_none()); + } + + #[test] + fn clear_drops_all_caches() { + let mut cache = VcsReadCache::new(); + cache.insert_diff(None, request("abc"), None, CompareOutput::default()); + cache.insert_file_text( + None, + revision("abc"), + "src/lib.rs".to_owned(), + TextStore::from_text(String::new()), + ); + cache.insert_stats(None, request("abc"), (1, 2)); + cache.clear(); + assert!(cache.cached_diff(None, &request("abc"), None).is_none()); + assert!( + cache + .cached_file_text(None, &revision("abc"), "src/lib.rs") + .is_none() + ); + assert!(cache.cached_stats(None, &request("abc")).is_none()); + } +} diff --git a/src/core/vcs/git/adapter.rs b/src/core/vcs/git/adapter.rs index 2eae67d0..765be862 100644 --- a/src/core/vcs/git/adapter.rs +++ b/src/core/vcs/git/adapter.rs @@ -1,4 +1,5 @@ use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; use carbon::TextStore; @@ -7,8 +8,10 @@ use crate::core::compare::{ CompareFileStatsTarget, CompareFileSummary, CompareMode, ComparePhase, CompareService, CompareSpec, ProgressSink, RendererKind, }; -use crate::core::error::{DiffyError, Result}; +use crate::core::error::{DiffyError, Result, VcsBackendKind}; use crate::core::vcs::backend::{VcsBackend, VcsRepository, VcsWatchPaths}; +use crate::core::vcs::cache::VcsReadCache; +use crate::core::vcs::git::service::is_full_hex_oid; use crate::core::vcs::git::status::StatusBits; use crate::core::vcs::git::{ BranchInfo, CommitInfo, GitService, PatchApplyTarget, PullOutcome, StatusItem, StatusOperation, @@ -54,7 +57,7 @@ impl VcsBackend for GitBackend { fn watch_paths(&self, location: &RepoLocation) -> Result { let repo = gix::open(&location.workspace_root) - .map_err(|error| DiffyError::General(error.to_string()))?; + .map_err(|error| DiffyError::vcs(VcsBackendKind::Git, "open", error.to_string()))?; let metadata_dir = repo.git_dir().to_path_buf(); let workdir = repo.workdir().map(Path::to_path_buf); let watched_paths = match workdir.as_ref() { @@ -74,6 +77,32 @@ impl VcsBackend for GitBackend { } } +/// Process-wide cache for Git reads whose results are immutable: compares +/// and file reads addressed entirely by full commit OIDs. Such results are +/// content-addressed by the object store and can never go stale — not even +/// across writes, fetches, or ref updates — so no invalidation is needed and +/// it is safe to share across the short-lived `GitRepository` instances the +/// runtime opens per operation. The cache epoch slot carries the workspace +/// root so entries never leak across repositories. Anything involving +/// movable refs, the index, or the working tree is deliberately not cached +/// (see the staleness note in `VcsReadCache`). +static IMMUTABLE_READ_CACHE: LazyLock> = + LazyLock::new(|| Mutex::new(VcsReadCache::new())); + +/// True when every revision in the request is a full hex OID, making the +/// compare result content-addressed: parents, trees, and merge bases of +/// fixed commits are themselves fixed. +fn compare_request_is_immutable(request: &VcsCompareRequest) -> bool { + match &request.spec { + VcsCompareSpec::WorkingCopy => false, + VcsCompareSpec::Change { revision } => is_full_hex_oid(revision), + VcsCompareSpec::Range { from, to } => is_full_hex_oid(from) && is_full_hex_oid(to), + VcsCompareSpec::MergeBaseRange { base, head } => { + is_full_hex_oid(base) && is_full_hex_oid(head) + } + } +} + pub struct GitRepository { service: GitService, location: RepoLocation, @@ -85,6 +114,75 @@ impl GitRepository { service.open(location.workspace_root.to_string_lossy().as_ref())?; Ok(Self { service, location }) } + + /// Epoch for [`IMMUTABLE_READ_CACHE`]: scopes entries to this repository. + fn immutable_cache_epoch(&self) -> String { + self.location.workspace_root.to_string_lossy().into_owned() + } + + fn compare_path_uncached( + &mut self, + request: &VcsCompareRequest, + path: &str, + deferred_file: Option<&CompareFileSummary>, + ) -> Result { + let spec = git_compare_spec(request); + let deferred_file = deferred_file.map(CompareFileSummary::to_file_diff); + let summary_fallback = deferred_file.is_some(); + match request.renderer { + crate::core::compare::RendererKind::Builtin => { + let output = deferred_file + .as_ref() + .map(|file| GitDiffBackend.compare_deferred_file(file, &self.service)) + .transpose()? + .flatten(); + match output { + Some(output) => Ok(output), + None if summary_fallback => GitDiffBackend + .compare_path_no_renames(&spec, path, &self.service)? + .ok_or_else(|| { + DiffyError::General("compare file returned no result".to_owned()) + }), + None => GitDiffBackend + .compare_path(&spec, path, &self.service)? + .ok_or_else(|| { + DiffyError::General("compare file returned no result".to_owned()) + }), + } + } + crate::core::compare::RendererKind::Difftastic if DifftasticBackend::is_available() => { + DifftasticBackend + .compare_path(&spec, path, &self.service)? + .ok_or_else(|| { + DiffyError::General("compare file returned no result".to_owned()) + }) + } + crate::core::compare::RendererKind::Difftastic => { + let output = deferred_file + .as_ref() + .map(|file| GitDiffBackend.compare_deferred_file(file, &self.service)) + .transpose()? + .flatten(); + match output { + Some(output) => Ok(output), + None => { + let path_output = if summary_fallback { + GitDiffBackend.compare_path_no_renames(&spec, path, &self.service)? + } else { + GitDiffBackend.compare_path(&spec, path, &self.service)? + }; + let mut output = path_output.ok_or_else(|| { + DiffyError::General("compare file returned no result".to_owned()) + })?; + output.used_fallback = true; + output.fallback_message = + "difftastic not compiled in, used built-in backend".to_owned(); + Ok(output) + } + } + } + } + } } impl VcsRepository for GitRepository { @@ -110,13 +208,7 @@ impl VcsRepository for GitRepository { .service .abbreviate_oid(&oid) .unwrap_or_else(|_| oid[..7].to_owned()); - let summary = self - .service - .commits(&oid, 1) - .ok() - .and_then(|mut commits| commits.pop()) - .map(|commit| commit.summary) - .unwrap_or_default(); + let summary = self.service.commit_summary(&oid).unwrap_or_default(); Ok((short_oid, summary)) } @@ -128,8 +220,7 @@ impl VcsRepository for GitRepository { if let Some(reporter) = reporter { reporter.phase(ComparePhase::ResolvingRefs); } - let branches = self.service.branches()?; - let tags = self.service.tags()?; + let (branches, tags) = self.service.branches_and_tags()?; if let Some(reporter) = reporter { reporter.phase(ComparePhase::FetchingHistory); } @@ -157,15 +248,39 @@ impl VcsRepository for GitRepository { request: &VcsCompareRequest, reporter: Option<&dyn ProgressSink>, ) -> Result { + let cacheable = compare_request_is_immutable(request); + let epoch = self.immutable_cache_epoch(); + if cacheable + && let Ok(cache) = IMMUTABLE_READ_CACHE.lock() + && let Some(output) = cache.cached_diff(Some(&epoch), request, None) + { + return Ok(output); + } let spec = git_compare_spec(request); - CompareService::default().compare(&spec, &self.service, reporter) + let output = CompareService::default().compare(&spec, &self.service, reporter)?; + if cacheable && let Ok(mut cache) = IMMUTABLE_READ_CACHE.lock() { + cache.insert_diff(Some(epoch), request.clone(), None, output.clone()); + } + Ok(output) } fn compare_stats(&mut self, request: &VcsCompareRequest) -> Result<(i32, i32)> { + let cacheable = compare_request_is_immutable(request); + let epoch = self.immutable_cache_epoch(); + if cacheable + && let Ok(cache) = IMMUTABLE_READ_CACHE.lock() + && let Some(stats) = cache.cached_stats(Some(&epoch), request) + { + return Ok(stats); + } let spec = git_compare_spec(request); - GitDiffBackend + let stats = GitDiffBackend .compare_stats(&spec, &self.service)? - .ok_or_else(|| DiffyError::General("compare stats returned no result".to_owned())) + .ok_or_else(|| DiffyError::General("compare stats returned no result".to_owned()))?; + if cacheable && let Ok(mut cache) = IMMUTABLE_READ_CACHE.lock() { + cache.insert_stats(Some(epoch), request.clone(), stats); + } + Ok(stats) } fn compare_history( @@ -202,62 +317,27 @@ impl VcsRepository for GitRepository { path: &str, deferred_file: Option<&CompareFileSummary>, ) -> Result { - let spec = git_compare_spec(request); - let deferred_file = deferred_file.map(CompareFileSummary::to_file_diff); - let summary_fallback = deferred_file.is_some(); - match request.renderer { - crate::core::compare::RendererKind::Builtin => { - let output = deferred_file - .as_ref() - .map(|file| GitDiffBackend.compare_deferred_file(file, &self.service)) - .transpose()? - .flatten(); - match output { - Some(output) => Ok(output), - None if summary_fallback => GitDiffBackend - .compare_path_no_renames(&spec, path, &self.service)? - .ok_or_else(|| { - DiffyError::General("compare file returned no result".to_owned()) - }), - None => GitDiffBackend - .compare_path(&spec, path, &self.service)? - .ok_or_else(|| { - DiffyError::General("compare file returned no result".to_owned()) - }), - } - } - crate::core::compare::RendererKind::Difftastic if DifftasticBackend::is_available() => { - DifftasticBackend - .compare_path(&spec, path, &self.service)? - .ok_or_else(|| { - DiffyError::General("compare file returned no result".to_owned()) - }) - } - crate::core::compare::RendererKind::Difftastic => { - let output = deferred_file - .as_ref() - .map(|file| GitDiffBackend.compare_deferred_file(file, &self.service)) - .transpose()? - .flatten(); - match output { - Some(output) => Ok(output), - None => { - let path_output = if summary_fallback { - GitDiffBackend.compare_path_no_renames(&spec, path, &self.service)? - } else { - GitDiffBackend.compare_path(&spec, path, &self.service)? - }; - let mut output = path_output.ok_or_else(|| { - DiffyError::General("compare file returned no result".to_owned()) - })?; - output.used_fallback = true; - output.fallback_message = - "difftastic not compiled in, used built-in backend".to_owned(); - Ok(output) - } - } - } + // Only the summary-less path shells out to `git diff`; deferred-file + // compares already run in-process on gix blobs, and their rename + // handling differs, so they are not folded into the same cache key. + let cacheable = deferred_file.is_none() && compare_request_is_immutable(request); + let epoch = self.immutable_cache_epoch(); + if cacheable + && let Ok(cache) = IMMUTABLE_READ_CACHE.lock() + && let Some(output) = cache.cached_diff(Some(&epoch), request, Some(path)) + { + return Ok(output); + } + let output = self.compare_path_uncached(request, path, deferred_file)?; + if cacheable && let Ok(mut cache) = IMMUTABLE_READ_CACHE.lock() { + cache.insert_diff( + Some(epoch), + request.clone(), + Some(path.to_owned()), + output.clone(), + ); } + Ok(output) } fn file_change_diff( @@ -334,7 +414,9 @@ impl VcsRepository for GitRepository { let branch = branches .iter() .find(|branch| branch.is_head && !branch.is_remote) - .ok_or_else(|| DiffyError::General("No current branch to push.".to_owned()))?; + .ok_or_else(|| { + DiffyError::vcs(VcsBackendKind::Git, "publish", "no current branch to push") + })?; let (remote, upstream_branch) = branch .upstream .as_deref() @@ -372,8 +454,10 @@ impl VcsRepository for GitRepository { label: completed_publish_label(&action.label), }) } - _ => Err(DiffyError::General( - "Git cannot run this publish action".to_owned(), + _ => Err(DiffyError::vcs_fatal( + VcsBackendKind::Git, + "publish", + "Git cannot run this publish action", )), } } @@ -387,7 +471,7 @@ impl VcsRepository for GitRepository { PullFastForwardOutcome::FastForwarded { behind } } }) - .map_err(|error| DiffyError::General(error.to_string())) + .map_err(|error| DiffyError::vcs(VcsBackendKind::Git, "pull", error.to_string())) } fn resolve_pull_request_comparison( @@ -406,7 +490,21 @@ impl VcsRepository for GitRepository { } fn read_file_text(&mut self, revision: &RevisionId, path: &str) -> Result { - self.service.read_file_text_store_at(&revision.id, path) + // Blob content at a fixed commit OID is immutable; workdir, index, + // and symbolic refs are not and bypass the cache. + let cacheable = is_full_hex_oid(&revision.id); + let epoch = self.immutable_cache_epoch(); + if cacheable + && let Ok(cache) = IMMUTABLE_READ_CACHE.lock() + && let Some(text) = cache.cached_file_text(Some(&epoch), revision, path) + { + return Ok(text); + } + let text = self.service.read_file_text_store_at(&revision.id, path)?; + if cacheable && let Ok(mut cache) = IMMUTABLE_READ_CACHE.lock() { + cache.insert_file_text(Some(epoch), revision.clone(), path.to_owned(), text.clone()); + } + Ok(text) } } @@ -690,3 +788,494 @@ fn sanitize_status_bits(status: StatusBits) -> StatusBits { | StatusBits::WT_RENAMED | StatusBits::CONFLICTED) } + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use git2::{Oid, Repository, Signature}; + use tempfile::TempDir; + + use super::{ + GitBackend, GitRepository, completed_publish_label, git_compare_spec, + git_location_or_error, preferred_git_remote, status_item_from_file_change, upstream_pair, + }; + use crate::core::compare::{CompareMode, LayoutMode, RendererKind}; + use crate::core::vcs::backend::{VcsBackend, VcsRepository}; + use crate::core::vcs::git::{BranchInfo, StatusScope, WORKDIR_REF}; + use crate::core::vcs::model::{ + ChangeBucket, FileChange, FileChangeStatus, RefKind, RevisionId, VcsCompareRequest, + VcsCompareSpec, VcsKind, + }; + use crate::events::RepositorySyncReason; + + fn commit_file( + repo: &Repository, + relative_path: &str, + content: &[u8], + message: &str, + ) -> String { + // Pin `core.autocrlf=false` so LF content written from these tests + // survives index round-trips unchanged on every platform. + repo.config() + .and_then(|mut cfg| cfg.set_bool("core.autocrlf", false)) + .expect("disable autocrlf on test repo"); + + let workdir = repo.workdir().expect("repo workdir"); + let full_path = workdir.join(relative_path); + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&full_path, content).unwrap(); + + let mut index = repo.index().unwrap(); + index.add_path(Path::new(relative_path)).unwrap(); + index.write().unwrap(); + + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let signature = Signature::now("Diffy", "diffy@example.com").unwrap(); + let parents = repo + .head() + .ok() + .and_then(|head| head.target()) + .map(|oid| repo.find_commit(oid).unwrap()) + .into_iter() + .collect::>(); + let parent_refs = parents.iter().collect::>(); + repo.commit( + Some("HEAD"), + &signature, + &signature, + message, + &tree, + &parent_refs, + ) + .unwrap() + .to_string() + } + + fn open_adapter(path: &Path) -> GitRepository { + GitRepository::open(git_location_or_error(path).unwrap()).unwrap() + } + + fn request(spec: VcsCompareSpec) -> VcsCompareRequest { + VcsCompareRequest { + spec, + layout: LayoutMode::Unified, + renderer: RendererKind::Builtin, + } + } + + fn remote_branch(name: &str) -> BranchInfo { + BranchInfo { + name: name.to_owned(), + is_remote: true, + is_head: false, + target_oid: "0".repeat(40), + upstream: None, + ahead_behind: None, + } + } + + #[test] + fn detect_reports_repo_location_and_ignores_plain_directories() { + let repo_dir = TempDir::new().unwrap(); + Repository::init(repo_dir.path()).unwrap(); + + let location = GitBackend + .detect(repo_dir.path()) + .unwrap() + .expect("repository detected"); + assert_eq!(location.kind, VcsKind::GIT); + assert_eq!( + location.workspace_root.canonicalize().unwrap(), + repo_dir.path().canonicalize().unwrap() + ); + assert!(location.store_root.is_some()); + + let plain_dir = TempDir::new().unwrap(); + assert!(GitBackend.detect(plain_dir.path()).unwrap().is_none()); + } + + #[test] + fn resolve_ref_returns_short_oid_and_summary() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + let oid = commit_file(&repo, "src/lib.rs", b"hello\n", "initial commit"); + + let mut adapter = open_adapter(repo_dir.path()); + let (short_oid, summary) = adapter.resolve_ref("HEAD").unwrap(); + + assert!(oid.starts_with(&short_oid), "{short_oid} prefixes {oid}"); + assert!(short_oid.len() < oid.len()); + assert_eq!(summary, "initial commit"); + } + + #[test] + fn resolve_ref_normalizes_at_shorthand_to_head() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + commit_file(&repo, "src/lib.rs", b"one\n", "first"); + commit_file(&repo, "src/lib.rs", b"two\n", "second"); + + let mut adapter = open_adapter(repo_dir.path()); + + assert_eq!( + adapter.resolve_ref("@").unwrap(), + adapter.resolve_ref("HEAD").unwrap() + ); + let (_, parent_summary) = adapter.resolve_ref("@~1").unwrap(); + assert_eq!(parent_summary, "first"); + } + + #[test] + fn resolve_ref_rejects_unknown_reference() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + commit_file(&repo, "src/lib.rs", b"hello\n", "initial"); + + let mut adapter = open_adapter(repo_dir.path()); + assert!(adapter.resolve_ref("does-not-exist").is_err()); + } + + #[test] + fn snapshot_collects_refs_changes_and_file_changes() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + let oid = commit_file(&repo, "src/lib.rs", b"hello\n", "initial commit"); + let target = repo + .find_object(Oid::from_str(&oid).unwrap(), None) + .unwrap(); + repo.tag_lightweight("v1", &target, false).unwrap(); + fs::write(repo_dir.path().join("src/lib.rs"), "changed\n").unwrap(); + fs::write(repo_dir.path().join("notes.txt"), "untracked\n").unwrap(); + let head_branch = repo.head().unwrap().shorthand().unwrap().to_owned(); + + let mut adapter = open_adapter(repo_dir.path()); + let snapshot = adapter.snapshot(RepositorySyncReason::Open, None).unwrap(); + + assert!(snapshot.capabilities.staging_area); + assert_eq!(snapshot.refs[0].name, WORKDIR_REF); + assert_eq!(snapshot.refs[0].kind, RefKind::WorkingCopy); + + let branch_ref = snapshot + .refs + .iter() + .find(|vcs_ref| vcs_ref.name == head_branch) + .expect("head branch listed"); + assert_eq!(branch_ref.kind, RefKind::Branch); + assert!(branch_ref.active); + assert_eq!(branch_ref.target, RevisionId::git(oid.clone())); + + let tag_ref = snapshot + .refs + .iter() + .find(|vcs_ref| vcs_ref.name == "v1") + .expect("tag listed"); + assert_eq!(tag_ref.kind, RefKind::Tag); + assert_eq!(tag_ref.target, RevisionId::git(oid.clone())); + + assert_eq!(snapshot.changes.len(), 1); + assert_eq!(snapshot.changes[0].revision, RevisionId::git(oid)); + assert_eq!(snapshot.changes[0].summary, "initial commit"); + assert!(snapshot.changes[0].flags.current); + + assert!(snapshot.file_changes.contains(&FileChange { + path: "src/lib.rs".to_owned(), + old_path: None, + status: FileChangeStatus::Modified, + bucket: ChangeBucket::Unstaged, + })); + assert!(snapshot.file_changes.contains(&FileChange { + path: "notes.txt".to_owned(), + old_path: None, + status: FileChangeStatus::Untracked, + bucket: ChangeBucket::Untracked, + })); + } + + #[test] + fn snapshot_of_empty_repo_lists_untracked_files_without_history() { + let repo_dir = TempDir::new().unwrap(); + Repository::init(repo_dir.path()).unwrap(); + fs::write(repo_dir.path().join("readme.md"), "hello\n").unwrap(); + + let mut adapter = open_adapter(repo_dir.path()); + let snapshot = adapter.snapshot(RepositorySyncReason::Open, None).unwrap(); + + assert_eq!(snapshot.refs.len(), 1); + assert_eq!(snapshot.refs[0].name, WORKDIR_REF); + assert!(snapshot.changes.is_empty()); + assert_eq!( + snapshot.file_changes, + vec![FileChange { + path: "readme.md".to_owned(), + old_path: None, + status: FileChangeStatus::Untracked, + bucket: ChangeBucket::Untracked, + }] + ); + } + + #[test] + fn snapshot_with_detached_head_marks_no_branch_active() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + commit_file(&repo, "src/lib.rs", b"one\n", "first"); + let second = commit_file(&repo, "src/lib.rs", b"two\n", "second"); + repo.set_head_detached(Oid::from_str(&second).unwrap()) + .unwrap(); + + let mut adapter = open_adapter(repo_dir.path()); + let snapshot = adapter + .snapshot(RepositorySyncReason::Rescan, None) + .unwrap(); + + assert!(snapshot.refs.iter().all(|vcs_ref| !vcs_ref.active)); + assert!( + snapshot + .refs + .iter() + .any(|vcs_ref| vcs_ref.kind == RefKind::Branch) + ); + assert_eq!(snapshot.changes.len(), 2); + assert!(snapshot.changes.iter().all(|change| !change.flags.current)); + } + + #[test] + fn snapshot_reports_staged_rename_with_old_path() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + commit_file(&repo, "src/old.rs", b"same content\n", "initial"); + fs::rename( + repo_dir.path().join("src/old.rs"), + repo_dir.path().join("src/new.rs"), + ) + .unwrap(); + let mut index = repo.index().unwrap(); + index.remove_path(Path::new("src/old.rs")).unwrap(); + index.add_path(Path::new("src/new.rs")).unwrap(); + index.write().unwrap(); + + let mut adapter = open_adapter(repo_dir.path()); + let snapshot = adapter.snapshot(RepositorySyncReason::Dirty, None).unwrap(); + + assert_eq!( + snapshot.file_changes, + vec![FileChange { + path: "src/new.rs".to_owned(), + old_path: Some("src/old.rs".to_owned()), + status: FileChangeStatus::Renamed, + bucket: ChangeBucket::Staged, + }] + ); + } + + #[test] + fn read_file_text_returns_content_at_revision() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + let first = commit_file(&repo, "src/lib.rs", b"hello\nworld\n", "first"); + commit_file(&repo, "src/lib.rs", b"changed\n", "second"); + + let mut adapter = open_adapter(repo_dir.path()); + + let old_text = adapter + .read_file_text(&RevisionId::git(first), "src/lib.rs") + .unwrap(); + assert_eq!(old_text.as_str(), Some("hello\nworld\n")); + + let workdir_text = adapter + .read_file_text(&RevisionId::git(WORKDIR_REF), "src/lib.rs") + .unwrap(); + assert_eq!(workdir_text.as_str(), Some("changed\n")); + + assert!( + adapter + .read_file_text(&RevisionId::git("HEAD"), "src/missing.rs") + .is_err() + ); + } + + #[test] + fn read_file_text_rejects_binary_content() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + let oid = commit_file(&repo, "blob.bin", b"\x00\x01\x02binary", "add binary"); + + let mut adapter = open_adapter(repo_dir.path()); + let error = adapter + .read_file_text(&RevisionId::git(oid), "blob.bin") + .expect_err("binary content rejected"); + assert!(error.to_string().contains("binary"), "{error}"); + } + + #[test] + fn resolve_compare_request_resolves_each_spec() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + let first = commit_file(&repo, "src/lib.rs", b"one\n", "first"); + let second = commit_file(&repo, "src/lib.rs", b"two\n", "second"); + + let mut adapter = open_adapter(repo_dir.path()); + + assert_eq!( + adapter + .resolve_compare_request(&request(VcsCompareSpec::WorkingCopy)) + .unwrap(), + (second.clone(), WORKDIR_REF.to_owned()) + ); + assert_eq!( + adapter + .resolve_compare_request(&request(VcsCompareSpec::Range { + from: first.clone(), + to: second.clone(), + })) + .unwrap(), + (first.clone(), second.clone()) + ); + // Single-commit mode diffs the commit against its first parent. + assert_eq!( + adapter + .resolve_compare_request(&request(VcsCompareSpec::Change { + revision: second.clone(), + })) + .unwrap(), + (first, second) + ); + } + + #[test] + fn compare_range_produces_file_diff() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + let first = commit_file(&repo, "src/lib.rs", b"old line\n", "first"); + let second = commit_file(&repo, "src/lib.rs", b"new line\n", "second"); + + let mut adapter = open_adapter(repo_dir.path()); + let output = adapter + .compare( + &request(VcsCompareSpec::Range { + from: first, + to: second, + }), + None, + ) + .unwrap(); + + assert_eq!(output.file_count(), 1); + let summary = output.summary_at(0).expect("file summary"); + assert_eq!(summary.paths.display_path(), "src/lib.rs"); + assert!(!output.used_fallback); + } + + #[test] + fn compare_working_file_requires_status_scope() { + let repo_dir = TempDir::new().unwrap(); + let repo = Repository::init(repo_dir.path()).unwrap(); + commit_file(&repo, "src/lib.rs", b"hello\n", "initial"); + + let mut adapter = open_adapter(repo_dir.path()); + assert!(adapter.compare_working_file("src/lib.rs").is_err()); + } + + #[test] + fn git_compare_spec_maps_request_specs() { + let working = git_compare_spec(&request(VcsCompareSpec::WorkingCopy)); + assert_eq!(working.left_ref, "HEAD"); + assert_eq!(working.right_ref, WORKDIR_REF); + assert_eq!(working.mode, CompareMode::TwoDot); + + let change = git_compare_spec(&request(VcsCompareSpec::Change { + revision: "abc123".to_owned(), + })); + assert_eq!(change.left_ref, ""); + assert_eq!(change.right_ref, "abc123"); + assert_eq!(change.mode, CompareMode::SingleCommit); + + let range = git_compare_spec(&request(VcsCompareSpec::Range { + from: "left".to_owned(), + to: "right".to_owned(), + })); + assert_eq!( + (range.left_ref.as_str(), range.right_ref.as_str()), + ("left", "right") + ); + assert_eq!(range.mode, CompareMode::TwoDot); + + let merge_base = git_compare_spec(&request(VcsCompareSpec::MergeBaseRange { + base: "main".to_owned(), + head: "feature".to_owned(), + })); + assert_eq!( + (merge_base.left_ref.as_str(), merge_base.right_ref.as_str()), + ("main", "feature") + ); + assert_eq!(merge_base.mode, CompareMode::ThreeDot); + } + + #[test] + fn status_item_from_file_change_maps_buckets_and_labels() { + let staged_added = status_item_from_file_change(&FileChange { + path: "src/new.rs".to_owned(), + old_path: None, + status: FileChangeStatus::Added, + bucket: ChangeBucket::Staged, + }); + assert_eq!(staged_added.scope, StatusScope::Staged); + assert_eq!(staged_added.status, "A"); + + let untracked = status_item_from_file_change(&FileChange { + path: "notes.txt".to_owned(), + old_path: None, + status: FileChangeStatus::Untracked, + bucket: ChangeBucket::Untracked, + }); + assert_eq!(untracked.scope, StatusScope::Untracked); + assert_eq!(untracked.status, "U"); + + let conflicted = status_item_from_file_change(&FileChange { + path: "src/lib.rs".to_owned(), + old_path: None, + status: FileChangeStatus::Modified, + bucket: ChangeBucket::Conflicted, + }); + assert_eq!(conflicted.scope, StatusScope::Unstaged); + assert_eq!(conflicted.status, "!"); + + let renamed = status_item_from_file_change(&FileChange { + path: "src/new.rs".to_owned(), + old_path: Some("src/old.rs".to_owned()), + status: FileChangeStatus::Renamed, + bucket: ChangeBucket::Unstaged, + }); + assert_eq!(renamed.status, "R"); + assert_eq!(renamed.old_path.as_deref(), Some("src/old.rs")); + } + + #[test] + fn publish_helpers_pick_remote_and_label() { + assert_eq!( + upstream_pair("origin/main"), + Some(("origin".to_owned(), "main".to_owned())) + ); + assert_eq!(upstream_pair("main"), None); + + let with_origin = [remote_branch("upstream/main"), remote_branch("origin/main")]; + assert_eq!( + preferred_git_remote(&with_origin).as_deref(), + Some("origin") + ); + let without_origin = [remote_branch("fork/main"), remote_branch("alt/dev")]; + assert_eq!( + preferred_git_remote(&without_origin).as_deref(), + Some("alt") + ); + assert_eq!(preferred_git_remote(&[]), None); + + assert_eq!(completed_publish_label("Push main"), "Pushed main"); + assert_eq!(completed_publish_label("Publish"), "Publish"); + } +} diff --git a/src/core/vcs/git/mod.rs b/src/core/vcs/git/mod.rs index 5b7ff9ac..84ac875b 100644 --- a/src/core/vcs/git/mod.rs +++ b/src/core/vcs/git/mod.rs @@ -5,6 +5,6 @@ pub mod status; pub use adapter::GitBackend; pub use service::{ BranchInfo, CommitInfo, GitService, INDEX_REF, PatchApplyTarget, PullError, PullOutcome, - TagInfo, WORKDIR_REF, pr_ref_path, + TagInfo, WORKDIR_REF, is_pr_ref, pr_ref_path, }; pub use status::{StatusItem, StatusOperation, StatusScope}; diff --git a/src/core/vcs/git/service.rs b/src/core/vcs/git/service.rs index 1124ec5c..0eed8d39 100644 --- a/src/core/vcs/git/service.rs +++ b/src/core/vcs/git/service.rs @@ -12,7 +12,7 @@ use crate::core::compare::backends::{RENAME_DETECTION_LIMIT, compare_output_from use crate::core::compare::service::CompareOutput; use crate::core::compare::spec::CompareMode; use crate::core::compare::stats::COMPARE_SUMMARY_FILE_LIMIT; -use crate::core::error::{DiffyError, Result}; +use crate::core::error::{DiffyError, Result, VcsBackendKind}; use crate::core::forge::github::{GitHubApi, PullRequestInfo, parse_pr_url}; use crate::core::vcs::git::status::{StatusBits, StatusItem, StatusOperation, StatusScope}; @@ -104,6 +104,10 @@ pub fn pr_ref_path(pr_number: i32, branch: &str) -> String { format!("{PR_REF_PREFIX}{pr_number}/{branch}") } +pub fn is_pr_ref(reference: &str) -> bool { + reference.starts_with(PR_REF_PREFIX) +} + /// Remove stale refs from prior fetches for this PR. Keeps only the targets /// the latest fetch wrote, and also cleans up the old `refs/diffy/pull/{N}/*` /// scheme we used to use. Uses a prefix filter so branch names with slashes are @@ -164,7 +168,7 @@ fn git_workdir(repo: &gix::Repository) -> Result { repo.workdir() .map(Path::to_path_buf) .or_else(|| repo.git_dir().parent().map(Path::to_path_buf)) - .ok_or_else(|| DiffyError::General("repository has no working directory".to_owned())) + .ok_or_else(|| git_error_fatal("open", "repository has no working directory")) } struct GitOutput { @@ -196,7 +200,7 @@ fn run_system_git_inner( .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_OPTIONAL_LOCKS", "0") .output() - .map_err(|e| DiffyError::General(format!("failed to run git: {e}")))?; + .map_err(|e| git_error_fatal(git_command_label(args), format!("failed to run git: {e}")))?; if output.status.success() || (allow_diff_exit && output.status.code() == Some(1)) { return Ok(GitOutput { @@ -214,9 +218,30 @@ fn run_system_git_inner( .map(str::to_owned) .unwrap_or_else(|| format!("git exited with {}", output.status)); let command = git_command_label(args); - Err(DiffyError::General(format!( - "git {command} failed: {detail}" - ))) + if failure_is_network(&detail) { + return Err(DiffyError::network(format!( + "git {command} failed: {detail}" + ))); + } + Err(git_error(command, detail)) +} + +/// Heuristic for remote-operation failures (fetch/push/pull) caused by the +/// network rather than repository state, so the UI can suggest retrying. +fn failure_is_network(detail: &str) -> bool { + let detail = detail.to_ascii_lowercase(); + [ + "could not resolve host", + "connection refused", + "connection reset", + "connection timed out", + "operation timed out", + "network is unreachable", + "could not read from remote repository", + "unable to access", + ] + .iter() + .any(|needle| detail.contains(needle)) } fn git_command_label(args: &[OsString]) -> String { @@ -256,8 +281,16 @@ fn sanitize_git_arg(arg: &str) -> String { } } +fn git_error(op: impl Into, details: impl Into) -> DiffyError { + DiffyError::vcs(VcsBackendKind::Git, op, details) +} + +fn git_error_fatal(op: impl Into, details: impl Into) -> DiffyError { + DiffyError::vcs_fatal(VcsBackendKind::Git, op, details) +} + fn gix_error(error: impl std::fmt::Display) -> DiffyError { - DiffyError::General(format!("Gitoxide error: {error}")) + git_error("repository read", error.to_string()) } fn github_repo_key_from_remote_url(url: &str) -> Option<(String, String)> { @@ -453,41 +486,68 @@ impl GitService { } pub fn branches(&self) -> Result> { + Ok(self.branches_and_tags()?.0) + } + + pub fn tags(&self) -> Result> { + Ok(self.branches_and_tags()?.1) + } + + /// List branches and tags with a single `for-each-ref` invocation. The + /// snapshot path needs both, and one subprocess per refresh is cheaper + /// than two on repositories with many refs. + pub fn branches_and_tags(&self) -> Result<(Vec, Vec)> { let output = run_system_git_capture( self.repo_path_ref()?, &[ OsString::from("for-each-ref"), OsString::from( - "--format=%(refname)%00%(refname:short)%00%(objectname)%00%(upstream:short)%00%(upstream:track)%00%(HEAD)", + "--format=%(refname)%00%(refname:short)%00%(objectname)%00%(*objectname)%00%(upstream:short)%00%(upstream:track)%00%(HEAD)", ), OsString::from("refs/heads"), OsString::from("refs/remotes"), + OsString::from("refs/tags"), ], )?; let mut branches = Vec::new(); + let mut tags = Vec::new(); for line in output.stdout.split(|byte| *byte == b'\n') { if line.is_empty() { continue; } let fields = line.split(|byte| *byte == 0).collect::>(); - if fields.len() < 6 { + if fields.len() < 7 { continue; } let full_name = String::from_utf8_lossy(fields[0]); let name = String::from_utf8_lossy(fields[1]).to_string(); - let target_oid = String::from_utf8_lossy(fields[2]).to_string(); + if full_name.starts_with("refs/tags/") { + // Annotated tags carry the peeled commit in `%(*objectname)`; + // lightweight tags only fill `%(objectname)`. + let peeled = if fields[3].is_empty() { + fields[2] + } else { + fields[3] + }; + tags.push(TagInfo { + name, + target_oid: String::from_utf8_lossy(peeled).to_string(), + }); + continue; + } if name.ends_with("/HEAD") { continue; } let is_remote = full_name.starts_with("refs/remotes/"); + let target_oid = String::from_utf8_lossy(fields[2]).to_string(); let upstream = - (!fields[3].is_empty()).then(|| String::from_utf8_lossy(fields[3]).to_string()); + (!fields[4].is_empty()).then(|| String::from_utf8_lossy(fields[4]).to_string()); let ahead_behind = if is_remote { None } else { - parse_upstream_track(upstream.as_ref(), &String::from_utf8_lossy(fields[4])) + parse_upstream_track(upstream.as_ref(), &String::from_utf8_lossy(fields[5])) }; - let is_head = !is_remote && fields[5] == b"*"; + let is_head = !is_remote && fields[6] == b"*"; branches.push(BranchInfo { name, is_remote, @@ -504,42 +564,8 @@ impl GitService { }, other => other, }); - Ok(branches) - } - - pub fn tags(&self) -> Result> { - let output = run_system_git_capture( - self.repo_path_ref()?, - &[ - OsString::from("for-each-ref"), - OsString::from("--format=%(refname:short)%00%(*objectname)%00%(objectname)"), - OsString::from("refs/tags"), - ], - )?; - let mut tags = output - .stdout - .split(|byte| *byte == b'\n') - .filter_map(|line| { - if line.is_empty() { - return None; - } - let fields = line.split(|byte| *byte == 0).collect::>(); - if fields.len() < 3 { - return None; - } - let peeled = if fields[1].is_empty() { - fields[2] - } else { - fields[1] - }; - Some(TagInfo { - name: String::from_utf8_lossy(fields[0]).to_string(), - target_oid: String::from_utf8_lossy(peeled).to_string(), - }) - }) - .collect::>(); tags.sort_by(|left, right| left.name.cmp(&right.name)); - Ok(tags) + Ok((branches, tags)) } pub fn commits(&self, reference: &str, max_count: usize) -> Result> { @@ -615,7 +641,10 @@ impl GitService { let entry = index .entry_by_path(path.as_bytes().as_bstr()) .ok_or_else(|| { - DiffyError::General(format!("path {path} is not present at {INDEX_REF}")) + git_error( + "read file", + format!("path {path} is not present at {INDEX_REF}"), + ) })?; Ok(self .repo()? @@ -648,10 +677,16 @@ impl GitService { .lookup_entry_by_path(path) .map_err(gix_error)? .ok_or_else(|| { - DiffyError::General(format!("path {path} is not present at {reference}")) + git_error( + "read file", + format!("path {path} is not present at {reference}"), + ) })?; if !entry.mode().is_blob_or_symlink() { - return Err(DiffyError::General(format!("path {path} is not a file"))); + return Err(git_error_fatal( + "read file", + format!("path {path} is not a file"), + )); } Ok(self .repo()? @@ -702,15 +737,17 @@ impl GitService { fn validate_text_bytes(reference: &str, path: &str, bytes: &[u8]) -> Result<()> { if bytes.contains(&0u8) { - return Err(DiffyError::General(format!( - "path {path} is binary at {reference}", - ))); + return Err(git_error_fatal( + "read file", + format!("path {path} is binary at {reference}"), + )); } std::str::from_utf8(bytes).map_err(|e| { - DiffyError::General(format!( - "path {path} at {reference} is not valid UTF-8: {e}" - )) + git_error_fatal( + "read file", + format!("path {path} at {reference} is not valid UTF-8: {e}"), + ) })?; Ok(()) } @@ -1338,15 +1375,26 @@ impl GitService { Ok(id) } + /// Subject line of the commit at `oid`, equivalent to + /// `git log -n1 --format=%s` (whitespace in the title folded to single + /// spaces) but answered from the gix object store without a subprocess. + pub fn commit_summary(&self, oid: &str) -> Result { + let commit = self + .repo()? + .find_commit(gix_object_id(oid)?) + .map_err(gix_error)?; + Ok(commit.message().map_err(gix_error)?.summary().to_string()) + } + pub fn repo(&self) -> Result<&gix::Repository> { self.repo .as_ref() - .ok_or_else(|| DiffyError::General("repository is not open".to_owned())) + .ok_or_else(|| git_error_fatal("open", "repository is not open")) } fn repo_path_ref(&self) -> Result<&Path> { if self.repo_path.is_empty() { - return Err(DiffyError::General("repository is not open".to_owned())); + return Err(git_error_fatal("open", "repository is not open")); } Ok(Path::new(&self.repo_path)) } @@ -1430,22 +1478,19 @@ impl GitService { .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() - .map_err(|e| DiffyError::General(format!("failed to run git apply: {e}")))?; + .map_err(|e| git_error_fatal("apply", format!("failed to run git apply: {e}")))?; use std::io::Write; child .stdin .as_mut() - .ok_or_else(|| DiffyError::General("failed to open git apply stdin".to_owned()))? + .ok_or_else(|| git_error_fatal("apply", "failed to open git apply stdin"))? .write_all(patch_text.as_bytes())?; let output = child.wait_with_output()?; if output.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr); - Err(DiffyError::General(format!( - "git apply failed: {}", - stderr.trim() - ))) + Err(git_error("apply", stderr.trim())) } } @@ -1802,7 +1847,7 @@ fn fixed_short_oid(oid: &str) -> &str { oid.get(..8).unwrap_or(oid) } -fn is_full_hex_oid(value: &str) -> bool { +pub(crate) fn is_full_hex_oid(value: &str) -> bool { value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) } @@ -1815,7 +1860,7 @@ mod tests { use tempfile::TempDir; use super::{ - INDEX_REF, PR_REF_PREFIX, WORKDIR_REF, github_fetch_source_for_repo, + INDEX_REF, PR_REF_PREFIX, WORKDIR_REF, github_fetch_source_for_repo, is_pr_ref, github_repo_key_from_remote_url, github_repo_url_from_remote_transport, local_remote_for_github_repo, parse_porcelain_status, parse_shortstat, pr_ref_path, }; @@ -1915,6 +1960,9 @@ mod tests { "refs/diffy/pr/77/feat/new-thing" ); assert!(pr_ref_path(1, "x").starts_with(PR_REF_PREFIX)); + assert!(is_pr_ref(&pr_ref_path(12, "main"))); + assert!(!is_pr_ref("refs/heads/main")); + assert!(!is_pr_ref("@workdir")); } #[test] diff --git a/src/core/vcs/jj/cli.rs b/src/core/vcs/jj/cli.rs index 3e840815..7dab6e79 100644 --- a/src/core/vcs/jj/cli.rs +++ b/src/core/vcs/jj/cli.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Instant; -use crate::core::error::{DiffyError, Result}; +use crate::core::error::{DiffyError, Result, VcsBackendKind}; #[derive(Debug, Clone)] pub struct JjCli { @@ -42,7 +42,7 @@ impl JjCli { fn run_inner(&self, args: &[OsString], ignore_working_copy: bool) -> Result { let stdout = self.run_inner_bytes(args, ignore_working_copy)?; String::from_utf8(stdout) - .map_err(|error| DiffyError::General(format!("jj emitted non-UTF8 output: {error}"))) + .map_err(|error| DiffyError::Parse(format!("jj emitted non-UTF8 output: {error}"))) } fn run_inner_bytes(&self, args: &[OsString], ignore_working_copy: bool) -> Result> { @@ -60,9 +60,13 @@ impl JjCli { } command.args(args); - let output = command - .output() - .map_err(|error| DiffyError::General(format!("failed to run jj: {error}")))?; + let output = command.output().map_err(|error| { + DiffyError::vcs_fatal( + VcsBackendKind::Jj, + command_label(args), + format!("failed to run jj: {error}"), + ) + })?; let command_label = command_label(args); let elapsed = started.elapsed(); if output.status.success() { @@ -85,10 +89,7 @@ impl JjCli { .or_else(|| stdout.trim().lines().last()) .map(str::to_owned) .unwrap_or_else(|| format!("jj exited with {}", output.status)); - Err(DiffyError::General(format!( - "jj {} failed: {detail}", - command_label - ))) + Err(DiffyError::vcs(VcsBackendKind::Jj, command_label, detail)) } } diff --git a/src/core/vcs/jj/service.rs b/src/core/vcs/jj/service.rs index 95636dfc..61fa3ed2 100644 --- a/src/core/vcs/jj/service.rs +++ b/src/core/vcs/jj/service.rs @@ -11,8 +11,9 @@ use crate::core::compare::{ COMPARE_SUMMARY_FILE_LIMIT, CompareFileStatsTarget, CompareFileSummary, CompareOutput, ProgressSink, RendererKind, }; -use crate::core::error::{DiffyError, Result}; +use crate::core::error::{DiffyError, Result, VcsBackendKind}; use crate::core::vcs::backend::{VcsBackend, VcsRepository, VcsWatchPaths}; +use crate::core::vcs::cache::VcsReadCache; use crate::core::vcs::jj::cli::JjCli; use crate::core::vcs::jj::parse::{ parse_bookmark_list, parse_change_log, parse_conflict_list, parse_diff_summary, @@ -70,24 +71,8 @@ pub struct JjRepository { location: RepoLocation, last_operation_id: Option, last_snapshot: Option, - diff_cache: Vec, - file_text_cache: Vec, -} - -#[derive(Clone)] -struct DiffCacheEntry { - operation_id: Option, - request: VcsCompareRequest, - path: Option, - output: CompareOutput, -} - -#[derive(Clone)] -struct FileTextCacheEntry { - operation_id: Option, - revision: RevisionId, - path: String, - text: TextStore, + /// Reads cached per operation id; see [`VcsReadCache`]. + read_cache: VcsReadCache, } #[derive(Debug, Clone)] @@ -106,6 +91,9 @@ struct MovableBookmark { name: String, target: String, allow_backwards: bool, + /// Set when the bookmark only exists on this remote (untracked); moving it + /// requires tracking it first to create the local bookmark. + track_remote: Option, } impl JjRepository { @@ -115,11 +103,14 @@ impl JjRepository { location, last_operation_id: None, last_snapshot: None, - diff_cache: Vec::new(), - file_text_cache: Vec::new(), + read_cache: VcsReadCache::new(), } } + fn is_colocated(&self) -> bool { + self.location.workspace_root.join(".git").exists() + } + fn diff_args_for_spec(&self, spec: &VcsCompareSpec) -> Result> { let mut args = vec![OsString::from("diff")]; match spec { @@ -267,8 +258,7 @@ impl JjRepository { fn set_operation_id(&mut self, operation_id: String) { if self.last_operation_id.as_deref() != Some(operation_id.as_str()) { - self.diff_cache.clear(); - self.file_text_cache.clear(); + self.read_cache.clear(); self.last_snapshot = None; } self.last_operation_id = Some(operation_id); @@ -283,76 +273,6 @@ impl JjRepository { Ok(self.last_operation_id.clone()) } - fn cached_diff( - &self, - operation_id: Option<&str>, - request: &VcsCompareRequest, - path: Option<&str>, - ) -> Option { - self.diff_cache - .iter() - .find(|entry| { - entry.operation_id.as_deref() == operation_id - && entry.request == *request - && entry.path.as_deref() == path - }) - .map(|entry| entry.output.clone()) - } - - fn insert_diff_cache( - &mut self, - operation_id: Option, - request: VcsCompareRequest, - path: Option, - output: CompareOutput, - ) { - const MAX_DIFF_CACHE_ENTRIES: usize = 8; - if self.diff_cache.len() >= MAX_DIFF_CACHE_ENTRIES { - self.diff_cache.remove(0); - } - self.diff_cache.push(DiffCacheEntry { - operation_id, - request, - path, - output, - }); - } - - fn cached_file_text( - &self, - operation_id: Option<&str>, - revision: &RevisionId, - path: &str, - ) -> Option { - self.file_text_cache - .iter() - .find(|entry| { - entry.operation_id.as_deref() == operation_id - && entry.revision == *revision - && entry.path == path - }) - .map(|entry| entry.text.clone()) - } - - fn insert_file_text_cache( - &mut self, - operation_id: Option, - revision: RevisionId, - path: String, - text: TextStore, - ) { - const MAX_FILE_TEXT_CACHE_ENTRIES: usize = 16; - if self.file_text_cache.len() >= MAX_FILE_TEXT_CACHE_ENTRIES { - self.file_text_cache.remove(0); - } - self.file_text_cache.push(FileTextCacheEntry { - operation_id, - revision, - path, - text, - }); - } - fn conflict_list(&self) -> Result { match self.cli.run_ignored_wc(&[ OsString::from("resolve"), @@ -369,8 +289,7 @@ impl JjRepository { fn clear_after_write(&mut self) { self.last_operation_id = None; self.last_snapshot = None; - self.diff_cache.clear(); - self.file_text_cache.clear(); + self.read_cache.clear(); } fn remote_names(&self) -> Result> { @@ -389,9 +308,7 @@ impl JjRepository { .find(|remote| remote.as_str() == "origin") .cloned() .or_else(|| remotes.first().cloned()) - .ok_or_else(|| { - DiffyError::General("No remotes are configured for this repository.".to_owned()) - }) + .ok_or_else(|| jj_error("publish", "no remotes are configured for this repository")) } fn default_publish_target(&self) -> Result { @@ -403,8 +320,9 @@ impl JjRepository { let head_target = self.publish_target("@")?; let target = if head_target.summary.trim().is_empty() { let mut parent = self.publish_target("@-").map_err(|_| { - DiffyError::General( - "Describe the current jj change before publishing it.".to_owned(), + jj_error( + "publish", + "describe the current jj change before publishing it", ) })?; parent.fell_back_from_empty_working_copy = true; @@ -413,8 +331,9 @@ impl JjRepository { head_target }; if target.summary.trim().is_empty() { - Err(DiffyError::General( - "Describe the jj change before publishing it.".to_owned(), + Err(jj_error( + "publish", + "describe the jj change before publishing it", )) } else { Ok(target) @@ -439,9 +358,10 @@ impl JjRepository { let change_id_rest = fields.next().unwrap_or_default(); let summary = fields.next().unwrap_or_default().to_owned(); if commit_id.is_empty() { - return Err(DiffyError::General(format!( - "Could not resolve jj revision {revision} for publishing." - ))); + return Err(jj_error( + "publish", + format!("could not resolve jj revision {revision} for publishing"), + )); } let short_change_id = format!("{change_id_prefix}{change_id_rest}"); let short_change_id_prefix_len = change_id_prefix.len(); @@ -483,23 +403,20 @@ impl JjRepository { .collect()) } - fn movable_bookmarks(&self, revision: &str) -> Result> { - let revset_after = format!("{revision}::"); - let revset = format!("::{revision} | {revset_after}"); + fn movable_bookmarks(&self, revision: &str, remote: &str) -> Result> { + // `bookmark list -r` only matches local bookmark targets, which would + // hide remote-only bookmarks entirely; list everything and let the + // template report ancestor/descendant containment instead. let output = self.cli.run_ignored_wc(&[ OsString::from("bookmark"), OsString::from("list"), - OsString::from("-r"), - OsString::from(revset), + OsString::from("--all-remotes"), OsString::from("-T"), OsString::from(format!( - "name ++ \"\\t\" ++ normal_target.commit_id() ++ \"\\t\" ++ normal_target.contained_in(\"{revset_after}\") ++ \"\\n\"" + "name ++ \"\\t\" ++ if(self.remote(), self.remote(), \"\") ++ \"\\t\" ++ if(self.tracked(), \"true\", \"false\") ++ \"\\t\" ++ normal_target.commit_id() ++ \"\\t\" ++ normal_target.contained_in(\"::({revision})\") ++ \"\\t\" ++ normal_target.contained_in(\"({revision})::\") ++ \"\\n\"" )), ])?; - let mut bookmarks = output - .lines() - .filter_map(parse_movable_bookmark_line) - .collect::>(); + let mut bookmarks = parse_movable_bookmark_list(&output, remote); bookmarks.sort_by(|left, right| { bookmark_priority(&left.name) .cmp(&bookmark_priority(&right.name)) @@ -508,6 +425,28 @@ impl JjRepository { Ok(bookmarks) } + /// Commit ids of the closest ancestor commits of `revision` that carry a + /// bookmark — jj's analog of "the branch you are on" in git. + fn nearest_bookmark_targets(&self, revision: &str, remote: &str) -> Result> { + let escaped_remote = remote.replace('\\', "\\\\").replace('"', "\\\""); + let output = self.cli.run_ignored_wc(&[ + OsString::from("log"), + OsString::from("--no-graph"), + OsString::from("-r"), + OsString::from(format!( + "heads(::(({revision})-) & (bookmarks() | remote_bookmarks(remote=\"{escaped_remote}\")))" + )), + OsString::from("-T"), + OsString::from("commit_id ++ \"\\n\""), + ])?; + Ok(output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_owned) + .collect()) + } + fn generated_bookmark_name(target: &JjPublishTarget) -> String { let suffix = if target.short_change_id.is_empty() { target.short_commit_id.as_str() @@ -531,6 +470,23 @@ impl JjRepository { } } + fn move_bookmark_description( + bookmark: &MovableBookmark, + target: &JjPublishTarget, + remote: &str, + ) -> String { + match &bookmark.track_remote { + Some(tracked) => format!( + "Track {}@{tracked}, move it to {}, and push it to {remote}", + bookmark.name, target.short_commit_id + ), + None => format!( + "Move jj bookmark {} to {} and push it to {remote}", + bookmark.name, target.short_commit_id + ), + } + } + fn push_change_label(target: &JjPublishTarget) -> String { let id = if target.short_change_id.is_empty() { target.short_commit_id.as_str() @@ -551,7 +507,7 @@ impl VcsRepository for JjRepository { } fn capabilities(&self) -> RepoCapabilities { - jj_capabilities() + jj_capabilities(self.is_colocated()) } fn resolve_ref(&mut self, reference: &str) -> Result<(String, String)> { @@ -689,7 +645,7 @@ impl VcsRepository for JjRepository { location: self.location.clone(), reason, change_kind: None, - capabilities: jj_capabilities(), + capabilities: jj_capabilities(self.is_colocated()), refs, changes, operation_log: parse_operation_log(&operation_log), @@ -714,13 +670,17 @@ impl VcsRepository for JjRepository { _reporter: Option<&dyn ProgressSink>, ) -> Result { let operation_id = self.ensure_read_epoch()?; - if let Some(output) = self.cached_diff(operation_id.as_deref(), request, None) { + if let Some(output) = self + .read_cache + .cached_diff(operation_id.as_deref(), request, None) + { return Ok(output); } #[cfg(feature = "difftastic")] if request.renderer == RendererKind::Difftastic { let output = self.compare_difftastic(request, _reporter, None)?; - self.insert_diff_cache(operation_id, request.clone(), None, output.clone()); + self.read_cache + .insert_diff(operation_id, request.clone(), None, output.clone()); return Ok(output); } @@ -733,13 +693,15 @@ impl VcsRepository for JjRepository { ..CompareOutput::default() }; output.compact_file_summaries(); - self.insert_diff_cache(operation_id, request.clone(), None, output.clone()); + self.read_cache + .insert_diff(operation_id, request.clone(), None, output.clone()); return Ok(output); } let args = self.diff_args_for_spec(&request.spec)?; let raw_diff = self.cli.run_ignored_wc(&args)?; let output = compare_output_from_raw_patch(&raw_diff)?; - self.insert_diff_cache(operation_id, request.clone(), None, output.clone()); + self.read_cache + .insert_diff(operation_id, request.clone(), None, output.clone()); Ok(output) } @@ -814,13 +776,16 @@ impl VcsRepository for JjRepository { _deferred_file: Option<&CompareFileSummary>, ) -> Result { let operation_id = self.ensure_read_epoch()?; - if let Some(output) = self.cached_diff(operation_id.as_deref(), request, Some(path)) { + if let Some(output) = + self.read_cache + .cached_diff(operation_id.as_deref(), request, Some(path)) + { return Ok(output); } #[cfg(feature = "difftastic")] if request.renderer == RendererKind::Difftastic { let output = self.compare_difftastic(request, None, Some(path))?; - self.insert_diff_cache( + self.read_cache.insert_diff( operation_id, request.clone(), Some(path.to_owned()), @@ -833,7 +798,7 @@ impl VcsRepository for JjRepository { args.push(jj_root_pathspec(path)); let raw_diff = self.cli.run_ignored_wc(&args)?; let output = compare_output_from_raw_patch(&raw_diff)?; - self.insert_diff_cache( + self.read_cache.insert_diff( operation_id, request.clone(), Some(path.to_owned()), @@ -866,8 +831,9 @@ impl VcsRepository for JjRepository { operation: FileOperation, ) -> Result<()> { if operation != FileOperation::Discard { - return Err(DiffyError::General( - "jj does not support stage or unstage operations".to_owned(), + return Err(jj_error_fatal( + "stage", + "jj does not support stage or unstage operations", )); } let mut args = vec![OsString::from("restore")]; @@ -985,7 +951,7 @@ impl VcsRepository for JjRepository { fn push(&mut self, remote: &str, refspec: &str, _force_with_lease: bool) -> Result<()> { let bookmark = bookmark_from_refspec(refspec) - .ok_or_else(|| DiffyError::General("jj push requires a bookmark refspec".to_owned()))?; + .ok_or_else(|| jj_error_fatal("push", "jj push requires a bookmark refspec"))?; self.cli.run(&[ OsString::from("git"), OsString::from("push"), @@ -1018,12 +984,34 @@ impl VcsRepository for JjRepository { } }); let mut movable_bookmarks = self - .movable_bookmarks(&target.revision) + .movable_bookmarks(&target.revision, &remote) .unwrap_or_default() .into_iter() .filter(|bookmark| bookmark.target != target.commit_id) - .take(6) .collect::>(); + // Mirror git's "push the branch you are on": when no bookmark sits on + // the target itself, advance the nearest ancestor bookmark forward. + let advance_bookmark = if bookmarks.is_empty() { + let nearest_targets = self + .nearest_bookmark_targets(&target.revision, &remote) + .unwrap_or_default(); + movable_bookmarks + .iter() + .enumerate() + .filter(|(_, bookmark)| { + !bookmark.allow_backwards && nearest_targets.contains(&bookmark.target) + }) + // Prefer feature bookmarks over main/master when both sit on + // nearest ancestors (e.g. right after merging main in). + .min_by_key(|(_, bookmark)| { + (bookmark_priority(&bookmark.name) == 0, bookmark.name.clone()) + }) + .map(|(index, _)| index) + .map(|index| movable_bookmarks.remove(index)) + } else { + None + }; + movable_bookmarks.truncate(6); let change_id_token = Self::change_id_token(&target); let primary = if let Some(bookmark) = bookmarks.first() { PublishAction { @@ -1039,6 +1027,20 @@ impl VcsRepository for JjRepository { disabled_reason: target_disabled_reason.clone(), change_id_token: None, } + } else if let Some(bookmark) = &advance_bookmark { + PublishAction { + label: format!("Push bookmark {}", bookmark.name), + description: Self::move_bookmark_description(bookmark, &target, &remote), + kind: PublishActionKind::MoveBookmarkAndPush { + remote: remote.clone(), + bookmark: bookmark.name.clone(), + revision: target.revision.clone(), + allow_backwards: false, + track_remote: bookmark.track_remote.clone(), + }, + disabled_reason: target_disabled_reason.clone(), + change_id_token: None, + } } else { PublishAction { label: Self::push_change_label(&target), @@ -1090,15 +1092,13 @@ impl VcsRepository for JjRepository { for bookmark in movable_bookmarks.drain(..) { alternatives.push(PublishAction { label: format!("Move bookmark {} here and push", bookmark.name), - description: format!( - "Move jj bookmark {} to {} and push it to {remote}", - bookmark.name, target.short_commit_id - ), + description: Self::move_bookmark_description(&bookmark, &target, &remote), kind: PublishActionKind::MoveBookmarkAndPush { remote: remote.clone(), bookmark: bookmark.name, revision: target.revision.clone(), allow_backwards: bookmark.allow_backwards, + track_remote: bookmark.track_remote, }, disabled_reason: target_disabled_reason.clone(), change_id_token: None, @@ -1157,7 +1157,17 @@ impl VcsRepository for JjRepository { bookmark, revision, allow_backwards, + track_remote, } => { + if let Some(track_remote) = track_remote { + self.cli.run(&[ + OsString::from("bookmark"), + OsString::from("track"), + OsString::from(bookmark), + OsString::from("--remote"), + OsString::from(track_remote), + ])?; + } let mut move_args = vec![ OsString::from("bookmark"), OsString::from("move"), @@ -1202,8 +1212,9 @@ impl VcsRepository for JjRepository { ])?; } PublishActionKind::PushRef { .. } => { - return Err(DiffyError::General( - "jj cannot run a Git refspec publish action".to_owned(), + return Err(jj_error_fatal( + "publish", + "jj cannot run a Git refspec publish action", )); } } @@ -1220,7 +1231,10 @@ impl VcsRepository for JjRepository { layout: crate::core::compare::LayoutMode::Unified, renderer: RendererKind::Builtin, }; - if let Some(output) = self.cached_diff(operation_id.as_deref(), &request, Some(path)) { + if let Some(output) = + self.read_cache + .cached_diff(operation_id.as_deref(), &request, Some(path)) + { return Ok(output); } let raw_diff = self.cli.run_ignored_wc(&[ @@ -1231,13 +1245,17 @@ impl VcsRepository for JjRepository { jj_root_pathspec(path), ])?; let output = compare_output_from_raw_patch(&raw_diff)?; - self.insert_diff_cache(operation_id, request, Some(path.to_owned()), output.clone()); + self.read_cache + .insert_diff(operation_id, request, Some(path.to_owned()), output.clone()); Ok(output) } fn read_file_text(&mut self, revision: &RevisionId, path: &str) -> Result { let operation_id = self.ensure_read_epoch()?; - if let Some(text) = self.cached_file_text(operation_id.as_deref(), revision, path) { + if let Some(text) = + self.read_cache + .cached_file_text(operation_id.as_deref(), revision, path) + { return Ok(text); } let output = self.cli.run_ignored_wc(&[ @@ -1248,7 +1266,7 @@ impl VcsRepository for JjRepository { jj_root_pathspec(path), ])?; let text = TextStore::from_text(output); - self.insert_file_text_cache( + self.read_cache.insert_file_text( operation_id, revision.clone(), path.to_owned(), @@ -1258,6 +1276,14 @@ impl VcsRepository for JjRepository { } } +fn jj_error(op: impl Into, details: impl Into) -> DiffyError { + DiffyError::vcs(VcsBackendKind::Jj, op, details) +} + +fn jj_error_fatal(op: impl Into, details: impl Into) -> DiffyError { + DiffyError::vcs_fatal(VcsBackendKind::Jj, op, details) +} + fn u32_to_i32_saturating(value: u32) -> i32 { i32::try_from(value).unwrap_or(i32::MAX) } @@ -1314,7 +1340,7 @@ fn parse_stat_count_before(line: &str, label: &str) -> Option { prefix[digits_start..].parse().ok() } -pub fn jj_capabilities() -> RepoCapabilities { +pub fn jj_capabilities(colocated: bool) -> RepoCapabilities { RepoCapabilities { staging_area: false, branches: false, @@ -1326,7 +1352,9 @@ pub fn jj_capabilities() -> RepoCapabilities { partial_file_restore: true, partial_hunk_mutation: false, operation_log: true, - github_pull_requests: false, + // PR comparisons run through the git backend against the colocated + // .git store, so they are only available in colocated workspaces. + github_pull_requests: colocated, } } @@ -1430,19 +1458,52 @@ fn looks_binary(bytes: &[u8]) -> bool { bytes.iter().take(1024).any(|byte| *byte == 0) } -fn parse_movable_bookmark_line(line: &str) -> Option { - let mut fields = line.splitn(3, '\t'); - let name = fields.next()?.trim(); - let target = fields.next()?.trim(); - let allow_backwards = fields.next()?.trim() == "true"; - if name.is_empty() || target.is_empty() { - return None; - } - Some(MovableBookmark { - name: name.to_owned(), - target: target.to_owned(), - allow_backwards, - }) +/// Local bookmarks are always movable. Remote bookmarks are movable only when +/// they live on the preferred remote and are untracked with no local +/// counterpart — moving them means track + move. Tracked remote bookmarks +/// without a local are deliberate deletions pending a push, so they are +/// skipped. +fn parse_movable_bookmark_list(output: &str, preferred_remote: &str) -> Vec { + let mut locals = Vec::new(); + let mut remote_only = Vec::new(); + for line in output.lines() { + let mut fields = line.splitn(6, '\t'); + let Some(name) = fields.next().map(str::trim) else { + continue; + }; + let Some(remote) = fields.next().map(str::trim) else { + continue; + }; + let Some(tracked) = fields.next().map(|field| field.trim() == "true") else { + continue; + }; + let Some(target) = fields.next().map(str::trim) else { + continue; + }; + let Some(is_ancestor) = fields.next().map(|field| field.trim() == "true") else { + continue; + }; + let Some(is_descendant) = fields.next().map(|field| field.trim() == "true") else { + continue; + }; + if name.is_empty() || target.is_empty() || (!is_ancestor && !is_descendant) { + continue; + } + let bookmark = MovableBookmark { + name: name.to_owned(), + target: target.to_owned(), + allow_backwards: is_descendant, + track_remote: (!remote.is_empty()).then(|| remote.to_owned()), + }; + if remote.is_empty() { + locals.push(bookmark); + } else if remote == preferred_remote && !tracked { + remote_only.push(bookmark); + } + } + remote_only.retain(|remote| !locals.iter().any(|local| local.name == remote.name)); + locals.extend(remote_only); + locals } fn bookmark_priority(name: &str) -> usize { @@ -1501,7 +1562,7 @@ mod tests { use super::{ JjBackend, compare_summaries_from_jj_diff_summary, jj_fork_point_revset, - parse_jj_diff_stat_total, + parse_jj_diff_stat_total, parse_movable_bookmark_list, }; use crate::core::compare::{CompareFileStatsTarget, LayoutMode, RendererKind}; use crate::core::vcs::backend::VcsBackend; @@ -1511,6 +1572,22 @@ mod tests { }; use crate::events::RepositorySyncReason; + #[test] + fn jj_pr_support_follows_colocation() { + let Some(colocated) = init_jj_repo_with(true) else { + return; + }; + let Some(plain) = init_jj_repo_with(false) else { + return; + }; + let backend = JjBackend; + for (dir, expected) in [(&colocated, true), (&plain, false)] { + let location = backend.detect(dir.path()).unwrap().unwrap(); + let repo = backend.open(location).unwrap(); + assert_eq!(repo.capabilities().github_pull_requests, expected); + } + } + #[test] fn jj_merge_base_revset_uses_fork_point() { assert_eq!( @@ -1583,6 +1660,7 @@ mod tests { .expect("jj snapshot"); assert!(snapshot.capabilities.bookmarks); assert!(!snapshot.capabilities.staging_area); + assert!(snapshot.capabilities.github_pull_requests); assert!(snapshot.file_changes.iter().any(|file| { file.path == "README.md" && file.status == FileChangeStatus::Added @@ -1798,13 +1876,146 @@ mod tests { })); } + #[test] + fn movable_bookmark_list_includes_untracked_remote_only_bookmarks() { + let output = "feat\t\tfalse\taaa\ttrue\tfalse\n\ + feat\torigin\ttrue\taaa\ttrue\tfalse\n\ + solo\torigin\tfalse\tbbb\ttrue\tfalse\n\ + gone\torigin\ttrue\tccc\ttrue\tfalse\n\ + other\tupstream\tfalse\tddd\ttrue\tfalse\n\ + desc\t\tfalse\teee\tfalse\ttrue\n\ + stray\t\tfalse\tfff\tfalse\tfalse\n"; + let bookmarks = parse_movable_bookmark_list(output, "origin"); + let names: Vec<&str> = bookmarks + .iter() + .map(|bookmark| bookmark.name.as_str()) + .collect(); + assert_eq!(names, ["feat", "desc", "solo"]); + assert_eq!(bookmarks[0].track_remote, None); + assert!(!bookmarks[0].allow_backwards); + assert!(bookmarks[1].allow_backwards); + assert_eq!(bookmarks[2].track_remote.as_deref(), Some("origin")); + assert!(!bookmarks[2].allow_backwards); + } + + #[test] + fn jj_publish_plan_advances_nearest_remote_bookmark() { + let Some(repo_dir) = init_jj_repo() else { + return; + }; + let remote_dir = TempDir::new().unwrap(); + let status = Command::new("git") + .arg("init") + .arg("--bare") + .arg(remote_dir.path()) + .status() + .unwrap(); + assert!(status.success()); + let status = Command::new("jj") + .arg("--quiet") + .arg("git") + .arg("remote") + .arg("add") + .arg("origin") + .arg(remote_dir.path()) + .current_dir(repo_dir.path()) + .status() + .unwrap(); + assert!(status.success()); + + let backend = JjBackend; + let location = backend.detect(repo_dir.path()).unwrap().unwrap(); + let mut repo = backend.open(location).unwrap(); + fs::write(repo_dir.path().join("BASE.md"), "base\n").unwrap(); + repo.create_commit("base").unwrap(); + + // Leave `feat` as a remote-only untracked bookmark on the parent — the + // state a fresh fetch of someone's branch (or a fetched PR head) is in. + for args in [ + ["--quiet", "bookmark", "create", "feat", "-r", "@-"].as_slice(), + &[ + "--quiet", + "git", + "push", + "--remote", + "origin", + "--bookmark", + "feat", + "--allow-new", + ], + &["--quiet", "bookmark", "untrack", "feat@origin"], + &["--quiet", "bookmark", "delete", "feat"], + ] { + let status = Command::new("jj") + .args(args) + .current_dir(repo_dir.path()) + .status() + .unwrap(); + assert!(status.success(), "jj {args:?} failed"); + } + + fs::write(repo_dir.path().join("NEXT.md"), "next\n").unwrap(); + repo.create_commit("next").unwrap(); + + let plan = repo.publish_plan().unwrap(); + match &plan.primary.kind { + PublishActionKind::MoveBookmarkAndPush { + remote, + bookmark, + revision, + allow_backwards, + track_remote, + } => { + assert_eq!(remote, "origin"); + assert_eq!(bookmark, "feat"); + assert_eq!(revision, "@-"); + assert!(!allow_backwards); + assert_eq!(track_remote.as_deref(), Some("origin")); + } + other => panic!("expected move-bookmark publish, got {other:?}"), + } + assert!(plan.primary.disabled_reason.is_none()); + assert_eq!(plan.primary.label, "Push bookmark feat"); + + repo.publish(&plan.primary).unwrap(); + + let remote_head = Command::new("git") + .arg("-C") + .arg(remote_dir.path()) + .args(["rev-parse", "refs/heads/feat"]) + .output() + .unwrap(); + assert!(remote_head.status.success()); + let local_head = Command::new("jj") + .args(["log", "--no-graph", "-r", "@-", "-T", "commit_id"]) + .current_dir(repo_dir.path()) + .output() + .unwrap(); + assert!(local_head.status.success()); + assert_eq!( + String::from_utf8_lossy(&remote_head.stdout).trim(), + String::from_utf8_lossy(&local_head.stdout).trim(), + "remote feat should fast-forward to the published commit" + ); + } + fn init_jj_repo() -> Option { + init_jj_repo_with(true) + } + + fn init_jj_repo_with(colocate: bool) -> Option { if Command::new("jj").arg("--version").output().is_err() { return None; } let repo_dir = TempDir::new().unwrap(); - let status = Command::new("jj") - .arg("--quiet") + let mut init = Command::new("jj"); + init.arg("--quiet"); + if colocate { + init.arg("--config").arg("git.colocate=true"); + } else { + init.arg("--config").arg("git.colocate=false"); + } + let status = init .arg("git") .arg("init") .arg(repo_dir.path()) diff --git a/src/core/vcs/mod.rs b/src/core/vcs/mod.rs index 0eb715cf..4486111a 100644 --- a/src/core/vcs/mod.rs +++ b/src/core/vcs/mod.rs @@ -1,4 +1,5 @@ pub mod backend; +pub mod cache; pub mod discovery; pub mod git; pub mod jj; diff --git a/src/core/vcs/model.rs b/src/core/vcs/model.rs index 0d58f281..3d09f1d1 100644 --- a/src/core/vcs/model.rs +++ b/src/core/vcs/model.rs @@ -415,6 +415,9 @@ pub enum PublishActionKind { bookmark: String, revision: String, allow_backwards: bool, + /// When the bookmark only exists on this remote, track it first so a + /// local bookmark exists to move (jj's analog of a checked-out branch). + track_remote: Option, }, CreateBookmarkAndPush { remote: String, @@ -446,6 +449,18 @@ pub enum VcsCompareSpec { MergeBaseRange { base: String, head: String }, } +impl VcsCompareSpec { + pub fn refs(&self) -> impl Iterator { + let (left, right) = match self { + Self::WorkingCopy => (None, None), + Self::Change { revision } => (Some(revision.as_str()), None), + Self::Range { from, to } => (Some(from.as_str()), Some(to.as_str())), + Self::MergeBaseRange { base, head } => (Some(base.as_str()), Some(head.as_str())), + }; + left.into_iter().chain(right) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct VcsCompareRequest { pub spec: VcsCompareSpec, diff --git a/src/editor/diff/element/hit_test.rs b/src/editor/diff/element/hit_test.rs new file mode 100644 index 00000000..c00987b1 --- /dev/null +++ b/src/editor/diff/element/hit_test.rs @@ -0,0 +1,703 @@ +use crate::actions::{Action, ContextMenuEntry}; +use crate::editor::diff::anchor::{EditorOverlayKind, ResolvedEditorOverlay}; +use crate::editor::diff::decoration::BlockActionCtx; +use crate::editor::diff::render_doc::{ + ByteRange, DisplayRow, RenderDoc, RenderLine, RenderRowKind, +}; +use crate::editor::diff::state::{ + EditorState, LineSelection, ViewportTextPoint, ViewportTextSelection, ViewportTextSide, +}; +use crate::render::Rect; + +use super::layout::{editor_scale, scaled}; +use super::paint::unified_body_side_with_side; +use super::{CachedTextLayout, EditorElement, EditorLayout, TextBlock}; + +impl EditorElement { + pub fn hit_test_row(&self, state: &EditorState, x: f32, y: f32) -> Option { + if !self.layout.content_bounds.contains(x, y) { + return None; + } + let content_y = (y - self.layout.content_bounds.y).max(0.0) + state.scroll_top_px as f32; + let index = self + .rows + .partition_point(|row| row.bottom_px() as f32 <= content_y); + self.rows.get(index).and_then(|row| { + (content_y >= row.y_px as f32 && content_y < row.bottom_px() as f32).then_some(index) + }) + } + + pub fn is_gutter_hit(&self, x: f32, _y: f32) -> bool { + if self.layout.split_mode { + self.layout + .left_gutter_rect + .contains(x, self.layout.left_gutter_rect.y) + } else { + self.layout + .unified_gutter_rect + .contains(x, self.layout.unified_gutter_rect.y) + } + } + + pub fn review_comment_line_for_row( + &self, + doc: &RenderDoc, + display_row_index: usize, + ) -> Option { + let row = self.rows.get(display_row_index)?; + if row.is_block() { + return None; + } + let line = doc.lines.get(row.line_index as usize)?; + review_comment_gutter_rect(&self.layout, line)?; + Some(row.line_index as usize) + } + + pub fn review_add_button_overlay( + &self, + state: &EditorState, + doc: &RenderDoc, + clip: Rect, + ) -> Option { + if !state.review_enabled { + return None; + } + let row_index = self.layout.highlighted_row?; + self.review_add_button_overlay_for_row(state, doc, row_index, clip) + } + + pub fn review_add_button_overlay_at( + &self, + state: &EditorState, + doc: &RenderDoc, + x: f32, + y: f32, + clip: Rect, + ) -> Option { + if !state.review_enabled { + return None; + } + let row_index = self.hit_test_row(state, x, y)?; + let overlay = self.review_add_button_overlay_for_row(state, doc, row_index, clip)?; + overlay.contains(x, y).then_some(overlay) + } + + pub fn block_action_for_row_at( + &self, + display_row_index: usize, + x: f32, + y: f32, + ) -> Option { + let row = self.rows.get(display_row_index)?; + if !row.is_block() { + return None; + } + let block = self.blocks.get(row.block_index as usize)?; + block.on_click_at( + &BlockActionCtx { + layout: &self.layout, + row_rect: self.row_rect_for(row), + }, + x, + y, + ) + } + + pub fn block_context_menu_for_row( + &self, + display_row_index: usize, + ) -> Option> { + let row = self.rows.get(display_row_index)?; + if !row.is_block() { + return None; + } + self.blocks + .get(row.block_index as usize)? + .context_menu_entries() + } + + pub fn hunk_action_bar_rect(&self, doc: &RenderDoc) -> Option<(Rect, i16)> { + let idx = self.layout.highlighted_row?; + let display_row = self.rows.get(idx)?; + if display_row.is_block() { + return None; + } + let line = doc.lines.get(display_row.line_index as usize)?; + if line.hunk_index < 0 { + return None; + } + let hunk_index = line.hunk_index; + + let mut first_idx = idx; + while first_idx > 0 { + let prev = self.rows.get(first_idx - 1)?; + if prev.is_block() { + break; + } + let prev_line = doc.lines.get(prev.line_index as usize)?; + if prev_line.hunk_index != hunk_index { + break; + } + first_idx -= 1; + } + + let mut last_idx = idx; + while last_idx + 1 < self.rows.len() { + let next = &self.rows[last_idx + 1]; + if next.is_block() { + break; + } + let Some(next_line) = doc.lines.get(next.line_index as usize) else { + break; + }; + if next_line.hunk_index != hunk_index { + break; + } + last_idx += 1; + } + + let first_rect = self.row_rect_for(&self.rows[first_idx]); + let last_rect = self.row_rect_for(&self.rows[last_idx]); + let row_h = first_rect.height; + let viewport_top = self.layout.content_bounds.y; + let viewport_bottom = self.layout.content_bounds.bottom(); + + let max_y = (last_rect.y + last_rect.height - row_h).max(first_rect.y); + let y = first_rect.y.max(viewport_top).min(max_y); + if y + row_h <= viewport_top || y >= viewport_bottom { + return None; + } + + // The bar floats on the hunk separator row, which spans the full + // content width in both split and unified modes. Use the full text span + // so the buttons right-align against the editor edge, not a column. + let (x, width) = if self.layout.split_mode { + let left = self.layout.left_text_rect.x; + let right = self.layout.right_text_rect.x + self.layout.right_text_rect.width; + (left, right - left) + } else { + ( + self.layout.unified_text_rect.x, + self.layout.unified_text_rect.width, + ) + }; + Some(( + Rect { + x, + y, + width, + height: row_h, + }, + hunk_index, + )) + } + + pub fn line_selection_bar_rect(&self, doc: &RenderDoc, state: &EditorState) -> Option { + if state.line_selection.is_empty() { + return None; + } + + let is_selected = |row: &DisplayRow| -> bool { + if row.is_block() { + return false; + } + let Some(line) = doc.lines.get(row.line_index as usize) else { + return false; + }; + if !matches!( + line.row_kind(), + RenderRowKind::Added | RenderRowKind::Removed | RenderRowKind::Modified + ) { + return false; + } + line_selection_contains_line( + &state.line_selection, + file_path_for_line(doc, row.line_index as usize), + line, + ) + }; + + let first = self.rows.iter().find(|r| is_selected(r))?; + let last = self.rows.iter().rev().find(|r| is_selected(r))?; + + let first_rect = self.row_rect_for(first); + let last_rect = self.row_rect_for(last); + let last_bottom = last_rect.y + last_rect.height; + let viewport_top = self.layout.content_bounds.y; + let viewport_bottom = self.layout.content_bounds.bottom(); + + // Hide entirely when the selection is fully outside the viewport. + if last_bottom <= viewport_top || first_rect.y >= viewport_bottom { + return None; + } + + let bar_h = first_rect.height; + // Float the bar above the first selected row. Once the user scrolls + // past that row the bar stays pinned to the viewport top, acting like + // a sticky header over the selection — no jumps, no disappearing. + let above_y = first_rect.y - bar_h; + let y = above_y.max(viewport_top); + // If even the sticky position would sit past the last selected row, + // the selection no longer covers enough area to anchor the bar. + if y >= last_bottom { + return None; + } + + // Span the full content width in both modes so the buttons right-align + // against the editor edge, never pinned to a narrow column. + let (x, width) = if self.layout.split_mode { + let left = self.layout.left_text_rect.x; + let right = self.layout.right_text_rect.x + self.layout.right_text_rect.width; + (left, right - left) + } else { + ( + self.layout.unified_text_rect.x, + self.layout.unified_text_rect.width, + ) + }; + Some(Rect { + x, + y, + width, + height: bar_h, + }) + } + + fn review_add_comment_button_rect_for_row( + &self, + line: &RenderLine, + display_row: &DisplayRow, + ) -> Option { + let gutter = review_comment_gutter_rect(&self.layout, line)?; + let row_rect = self.row_rect_for(display_row); + if !self.row_in_viewport(&row_rect) { + return None; + } + let s = editor_scale(self.text_metrics); + let size = (self.layout.line_height * 0.72).clamp(scaled(14.0, s), scaled(20.0, s)); + // Straddle the gutter→code divider like GitHub: centered on the boundary, + // but never reaching left past the line number's right edge. + let x = (gutter.right() - size * 0.5).max(gutter.right() - self.layout.gutter_padding); + Some(Rect { + x, + y: row_rect.y + (self.layout.line_height - size).max(0.0) * 0.5, + width: size, + height: size, + }) + } + + fn review_add_button_overlay_for_row( + &self, + state: &EditorState, + doc: &RenderDoc, + row_index: usize, + clip: Rect, + ) -> Option { + let display_row = self.rows.get(row_index).copied()?; + if display_row.is_block() { + return None; + } + let line = doc.lines.get(display_row.line_index as usize)?; + let rect = self.review_add_comment_button_rect_for_row(line, &display_row)?; + let emphasised = state.review_add_hovered + || (!state.line_selection.is_empty() + && line_selection_contains_line( + &state.line_selection, + file_path_for_line(doc, display_row.line_index as usize), + line, + )); + ResolvedEditorOverlay::new( + EditorOverlayKind::ReviewAddButton { + line_index: display_row.line_index as usize, + emphasised, + }, + rect, + clip, + ) + } + + pub fn file_header_path_at(&self, x: f32, y: f32) -> Option { + if let Some((rect, path)) = self.sticky_header_hit.as_ref() + && rect.contains(x, y) + { + return Some(path.clone()); + } + if !self.layout.content_bounds.contains(x, y) { + return None; + } + let content_y = (y - self.layout.content_bounds.y).max(0.0) + self.layout.scroll_top_px; + for hit in &self.file_header_hits { + let top = hit.y_px as f32; + let bottom = top + hit.h_px as f32; + if content_y >= top && content_y < bottom { + return Some(hit.path.clone()); + } + } + None + } + + pub fn hit_test_text_point( + &self, + state: &EditorState, + doc: &RenderDoc, + x: f32, + y: f32, + ) -> Option { + let row_index = self.hit_test_row(state, x, y)?; + let display_row = self.rows.get(row_index).copied()?; + if display_row.is_block() { + return None; + } + let line = doc.lines.get(display_row.line_index as usize)?; + if !line.row_kind().is_body() { + return None; + } + let row_rect = self.row_rect_for(&display_row); + let blocks = self.text_blocks_for_line(line, &display_row, row_rect); + blocks + .into_iter() + .flatten() + .filter(|block| { + let bottom = block.y + block.segment_count.max(1) as f32 * self.layout.line_height; + y >= block.y && y < bottom + }) + .min_by(|a, b| { + distance_to_text_block_x(*a, x) + .partial_cmp(&distance_to_text_block_x(*b, x)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|block| { + let text = doc.line_text(block.text_range); + let layout = CachedTextLayout::new(text); + let segment = ((y - block.y) / self.layout.line_height.max(1.0)) + .floor() + .max(0.0) as u32; + let segment = segment.min(u32::from(block.segment_count.max(1).saturating_sub(1))); + let local_col = + ((x - block.text_x) / self.text_metrics.mono_char_width_px.max(1.0)).max(0.0); + let col = local_col + segment.saturating_mul(u32::from(block.segment_cols)) as f32; + ViewportTextPoint { + line_index: block.line_index, + side: block.side, + byte_offset: layout.byte_for_col_nearest(col), + } + }) + } + + pub fn viewport_selection_text( + &self, + doc: &RenderDoc, + selection: &ViewportTextSelection, + ) -> Option { + if selection.is_collapsed() { + return None; + } + let mut copied = String::new(); + let (start, end) = selection.normalized(); + for line_index in start.line_index..=end.line_index { + let Some(line) = doc.lines.get(line_index as usize) else { + continue; + }; + for (side, range) in text_side_ranges_for_line(self.layout.split_mode, line) + .into_iter() + .flatten() + { + let text = doc.line_text(range); + let Some((byte_start, byte_end)) = selection_byte_range_for_side( + selection, + self.layout.split_mode, + line_index, + side, + text, + ) else { + continue; + }; + if byte_end <= byte_start { + continue; + } + if !copied.is_empty() { + copied.push('\n'); + } + copied.push_str(&text[byte_start..byte_end]); + } + } + (!copied.is_empty()).then_some(copied) + } + + pub fn viewport_line_text_at_point( + &self, + doc: &RenderDoc, + point: ViewportTextPoint, + ) -> Option { + let line = doc.lines.get(point.line_index as usize)?; + let range = match point.side { + ViewportTextSide::Left => line.left_text, + ViewportTextSide::Right => line.right_text, + }; + range.is_valid().then(|| doc.line_text(range).to_owned()) + } + + pub(super) fn text_blocks_for_line( + &self, + line: &RenderLine, + display_row: &DisplayRow, + row_rect: Rect, + ) -> [Option; 2] { + let mut blocks = [None, None]; + let mut next = 0_usize; + let mut push_block = |block: TextBlock| { + if next < blocks.len() { + blocks[next] = Some(block); + next += 1; + } + }; + + let line_height = self.layout.line_height; + if self.layout.split_mode { + let segment_cols = self.render_cols_split(); + if line.left_text.is_valid() { + push_block(TextBlock { + line_index: display_row.line_index, + side: ViewportTextSide::Left, + text_range: line.left_text, + text_x: self.layout.left_text_rect.x, + text_width: self.layout.left_text_rect.width, + y: row_rect.y, + segment_count: if self.config.wrap_enabled { + display_row.wrap_left.max(1) + } else { + 1 + }, + segment_cols, + }); + } + if line.right_text.is_valid() { + push_block(TextBlock { + line_index: display_row.line_index, + side: ViewportTextSide::Right, + text_range: line.right_text, + text_x: self.layout.right_text_rect.x, + text_width: self.layout.right_text_rect.width, + y: row_rect.y, + segment_count: if self.config.wrap_enabled { + display_row.wrap_right.max(1) + } else { + 1 + }, + segment_cols, + }); + } + return blocks; + } + + let segment_cols = self.render_cols_unified(); + if line.row_kind() == RenderRowKind::Modified + && line.left_text.is_valid() + && line.right_text.is_valid() + { + let left_segments = if self.config.wrap_enabled { + display_row.wrap_left.max(1) + } else { + 1 + }; + push_block(TextBlock { + line_index: display_row.line_index, + side: ViewportTextSide::Left, + text_range: line.left_text, + text_x: self.layout.unified_text_rect.x, + text_width: self.layout.unified_text_rect.width, + y: row_rect.y, + segment_count: left_segments, + segment_cols, + }); + push_block(TextBlock { + line_index: display_row.line_index, + side: ViewportTextSide::Right, + text_range: line.right_text, + text_x: self.layout.unified_text_rect.x, + text_width: self.layout.unified_text_rect.width, + y: row_rect.y + left_segments as f32 * line_height, + segment_count: if self.config.wrap_enabled { + display_row.wrap_right.max(1) + } else { + 1 + }, + segment_cols, + }); + } else if let Some((side, text_range, _, _)) = unified_body_side_with_side(line) { + push_block(TextBlock { + line_index: display_row.line_index, + side, + text_range, + text_x: self.layout.unified_text_rect.x, + text_width: self.layout.unified_text_rect.width, + y: row_rect.y, + segment_count: if self.config.wrap_enabled { + display_row.wrap_left.max(1) + } else { + 1 + }, + segment_cols, + }); + } + blocks + } +} + +pub(super) fn line_selection_contains_line( + selection: &LineSelection, + file_path: Option<&str>, + line: &RenderLine, +) -> bool { + let Ok(hunk_id) = u32::try_from(line.hunk_index) else { + return false; + }; + (line.old_line_index >= 0 + && selection.contains_in_file( + file_path, + hunk_id, + carbon::DiffSide::Old, + line.old_line_index as u32, + )) + || (line.new_line_index >= 0 + && selection.contains_in_file( + file_path, + hunk_id, + carbon::DiffSide::New, + line.new_line_index as u32, + )) +} + +pub(super) fn file_path_for_line(doc: &RenderDoc, line_index: usize) -> Option<&str> { + doc.lines.get(..=line_index)?.iter().rev().find_map(|line| { + if line.row_kind() == RenderRowKind::FileHeader { + doc.file_meta(line).map(|meta| meta.path.as_str()) + } else { + None + } + }) +} + +fn review_comment_gutter_rect(layout: &EditorLayout, line: &RenderLine) -> Option { + // Any body line is commentable (incl. context) — selected_review_range maps it + // to the new side, or the old side for removed-only lines. + if line.hunk_index < 0 || !line.row_kind().is_body() { + return None; + } + match line.row_kind() { + RenderRowKind::Removed if layout.split_mode => Some(layout.left_gutter_rect), + _ if layout.split_mode => Some(layout.right_gutter_rect), + _ => Some(layout.unified_gutter_rect), + } +} + +fn distance_to_text_block_x(block: TextBlock, x: f32) -> f32 { + if x < block.text_x { + block.text_x - x + } else if x > block.text_x + block.text_width { + x - (block.text_x + block.text_width) + } else { + 0.0 + } +} + +fn text_side_ranges_for_line( + split_mode: bool, + line: &RenderLine, +) -> [Option<(ViewportTextSide, ByteRange)>; 2] { + let mut ranges = [None, None]; + if !line.row_kind().is_body() { + return ranges; + } + let mut next = 0_usize; + let mut push = |side, range: ByteRange| { + if range.is_valid() && next < ranges.len() { + ranges[next] = Some((side, range)); + next += 1; + } + }; + + if split_mode { + push(ViewportTextSide::Left, line.left_text); + push(ViewportTextSide::Right, line.right_text); + } else if line.row_kind() == RenderRowKind::Modified + && line.left_text.is_valid() + && line.right_text.is_valid() + { + push(ViewportTextSide::Left, line.left_text); + push(ViewportTextSide::Right, line.right_text); + } else if let Some((side, range, _, _)) = unified_body_side_with_side(line) { + push(side, range); + } + ranges +} + +pub(super) fn selection_byte_range_for_side( + selection: &ViewportTextSelection, + split_mode: bool, + line_index: u32, + side: ViewportTextSide, + text: &str, +) -> Option<(usize, usize)> { + let (start, end) = selection_bounds_for_side(selection, split_mode, side)?; + let text_len = text.len(); + let text_len_u32 = text_len.min(u32::MAX as usize) as u32; + let side_start = ViewportTextPoint { + line_index, + side, + byte_offset: 0, + }; + let side_end = ViewportTextPoint { + line_index, + side, + byte_offset: text_len_u32, + }; + if side_end <= start || side_start >= end { + return None; + } + + let byte_start = if start.line_index == line_index && start.side == side { + start.byte_offset.min(text_len_u32) + } else { + 0 + }; + let byte_end = if end.line_index == line_index && end.side == side { + end.byte_offset.min(text_len_u32) + } else { + text_len_u32 + }; + let byte_start = previous_char_boundary(text, byte_start as usize); + let byte_end = previous_char_boundary(text, byte_end as usize); + (byte_end > byte_start).then_some((byte_start, byte_end)) +} + +fn selection_bounds_for_side( + selection: &ViewportTextSelection, + split_mode: bool, + side: ViewportTextSide, +) -> Option<(ViewportTextPoint, ViewportTextPoint)> { + if !split_mode { + return Some(selection.normalized()); + } + if selection.anchor.side != side { + return None; + } + let anchor = selection.anchor; + let focus = ViewportTextPoint { + side, + ..selection.focus + }; + Some(if anchor <= focus { + (anchor, focus) + } else { + (focus, anchor) + }) +} + +fn previous_char_boundary(text: &str, byte: usize) -> usize { + let mut byte = byte.min(text.len()); + while byte > 0 && !text.is_char_boundary(byte) { + byte -= 1; + } + byte +} diff --git a/src/editor/diff/element/layout.rs b/src/editor/diff/element/layout.rs new file mode 100644 index 00000000..e9ec04de --- /dev/null +++ b/src/editor/diff/element/layout.rs @@ -0,0 +1,605 @@ +use std::ops::Range; +use std::sync::Arc; + +use crate::core::compare::LayoutMode; +use crate::editor::diff::anchor::{EditorOverlayKind, ResolvedEditorOverlay}; +use crate::editor::diff::display_layout::{ + DisplayLayoutConfig, DisplayLayoutMetrics, compute_gutter_digits, rebuild_display_rows, +}; +use crate::editor::diff::render_doc::{DisplayRow, RenderDoc, RenderRowKind}; +use crate::editor::diff::state::EditorState; +use crate::editor::diff::strip_layout::{build_strip_layouts, visible_strip_range}; +use crate::render::{Rect, TextMetrics}; + +use super::{ + BASE_MONO_FONT_SIZE, EditorDocument, EditorElement, EditorLayout, EditorLayoutKey, + FileHeaderHit, ScrollbarLayout, ScrollbarOverride, VisibleRange, +}; + +const BASE_VIEWPORT_PADDING: f32 = 14.0; +const BASE_COLUMN_GAP: f32 = 18.0; +const BASE_GUTTER_PADDING: f32 = 8.0; +const BASE_SCROLLBAR_WIDTH: f32 = 8.0; +const BASE_SCROLLBAR_MARGIN: f32 = 6.0; +const FILE_HEADER_ROW_MULTIPLE: u16 = 1; +const HUNK_ROW_MULTIPLE: u16 = 1; +const BASE_SCROLLBAR_THUMB_MIN: f32 = 32.0; + +const STRIP_TARGET_HEIGHT_PX: u32 = 480; +const STRIP_OVERSCAN: usize = 1; +const UNWRAPPED_RENDER_OVERSCAN_COLS: u16 = 16; + +pub(super) fn editor_scale(text_metrics: TextMetrics) -> f32 { + (text_metrics.mono_font_size_px / BASE_MONO_FONT_SIZE).max(0.5) +} + +fn display_layout_metrics(text_metrics: TextMetrics) -> DisplayLayoutMetrics { + let body_h = text_metrics.mono_line_height_px.round().max(1.0) as u16; + DisplayLayoutMetrics { + body_row_height_px: body_h, + file_header_height_px: body_h * FILE_HEADER_ROW_MULTIPLE, + hunk_height_px: body_h * HUNK_ROW_MULTIPLE, + } +} + +pub(super) fn scaled(base: f32, scale: f32) -> f32 { + base * scale +} + +fn content_bounds_for_viewport(bounds: Rect, text_metrics: TextMetrics) -> Rect { + bounds.inset(scaled(BASE_VIEWPORT_PADDING, editor_scale(text_metrics))) +} + +pub(super) fn editor_bottom_padding_px(metrics: DisplayLayoutMetrics) -> u32 { + u32::from(metrics.body_row_height_px) +} + +impl EditorElement { + pub fn prepare( + &mut self, + state: &mut EditorState, + document: EditorDocument<'_>, + bounds: Rect, + text_metrics: TextMetrics, + ) -> EditorLayout { + self.text_metrics = text_metrics; + let gutter_digits = match document { + EditorDocument::Text { doc, .. } => compute_gutter_digits(doc), + _ => 3, + }; + self.layout = build_spatial_layout(bounds, state.layout, gutter_digits, text_metrics); + state.viewport_width_px = self.layout.content_bounds.width.max(0.0).round() as u32; + state.viewport_height_px = self.layout.content_bounds.height.max(0.0).round() as u32; + + let s = editor_scale(text_metrics); + self.layout.font_size = text_metrics.mono_font_size_px; + self.layout.line_height = self.metrics.body_row_height_px as f32; + let glyphon_line_h = text_metrics.mono_font_size_px * 1.35; + self.layout.text_y_offset = ((self.layout.line_height - glyphon_line_h) * 0.5).max(0.0); + self.layout.gutter_padding = scaled(BASE_GUTTER_PADDING, s); + self.layout.column_gap = scaled(BASE_COLUMN_GAP, s); + + match document { + EditorDocument::Text { + compare_generation, + file_index, + doc, + show_file_headers, + .. + } => { + let key = EditorLayoutKey { + compare_generation, + file_index, + show_file_headers, + split_mode: state.layout == LayoutMode::Split, + wrap_enabled: state.wrap_enabled, + wrap_column: state.wrap_column, + viewport_width_bits: self.layout.content_bounds.width.to_bits(), + viewport_height_bits: self.layout.content_bounds.height.to_bits(), + mono_char_width_bits: text_metrics.mono_char_width_px.to_bits(), + mono_line_height_bits: text_metrics.mono_line_height_px.to_bits(), + doc_line_count: doc.line_count() as u32, + doc_text_len: doc.text_bytes.len().min(u32::MAX as usize) as u32, + block_layout_signature: self + .blocks + .layout_signature(display_layout_metrics(text_metrics)), + }; + + if self.layout_key != Some(key) { + self.rebuild_rows(doc, state, text_metrics, show_file_headers); + self.clear_document_caches(); + self.layout_key = Some(key); + } + + // Stamp the generation this geometry belongs to. When a new + // compare generation replaces the document, the carried-over + // scroll offset may point past geometry that no longer + // exists; the `clamp_scroll` below re-clamps it against the + // freshly built layout. Scroll is intentionally not reset + // here: per-file resets (and continuous-scroll restore) are + // owned by the reducer so a recompare of the same file keeps + // the user's place. + state.doc_generation = compare_generation; + + state.content_height_px = self + .summary + .content_height_px + .saturating_add(editor_bottom_padding_px(self.metrics)); + self.rebuild_navigation_positions(state); + state.clamp_scroll(); + self.update_visible_ranges(state); + + self.layout.line_height = self.metrics.body_row_height_px as f32; + } + _ => { + self.layout_key = None; + self.rows.clear(); + self.strips.clear(); + self.clear_document_caches(); + state.clear_document(); + } + } + + self.layout.scroll_top_px = state.scroll_top_px as f32; + self.layout.highlighted_row = state.hovered_row; + self.layout.scrollbar = + compute_scrollbar_layout(&self.layout, state, self.scrollbar_override); + + self.layout + } + + pub fn body_bounds(&self) -> Rect { + self.layout.content_bounds + } + + /// Pins the single card-width formula used by the review-thread overlay. Mirrors + /// the inset `build_spatial_layout` applies to produce `content_bounds.width`, so a + /// caller can derive the width BEFORE `prepare` runs (it needs the width to measure + /// card heights before blocks are populated). `text_metrics` is passed explicitly + /// rather than read from `self` so it does not depend on prepare-ordering. + pub fn content_width_for_bounds(&self, bounds: Rect, text_metrics: TextMetrics) -> f32 { + content_bounds_for_viewport(bounds, text_metrics).width + } + + pub fn content_height_for_bounds(&self, bounds: Rect, text_metrics: TextMetrics) -> f32 { + content_bounds_for_viewport(bounds, text_metrics).height + } + + /// Top edge band occupied by the sticky file header, if any, so overlays can avoid + /// painting/clicking over it. + pub fn sticky_header_rect(&self) -> Option { + self.sticky_header_hit.as_ref().map(|(rect, _)| *rect) + } + + pub fn overlay_clip_rect(&self, viewport_bounds: Rect) -> Rect { + match self.sticky_header_rect() { + Some(header) => Rect { + x: viewport_bounds.x, + y: header.bottom(), + width: viewport_bounds.width, + height: (viewport_bounds.bottom() - header.bottom()).max(0.0), + }, + None => viewport_bounds, + } + } + + /// Visible review block rows resolved into the same overlay rects used for both + /// rendering and clipping their element hit regions. + pub fn visible_review_block_overlays( + &self, + overlay_width: f32, + clip: Rect, + ) -> Vec { + let mut out = Vec::new(); + for row in &self.rows { + if !row.is_block() { + continue; + } + let rect = self.row_rect_for(row); + if !self.row_in_viewport(&rect) { + continue; + } + let block_index = row.block_index as usize; + let Some(block) = self.blocks.get(block_index) else { + continue; + }; + let kind = if block.is_composer() { + EditorOverlayKind::ReviewComposerBlock { block_index } + } else if block.review_card().is_some() { + EditorOverlayKind::ReviewThreadBlock { block_index } + } else { + continue; + }; + // Start the card/composer at the code column so the line-number gutter + // stays visible beside it (like GitHub), rather than covering it. + let code_x = if self.layout.split_mode { + self.layout.left_text_rect.x + } else { + self.layout.unified_text_rect.x + }; + let overlay_rect = Rect { + x: code_x.max(rect.x), + y: rect.y, + width: overlay_width, + height: rect.height, + }; + if let Some(overlay) = ResolvedEditorOverlay::new(kind, overlay_rect, clip) { + out.push(overlay); + } + } + out + } + + pub fn render_line_index_for_row(&self, display_row_index: usize) -> Option { + let row = self.rows.get(display_row_index)?; + if row.is_block() { + return None; + } + Some(row.line_index) + } + + pub fn is_block_row(&self, display_row_index: usize) -> bool { + self.rows + .get(display_row_index) + .is_some_and(|row| row.is_block()) + } + + fn rebuild_rows( + &mut self, + doc: &RenderDoc, + state: &EditorState, + text_metrics: TextMetrics, + show_file_headers: bool, + ) { + self.metrics = display_layout_metrics(text_metrics); + self.config = DisplayLayoutConfig { + split_mode: state.layout == LayoutMode::Split, + wrap_enabled: state.wrap_enabled, + wrap_column: state.wrap_column, + show_file_headers, + char_width_px: text_metrics.mono_char_width_px as f64, + unified_text_width_px: self.layout.unified_text_rect.width as f64, + split_text_width_px: self.layout.left_text_rect.width as f64, + }; + self.summary = + rebuild_display_rows(doc, self.config, self.metrics, &self.blocks, &mut self.rows); + build_strip_layouts(&self.rows, STRIP_TARGET_HEIGHT_PX, &mut self.strips); + self.file_header_hits.clear(); + if show_file_headers { + for row in &self.rows { + if row.kind != RenderRowKind::FileHeader as u8 { + continue; + } + let Some(line) = doc.lines.get(row.line_index as usize) else { + continue; + }; + if let Some(meta) = doc.file_meta(line) { + self.file_header_hits.push(FileHeaderHit { + y_px: row.y_px, + h_px: row.h_px, + path: meta.path.clone(), + }); + } + } + } + } + + fn rebuild_navigation_positions(&mut self, state: &mut EditorState) { + if !self.nav_positions_valid { + let mut hunk_positions = Vec::new(); + let mut file_positions = Vec::new(); + for row in &self.rows { + if row.kind == RenderRowKind::HunkSeparator as u8 { + hunk_positions.push(row.y_px); + } else if row.kind == RenderRowKind::FileHeader as u8 { + file_positions.push(row.y_px); + } + } + self.nav_hunk_positions = Arc::new(hunk_positions); + self.nav_file_positions = Arc::new(file_positions); + // Search Y positions are derived from row geometry too. + self.nav_search_matches = None; + self.nav_positions_valid = true; + } + state.hunk_positions = Arc::clone(&self.nav_hunk_positions); + state.file_positions = Arc::clone(&self.nav_file_positions); + + if state.search.open && !state.search.matches.is_empty() { + let cached = self + .nav_search_matches + .as_ref() + .is_some_and(|matches| Arc::ptr_eq(matches, &state.search.matches)); + if !cached { + let mut y_positions = Vec::with_capacity(state.search.matches.len()); + for m in state.search.matches.iter() { + let y = self + .rows + .iter() + .find(|r| !r.is_block() && r.line_index == m.line_index) + .map(|r| r.y_px) + .unwrap_or(0); + y_positions.push(y); + } + self.nav_search_y_positions = Arc::new(y_positions); + self.nav_search_matches = Some(Arc::clone(&state.search.matches)); + } + state.search_match_y_positions = Arc::clone(&self.nav_search_y_positions); + } else { + self.nav_search_matches = None; + if !state.search_match_y_positions.is_empty() { + state.search_match_y_positions = Arc::default(); + } + } + } + + fn update_visible_ranges(&mut self, state: &mut EditorState) { + let viewport_top_px = state.scroll_top_px; + let viewport_height_px = state.viewport_height_px.max(1); + let strip_range = visible_strip_range( + &self.strips, + viewport_top_px, + viewport_height_px, + STRIP_OVERSCAN, + ); + self.layout.visible_row_range = if strip_range.is_empty() { + VisibleRange::default() + } else { + let first = self.strips[strip_range.start].row_start; + let last = self.strips[strip_range.end - 1].row_end; + VisibleRange { + start: first, + end: last, + } + }; + + let visible_bottom_px = viewport_top_px.saturating_add(viewport_height_px); + let visible_start = self + .rows + .partition_point(|row| row.bottom_px() <= viewport_top_px); + let visible_end = self + .rows + .partition_point(|row| row.y_px < visible_bottom_px); + if visible_start < visible_end { + state.visible_row_start = Some(visible_start); + state.visible_row_end = Some(visible_end); + } else { + state.visible_row_start = None; + state.visible_row_end = None; + } + } + + pub(super) fn row_rect_for(&self, display_row: &DisplayRow) -> Rect { + Rect { + x: self.layout.content_bounds.x, + y: self.layout.content_bounds.y + display_row.y_px as f32 - self.layout.scroll_top_px, + width: self.layout.content_bounds.width, + height: display_row.h_px as f32, + } + } + + pub(super) fn row_in_viewport(&self, row_rect: &Rect) -> bool { + row_rect.bottom() >= self.layout.content_bounds.y + && row_rect.y <= self.layout.content_bounds.bottom() + } + + pub(super) fn render_cols_unified(&self) -> u16 { + render_cols_for_width( + self.config.wrap_enabled, + self.config.wrap_column, + self.config.char_width_px as f32, + self.layout.unified_text_rect.width, + ) + } + + pub(super) fn render_cols_split(&self) -> u16 { + render_cols_for_width( + self.config.wrap_enabled, + self.config.wrap_column, + self.config.char_width_px as f32, + self.layout.left_text_rect.width, + ) + } + + pub(super) fn visible_segment_range(&self, block_y: f32, segment_count: u16) -> Range { + visible_segment_range_for_block( + block_y, + segment_count.max(1), + self.layout.line_height, + self.layout.content_bounds.y, + self.layout.content_bounds.bottom(), + ) + } +} + +fn compute_scrollbar_layout( + layout: &EditorLayout, + state: &EditorState, + override_metrics: Option, +) -> Option { + if state.viewport_height_px == 0 { + return None; + } + let (content_height_px, scroll_top_px, max_scroll_top_px) = match override_metrics { + Some(o) => (o.total_height_px, o.scroll_top_px, o.max_scroll_top_px), + None => ( + state.content_height_px, + state.scroll_top_px, + state.max_scroll_top_px(), + ), + }; + if content_height_px <= state.viewport_height_px { + return None; + } + let s = layout.font_size / BASE_MONO_FONT_SIZE; + let sb_width = scaled(BASE_SCROLLBAR_WIDTH, s); + let sb_margin = scaled(BASE_SCROLLBAR_MARGIN, s); + let cb = layout.content_bounds; + let track = Rect { + x: cb.right() - sb_width, + y: cb.y + sb_margin, + width: sb_width, + height: (cb.height - sb_margin * 2.0).max(0.0), + }; + let ratio = state.viewport_height_px as f32 / content_height_px as f32; + let thumb_min = scaled(BASE_SCROLLBAR_THUMB_MIN, s); + let thumb_height = (track.height * ratio).max(thumb_min).min(track.height); + let scroll_range = max_scroll_top_px.max(1) as f32; + let top_ratio = (scroll_top_px as f32 / scroll_range).clamp(0.0, 1.0); + let thumb_y = track.y + (track.height - thumb_height) * top_ratio; + Some(ScrollbarLayout { + track, + thumb_top: thumb_y, + thumb_height, + thumb: Rect { + x: track.x + 1.0, + y: thumb_y + 1.0, + width: track.width - 2.0, + height: thumb_height - 2.0, + }, + }) +} + +fn build_spatial_layout( + bounds: Rect, + layout: LayoutMode, + gutter_digits: u32, + text_metrics: TextMetrics, +) -> EditorLayout { + let s = editor_scale(text_metrics); + let column_gap = scaled(BASE_COLUMN_GAP, s); + let gutter_padding = scaled(BASE_GUTTER_PADDING, s); + let scrollbar_width = scaled(BASE_SCROLLBAR_WIDTH, s); + let scrollbar_margin = scaled(BASE_SCROLLBAR_MARGIN, s); + + let content_bounds = content_bounds_for_viewport(bounds, text_metrics); + let usable_width = (content_bounds.width - scrollbar_width - scrollbar_margin).max(0.0); + let gutter_width = + gutter_digits as f32 * text_metrics.mono_char_width_px + gutter_padding * 2.0; + let unified_gutter_width = gutter_digits as f32 * text_metrics.mono_char_width_px * 2.0 + + text_metrics.mono_char_width_px + + gutter_padding * 2.0; + + if layout == LayoutMode::Split { + let col_width = ((usable_width - gutter_width * 2.0 - column_gap) / 2.0).max(60.0); + let left_gutter_rect = Rect { + x: content_bounds.x, + y: content_bounds.y, + width: gutter_width, + height: content_bounds.height, + }; + let text_left_pad = gutter_padding; + let left_text_rect = Rect { + x: left_gutter_rect.right() + text_left_pad, + y: content_bounds.y, + width: (col_width - text_left_pad).max(60.0), + height: content_bounds.height, + }; + let right_gutter_rect = Rect { + x: left_gutter_rect.right() + col_width + column_gap, + y: content_bounds.y, + width: gutter_width, + height: content_bounds.height, + }; + let right_text_rect = Rect { + x: right_gutter_rect.right() + text_left_pad, + y: content_bounds.y, + width: (content_bounds.right() + - scrollbar_width + - scrollbar_margin + - right_gutter_rect.right() + - text_left_pad) + .max(60.0), + height: content_bounds.height, + }; + EditorLayout { + outer_bounds: bounds, + content_bounds, + split_mode: true, + gutter_digits, + unified_gutter_rect: Rect::default(), + unified_text_rect: Rect::default(), + left_gutter_rect, + left_text_rect, + right_gutter_rect, + right_text_rect, + ..EditorLayout::default() + } + } else { + let unified_gutter_rect = Rect { + x: content_bounds.x, + y: content_bounds.y, + width: unified_gutter_width, + height: content_bounds.height, + }; + let text_left_pad = gutter_padding; + let unified_text_rect = Rect { + x: unified_gutter_rect.right() + text_left_pad, + y: content_bounds.y, + width: (usable_width - unified_gutter_width - text_left_pad).max(60.0), + height: content_bounds.height, + }; + EditorLayout { + outer_bounds: bounds, + content_bounds, + split_mode: false, + gutter_digits, + unified_gutter_rect, + unified_text_rect, + ..EditorLayout::default() + } + } +} + +fn wrap_cols_for_width( + wrap_enabled: bool, + wrap_column: u32, + char_width_px: f32, + width_px: f32, +) -> u16 { + if !wrap_enabled { + return u16::MAX; + } + let width_cols = (width_px / char_width_px.max(1.0)).floor() as u32; + let cols = if wrap_column > 0 { + width_cols.min(wrap_column) + } else { + width_cols + }; + cols.max(1).min(u16::MAX as u32) as u16 +} + +pub(super) fn render_cols_for_width( + wrap_enabled: bool, + wrap_column: u32, + char_width_px: f32, + width_px: f32, +) -> u16 { + if wrap_enabled { + return wrap_cols_for_width(true, wrap_column, char_width_px, width_px); + } + + let visible_cols = (width_px / char_width_px.max(1.0)).ceil() as u32; + visible_cols + .saturating_add(u32::from(UNWRAPPED_RENDER_OVERSCAN_COLS)) + .max(1) + .min(u16::MAX as u32) as u16 +} + +pub(super) fn visible_segment_range_for_block( + block_y: f32, + segment_count: u16, + line_height: f32, + viewport_top: f32, + viewport_bottom: f32, +) -> Range { + if segment_count == 0 || line_height <= 0.0 { + return 0..0; + } + + let max_segments = u32::from(segment_count); + let start = ((viewport_top - block_y) / line_height).floor().max(0.0) as u32; + let end = ((viewport_bottom - block_y) / line_height).ceil().max(0.0) as u32; + let start = start.min(max_segments); + let end = end.max(start).min(max_segments); + start as u16..end as u16 +} diff --git a/src/editor/diff/element/mod.rs b/src/editor/diff/element/mod.rs new file mode 100644 index 00000000..2898f37b --- /dev/null +++ b/src/editor/diff/element/mod.rs @@ -0,0 +1,487 @@ +use std::{collections::HashMap, ops::Range, sync::Arc}; + +use crate::render::{Rect, RichTextSpan, TextMetrics}; +use crate::ui::theme::{Color, Theme}; + +use super::decoration::BlockRegistry; +use super::display_layout::{DisplayLayoutConfig, DisplayLayoutMetrics, DisplayLayoutSummary}; +use super::render_doc::{ + ByteRange, DisplayRow, INVALID_U32, RenderDoc, RunRange, advance_display_col, +}; +use super::state::{SearchMatch, ViewportTextSide}; +use super::strip_layout::StripLayout; + +mod hit_test; +mod layout; +mod paint; +#[cfg(test)] +mod tests; + +use paint::{RowTone, build_wrapped_rich_text}; + +pub(crate) const BASE_MONO_FONT_SIZE: f32 = 13.0; + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct EditorLayout { + pub outer_bounds: Rect, + pub content_bounds: Rect, + pub split_mode: bool, + pub gutter_digits: u32, + pub unified_gutter_rect: Rect, + pub unified_text_rect: Rect, + pub left_gutter_rect: Rect, + pub left_text_rect: Rect, + pub right_gutter_rect: Rect, + pub right_text_rect: Rect, + + pub line_height: f32, + pub font_size: f32, + pub text_y_offset: f32, + pub gutter_padding: f32, + pub column_gap: f32, + pub scroll_top_px: f32, + pub visible_row_range: VisibleRange, + pub highlighted_row: Option, + pub scrollbar: Option, + pub show_staging_controls: bool, + pub file_is_staged: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct VisibleRange { + pub start: usize, + pub end: usize, +} + +impl VisibleRange { + pub fn iter(&self) -> Range { + self.start..self.end + } + + pub fn is_empty(&self) -> bool { + self.start >= self.end + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct ScrollbarLayout { + pub track: Rect, + pub thumb: Rect, + pub thumb_top: f32, + pub thumb_height: f32, +} + +#[derive(Debug, Clone, Copy)] +pub enum EditorDocument<'a> { + Empty, + Loading { + path: &'a str, + }, + Binary { + path: &'a str, + }, + Text { + compare_generation: u64, + file_index: usize, + path: &'a str, + doc: &'a RenderDoc, + show_file_headers: bool, + }, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct EditorLayoutKey { + compare_generation: u64, + file_index: usize, + show_file_headers: bool, + split_mode: bool, + wrap_enabled: bool, + wrap_column: u32, + viewport_width_bits: u32, + viewport_height_bits: u32, + mono_char_width_bits: u32, + mono_line_height_bits: u32, + doc_line_count: u32, + doc_text_len: u32, + block_layout_signature: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EditorThemeKey { + text_strong: Color, + text_muted: Color, + accent: Color, + line_add_text: Color, + line_del_text: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct WrappedTextCacheKey { + text_start: u32, + text_len: u32, + runs_start: u32, + runs_len: u32, + segment_index: u16, + wrap_cols: u16, + tone: RowTone, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct GutterTextCacheKey { + old_line_no: u32, + new_line_no: u32, + digits: u32, + kind: GutterTextKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum GutterTextKind { + SplitLeft, + SplitRight, + Unified, + UnifiedOldOnly, + UnifiedNewOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct TextLayoutCacheKey { + text_start: u32, + text_len: u32, +} + +#[derive(Debug, Clone)] +struct CachedTextLayout { + char_boundaries: Arc<[u32]>, + col_boundaries: Arc<[u32]>, +} + +impl CachedTextLayout { + fn new(text: &str) -> Self { + let mut char_boundaries = Vec::with_capacity(text.chars().count().saturating_add(1)); + let mut col_boundaries = Vec::with_capacity(text.chars().count().saturating_add(1)); + let mut cols = 0_u32; + + for (idx, ch) in text.char_indices() { + char_boundaries.push(idx as u32); + col_boundaries.push(cols); + cols = advance_display_col(cols, ch); + } + char_boundaries.push(text.len() as u32); + col_boundaries.push(cols); + Self { + char_boundaries: Arc::from(char_boundaries), + col_boundaries: Arc::from(col_boundaries), + } + } + + fn char_count(&self) -> u32 { + self.char_boundaries.len().saturating_sub(1) as u32 + } + + fn total_cols(&self) -> u32 { + self.col_boundaries.last().copied().unwrap_or(0) + } + + fn char_range_for_cols(&self, start_col: u32, end_col: u32) -> (usize, usize) { + let total_cols = self.total_cols(); + let start_col = start_col.min(total_cols); + let end_col = end_col.min(total_cols).max(start_col); + let char_count = self.char_count() as usize; + let start = self + .col_boundaries + .partition_point(|boundary| *boundary <= start_col) + .saturating_sub(1) + .min(char_count); + let end = self + .col_boundaries + .partition_point(|boundary| *boundary < end_col) + .min(char_count); + (start, end.max(start)) + } + + #[cfg(test)] + fn byte_range_for_cols(&self, start_col: u32, end_col: u32) -> (usize, usize) { + let (start, end) = self.char_range_for_cols(start_col, end_col); + ( + self.char_boundaries[start] as usize, + self.char_boundaries[end] as usize, + ) + } + + fn col_for_byte(&self, byte: usize) -> u32 { + let byte = (byte as u32).min(self.char_boundaries.last().copied().unwrap_or(0)); + let idx = self + .char_boundaries + .partition_point(|boundary| *boundary <= byte) + .saturating_sub(1); + self.col_boundaries.get(idx).copied().unwrap_or(0) + } + + fn byte_for_col_nearest(&self, col: f32) -> u32 { + let Some(&last_byte) = self.char_boundaries.last() else { + return 0; + }; + let Some(&last_col) = self.col_boundaries.last() else { + return 0; + }; + if col <= 0.0 { + return 0; + } + if col >= last_col as f32 { + return last_byte; + } + + let upper = self + .col_boundaries + .partition_point(|boundary| (*boundary as f32) < col) + .min(self.col_boundaries.len().saturating_sub(1)); + let lower = upper.saturating_sub(1); + let lower_col = self.col_boundaries[lower] as f32; + let upper_col = self.col_boundaries[upper] as f32; + if (col - lower_col).abs() <= (upper_col - col).abs() { + self.char_boundaries[lower] + } else { + self.char_boundaries[upper] + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScrollbarOverride { + pub total_height_px: u32, + pub scroll_top_px: u32, + pub max_scroll_top_px: u32, +} + +#[derive(Debug)] +pub struct EditorElement { + layout_key: Option, + pub layout: EditorLayout, + config: DisplayLayoutConfig, + metrics: DisplayLayoutMetrics, + summary: DisplayLayoutSummary, + rows: Vec, + strips: Vec, + blocks: BlockRegistry, + hunk_expand_caps: Vec, + theme_cache_key: Option, + wrapped_text_cache: HashMap>, + text_layout_cache: HashMap>, + gutter_text_cache: HashMap>, + text_metrics: TextMetrics, + scrollbar_override: Option, + sticky_header_hit: Option<(Rect, String)>, + file_header_hits: Vec, + mouse_pos: Option<(f32, f32)>, + /// Memoized navigation positions. Hunk/file positions depend only on + /// `rows` (rebuilt when `layout_key` changes); search Y positions + /// additionally depend on the search match set. Recomputing these every + /// frame meant iterating every row per frame, so cache and hand out + /// shared Arcs instead. + nav_positions_valid: bool, + nav_hunk_positions: Arc>, + nav_file_positions: Arc>, + nav_search_matches: Option>>, + nav_search_y_positions: Arc>, +} + +#[derive(Debug, Clone)] +struct FileHeaderHit { + y_px: u32, + h_px: u16, + path: String, +} + +#[derive(Debug, Clone, Copy)] +struct TextBlock { + line_index: u32, + side: ViewportTextSide, + text_range: ByteRange, + text_x: f32, + text_width: f32, + y: f32, + segment_count: u16, + segment_cols: u16, +} + +impl Default for EditorElement { + fn default() -> Self { + Self { + layout_key: None, + layout: EditorLayout::default(), + config: DisplayLayoutConfig::default(), + metrics: DisplayLayoutMetrics::default(), + summary: DisplayLayoutSummary::default(), + rows: Vec::new(), + strips: Vec::new(), + blocks: BlockRegistry::new(), + hunk_expand_caps: Vec::new(), + theme_cache_key: None, + wrapped_text_cache: HashMap::new(), + text_layout_cache: HashMap::new(), + gutter_text_cache: HashMap::new(), + text_metrics: TextMetrics::default(), + scrollbar_override: None, + sticky_header_hit: None, + file_header_hits: Vec::new(), + mouse_pos: None, + nav_positions_valid: false, + nav_hunk_positions: Arc::default(), + nav_file_positions: Arc::default(), + nav_search_matches: None, + nav_search_y_positions: Arc::default(), + } + } +} + +impl EditorElement { + pub fn set_scrollbar_override(&mut self, value: Option) { + self.scrollbar_override = value; + } + + pub fn set_mouse_pos(&mut self, pos: Option<(f32, f32)>) { + self.mouse_pos = pos; + } + + pub fn scrollbar_layout(&self) -> Option { + self.layout.scrollbar + } + + pub fn scroll_line_height_px(&self) -> f32 { + let lh = self.layout.line_height; + if lh > 0.0 { lh } else { 20.0 } + } + + pub fn blocks_mut(&mut self) -> &mut BlockRegistry { + &mut self.blocks + } + + pub fn blocks(&self) -> &BlockRegistry { + &self.blocks + } + + pub fn set_hunk_expand_caps(&mut self, caps: Vec) { + self.hunk_expand_caps = caps; + } + + fn clear_document_caches(&mut self) { + self.wrapped_text_cache.clear(); + self.text_layout_cache.clear(); + self.gutter_text_cache.clear(); + // Rows changed (or went away) — navigation positions must be + // recomputed from the new row geometry. + self.nav_positions_valid = false; + self.nav_search_matches = None; + } + + fn sync_theme_cache(&mut self, theme: &Theme) { + let key = EditorThemeKey { + text_strong: theme.colors.text_strong, + text_muted: theme.colors.text_muted, + accent: theme.colors.accent, + line_add_text: theme.colors.line_add_text, + line_del_text: theme.colors.line_del_text, + }; + if self.theme_cache_key != Some(key) { + self.wrapped_text_cache.clear(); + self.theme_cache_key = Some(key); + } + } + + fn cached_wrapped_rich_text( + &mut self, + doc: &RenderDoc, + text_range: ByteRange, + runs: RunRange, + segment_index: u16, + wrap_cols: u16, + tone: RowTone, + theme: &Theme, + ) -> Option> { + if !text_range.is_valid() { + return None; + } + let key = WrappedTextCacheKey { + text_start: text_range.start, + text_len: text_range.len, + runs_start: runs.start, + runs_len: runs.len, + segment_index, + wrap_cols, + tone, + }; + if let Some(cached) = self.wrapped_text_cache.get(&key) { + return Some(cached.clone()); + } + + let text_layout = self.cached_text_layout(doc, text_range); + let spans = build_wrapped_rich_text( + doc, + text_layout.as_ref(), + text_range, + runs, + segment_index, + wrap_cols, + tone, + theme, + )?; + self.wrapped_text_cache.insert(key, spans.clone()); + Some(spans) + } + + fn cached_text_layout( + &mut self, + doc: &RenderDoc, + text_range: ByteRange, + ) -> Arc { + let key = TextLayoutCacheKey { + text_start: text_range.start, + text_len: text_range.len, + }; + if let Some(cached) = self.text_layout_cache.get(&key) { + return cached.clone(); + } + + let layout = Arc::new(CachedTextLayout::new(doc.line_text(text_range))); + self.text_layout_cache.insert(key, layout.clone()); + layout + } + + fn cached_gutter_text(&mut self, key: GutterTextCacheKey) -> Arc { + if let Some(cached) = self.gutter_text_cache.get(&key) { + return cached.clone(); + } + + let spaces = " ".repeat(key.digits as usize); + let text: Arc = match key.kind { + GutterTextKind::SplitLeft => format_line_number_string(key.old_line_no, key.digits), + GutterTextKind::SplitRight => format_line_number_string(key.new_line_no, key.digits), + GutterTextKind::Unified => format!( + "{} {}", + format_line_number_string(key.old_line_no, key.digits), + format_line_number_string(key.new_line_no, key.digits) + ), + GutterTextKind::UnifiedOldOnly => format!( + "{} {}", + format_line_number_string(key.old_line_no, key.digits), + spaces + ), + GutterTextKind::UnifiedNewOnly => format!( + "{} {}", + spaces, + format_line_number_string(key.new_line_no, key.digits) + ), + } + .into(); + self.gutter_text_cache.insert(key, text.clone()); + text + } +} + +fn format_line_number_string(line_no: u32, digits: u32) -> String { + if line_no == INVALID_U32 { + " ".repeat(digits as usize) + } else { + format!("{line_no:>width$}", width = digits as usize) + } +} diff --git a/src/editor/diff/element.rs b/src/editor/diff/element/paint.rs similarity index 50% rename from src/editor/diff/element.rs rename to src/editor/diff/element/paint.rs index b575fd86..114581e7 100644 --- a/src/editor/diff/element.rs +++ b/src/editor/diff/element/paint.rs @@ -1,936 +1,38 @@ -use std::{collections::HashMap, ops::Range, sync::Arc}; +use std::ops::Range; +use std::sync::Arc; -use crate::actions::{Action, ContextMenuEntry}; -use crate::core::compare::LayoutMode; -use crate::core::text::SyntaxTokenKind; -use crate::render::{ - FontKind, FontStyle, FontWeight, Rect, RectPrimitive, RichTextPrimitive, RichTextSpan, - RoundedRectPrimitive, Scene, TextMetrics, TextPrimitive, -}; -use crate::ui::accessibility::{AccessibilityAction, AccessibilityFrame, AccessibilityNode}; -use crate::ui::design::{Alpha, Sz}; -use crate::ui::element::ScrollActionBuilder; -use crate::ui::state::FocusTarget; -use crate::ui::theme::{Color, Theme}; -use accesskit::Role; - -use super::anchor::{EditorOverlayKind, ResolvedEditorOverlay}; -use super::decoration::{ - BlockActionCtx, BlockPaintCtx, BlockRegistry, FileHeaderDecoration, RowDecoration, RowPaintCtx, - decoration_for_kind, -}; -use super::display_layout::{ - DisplayLayoutConfig, DisplayLayoutMetrics, DisplayLayoutSummary, compute_gutter_digits, - rebuild_display_rows, -}; -use super::render_doc::{ - ByteRange, DisplayRow, FileHeaderMeta, INVALID_U32, RENDER_FLAG_STRUCTURAL, RenderDoc, - RenderLine, RenderRowKind, RunRange, STYLE_FLAG_CHANGE, STYLE_FLAG_UNCHANGED_CTX, StyleRun, - advance_display_col, -}; -use super::state::{EditorState, ViewportTextPoint, ViewportTextSelection, ViewportTextSide}; -use super::strip_layout::{StripLayout, build_strip_layouts, visible_strip_range}; - -const BASE_VIEWPORT_PADDING: f32 = 14.0; -const BASE_COLUMN_GAP: f32 = 18.0; -const BASE_GUTTER_PADDING: f32 = 8.0; -const BASE_SCROLLBAR_WIDTH: f32 = 8.0; -const BASE_SCROLLBAR_MARGIN: f32 = 6.0; -const FILE_HEADER_ROW_MULTIPLE: u16 = 1; -const HUNK_ROW_MULTIPLE: u16 = 1; -const BASE_SCROLLBAR_THUMB_MIN: f32 = 32.0; -pub(crate) const BASE_MONO_FONT_SIZE: f32 = 13.0; -const STRIP_TARGET_HEIGHT_PX: u32 = 480; -const STRIP_OVERSCAN: usize = 1; -const UNWRAPPED_RENDER_OVERSCAN_COLS: u16 = 16; -const STICKY_HEADER_Z: i32 = 10; -const INLINE_CHANGE_BG_MERGE_GAP_COLS: u32 = 2; -const INLINE_CHANGE_BG_Y_INSET_RATIO: f32 = 0.10; - -fn editor_scale(text_metrics: TextMetrics) -> f32 { - (text_metrics.mono_font_size_px / BASE_MONO_FONT_SIZE).max(0.5) -} - -fn display_layout_metrics(text_metrics: TextMetrics) -> DisplayLayoutMetrics { - let body_h = text_metrics.mono_line_height_px.round().max(1.0) as u16; - DisplayLayoutMetrics { - body_row_height_px: body_h, - file_header_height_px: body_h * FILE_HEADER_ROW_MULTIPLE, - hunk_height_px: body_h * HUNK_ROW_MULTIPLE, - } -} - -fn scaled(base: f32, scale: f32) -> f32 { - base * scale -} - -fn content_bounds_for_viewport(bounds: Rect, text_metrics: TextMetrics) -> Rect { - bounds.inset(scaled(BASE_VIEWPORT_PADDING, editor_scale(text_metrics))) -} - -fn editor_bottom_padding_px(metrics: DisplayLayoutMetrics) -> u32 { - u32::from(metrics.body_row_height_px) -} - -#[derive(Debug, Clone, Copy, Default, PartialEq)] -pub struct EditorLayout { - pub outer_bounds: Rect, - pub content_bounds: Rect, - pub split_mode: bool, - pub gutter_digits: u32, - pub unified_gutter_rect: Rect, - pub unified_text_rect: Rect, - pub left_gutter_rect: Rect, - pub left_text_rect: Rect, - pub right_gutter_rect: Rect, - pub right_text_rect: Rect, - - pub line_height: f32, - pub font_size: f32, - pub text_y_offset: f32, - pub gutter_padding: f32, - pub column_gap: f32, - pub scroll_top_px: f32, - pub visible_row_range: VisibleRange, - pub highlighted_row: Option, - pub scrollbar: Option, - pub show_staging_controls: bool, - pub file_is_staged: bool, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq)] -pub struct VisibleRange { - pub start: usize, - pub end: usize, -} - -impl VisibleRange { - pub fn iter(&self) -> Range { - self.start..self.end - } - - pub fn is_empty(&self) -> bool { - self.start >= self.end - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq)] -pub struct ScrollbarLayout { - pub track: Rect, - pub thumb: Rect, - pub thumb_top: f32, - pub thumb_height: f32, -} - -#[derive(Debug, Clone, Copy)] -pub enum EditorDocument<'a> { - Empty, - Loading { - path: &'a str, - }, - Binary { - path: &'a str, - }, - Text { - compare_generation: u64, - file_index: usize, - path: &'a str, - doc: &'a RenderDoc, - show_file_headers: bool, - }, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -struct EditorLayoutKey { - compare_generation: u64, - file_index: usize, - show_file_headers: bool, - split_mode: bool, - wrap_enabled: bool, - wrap_column: u32, - viewport_width_bits: u32, - viewport_height_bits: u32, - mono_char_width_bits: u32, - mono_line_height_bits: u32, - doc_line_count: u32, - doc_text_len: u32, - block_layout_signature: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct EditorThemeKey { - text_strong: Color, - text_muted: Color, - accent: Color, - line_add_text: Color, - line_del_text: Color, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -struct WrappedTextCacheKey { - text_start: u32, - text_len: u32, - runs_start: u32, - runs_len: u32, - segment_index: u16, - wrap_cols: u16, - tone: RowTone, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -struct GutterTextCacheKey { - old_line_no: u32, - new_line_no: u32, - digits: u32, - kind: GutterTextKind, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum GutterTextKind { - SplitLeft, - SplitRight, - Unified, - UnifiedOldOnly, - UnifiedNewOnly, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -struct TextLayoutCacheKey { - text_start: u32, - text_len: u32, -} - -#[derive(Debug, Clone)] -struct CachedTextLayout { - char_boundaries: Arc<[u32]>, - col_boundaries: Arc<[u32]>, -} - -impl CachedTextLayout { - fn new(text: &str) -> Self { - let mut char_boundaries = Vec::with_capacity(text.chars().count().saturating_add(1)); - let mut col_boundaries = Vec::with_capacity(text.chars().count().saturating_add(1)); - let mut cols = 0_u32; - - for (idx, ch) in text.char_indices() { - char_boundaries.push(idx as u32); - col_boundaries.push(cols); - cols = advance_display_col(cols, ch); - } - char_boundaries.push(text.len() as u32); - col_boundaries.push(cols); - Self { - char_boundaries: Arc::from(char_boundaries), - col_boundaries: Arc::from(col_boundaries), - } - } - - fn char_count(&self) -> u32 { - self.char_boundaries.len().saturating_sub(1) as u32 - } - - fn total_cols(&self) -> u32 { - self.col_boundaries.last().copied().unwrap_or(0) - } - - fn char_range_for_cols(&self, start_col: u32, end_col: u32) -> (usize, usize) { - let total_cols = self.total_cols(); - let start_col = start_col.min(total_cols); - let end_col = end_col.min(total_cols).max(start_col); - let char_count = self.char_count() as usize; - let start = self - .col_boundaries - .partition_point(|boundary| *boundary <= start_col) - .saturating_sub(1) - .min(char_count); - let end = self - .col_boundaries - .partition_point(|boundary| *boundary < end_col) - .min(char_count); - (start, end.max(start)) - } - - #[cfg(test)] - fn byte_range_for_cols(&self, start_col: u32, end_col: u32) -> (usize, usize) { - let (start, end) = self.char_range_for_cols(start_col, end_col); - ( - self.char_boundaries[start] as usize, - self.char_boundaries[end] as usize, - ) - } - - fn col_for_byte(&self, byte: usize) -> u32 { - let byte = (byte as u32).min(self.char_boundaries.last().copied().unwrap_or(0)); - let idx = self - .char_boundaries - .partition_point(|boundary| *boundary <= byte) - .saturating_sub(1); - self.col_boundaries.get(idx).copied().unwrap_or(0) - } - - fn byte_for_col_nearest(&self, col: f32) -> u32 { - let Some(&last_byte) = self.char_boundaries.last() else { - return 0; - }; - let Some(&last_col) = self.col_boundaries.last() else { - return 0; - }; - if col <= 0.0 { - return 0; - } - if col >= last_col as f32 { - return last_byte; - } - - let upper = self - .col_boundaries - .partition_point(|boundary| (*boundary as f32) < col) - .min(self.col_boundaries.len().saturating_sub(1)); - let lower = upper.saturating_sub(1); - let lower_col = self.col_boundaries[lower] as f32; - let upper_col = self.col_boundaries[upper] as f32; - if (col - lower_col).abs() <= (upper_col - col).abs() { - self.char_boundaries[lower] - } else { - self.char_boundaries[upper] - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ScrollbarOverride { - pub total_height_px: u32, - pub scroll_top_px: u32, - pub max_scroll_top_px: u32, -} - -#[derive(Debug)] -pub struct EditorElement { - layout_key: Option, - pub layout: EditorLayout, - config: DisplayLayoutConfig, - metrics: DisplayLayoutMetrics, - summary: DisplayLayoutSummary, - rows: Vec, - strips: Vec, - blocks: BlockRegistry, - hunk_expand_caps: Vec, - theme_cache_key: Option, - wrapped_text_cache: HashMap>, - text_layout_cache: HashMap>, - gutter_text_cache: HashMap>, - text_metrics: TextMetrics, - scrollbar_override: Option, - sticky_header_hit: Option<(Rect, String)>, - file_header_hits: Vec, - mouse_pos: Option<(f32, f32)>, -} - -#[derive(Debug, Clone)] -struct FileHeaderHit { - y_px: u32, - h_px: u16, - path: String, -} - -#[derive(Debug, Clone, Copy)] -struct TextBlock { - line_index: u32, - side: ViewportTextSide, - text_range: ByteRange, - text_x: f32, - text_width: f32, - y: f32, - segment_count: u16, - segment_cols: u16, -} - -impl Default for EditorElement { - fn default() -> Self { - Self { - layout_key: None, - layout: EditorLayout::default(), - config: DisplayLayoutConfig::default(), - metrics: DisplayLayoutMetrics::default(), - summary: DisplayLayoutSummary::default(), - rows: Vec::new(), - strips: Vec::new(), - blocks: BlockRegistry::new(), - hunk_expand_caps: Vec::new(), - theme_cache_key: None, - wrapped_text_cache: HashMap::new(), - text_layout_cache: HashMap::new(), - gutter_text_cache: HashMap::new(), - text_metrics: TextMetrics::default(), - scrollbar_override: None, - sticky_header_hit: None, - file_header_hits: Vec::new(), - mouse_pos: None, - } - } -} - -impl EditorElement { - pub fn set_scrollbar_override(&mut self, value: Option) { - self.scrollbar_override = value; - } - - pub fn set_mouse_pos(&mut self, pos: Option<(f32, f32)>) { - self.mouse_pos = pos; - } - - pub fn scrollbar_layout(&self) -> Option { - self.layout.scrollbar - } - - pub fn scroll_line_height_px(&self) -> f32 { - let lh = self.layout.line_height; - if lh > 0.0 { lh } else { 20.0 } - } - - pub fn prepare( - &mut self, - state: &mut EditorState, - document: EditorDocument<'_>, - bounds: Rect, - text_metrics: TextMetrics, - ) -> EditorLayout { - self.text_metrics = text_metrics; - let gutter_digits = match document { - EditorDocument::Text { doc, .. } => compute_gutter_digits(doc), - _ => 3, - }; - self.layout = build_spatial_layout(bounds, state.layout, gutter_digits, text_metrics); - state.viewport_width_px = self.layout.content_bounds.width.max(0.0).round() as u32; - state.viewport_height_px = self.layout.content_bounds.height.max(0.0).round() as u32; - - let s = editor_scale(text_metrics); - self.layout.font_size = text_metrics.mono_font_size_px; - self.layout.line_height = self.metrics.body_row_height_px as f32; - let glyphon_line_h = text_metrics.mono_font_size_px * 1.35; - self.layout.text_y_offset = ((self.layout.line_height - glyphon_line_h) * 0.5).max(0.0); - self.layout.gutter_padding = scaled(BASE_GUTTER_PADDING, s); - self.layout.column_gap = scaled(BASE_COLUMN_GAP, s); - - match document { - EditorDocument::Text { - compare_generation, - file_index, - doc, - show_file_headers, - .. - } => { - let key = EditorLayoutKey { - compare_generation, - file_index, - show_file_headers, - split_mode: state.layout == LayoutMode::Split, - wrap_enabled: state.wrap_enabled, - wrap_column: state.wrap_column, - viewport_width_bits: self.layout.content_bounds.width.to_bits(), - viewport_height_bits: self.layout.content_bounds.height.to_bits(), - mono_char_width_bits: text_metrics.mono_char_width_px.to_bits(), - mono_line_height_bits: text_metrics.mono_line_height_px.to_bits(), - doc_line_count: doc.line_count() as u32, - doc_text_len: doc.text_bytes.len().min(u32::MAX as usize) as u32, - block_layout_signature: self - .blocks - .layout_signature(display_layout_metrics(text_metrics)), - }; - - if self.layout_key != Some(key) { - self.rebuild_rows(doc, state, text_metrics, show_file_headers); - self.clear_document_caches(); - self.layout_key = Some(key); - } - - state.content_height_px = self - .summary - .content_height_px - .saturating_add(editor_bottom_padding_px(self.metrics)); - self.rebuild_navigation_positions(state); - state.clamp_scroll(); - self.update_visible_ranges(state); - - self.layout.line_height = self.metrics.body_row_height_px as f32; - } - _ => { - self.layout_key = None; - self.rows.clear(); - self.strips.clear(); - self.clear_document_caches(); - state.clear_document(); - } - } - - self.layout.scroll_top_px = state.scroll_top_px as f32; - self.layout.highlighted_row = state.hovered_row; - self.layout.scrollbar = - compute_scrollbar_layout(&self.layout, state, self.scrollbar_override); - - self.layout - } - - pub fn body_bounds(&self) -> Rect { - self.layout.content_bounds - } - - pub fn hit_test_row(&self, state: &EditorState, x: f32, y: f32) -> Option { - if !self.layout.content_bounds.contains(x, y) { - return None; - } - let content_y = (y - self.layout.content_bounds.y).max(0.0) + state.scroll_top_px as f32; - let index = self - .rows - .partition_point(|row| row.bottom_px() as f32 <= content_y); - self.rows.get(index).and_then(|row| { - (content_y >= row.y_px as f32 && content_y < row.bottom_px() as f32).then_some(index) - }) - } - - pub fn is_gutter_hit(&self, x: f32, _y: f32) -> bool { - if self.layout.split_mode { - self.layout - .left_gutter_rect - .contains(x, self.layout.left_gutter_rect.y) - } else { - self.layout - .unified_gutter_rect - .contains(x, self.layout.unified_gutter_rect.y) - } - } - - pub fn blocks_mut(&mut self) -> &mut BlockRegistry { - &mut self.blocks - } - - pub fn blocks(&self) -> &BlockRegistry { - &self.blocks - } - - /// Pins the single card-width formula used by the review-thread overlay. Mirrors - /// the inset `build_spatial_layout` applies to produce `content_bounds.width`, so a - /// caller can derive the width BEFORE `prepare` runs (it needs the width to measure - /// card heights before blocks are populated). `text_metrics` is passed explicitly - /// rather than read from `self` so it does not depend on prepare-ordering. - pub fn content_width_for_bounds(&self, bounds: Rect, text_metrics: TextMetrics) -> f32 { - content_bounds_for_viewport(bounds, text_metrics).width - } - - pub fn content_height_for_bounds(&self, bounds: Rect, text_metrics: TextMetrics) -> f32 { - content_bounds_for_viewport(bounds, text_metrics).height - } - - /// Top edge band occupied by the sticky file header, if any, so overlays can avoid - /// painting/clicking over it. - pub fn sticky_header_rect(&self) -> Option { - self.sticky_header_hit.as_ref().map(|(rect, _)| *rect) - } - - pub fn overlay_clip_rect(&self, viewport_bounds: Rect) -> Rect { - match self.sticky_header_rect() { - Some(header) => Rect { - x: viewport_bounds.x, - y: header.bottom(), - width: viewport_bounds.width, - height: (viewport_bounds.bottom() - header.bottom()).max(0.0), - }, - None => viewport_bounds, - } - } - - /// Visible review block rows resolved into the same overlay rects used for both - /// rendering and clipping their element hit regions. - pub fn visible_review_block_overlays( - &self, - overlay_width: f32, - clip: Rect, - ) -> Vec { - let mut out = Vec::new(); - for row in &self.rows { - if !row.is_block() { - continue; - } - let rect = self.row_rect_for(row); - if !self.row_in_viewport(&rect) { - continue; - } - let block_index = row.block_index as usize; - let Some(block) = self.blocks.get(block_index) else { - continue; - }; - let kind = if block.is_composer() { - EditorOverlayKind::ReviewComposerBlock { block_index } - } else if block.review_card().is_some() { - EditorOverlayKind::ReviewThreadBlock { block_index } - } else { - continue; - }; - // Start the card/composer at the code column so the line-number gutter - // stays visible beside it (like GitHub), rather than covering it. - let code_x = if self.layout.split_mode { - self.layout.left_text_rect.x - } else { - self.layout.unified_text_rect.x - }; - let overlay_rect = Rect { - x: code_x.max(rect.x), - y: rect.y, - width: overlay_width, - height: rect.height, - }; - if let Some(overlay) = ResolvedEditorOverlay::new(kind, overlay_rect, clip) { - out.push(overlay); - } - } - out - } - - pub fn set_hunk_expand_caps(&mut self, caps: Vec) { - self.hunk_expand_caps = caps; - } - - pub fn render_line_index_for_row(&self, display_row_index: usize) -> Option { - let row = self.rows.get(display_row_index)?; - if row.is_block() { - return None; - } - Some(row.line_index) - } - - pub fn is_block_row(&self, display_row_index: usize) -> bool { - self.rows - .get(display_row_index) - .is_some_and(|row| row.is_block()) - } - - pub fn review_comment_line_for_row( - &self, - doc: &RenderDoc, - display_row_index: usize, - ) -> Option { - let row = self.rows.get(display_row_index)?; - if row.is_block() { - return None; - } - let line = doc.lines.get(row.line_index as usize)?; - review_comment_gutter_rect(&self.layout, line)?; - Some(row.line_index as usize) - } - - pub fn review_add_button_overlay( - &self, - state: &EditorState, - doc: &RenderDoc, - clip: Rect, - ) -> Option { - if !state.review_enabled { - return None; - } - let row_index = self.layout.highlighted_row?; - self.review_add_button_overlay_for_row(state, doc, row_index, clip) - } - - pub fn review_add_button_overlay_at( - &self, - state: &EditorState, - doc: &RenderDoc, - x: f32, - y: f32, - clip: Rect, - ) -> Option { - if !state.review_enabled { - return None; - } - let row_index = self.hit_test_row(state, x, y)?; - let overlay = self.review_add_button_overlay_for_row(state, doc, row_index, clip)?; - overlay.contains(x, y).then_some(overlay) - } - - pub fn block_action_for_row_at( - &self, - display_row_index: usize, - x: f32, - y: f32, - ) -> Option { - let row = self.rows.get(display_row_index)?; - if !row.is_block() { - return None; - } - let block = self.blocks.get(row.block_index as usize)?; - block.on_click_at( - &BlockActionCtx { - layout: &self.layout, - row_rect: self.row_rect_for(row), - }, - x, - y, - ) - } - - pub fn block_context_menu_for_row( - &self, - display_row_index: usize, - ) -> Option> { - let row = self.rows.get(display_row_index)?; - if !row.is_block() { - return None; - } - self.blocks - .get(row.block_index as usize)? - .context_menu_entries() - } - - pub fn hunk_action_bar_rect(&self, doc: &RenderDoc) -> Option<(Rect, i16)> { - let idx = self.layout.highlighted_row?; - let display_row = self.rows.get(idx)?; - if display_row.is_block() { - return None; - } - let line = doc.lines.get(display_row.line_index as usize)?; - if line.hunk_index < 0 { - return None; - } - let hunk_index = line.hunk_index; - - let mut first_idx = idx; - while first_idx > 0 { - let prev = self.rows.get(first_idx - 1)?; - if prev.is_block() { - break; - } - let prev_line = doc.lines.get(prev.line_index as usize)?; - if prev_line.hunk_index != hunk_index { - break; - } - first_idx -= 1; - } - - let mut last_idx = idx; - while last_idx + 1 < self.rows.len() { - let next = &self.rows[last_idx + 1]; - if next.is_block() { - break; - } - let Some(next_line) = doc.lines.get(next.line_index as usize) else { - break; - }; - if next_line.hunk_index != hunk_index { - break; - } - last_idx += 1; - } - - let first_rect = self.row_rect_for(&self.rows[first_idx]); - let last_rect = self.row_rect_for(&self.rows[last_idx]); - let row_h = first_rect.height; - let viewport_top = self.layout.content_bounds.y; - let viewport_bottom = self.layout.content_bounds.bottom(); - - let max_y = (last_rect.y + last_rect.height - row_h).max(first_rect.y); - let y = first_rect.y.max(viewport_top).min(max_y); - if y + row_h <= viewport_top || y >= viewport_bottom { - return None; - } - - // The bar floats on the hunk separator row, which spans the full - // content width in both split and unified modes. Use the full text span - // so the buttons right-align against the editor edge, not a column. - let (x, width) = if self.layout.split_mode { - let left = self.layout.left_text_rect.x; - let right = self.layout.right_text_rect.x + self.layout.right_text_rect.width; - (left, right - left) - } else { - ( - self.layout.unified_text_rect.x, - self.layout.unified_text_rect.width, - ) - }; - Some(( - Rect { - x, - y, - width, - height: row_h, - }, - hunk_index, - )) - } - - pub fn line_selection_bar_rect(&self, doc: &RenderDoc, state: &EditorState) -> Option { - use super::render_doc::RenderRowKind; - - if state.line_selection.is_empty() { - return None; - } - - let is_selected = |row: &DisplayRow| -> bool { - if row.is_block() { - return false; - } - let Some(line) = doc.lines.get(row.line_index as usize) else { - return false; - }; - if !matches!( - line.row_kind(), - RenderRowKind::Added | RenderRowKind::Removed | RenderRowKind::Modified - ) { - return false; - } - line_selection_contains_line( - &state.line_selection, - file_path_for_line(doc, row.line_index as usize), - line, - ) - }; - - let first = self.rows.iter().find(|r| is_selected(r))?; - let last = self.rows.iter().rev().find(|r| is_selected(r))?; - - let first_rect = self.row_rect_for(first); - let last_rect = self.row_rect_for(last); - let last_bottom = last_rect.y + last_rect.height; - let viewport_top = self.layout.content_bounds.y; - let viewport_bottom = self.layout.content_bounds.bottom(); - - // Hide entirely when the selection is fully outside the viewport. - if last_bottom <= viewport_top || first_rect.y >= viewport_bottom { - return None; - } - - let bar_h = first_rect.height; - // Float the bar above the first selected row. Once the user scrolls - // past that row the bar stays pinned to the viewport top, acting like - // a sticky header over the selection — no jumps, no disappearing. - let above_y = first_rect.y - bar_h; - let y = above_y.max(viewport_top); - // If even the sticky position would sit past the last selected row, - // the selection no longer covers enough area to anchor the bar. - if y >= last_bottom { - return None; - } - - // Span the full content width in both modes so the buttons right-align - // against the editor edge, never pinned to a narrow column. - let (x, width) = if self.layout.split_mode { - let left = self.layout.left_text_rect.x; - let right = self.layout.right_text_rect.x + self.layout.right_text_rect.width; - (left, right - left) - } else { - ( - self.layout.unified_text_rect.x, - self.layout.unified_text_rect.width, - ) - }; - Some(Rect { - x, - y, - width, - height: bar_h, - }) - } - - fn rebuild_rows( - &mut self, - doc: &RenderDoc, - state: &EditorState, - text_metrics: TextMetrics, - show_file_headers: bool, - ) { - self.metrics = display_layout_metrics(text_metrics); - self.config = DisplayLayoutConfig { - split_mode: state.layout == LayoutMode::Split, - wrap_enabled: state.wrap_enabled, - wrap_column: state.wrap_column, - show_file_headers, - char_width_px: text_metrics.mono_char_width_px as f64, - unified_text_width_px: self.layout.unified_text_rect.width as f64, - split_text_width_px: self.layout.left_text_rect.width as f64, - }; - self.summary = - rebuild_display_rows(doc, self.config, self.metrics, &self.blocks, &mut self.rows); - build_strip_layouts(&self.rows, STRIP_TARGET_HEIGHT_PX, &mut self.strips); - self.file_header_hits.clear(); - if show_file_headers { - for row in &self.rows { - if row.kind != RenderRowKind::FileHeader as u8 { - continue; - } - let Some(line) = doc.lines.get(row.line_index as usize) else { - continue; - }; - if let Some(meta) = doc.file_meta(line) { - self.file_header_hits.push(FileHeaderHit { - y_px: row.y_px, - h_px: row.h_px, - path: meta.path.clone(), - }); - } - } - } - } - - fn rebuild_navigation_positions(&self, state: &mut EditorState) { - state.hunk_positions.clear(); - state.file_positions.clear(); - for row in &self.rows { - if row.kind == RenderRowKind::HunkSeparator as u8 { - state.hunk_positions.push(row.y_px); - } else if row.kind == RenderRowKind::FileHeader as u8 { - state.file_positions.push(row.y_px); - } - } +use accesskit::Role; - state.search_match_y_positions.clear(); - if state.search.open && !state.search.matches.is_empty() { - for m in &state.search.matches { - let y = self - .rows - .iter() - .find(|r| !r.is_block() && r.line_index == m.line_index) - .map(|r| r.y_px) - .unwrap_or(0); - state.search_match_y_positions.push(y); - } - } - } +use crate::core::text::SyntaxTokenKind; +use crate::editor::diff::decoration::{ + BlockPaintCtx, FileHeaderDecoration, RowDecoration, RowPaintCtx, decoration_for_kind, +}; +use crate::editor::diff::render_doc::{ + ByteRange, DisplayRow, FileHeaderMeta, INVALID_U32, RENDER_FLAG_STRUCTURAL, RenderDoc, + RenderLine, RenderRowKind, RunRange, STYLE_FLAG_CHANGE, STYLE_FLAG_UNCHANGED_CTX, StyleRun, +}; +use crate::editor::diff::state::{EditorState, ViewportTextSide}; +use crate::render::{ + FontKind, FontStyle, FontWeight, Rect, RectPrimitive, RichTextPrimitive, RichTextSpan, + RoundedRectPrimitive, Scene, TextPrimitive, +}; +use crate::ui::accessibility::{AccessibilityAction, AccessibilityFrame, AccessibilityNode}; +use crate::ui::design::{Alpha, Sz}; +use crate::ui::element::ScrollActionBuilder; +use crate::ui::state::FocusTarget; +use crate::ui::theme::{Color, Theme}; - fn update_visible_ranges(&mut self, state: &mut EditorState) { - let viewport_top_px = state.scroll_top_px; - let viewport_height_px = state.viewport_height_px.max(1); - let strip_range = visible_strip_range( - &self.strips, - viewport_top_px, - viewport_height_px, - STRIP_OVERSCAN, - ); - self.layout.visible_row_range = if strip_range.is_empty() { - VisibleRange::default() - } else { - let first = self.strips[strip_range.start].row_start; - let last = self.strips[strip_range.end - 1].row_end; - VisibleRange { - start: first, - end: last, - } - }; +use super::hit_test::{ + file_path_for_line, line_selection_contains_line, selection_byte_range_for_side, +}; +use super::layout::{editor_scale, scaled}; +use super::{CachedTextLayout, EditorDocument, EditorElement, GutterTextCacheKey, GutterTextKind}; - let visible_bottom_px = viewport_top_px.saturating_add(viewport_height_px); - let visible_start = self - .rows - .partition_point(|row| row.bottom_px() <= viewport_top_px); - let visible_end = self - .rows - .partition_point(|row| row.y_px < visible_bottom_px); - if visible_start < visible_end { - state.visible_row_start = Some(visible_start); - state.visible_row_end = Some(visible_end); - } else { - state.visible_row_start = None; - state.visible_row_end = None; - } - } +const STICKY_HEADER_Z: i32 = 10; +const INLINE_CHANGE_BG_MERGE_GAP_COLS: u32 = 2; +const INLINE_CHANGE_BG_Y_INSET_RATIO: f32 = 0.10; +impl EditorElement { pub fn paint( &mut self, scene: &mut Scene, @@ -958,11 +60,29 @@ impl EditorElement { } EditorDocument::Text { compare_generation, + file_index, path, doc, show_file_headers, - .. } => { + // Row geometry is only valid for the exact document `prepare` + // built it from. If a stale document (older compare + // generation or different file) reaches paint, skip the body + // for this frame instead of painting mismatched geometry; + // the next prepare/paint pass recovers. + let layout_matches = self.layout_key.is_some_and(|key| { + key.compare_generation == compare_generation && key.file_index == file_index + }); + if !layout_matches || _state.doc_generation != compare_generation { + tracing::warn!( + compare_generation, + file_index, + layout_generation = ?self.layout_key.map(|key| key.compare_generation), + state_generation = _state.doc_generation, + "editor layout/document generation mismatch; skipping paint" + ); + return; + } self.sync_theme_cache(theme); scene.clip(self.layout.content_bounds); @@ -1285,73 +405,6 @@ impl EditorElement { }); } - fn row_rect_for(&self, display_row: &DisplayRow) -> Rect { - Rect { - x: self.layout.content_bounds.x, - y: self.layout.content_bounds.y + display_row.y_px as f32 - self.layout.scroll_top_px, - width: self.layout.content_bounds.width, - height: display_row.h_px as f32, - } - } - - fn row_in_viewport(&self, row_rect: &Rect) -> bool { - row_rect.bottom() >= self.layout.content_bounds.y - && row_rect.y <= self.layout.content_bounds.bottom() - } - - fn review_add_comment_button_rect_for_row( - &self, - line: &RenderLine, - display_row: &DisplayRow, - ) -> Option { - let gutter = review_comment_gutter_rect(&self.layout, line)?; - let row_rect = self.row_rect_for(display_row); - if !self.row_in_viewport(&row_rect) { - return None; - } - let s = editor_scale(self.text_metrics); - let size = (self.layout.line_height * 0.72).clamp(scaled(14.0, s), scaled(20.0, s)); - // Straddle the gutter→code divider like GitHub: centered on the boundary, - // but never reaching left past the line number's right edge. - let x = (gutter.right() - size * 0.5).max(gutter.right() - self.layout.gutter_padding); - Some(Rect { - x, - y: row_rect.y + (self.layout.line_height - size).max(0.0) * 0.5, - width: size, - height: size, - }) - } - - fn review_add_button_overlay_for_row( - &self, - state: &EditorState, - doc: &RenderDoc, - row_index: usize, - clip: Rect, - ) -> Option { - let display_row = self.rows.get(row_index).copied()?; - if display_row.is_block() { - return None; - } - let line = doc.lines.get(display_row.line_index as usize)?; - let rect = self.review_add_comment_button_rect_for_row(line, &display_row)?; - let emphasised = state.review_add_hovered - || (!state.line_selection.is_empty() - && line_selection_contains_line( - &state.line_selection, - file_path_for_line(doc, display_row.line_index as usize), - line, - )); - ResolvedEditorOverlay::new( - EditorOverlayKind::ReviewAddButton { - line_index: display_row.line_index as usize, - emphasised, - }, - rect, - clip, - ) - } - fn paint_sticky_file_header( &mut self, scene: &mut Scene, @@ -1428,233 +481,6 @@ impl EditorElement { scene.pop_z_index(); } - pub fn file_header_path_at(&self, x: f32, y: f32) -> Option { - if let Some((rect, path)) = self.sticky_header_hit.as_ref() - && rect.contains(x, y) - { - return Some(path.clone()); - } - if !self.layout.content_bounds.contains(x, y) { - return None; - } - let content_y = (y - self.layout.content_bounds.y).max(0.0) + self.layout.scroll_top_px; - for hit in &self.file_header_hits { - let top = hit.y_px as f32; - let bottom = top + hit.h_px as f32; - if content_y >= top && content_y < bottom { - return Some(hit.path.clone()); - } - } - None - } - - pub fn hit_test_text_point( - &self, - state: &EditorState, - doc: &RenderDoc, - x: f32, - y: f32, - ) -> Option { - let row_index = self.hit_test_row(state, x, y)?; - let display_row = self.rows.get(row_index).copied()?; - if display_row.is_block() { - return None; - } - let line = doc.lines.get(display_row.line_index as usize)?; - if !line.row_kind().is_body() { - return None; - } - let row_rect = self.row_rect_for(&display_row); - let blocks = self.text_blocks_for_line(line, &display_row, row_rect); - blocks - .into_iter() - .flatten() - .filter(|block| { - let bottom = block.y + block.segment_count.max(1) as f32 * self.layout.line_height; - y >= block.y && y < bottom - }) - .min_by(|a, b| { - distance_to_text_block_x(*a, x) - .partial_cmp(&distance_to_text_block_x(*b, x)) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .map(|block| { - let text = doc.line_text(block.text_range); - let layout = CachedTextLayout::new(text); - let segment = ((y - block.y) / self.layout.line_height.max(1.0)) - .floor() - .max(0.0) as u32; - let segment = segment.min(u32::from(block.segment_count.max(1).saturating_sub(1))); - let local_col = - ((x - block.text_x) / self.text_metrics.mono_char_width_px.max(1.0)).max(0.0); - let col = local_col + segment.saturating_mul(u32::from(block.segment_cols)) as f32; - ViewportTextPoint { - line_index: block.line_index, - side: block.side, - byte_offset: layout.byte_for_col_nearest(col), - } - }) - } - - pub fn viewport_selection_text( - &self, - doc: &RenderDoc, - selection: &ViewportTextSelection, - ) -> Option { - if selection.is_collapsed() { - return None; - } - let mut copied = String::new(); - let (start, end) = selection.normalized(); - for line_index in start.line_index..=end.line_index { - let Some(line) = doc.lines.get(line_index as usize) else { - continue; - }; - for (side, range) in text_side_ranges_for_line(self.layout.split_mode, line) - .into_iter() - .flatten() - { - let text = doc.line_text(range); - let Some((byte_start, byte_end)) = selection_byte_range_for_side( - selection, - self.layout.split_mode, - line_index, - side, - text, - ) else { - continue; - }; - if byte_end <= byte_start { - continue; - } - if !copied.is_empty() { - copied.push('\n'); - } - copied.push_str(&text[byte_start..byte_end]); - } - } - (!copied.is_empty()).then_some(copied) - } - - pub fn viewport_line_text_at_point( - &self, - doc: &RenderDoc, - point: ViewportTextPoint, - ) -> Option { - let line = doc.lines.get(point.line_index as usize)?; - let range = match point.side { - ViewportTextSide::Left => line.left_text, - ViewportTextSide::Right => line.right_text, - }; - range.is_valid().then(|| doc.line_text(range).to_owned()) - } - - fn text_blocks_for_line( - &self, - line: &RenderLine, - display_row: &DisplayRow, - row_rect: Rect, - ) -> [Option; 2] { - let mut blocks = [None, None]; - let mut next = 0_usize; - let mut push_block = |block: TextBlock| { - if next < blocks.len() { - blocks[next] = Some(block); - next += 1; - } - }; - - let line_height = self.layout.line_height; - if self.layout.split_mode { - let segment_cols = self.render_cols_split(); - if line.left_text.is_valid() { - push_block(TextBlock { - line_index: display_row.line_index, - side: ViewportTextSide::Left, - text_range: line.left_text, - text_x: self.layout.left_text_rect.x, - text_width: self.layout.left_text_rect.width, - y: row_rect.y, - segment_count: if self.config.wrap_enabled { - display_row.wrap_left.max(1) - } else { - 1 - }, - segment_cols, - }); - } - if line.right_text.is_valid() { - push_block(TextBlock { - line_index: display_row.line_index, - side: ViewportTextSide::Right, - text_range: line.right_text, - text_x: self.layout.right_text_rect.x, - text_width: self.layout.right_text_rect.width, - y: row_rect.y, - segment_count: if self.config.wrap_enabled { - display_row.wrap_right.max(1) - } else { - 1 - }, - segment_cols, - }); - } - return blocks; - } - - let segment_cols = self.render_cols_unified(); - if line.row_kind() == RenderRowKind::Modified - && line.left_text.is_valid() - && line.right_text.is_valid() - { - let left_segments = if self.config.wrap_enabled { - display_row.wrap_left.max(1) - } else { - 1 - }; - push_block(TextBlock { - line_index: display_row.line_index, - side: ViewportTextSide::Left, - text_range: line.left_text, - text_x: self.layout.unified_text_rect.x, - text_width: self.layout.unified_text_rect.width, - y: row_rect.y, - segment_count: left_segments, - segment_cols, - }); - push_block(TextBlock { - line_index: display_row.line_index, - side: ViewportTextSide::Right, - text_range: line.right_text, - text_x: self.layout.unified_text_rect.x, - text_width: self.layout.unified_text_rect.width, - y: row_rect.y + left_segments as f32 * line_height, - segment_count: if self.config.wrap_enabled { - display_row.wrap_right.max(1) - } else { - 1 - }, - segment_cols, - }); - } else if let Some((side, text_range, _, _)) = unified_body_side_with_side(line) { - push_block(TextBlock { - line_index: display_row.line_index, - side, - text_range, - text_x: self.layout.unified_text_rect.x, - text_width: self.layout.unified_text_rect.width, - y: row_rect.y, - segment_count: if self.config.wrap_enabled { - display_row.wrap_left.max(1) - } else { - 1 - }, - segment_cols, - }); - } - blocks - } - fn paint_gutter_backgrounds(&self, scene: &mut Scene, theme: &Theme) { if self.layout.split_mode { scene.rect(RectPrimitive { @@ -2051,8 +877,6 @@ impl EditorElement { state: &EditorState, doc: &RenderDoc, ) { - use super::render_doc::RenderRowKind; - if state.line_selection.is_empty() { return; } @@ -2896,328 +1720,33 @@ impl EditorElement { let rect = Rect { x: self.layout.unified_text_rect.x, y, - width: self.layout.unified_text_rect.width, - height: line_height, - }; - if let Some(spans) = self.cached_wrapped_rich_text( - doc, - line.right_text, - line.right_runs, - seg, - render_cols, - tone_for_right_side(line), - theme, - ) { - scene.rich_text(RichTextPrimitive { - rect, - spans, - default_color: tone_for_right_side(line).default_text(theme), - font_size, - font_kind: FontKind::Mono, - font_weight: FontWeight::Normal, - }); - } - } - } - - fn clear_document_caches(&mut self) { - self.wrapped_text_cache.clear(); - self.text_layout_cache.clear(); - self.gutter_text_cache.clear(); - } - - fn sync_theme_cache(&mut self, theme: &Theme) { - let key = EditorThemeKey { - text_strong: theme.colors.text_strong, - text_muted: theme.colors.text_muted, - accent: theme.colors.accent, - line_add_text: theme.colors.line_add_text, - line_del_text: theme.colors.line_del_text, - }; - if self.theme_cache_key != Some(key) { - self.wrapped_text_cache.clear(); - self.theme_cache_key = Some(key); - } - } - - fn cached_wrapped_rich_text( - &mut self, - doc: &RenderDoc, - text_range: ByteRange, - runs: RunRange, - segment_index: u16, - wrap_cols: u16, - tone: RowTone, - theme: &Theme, - ) -> Option> { - if !text_range.is_valid() { - return None; - } - let key = WrappedTextCacheKey { - text_start: text_range.start, - text_len: text_range.len, - runs_start: runs.start, - runs_len: runs.len, - segment_index, - wrap_cols, - tone, - }; - if let Some(cached) = self.wrapped_text_cache.get(&key) { - return Some(cached.clone()); - } - - let text_layout = self.cached_text_layout(doc, text_range); - let spans = build_wrapped_rich_text( - doc, - text_layout.as_ref(), - text_range, - runs, - segment_index, - wrap_cols, - tone, - theme, - )?; - self.wrapped_text_cache.insert(key, spans.clone()); - Some(spans) - } - - fn cached_text_layout( - &mut self, - doc: &RenderDoc, - text_range: ByteRange, - ) -> Arc { - let key = TextLayoutCacheKey { - text_start: text_range.start, - text_len: text_range.len, - }; - if let Some(cached) = self.text_layout_cache.get(&key) { - return cached.clone(); - } - - let layout = Arc::new(CachedTextLayout::new(doc.line_text(text_range))); - self.text_layout_cache.insert(key, layout.clone()); - layout - } - - fn cached_gutter_text(&mut self, key: GutterTextCacheKey) -> Arc { - if let Some(cached) = self.gutter_text_cache.get(&key) { - return cached.clone(); - } - - let spaces = " ".repeat(key.digits as usize); - let text: Arc = match key.kind { - GutterTextKind::SplitLeft => format_line_number_string(key.old_line_no, key.digits), - GutterTextKind::SplitRight => format_line_number_string(key.new_line_no, key.digits), - GutterTextKind::Unified => format!( - "{} {}", - format_line_number_string(key.old_line_no, key.digits), - format_line_number_string(key.new_line_no, key.digits) - ), - GutterTextKind::UnifiedOldOnly => format!( - "{} {}", - format_line_number_string(key.old_line_no, key.digits), - spaces - ), - GutterTextKind::UnifiedNewOnly => format!( - "{} {}", - spaces, - format_line_number_string(key.new_line_no, key.digits) - ), - } - .into(); - self.gutter_text_cache.insert(key, text.clone()); - text - } - - fn render_cols_unified(&self) -> u16 { - render_cols_for_width( - self.config.wrap_enabled, - self.config.wrap_column, - self.config.char_width_px as f32, - self.layout.unified_text_rect.width, - ) - } - - fn render_cols_split(&self) -> u16 { - render_cols_for_width( - self.config.wrap_enabled, - self.config.wrap_column, - self.config.char_width_px as f32, - self.layout.left_text_rect.width, - ) - } - - fn visible_segment_range(&self, block_y: f32, segment_count: u16) -> Range { - visible_segment_range_for_block( - block_y, - segment_count.max(1), - self.layout.line_height, - self.layout.content_bounds.y, - self.layout.content_bounds.bottom(), - ) - } -} - -fn line_selection_contains_line( - selection: &super::state::LineSelection, - file_path: Option<&str>, - line: &RenderLine, -) -> bool { - let Ok(hunk_id) = u32::try_from(line.hunk_index) else { - return false; - }; - (line.old_line_index >= 0 - && selection.contains_in_file( - file_path, - hunk_id, - carbon::DiffSide::Old, - line.old_line_index as u32, - )) - || (line.new_line_index >= 0 - && selection.contains_in_file( - file_path, - hunk_id, - carbon::DiffSide::New, - line.new_line_index as u32, - )) -} - -fn file_path_for_line(doc: &RenderDoc, line_index: usize) -> Option<&str> { - doc.lines.get(..=line_index)?.iter().rev().find_map(|line| { - if line.row_kind() == RenderRowKind::FileHeader { - doc.file_meta(line).map(|meta| meta.path.as_str()) - } else { - None - } - }) -} - -fn review_comment_gutter_rect(layout: &EditorLayout, line: &RenderLine) -> Option { - // Any body line is commentable (incl. context) — selected_review_range maps it - // to the new side, or the old side for removed-only lines. - if line.hunk_index < 0 || !line.row_kind().is_body() { - return None; - } - match line.row_kind() { - RenderRowKind::Removed if layout.split_mode => Some(layout.left_gutter_rect), - _ if layout.split_mode => Some(layout.right_gutter_rect), - _ => Some(layout.unified_gutter_rect), - } -} - -fn distance_to_text_block_x(block: TextBlock, x: f32) -> f32 { - if x < block.text_x { - block.text_x - x - } else if x > block.text_x + block.text_width { - x - (block.text_x + block.text_width) - } else { - 0.0 - } -} - -fn text_side_ranges_for_line( - split_mode: bool, - line: &RenderLine, -) -> [Option<(ViewportTextSide, ByteRange)>; 2] { - let mut ranges = [None, None]; - if !line.row_kind().is_body() { - return ranges; - } - let mut next = 0_usize; - let mut push = |side, range: ByteRange| { - if range.is_valid() && next < ranges.len() { - ranges[next] = Some((side, range)); - next += 1; + width: self.layout.unified_text_rect.width, + height: line_height, + }; + if let Some(spans) = self.cached_wrapped_rich_text( + doc, + line.right_text, + line.right_runs, + seg, + render_cols, + tone_for_right_side(line), + theme, + ) { + scene.rich_text(RichTextPrimitive { + rect, + spans, + default_color: tone_for_right_side(line).default_text(theme), + font_size, + font_kind: FontKind::Mono, + font_weight: FontWeight::Normal, + }); + } } - }; - - if split_mode { - push(ViewportTextSide::Left, line.left_text); - push(ViewportTextSide::Right, line.right_text); - } else if line.row_kind() == RenderRowKind::Modified - && line.left_text.is_valid() - && line.right_text.is_valid() - { - push(ViewportTextSide::Left, line.left_text); - push(ViewportTextSide::Right, line.right_text); - } else if let Some((side, range, _, _)) = unified_body_side_with_side(line) { - push(side, range); - } - ranges -} - -fn selection_byte_range_for_side( - selection: &ViewportTextSelection, - split_mode: bool, - line_index: u32, - side: ViewportTextSide, - text: &str, -) -> Option<(usize, usize)> { - let (start, end) = selection_bounds_for_side(selection, split_mode, side)?; - let text_len = text.len(); - let text_len_u32 = text_len.min(u32::MAX as usize) as u32; - let side_start = ViewportTextPoint { - line_index, - side, - byte_offset: 0, - }; - let side_end = ViewportTextPoint { - line_index, - side, - byte_offset: text_len_u32, - }; - if side_end <= start || side_start >= end { - return None; - } - - let byte_start = if start.line_index == line_index && start.side == side { - start.byte_offset.min(text_len_u32) - } else { - 0 - }; - let byte_end = if end.line_index == line_index && end.side == side { - end.byte_offset.min(text_len_u32) - } else { - text_len_u32 - }; - let byte_start = previous_char_boundary(text, byte_start as usize); - let byte_end = previous_char_boundary(text, byte_end as usize); - (byte_end > byte_start).then_some((byte_start, byte_end)) -} - -fn selection_bounds_for_side( - selection: &ViewportTextSelection, - split_mode: bool, - side: ViewportTextSide, -) -> Option<(ViewportTextPoint, ViewportTextPoint)> { - if !split_mode { - return Some(selection.normalized()); - } - if selection.anchor.side != side { - return None; - } - let anchor = selection.anchor; - let focus = ViewportTextPoint { - side, - ..selection.focus - }; - Some(if anchor <= focus { - (anchor, focus) - } else { - (focus, anchor) - }) -} - -fn previous_char_boundary(text: &str, byte: usize) -> usize { - let mut byte = byte.min(text.len()); - while byte > 0 && !text.is_char_boundary(byte) { - byte -= 1; } - byte } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum RowTone { +pub(super) enum RowTone { Neutral, Added, Removed, @@ -3237,145 +1766,6 @@ impl RowTone { } } -fn compute_scrollbar_layout( - layout: &EditorLayout, - state: &EditorState, - override_metrics: Option, -) -> Option { - if state.viewport_height_px == 0 { - return None; - } - let (content_height_px, scroll_top_px, max_scroll_top_px) = match override_metrics { - Some(o) => (o.total_height_px, o.scroll_top_px, o.max_scroll_top_px), - None => ( - state.content_height_px, - state.scroll_top_px, - state.max_scroll_top_px(), - ), - }; - if content_height_px <= state.viewport_height_px { - return None; - } - let s = layout.font_size / BASE_MONO_FONT_SIZE; - let sb_width = scaled(BASE_SCROLLBAR_WIDTH, s); - let sb_margin = scaled(BASE_SCROLLBAR_MARGIN, s); - let cb = layout.content_bounds; - let track = Rect { - x: cb.right() - sb_width, - y: cb.y + sb_margin, - width: sb_width, - height: (cb.height - sb_margin * 2.0).max(0.0), - }; - let ratio = state.viewport_height_px as f32 / content_height_px as f32; - let thumb_min = scaled(BASE_SCROLLBAR_THUMB_MIN, s); - let thumb_height = (track.height * ratio).max(thumb_min).min(track.height); - let scroll_range = max_scroll_top_px.max(1) as f32; - let top_ratio = (scroll_top_px as f32 / scroll_range).clamp(0.0, 1.0); - let thumb_y = track.y + (track.height - thumb_height) * top_ratio; - Some(ScrollbarLayout { - track, - thumb_top: thumb_y, - thumb_height, - thumb: Rect { - x: track.x + 1.0, - y: thumb_y + 1.0, - width: track.width - 2.0, - height: thumb_height - 2.0, - }, - }) -} - -fn build_spatial_layout( - bounds: Rect, - layout: LayoutMode, - gutter_digits: u32, - text_metrics: TextMetrics, -) -> EditorLayout { - let s = editor_scale(text_metrics); - let column_gap = scaled(BASE_COLUMN_GAP, s); - let gutter_padding = scaled(BASE_GUTTER_PADDING, s); - let scrollbar_width = scaled(BASE_SCROLLBAR_WIDTH, s); - let scrollbar_margin = scaled(BASE_SCROLLBAR_MARGIN, s); - - let content_bounds = content_bounds_for_viewport(bounds, text_metrics); - let usable_width = (content_bounds.width - scrollbar_width - scrollbar_margin).max(0.0); - let gutter_width = - gutter_digits as f32 * text_metrics.mono_char_width_px + gutter_padding * 2.0; - let unified_gutter_width = gutter_digits as f32 * text_metrics.mono_char_width_px * 2.0 - + text_metrics.mono_char_width_px - + gutter_padding * 2.0; - - if layout == LayoutMode::Split { - let col_width = ((usable_width - gutter_width * 2.0 - column_gap) / 2.0).max(60.0); - let left_gutter_rect = Rect { - x: content_bounds.x, - y: content_bounds.y, - width: gutter_width, - height: content_bounds.height, - }; - let text_left_pad = gutter_padding; - let left_text_rect = Rect { - x: left_gutter_rect.right() + text_left_pad, - y: content_bounds.y, - width: (col_width - text_left_pad).max(60.0), - height: content_bounds.height, - }; - let right_gutter_rect = Rect { - x: left_gutter_rect.right() + col_width + column_gap, - y: content_bounds.y, - width: gutter_width, - height: content_bounds.height, - }; - let right_text_rect = Rect { - x: right_gutter_rect.right() + text_left_pad, - y: content_bounds.y, - width: (content_bounds.right() - - scrollbar_width - - scrollbar_margin - - right_gutter_rect.right() - - text_left_pad) - .max(60.0), - height: content_bounds.height, - }; - EditorLayout { - outer_bounds: bounds, - content_bounds, - split_mode: true, - gutter_digits, - unified_gutter_rect: Rect::default(), - unified_text_rect: Rect::default(), - left_gutter_rect, - left_text_rect, - right_gutter_rect, - right_text_rect, - ..EditorLayout::default() - } - } else { - let unified_gutter_rect = Rect { - x: content_bounds.x, - y: content_bounds.y, - width: unified_gutter_width, - height: content_bounds.height, - }; - let text_left_pad = gutter_padding; - let unified_text_rect = Rect { - x: unified_gutter_rect.right() + text_left_pad, - y: content_bounds.y, - width: (usable_width - unified_gutter_width - text_left_pad).max(60.0), - height: content_bounds.height, - }; - EditorLayout { - outer_bounds: bounds, - content_bounds, - split_mode: false, - gutter_digits, - unified_gutter_rect, - unified_text_rect, - ..EditorLayout::default() - } - } -} - fn dim_bg(c: Color) -> Color { Color { a: ((c.a as u16 * 200) / 255) as u8, @@ -3403,15 +1793,7 @@ fn paint_row_background(scene: &mut Scene, theme: &Theme, row_rect: Rect, line: }); } -fn format_line_number_string(line_no: u32, digits: u32) -> String { - if line_no == INVALID_U32 { - " ".repeat(digits as usize) - } else { - format!("{line_no:>width$}", width = digits as usize) - } -} - -fn unified_body_side_with_side( +pub(super) fn unified_body_side_with_side( line: &RenderLine, ) -> Option<(ViewportTextSide, ByteRange, RunRange, RowTone)> { let structural = line.flags & RENDER_FLAG_STRUCTURAL != 0; @@ -3614,60 +1996,6 @@ fn accessibility_side_key(side: ViewportTextSide) -> &'static str { } } -fn wrap_cols_for_width( - wrap_enabled: bool, - wrap_column: u32, - char_width_px: f32, - width_px: f32, -) -> u16 { - if !wrap_enabled { - return u16::MAX; - } - let width_cols = (width_px / char_width_px.max(1.0)).floor() as u32; - let cols = if wrap_column > 0 { - width_cols.min(wrap_column) - } else { - width_cols - }; - cols.max(1).min(u16::MAX as u32) as u16 -} - -fn render_cols_for_width( - wrap_enabled: bool, - wrap_column: u32, - char_width_px: f32, - width_px: f32, -) -> u16 { - if wrap_enabled { - return wrap_cols_for_width(true, wrap_column, char_width_px, width_px); - } - - let visible_cols = (width_px / char_width_px.max(1.0)).ceil() as u32; - visible_cols - .saturating_add(u32::from(UNWRAPPED_RENDER_OVERSCAN_COLS)) - .max(1) - .min(u16::MAX as u32) as u16 -} - -fn visible_segment_range_for_block( - block_y: f32, - segment_count: u16, - line_height: f32, - viewport_top: f32, - viewport_bottom: f32, -) -> Range { - if segment_count == 0 || line_height <= 0.0 { - return 0..0; - } - - let max_segments = u32::from(segment_count); - let start = ((viewport_top - block_y) / line_height).floor().max(0.0) as u32; - let end = ((viewport_bottom - block_y) / line_height).ceil().max(0.0) as u32; - let start = start.min(max_segments); - let end = end.max(start).min(max_segments); - start as u16..end as u16 -} - fn paint_column_range_rects_with_vertical_inset( scene: &mut Scene, col_start: u32, @@ -3778,7 +2106,7 @@ fn paint_column_range_rects( } } -fn build_wrapped_rich_text( +pub(super) fn build_wrapped_rich_text( doc: &RenderDoc, text_layout: &CachedTextLayout, text_range: ByteRange, @@ -3829,7 +2157,7 @@ fn wrapped_col_slice( } #[cfg(test)] -fn wrapped_byte_slice( +pub(super) fn wrapped_byte_slice( text_layout: &CachedTextLayout, wrap_cols: u16, segment_index: u16, @@ -4014,734 +2342,3 @@ fn syntax_kind_from_style_id(style_id: u16) -> SyntaxTokenKind { _ => SyntaxTokenKind::Normal, } } - -#[cfg(test)] -mod tests { - use super::{ - CachedTextLayout, EditorDocument, EditorElement, build_wrapped_rich_text, - editor_bottom_padding_px, render_cols_for_width, visible_segment_range_for_block, - wrapped_byte_slice, - }; - use crate::core::compare::LayoutMode; - use crate::editor::diff::render_doc::{ - ByteRange, RenderDoc, RenderLine, RenderRowKind, RunRange, - }; - use crate::editor::diff::state::{ - EditorState, ViewportTextPoint, ViewportTextSelection, ViewportTextSide, - }; - use crate::render::{FontStyle, FontWeight, Rect, TextMetrics}; - use crate::ui::theme::Theme; - - #[test] - fn wrapped_byte_slice_breaks_monospaced_text_by_columns() { - let layout = CachedTextLayout::new("abcdefghij"); - assert_eq!(wrapped_byte_slice(&layout, 4, 0), Some((0, 4))); - assert_eq!(wrapped_byte_slice(&layout, 4, 1), Some((4, 8))); - assert_eq!(wrapped_byte_slice(&layout, 4, 2), Some((8, 10))); - assert_eq!(wrapped_byte_slice(&layout, 4, 3), None); - } - - #[test] - fn cached_text_layout_tracks_visual_columns_for_tabs() { - let layout = CachedTextLayout::new("\ta\t"); - assert_eq!(layout.total_cols(), 16); - assert_eq!(layout.col_for_byte(0), 0); - assert_eq!(layout.col_for_byte(1), 8); - assert_eq!(layout.col_for_byte(2), 9); - assert_eq!(layout.col_for_byte(3), 16); - } - - #[test] - fn rich_text_builder_returns_spans_for_requested_segment() { - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"keyword // comment".to_vec(), - style_runs: vec![ - crate::editor::diff::render_doc::StyleRun { - byte_start: 0, - byte_len: 7, - style_id: 1, - flags: 0, - }, - crate::editor::diff::render_doc::StyleRun { - byte_start: 7, - byte_len: 1, - style_id: 0, - flags: 0, - }, - crate::editor::diff::render_doc::StyleRun { - byte_start: 8, - byte_len: 10, - style_id: 3, - flags: 0, - }, - ], - lines: vec![RenderLine { - kind: RenderRowKind::Context as u8, - right_text: ByteRange { start: 0, len: 18 }, - right_runs: RunRange { start: 0, len: 3 }, - right_cols: 18, - ..RenderLine::default() - }], - }; - - let text_layout = CachedTextLayout::new("keyword // comment"); - let spans = build_wrapped_rich_text( - &doc, - &text_layout, - doc.lines[0].right_text, - doc.lines[0].right_runs, - 0, - u16::MAX, - super::RowTone::Neutral, - &Theme::default_dark(), - ) - .expect("spans"); - - assert!(!spans.is_empty()); - assert_eq!( - spans - .iter() - .map(|span| span.text.as_ref()) - .collect::(), - "keyword // comment" - ); - assert_eq!(spans[0].text.as_ref(), "keyword"); - assert_eq!(spans[0].font_weight, Some(FontWeight::Semibold)); - let comment = spans - .iter() - .find(|span| span.text.as_ref() == "// comment") - .expect("comment span"); - assert_eq!(comment.font_style, Some(FontStyle::Italic)); - } - - #[test] - fn rich_text_builder_expands_tabs_across_wrapped_segments() { - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"\tabc".to_vec(), - style_runs: vec![crate::editor::diff::render_doc::StyleRun { - byte_start: 0, - byte_len: 4, - style_id: 0, - flags: 0, - }], - lines: vec![RenderLine { - kind: RenderRowKind::Context as u8, - right_text: ByteRange { start: 0, len: 4 }, - right_runs: RunRange { start: 0, len: 1 }, - right_cols: 11, - ..RenderLine::default() - }], - }; - - let text_layout = CachedTextLayout::new("\tabc"); - let theme = Theme::default_dark(); - - let seg0 = build_wrapped_rich_text( - &doc, - &text_layout, - doc.lines[0].right_text, - doc.lines[0].right_runs, - 0, - 4, - super::RowTone::Neutral, - &theme, - ) - .expect("segment 0"); - let seg1 = build_wrapped_rich_text( - &doc, - &text_layout, - doc.lines[0].right_text, - doc.lines[0].right_runs, - 1, - 4, - super::RowTone::Neutral, - &theme, - ) - .expect("segment 1"); - let seg2 = build_wrapped_rich_text( - &doc, - &text_layout, - doc.lines[0].right_text, - doc.lines[0].right_runs, - 2, - 4, - super::RowTone::Neutral, - &theme, - ) - .expect("segment 2"); - - assert_eq!( - seg0.iter() - .map(|span| span.text.as_ref()) - .collect::(), - " " - ); - assert_eq!( - seg1.iter() - .map(|span| span.text.as_ref()) - .collect::(), - " " - ); - assert_eq!( - seg2.iter() - .map(|span| span.text.as_ref()) - .collect::(), - "abc" - ); - } - - #[test] - fn render_cols_cap_unwrapped_rows_to_viewport_budget() { - assert_eq!(render_cols_for_width(false, 0, 8.0, 80.0), 26); - assert_eq!(render_cols_for_width(true, 0, 8.0, 80.0), 10); - } - - #[test] - fn visible_segment_range_limits_wrapped_blocks_to_viewport() { - assert_eq!( - visible_segment_range_for_block(100.0, 10, 20.0, 120.0, 170.0), - 1..4 - ); - assert_eq!( - visible_segment_range_for_block(100.0, 10, 20.0, 0.0, 50.0), - 0..0 - ); - } - - #[test] - fn prepare_populates_visible_range_and_hit_testing() { - let mut state = EditorState { - layout: LayoutMode::Unified, - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"demo.txt@@ -1 +1 @@line".to_vec(), - style_runs: Vec::new(), - lines: vec![ - RenderLine { - kind: RenderRowKind::FileHeader as u8, - left_text: ByteRange { start: 0, len: 8 }, - left_cols: 8, - ..RenderLine::default() - }, - RenderLine { - kind: RenderRowKind::HunkSeparator as u8, - left_text: ByteRange { start: 8, len: 11 }, - left_cols: 11, - ..RenderLine::default() - }, - RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 1, - new_line_no: 1, - right_text: ByteRange { start: 19, len: 4 }, - right_cols: 4, - ..RenderLine::default() - }, - ], - }; - - let mut runtime = EditorElement::default(); - runtime.prepare( - &mut state, - EditorDocument::Text { - compare_generation: 1, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }, - Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }, - TextMetrics::default(), - ); - - assert_eq!(state.visible_row_start, Some(0)); - // FileHeader lines are skipped in layout, so only 2 display rows exist. - assert!(state.visible_row_end.expect("visible end") >= 2); - let body = runtime.body_bounds(); - assert_eq!( - runtime.hit_test_row(&state, body.x + 20.0, body.y + 5.0), - Some(0) - ); - } - - #[test] - fn prepare_adds_bottom_padding_to_keep_last_row_above_viewport_clip() { - let mut state = EditorState { - layout: LayoutMode::Unified, - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"last".to_vec(), - style_runs: Vec::new(), - lines: vec![RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 1, - new_line_no: 1, - right_text: ByteRange { start: 0, len: 4 }, - right_cols: 4, - ..RenderLine::default() - }], - }; - let mut runtime = EditorElement::default(); - runtime.prepare( - &mut state, - EditorDocument::Text { - compare_generation: 1, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }, - Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 16.0, - }, - TextMetrics::default(), - ); - - let bottom_padding = editor_bottom_padding_px(runtime.metrics); - assert_eq!( - state.content_height_px, - runtime.summary.content_height_px + bottom_padding - ); - let unpadded_max = runtime - .summary - .content_height_px - .saturating_sub(state.viewport_height_px.max(1)); - assert_eq!(state.max_scroll_top_px(), unpadded_max + bottom_padding); - } - - #[test] - fn preprepare_content_height_matches_prepared_viewport_height() { - let mut state = EditorState { - layout: LayoutMode::Unified, - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"last".to_vec(), - style_runs: Vec::new(), - lines: vec![RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 1, - new_line_no: 1, - right_text: ByteRange { start: 0, len: 4 }, - right_cols: 4, - ..RenderLine::default() - }], - }; - let mut runtime = EditorElement::default(); - let bounds = Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }; - let text_metrics = TextMetrics::default(); - let expected_height = runtime - .content_height_for_bounds(bounds, text_metrics) - .max(0.0) - .round() as u32; - - runtime.prepare( - &mut state, - EditorDocument::Text { - compare_generation: 1, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }, - bounds, - text_metrics, - ); - - assert_eq!(state.viewport_height_px, expected_height); - } - - #[test] - fn hit_test_text_point_maps_viewport_columns_to_line_bytes() { - let mut state = EditorState { - layout: LayoutMode::Unified, - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"hello".to_vec(), - style_runs: Vec::new(), - lines: vec![RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 1, - new_line_no: 1, - right_text: ByteRange { start: 0, len: 5 }, - right_cols: 5, - ..RenderLine::default() - }], - }; - let mut runtime = EditorElement::default(); - runtime.prepare( - &mut state, - EditorDocument::Text { - compare_generation: 1, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }, - Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }, - TextMetrics::default(), - ); - - let x = - runtime.layout.unified_text_rect.x + TextMetrics::default().mono_char_width_px * 3.1; - let y = runtime.body_bounds().y + runtime.layout.line_height * 0.5; - let point = runtime - .hit_test_text_point(&state, &doc, x, y) - .expect("text point"); - - assert_eq!( - point, - ViewportTextPoint { - line_index: 0, - side: ViewportTextSide::Right, - byte_offset: 3, - } - ); - } - - #[test] - fn viewport_selection_text_copies_visible_line_segments() { - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"alphaBRAVO".to_vec(), - style_runs: Vec::new(), - lines: vec![ - RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 1, - new_line_no: 1, - right_text: ByteRange { start: 0, len: 5 }, - right_cols: 5, - ..RenderLine::default() - }, - RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 2, - new_line_no: 2, - right_text: ByteRange { start: 5, len: 5 }, - right_cols: 5, - ..RenderLine::default() - }, - ], - }; - let selection = ViewportTextSelection { - generation: 7, - anchor: ViewportTextPoint { - line_index: 0, - side: ViewportTextSide::Right, - byte_offset: 1, - }, - focus: ViewportTextPoint { - line_index: 1, - side: ViewportTextSide::Right, - byte_offset: 3, - }, - }; - let runtime = EditorElement::default(); - - assert_eq!( - runtime.viewport_selection_text(&doc, &selection).as_deref(), - Some("lpha\nBRA") - ); - } - - #[test] - fn split_viewport_selection_text_stays_on_selected_side() { - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"old-aNEW-Aold-bNEW-B".to_vec(), - style_runs: Vec::new(), - lines: vec![ - RenderLine { - kind: RenderRowKind::Modified as u8, - old_line_no: 1, - new_line_no: 1, - left_text: ByteRange { start: 0, len: 5 }, - right_text: ByteRange { start: 5, len: 5 }, - left_cols: 5, - right_cols: 5, - ..RenderLine::default() - }, - RenderLine { - kind: RenderRowKind::Modified as u8, - old_line_no: 2, - new_line_no: 2, - left_text: ByteRange { start: 10, len: 5 }, - right_text: ByteRange { start: 15, len: 5 }, - left_cols: 5, - right_cols: 5, - ..RenderLine::default() - }, - ], - }; - let selection = ViewportTextSelection { - generation: 7, - anchor: ViewportTextPoint { - line_index: 0, - side: ViewportTextSide::Left, - byte_offset: 1, - }, - focus: ViewportTextPoint { - line_index: 1, - side: ViewportTextSide::Left, - byte_offset: 4, - }, - }; - let mut runtime = EditorElement::default(); - runtime.layout.split_mode = true; - - assert_eq!( - runtime.viewport_selection_text(&doc, &selection).as_deref(), - Some("ld-a\nold-") - ); - } - - #[test] - fn viewport_text_selection_paints_square_rectangles() { - use crate::render::{Primitive, Scene}; - - let mut state = EditorState { - layout: LayoutMode::Unified, - text_selection: Some(ViewportTextSelection { - generation: 1, - anchor: ViewportTextPoint { - line_index: 0, - side: ViewportTextSide::Right, - byte_offset: 1, - }, - focus: ViewportTextPoint { - line_index: 1, - side: ViewportTextSide::Right, - byte_offset: 4, - }, - }), - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"alphabravo".to_vec(), - style_runs: Vec::new(), - lines: vec![ - RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 1, - new_line_no: 1, - right_text: ByteRange { start: 0, len: 5 }, - right_cols: 5, - ..RenderLine::default() - }, - RenderLine { - kind: RenderRowKind::Context as u8, - old_line_no: 2, - new_line_no: 2, - right_text: ByteRange { start: 5, len: 5 }, - right_cols: 5, - ..RenderLine::default() - }, - ], - }; - let mut runtime = EditorElement::default(); - let document = EditorDocument::Text { - compare_generation: 1, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }; - runtime.prepare( - &mut state, - document, - Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }, - TextMetrics::default(), - ); - - let theme = Theme::default_dark(); - let selection_bg = theme.colors.selection_bg; - let mut scene = Scene::default(); - runtime.paint(&mut scene, &theme, &state, document); - - assert!( - scene - .primitives - .iter() - .any(|p| matches!(p, Primitive::Rect(r) if r.color == selection_bg)) - ); - assert!( - !scene - .primitives - .iter() - .any(|p| matches!(p, Primitive::RoundedRect(r) if r.color == selection_bg)) - ); - } - - #[test] - fn block_paint_emits_primitive_for_registered_decoration() { - use super::super::decoration::{BlockDecoration, BlockPaintCtx, BlockPlacement}; - use crate::render::{Primitive, RectPrimitive, Scene}; - use crate::ui::theme::Color; - - #[derive(Debug)] - struct StubBlock { - color: Color, - } - - impl BlockDecoration for StubBlock { - fn height(&self, _metrics: &super::super::display_layout::DisplayLayoutMetrics) -> u16 { - 20 - } - - fn paint(&self, ctx: &mut BlockPaintCtx) { - let _ = ctx.hovered; - ctx.scene.rect(RectPrimitive { - rect: ctx.row_rect, - color: self.color, - }); - } - } - - let mut state = EditorState { - layout: LayoutMode::Unified, - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"@@ hdr @@".to_vec(), - style_runs: Vec::new(), - lines: vec![RenderLine { - kind: RenderRowKind::HunkSeparator as u8, - left_text: ByteRange { start: 0, len: 9 }, - left_cols: 9, - ..RenderLine::default() - }], - }; - - let marker = Color { - r: 11, - g: 22, - b: 33, - a: 255, - }; - - let mut runtime = EditorElement::default(); - runtime.blocks_mut().push( - BlockPlacement::Above(0), - Box::new(StubBlock { color: marker }), - ); - - let document = EditorDocument::Text { - compare_generation: 7, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }; - runtime.prepare( - &mut state, - document, - Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }, - TextMetrics::default(), - ); - - let theme = Theme::default_dark(); - let mut scene = Scene::default(); - runtime.paint(&mut scene, &theme, &state, document); - - let has_marker = scene.primitives.iter().any(|p| match p { - Primitive::Rect(r) => r.color == marker, - _ => false, - }); - assert!(has_marker, "block paint() should emit its own primitives"); - } - - #[test] - fn hunk_separator_decoration_emits_background_rect() { - use crate::render::{Primitive, Scene}; - - let mut state = EditorState { - layout: LayoutMode::Unified, - ..EditorState::default() - }; - let doc = RenderDoc { - file_metadata: Vec::new(), - text_bytes: b"@@ hdr @@".to_vec(), - style_runs: Vec::new(), - lines: vec![RenderLine { - kind: RenderRowKind::HunkSeparator as u8, - left_text: ByteRange { start: 0, len: 9 }, - left_cols: 9, - ..RenderLine::default() - }], - }; - - let mut runtime = EditorElement::default(); - let document = EditorDocument::Text { - compare_generation: 1, - file_index: 0, - path: "demo.txt", - doc: &doc, - show_file_headers: false, - }; - runtime.prepare( - &mut state, - document, - Rect { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }, - TextMetrics::default(), - ); - - let theme = Theme::default_dark(); - let mut scene = Scene::default(); - runtime.paint(&mut scene, &theme, &state, document); - - let hunk_bg = theme.colors.hunk_header_bg; - let has_hunk_bg = scene.primitives.iter().any(|p| match p { - Primitive::Rect(r) => r.color == hunk_bg, - _ => false, - }); - assert!( - has_hunk_bg, - "expected a rect with hunk_header_bg color to be emitted" - ); - } -} diff --git a/src/editor/diff/element/tests.rs b/src/editor/diff/element/tests.rs new file mode 100644 index 00000000..d32c87ee --- /dev/null +++ b/src/editor/diff/element/tests.rs @@ -0,0 +1,724 @@ +use super::layout::{ + editor_bottom_padding_px, render_cols_for_width, visible_segment_range_for_block, +}; +use super::paint::{RowTone, build_wrapped_rich_text, wrapped_byte_slice}; +use super::{CachedTextLayout, EditorDocument, EditorElement}; +use crate::core::compare::LayoutMode; +use crate::editor::diff::render_doc::{ByteRange, RenderDoc, RenderLine, RenderRowKind, RunRange}; +use crate::editor::diff::state::{ + EditorState, ViewportTextPoint, ViewportTextSelection, ViewportTextSide, +}; +use crate::render::{FontStyle, FontWeight, Rect, TextMetrics}; +use crate::ui::theme::Theme; + +#[test] +fn wrapped_byte_slice_breaks_monospaced_text_by_columns() { + let layout = CachedTextLayout::new("abcdefghij"); + assert_eq!(wrapped_byte_slice(&layout, 4, 0), Some((0, 4))); + assert_eq!(wrapped_byte_slice(&layout, 4, 1), Some((4, 8))); + assert_eq!(wrapped_byte_slice(&layout, 4, 2), Some((8, 10))); + assert_eq!(wrapped_byte_slice(&layout, 4, 3), None); +} + +#[test] +fn cached_text_layout_tracks_visual_columns_for_tabs() { + let layout = CachedTextLayout::new("\ta\t"); + assert_eq!(layout.total_cols(), 16); + assert_eq!(layout.col_for_byte(0), 0); + assert_eq!(layout.col_for_byte(1), 8); + assert_eq!(layout.col_for_byte(2), 9); + assert_eq!(layout.col_for_byte(3), 16); +} + +#[test] +fn rich_text_builder_returns_spans_for_requested_segment() { + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"keyword // comment".to_vec(), + style_runs: vec![ + crate::editor::diff::render_doc::StyleRun { + byte_start: 0, + byte_len: 7, + style_id: 1, + flags: 0, + }, + crate::editor::diff::render_doc::StyleRun { + byte_start: 7, + byte_len: 1, + style_id: 0, + flags: 0, + }, + crate::editor::diff::render_doc::StyleRun { + byte_start: 8, + byte_len: 10, + style_id: 3, + flags: 0, + }, + ], + lines: vec![RenderLine { + kind: RenderRowKind::Context as u8, + right_text: ByteRange { start: 0, len: 18 }, + right_runs: RunRange { start: 0, len: 3 }, + right_cols: 18, + ..RenderLine::default() + }], + }; + + let text_layout = CachedTextLayout::new("keyword // comment"); + let spans = build_wrapped_rich_text( + &doc, + &text_layout, + doc.lines[0].right_text, + doc.lines[0].right_runs, + 0, + u16::MAX, + RowTone::Neutral, + &Theme::default_dark(), + ) + .expect("spans"); + + assert!(!spans.is_empty()); + assert_eq!( + spans + .iter() + .map(|span| span.text.as_ref()) + .collect::(), + "keyword // comment" + ); + assert_eq!(spans[0].text.as_ref(), "keyword"); + assert_eq!(spans[0].font_weight, Some(FontWeight::Semibold)); + let comment = spans + .iter() + .find(|span| span.text.as_ref() == "// comment") + .expect("comment span"); + assert_eq!(comment.font_style, Some(FontStyle::Italic)); +} + +#[test] +fn rich_text_builder_expands_tabs_across_wrapped_segments() { + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"\tabc".to_vec(), + style_runs: vec![crate::editor::diff::render_doc::StyleRun { + byte_start: 0, + byte_len: 4, + style_id: 0, + flags: 0, + }], + lines: vec![RenderLine { + kind: RenderRowKind::Context as u8, + right_text: ByteRange { start: 0, len: 4 }, + right_runs: RunRange { start: 0, len: 1 }, + right_cols: 11, + ..RenderLine::default() + }], + }; + + let text_layout = CachedTextLayout::new("\tabc"); + let theme = Theme::default_dark(); + + let seg0 = build_wrapped_rich_text( + &doc, + &text_layout, + doc.lines[0].right_text, + doc.lines[0].right_runs, + 0, + 4, + RowTone::Neutral, + &theme, + ) + .expect("segment 0"); + let seg1 = build_wrapped_rich_text( + &doc, + &text_layout, + doc.lines[0].right_text, + doc.lines[0].right_runs, + 1, + 4, + RowTone::Neutral, + &theme, + ) + .expect("segment 1"); + let seg2 = build_wrapped_rich_text( + &doc, + &text_layout, + doc.lines[0].right_text, + doc.lines[0].right_runs, + 2, + 4, + RowTone::Neutral, + &theme, + ) + .expect("segment 2"); + + assert_eq!( + seg0.iter() + .map(|span| span.text.as_ref()) + .collect::(), + " " + ); + assert_eq!( + seg1.iter() + .map(|span| span.text.as_ref()) + .collect::(), + " " + ); + assert_eq!( + seg2.iter() + .map(|span| span.text.as_ref()) + .collect::(), + "abc" + ); +} + +#[test] +fn render_cols_cap_unwrapped_rows_to_viewport_budget() { + assert_eq!(render_cols_for_width(false, 0, 8.0, 80.0), 26); + assert_eq!(render_cols_for_width(true, 0, 8.0, 80.0), 10); +} + +#[test] +fn visible_segment_range_limits_wrapped_blocks_to_viewport() { + assert_eq!( + visible_segment_range_for_block(100.0, 10, 20.0, 120.0, 170.0), + 1..4 + ); + assert_eq!( + visible_segment_range_for_block(100.0, 10, 20.0, 0.0, 50.0), + 0..0 + ); +} + +#[test] +fn prepare_populates_visible_range_and_hit_testing() { + let mut state = EditorState { + layout: LayoutMode::Unified, + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"demo.txt@@ -1 +1 @@line".to_vec(), + style_runs: Vec::new(), + lines: vec![ + RenderLine { + kind: RenderRowKind::FileHeader as u8, + left_text: ByteRange { start: 0, len: 8 }, + left_cols: 8, + ..RenderLine::default() + }, + RenderLine { + kind: RenderRowKind::HunkSeparator as u8, + left_text: ByteRange { start: 8, len: 11 }, + left_cols: 11, + ..RenderLine::default() + }, + RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 1, + new_line_no: 1, + right_text: ByteRange { start: 19, len: 4 }, + right_cols: 4, + ..RenderLine::default() + }, + ], + }; + + let mut runtime = EditorElement::default(); + runtime.prepare( + &mut state, + EditorDocument::Text { + compare_generation: 1, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }, + Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }, + TextMetrics::default(), + ); + + assert_eq!(state.visible_row_start, Some(0)); + // FileHeader lines are skipped in layout, so only 2 display rows exist. + assert!(state.visible_row_end.expect("visible end") >= 2); + let body = runtime.body_bounds(); + assert_eq!( + runtime.hit_test_row(&state, body.x + 20.0, body.y + 5.0), + Some(0) + ); +} + +#[test] +fn prepare_adds_bottom_padding_to_keep_last_row_above_viewport_clip() { + let mut state = EditorState { + layout: LayoutMode::Unified, + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"last".to_vec(), + style_runs: Vec::new(), + lines: vec![RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 1, + new_line_no: 1, + right_text: ByteRange { start: 0, len: 4 }, + right_cols: 4, + ..RenderLine::default() + }], + }; + let mut runtime = EditorElement::default(); + runtime.prepare( + &mut state, + EditorDocument::Text { + compare_generation: 1, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }, + Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 16.0, + }, + TextMetrics::default(), + ); + + let bottom_padding = editor_bottom_padding_px(runtime.metrics); + assert_eq!( + state.content_height_px, + runtime.summary.content_height_px + bottom_padding + ); + let unpadded_max = runtime + .summary + .content_height_px + .saturating_sub(state.viewport_height_px.max(1)); + assert_eq!(state.max_scroll_top_px(), unpadded_max + bottom_padding); +} + +#[test] +fn preprepare_content_height_matches_prepared_viewport_height() { + let mut state = EditorState { + layout: LayoutMode::Unified, + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"last".to_vec(), + style_runs: Vec::new(), + lines: vec![RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 1, + new_line_no: 1, + right_text: ByteRange { start: 0, len: 4 }, + right_cols: 4, + ..RenderLine::default() + }], + }; + let mut runtime = EditorElement::default(); + let bounds = Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }; + let text_metrics = TextMetrics::default(); + let expected_height = runtime + .content_height_for_bounds(bounds, text_metrics) + .max(0.0) + .round() as u32; + + runtime.prepare( + &mut state, + EditorDocument::Text { + compare_generation: 1, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }, + bounds, + text_metrics, + ); + + assert_eq!(state.viewport_height_px, expected_height); +} + +#[test] +fn hit_test_text_point_maps_viewport_columns_to_line_bytes() { + let mut state = EditorState { + layout: LayoutMode::Unified, + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"hello".to_vec(), + style_runs: Vec::new(), + lines: vec![RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 1, + new_line_no: 1, + right_text: ByteRange { start: 0, len: 5 }, + right_cols: 5, + ..RenderLine::default() + }], + }; + let mut runtime = EditorElement::default(); + runtime.prepare( + &mut state, + EditorDocument::Text { + compare_generation: 1, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }, + Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }, + TextMetrics::default(), + ); + + let x = runtime.layout.unified_text_rect.x + TextMetrics::default().mono_char_width_px * 3.1; + let y = runtime.body_bounds().y + runtime.layout.line_height * 0.5; + let point = runtime + .hit_test_text_point(&state, &doc, x, y) + .expect("text point"); + + assert_eq!( + point, + ViewportTextPoint { + line_index: 0, + side: ViewportTextSide::Right, + byte_offset: 3, + } + ); +} + +#[test] +fn viewport_selection_text_copies_visible_line_segments() { + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"alphaBRAVO".to_vec(), + style_runs: Vec::new(), + lines: vec![ + RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 1, + new_line_no: 1, + right_text: ByteRange { start: 0, len: 5 }, + right_cols: 5, + ..RenderLine::default() + }, + RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 2, + new_line_no: 2, + right_text: ByteRange { start: 5, len: 5 }, + right_cols: 5, + ..RenderLine::default() + }, + ], + }; + let selection = ViewportTextSelection { + generation: 7, + anchor: ViewportTextPoint { + line_index: 0, + side: ViewportTextSide::Right, + byte_offset: 1, + }, + focus: ViewportTextPoint { + line_index: 1, + side: ViewportTextSide::Right, + byte_offset: 3, + }, + }; + let runtime = EditorElement::default(); + + assert_eq!( + runtime.viewport_selection_text(&doc, &selection).as_deref(), + Some("lpha\nBRA") + ); +} + +#[test] +fn split_viewport_selection_text_stays_on_selected_side() { + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"old-aNEW-Aold-bNEW-B".to_vec(), + style_runs: Vec::new(), + lines: vec![ + RenderLine { + kind: RenderRowKind::Modified as u8, + old_line_no: 1, + new_line_no: 1, + left_text: ByteRange { start: 0, len: 5 }, + right_text: ByteRange { start: 5, len: 5 }, + left_cols: 5, + right_cols: 5, + ..RenderLine::default() + }, + RenderLine { + kind: RenderRowKind::Modified as u8, + old_line_no: 2, + new_line_no: 2, + left_text: ByteRange { start: 10, len: 5 }, + right_text: ByteRange { start: 15, len: 5 }, + left_cols: 5, + right_cols: 5, + ..RenderLine::default() + }, + ], + }; + let selection = ViewportTextSelection { + generation: 7, + anchor: ViewportTextPoint { + line_index: 0, + side: ViewportTextSide::Left, + byte_offset: 1, + }, + focus: ViewportTextPoint { + line_index: 1, + side: ViewportTextSide::Left, + byte_offset: 4, + }, + }; + let mut runtime = EditorElement::default(); + runtime.layout.split_mode = true; + + assert_eq!( + runtime.viewport_selection_text(&doc, &selection).as_deref(), + Some("ld-a\nold-") + ); +} + +#[test] +fn viewport_text_selection_paints_square_rectangles() { + use crate::render::{Primitive, Scene}; + + let mut state = EditorState { + layout: LayoutMode::Unified, + text_selection: Some(ViewportTextSelection { + generation: 1, + anchor: ViewportTextPoint { + line_index: 0, + side: ViewportTextSide::Right, + byte_offset: 1, + }, + focus: ViewportTextPoint { + line_index: 1, + side: ViewportTextSide::Right, + byte_offset: 4, + }, + }), + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"alphabravo".to_vec(), + style_runs: Vec::new(), + lines: vec![ + RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 1, + new_line_no: 1, + right_text: ByteRange { start: 0, len: 5 }, + right_cols: 5, + ..RenderLine::default() + }, + RenderLine { + kind: RenderRowKind::Context as u8, + old_line_no: 2, + new_line_no: 2, + right_text: ByteRange { start: 5, len: 5 }, + right_cols: 5, + ..RenderLine::default() + }, + ], + }; + let mut runtime = EditorElement::default(); + let document = EditorDocument::Text { + compare_generation: 1, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }; + runtime.prepare( + &mut state, + document, + Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }, + TextMetrics::default(), + ); + + let theme = Theme::default_dark(); + let selection_bg = theme.colors.selection_bg; + let mut scene = Scene::default(); + runtime.paint(&mut scene, &theme, &state, document); + + assert!( + scene + .primitives + .iter() + .any(|p| matches!(p, Primitive::Rect(r) if r.color == selection_bg)) + ); + assert!( + !scene + .primitives + .iter() + .any(|p| matches!(p, Primitive::RoundedRect(r) if r.color == selection_bg)) + ); +} + +#[test] +fn block_paint_emits_primitive_for_registered_decoration() { + use super::super::decoration::{BlockDecoration, BlockPaintCtx, BlockPlacement}; + use crate::render::{Primitive, RectPrimitive, Scene}; + use crate::ui::theme::Color; + + #[derive(Debug)] + struct StubBlock { + color: Color, + } + + impl BlockDecoration for StubBlock { + fn height(&self, _metrics: &super::super::display_layout::DisplayLayoutMetrics) -> u16 { + 20 + } + + fn paint(&self, ctx: &mut BlockPaintCtx) { + let _ = ctx.hovered; + ctx.scene.rect(RectPrimitive { + rect: ctx.row_rect, + color: self.color, + }); + } + } + + let mut state = EditorState { + layout: LayoutMode::Unified, + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"@@ hdr @@".to_vec(), + style_runs: Vec::new(), + lines: vec![RenderLine { + kind: RenderRowKind::HunkSeparator as u8, + left_text: ByteRange { start: 0, len: 9 }, + left_cols: 9, + ..RenderLine::default() + }], + }; + + let marker = Color { + r: 11, + g: 22, + b: 33, + a: 255, + }; + + let mut runtime = EditorElement::default(); + runtime.blocks_mut().push( + BlockPlacement::Above(0), + Box::new(StubBlock { color: marker }), + ); + + let document = EditorDocument::Text { + compare_generation: 7, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }; + runtime.prepare( + &mut state, + document, + Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }, + TextMetrics::default(), + ); + + let theme = Theme::default_dark(); + let mut scene = Scene::default(); + runtime.paint(&mut scene, &theme, &state, document); + + let has_marker = scene.primitives.iter().any(|p| match p { + Primitive::Rect(r) => r.color == marker, + _ => false, + }); + assert!(has_marker, "block paint() should emit its own primitives"); +} + +#[test] +fn hunk_separator_decoration_emits_background_rect() { + use crate::render::{Primitive, Scene}; + + let mut state = EditorState { + layout: LayoutMode::Unified, + ..EditorState::default() + }; + let doc = RenderDoc { + file_metadata: Vec::new(), + text_bytes: b"@@ hdr @@".to_vec(), + style_runs: Vec::new(), + lines: vec![RenderLine { + kind: RenderRowKind::HunkSeparator as u8, + left_text: ByteRange { start: 0, len: 9 }, + left_cols: 9, + ..RenderLine::default() + }], + }; + + let mut runtime = EditorElement::default(); + let document = EditorDocument::Text { + compare_generation: 1, + file_index: 0, + path: "demo.txt", + doc: &doc, + show_file_headers: false, + }; + runtime.prepare( + &mut state, + document, + Rect { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }, + TextMetrics::default(), + ); + + let theme = Theme::default_dark(); + let mut scene = Scene::default(); + runtime.paint(&mut scene, &theme, &state, document); + + let hunk_bg = theme.colors.hunk_header_bg; + let has_hunk_bg = scene.primitives.iter().any(|p| match p { + Primitive::Rect(r) => r.color == hunk_bg, + _ => false, + }); + assert!( + has_hunk_bg, + "expected a rect with hunk_header_bg color to be emitted" + ); +} diff --git a/src/editor/diff/state.rs b/src/editor/diff/state.rs index 2ad26264..1a0dc3eb 100644 --- a/src/editor/diff/state.rs +++ b/src/editor/diff/state.rs @@ -1,4 +1,5 @@ use std::collections::BTreeSet; +use std::sync::Arc; use halogen::Store; @@ -81,7 +82,8 @@ impl ViewportTextSelection { pub struct SearchState { pub open: bool, pub query: String, - pub matches: Vec, + /// Shared so per-frame snapshots are pointer bumps, not Vec clones. + pub matches: Arc>, pub active_index: Option, } @@ -90,7 +92,7 @@ impl Default for SearchState { Self { open: false, query: String::new(), - matches: Vec::new(), + matches: Arc::default(), active_index: None, } } @@ -101,6 +103,12 @@ pub struct EditorState { pub layout: LayoutMode, pub wrap_enabled: bool, pub wrap_column: u32, + /// Compare generation the document geometry below (scroll, content + /// height, visible rows, hunk/file positions) was last prepared for. + /// `0` means "no document". Lets prepare re-clamp scroll when a new + /// compare generation replaces the active document and lets paint guard + /// against layout/state mismatches without panicking. + pub doc_generation: u64, pub scroll_top_px: u32, pub content_height_px: u32, pub viewport_width_px: u32, @@ -113,11 +121,13 @@ pub struct EditorState { pub visible_row_end: Option, pub focused: bool, pub review_enabled: bool, - pub hunk_positions: Vec, - pub file_positions: Vec, + /// Arc-shared so per-frame snapshot reads and `set_if_changed` + /// write-backs are pointer swaps/compares instead of Vec clones. + pub hunk_positions: Arc>, + pub file_positions: Arc>, #[store(flatten)] pub search: SearchState, - pub search_match_y_positions: Vec, + pub search_match_y_positions: Arc>, pub line_selection: LineSelection, pub text_selection: Option, } @@ -195,6 +205,7 @@ impl Default for EditorState { layout: LayoutMode::Unified, wrap_enabled: false, wrap_column: 0, + doc_generation: 0, scroll_top_px: 0, content_height_px: 0, viewport_width_px: 0, @@ -207,10 +218,10 @@ impl Default for EditorState { visible_row_end: None, focused: false, review_enabled: false, - hunk_positions: Vec::new(), - file_positions: Vec::new(), + hunk_positions: Arc::default(), + file_positions: Arc::default(), search: SearchState::default(), - search_match_y_positions: Vec::new(), + search_match_y_positions: Arc::default(), line_selection: LineSelection::default(), text_selection: None, } @@ -219,6 +230,7 @@ impl Default for EditorState { impl EditorState { pub fn clear_document(&mut self) { + self.doc_generation = 0; self.scroll_top_px = 0; self.content_height_px = 0; self.hovered_row = None; @@ -228,9 +240,17 @@ impl EditorState { self.visible_row_start = None; self.visible_row_end = None; self.review_enabled = false; - self.hunk_positions.clear(); - self.file_positions.clear(); - self.search_match_y_positions.clear(); + // Swap in empty Arcs (only when non-empty, to avoid churning + // allocations when this runs every frame without a document). + if !self.hunk_positions.is_empty() { + self.hunk_positions = Arc::default(); + } + if !self.file_positions.is_empty() { + self.file_positions = Arc::default(); + } + if !self.search_match_y_positions.is_empty() { + self.search_match_y_positions = Arc::default(); + } self.line_selection.clear(); self.text_selection = None; } diff --git a/src/events.rs b/src/events.rs index 9f25aeaa..3a37f454 100644 --- a/src/events.rs +++ b/src/events.rs @@ -51,6 +51,10 @@ pub struct RepositorySnapshot { pub changes: Vec, pub operation_log: Vec, pub file_changes: Vec, + /// The publish plan computed against this exact snapshot, so plan and + /// refs update atomically. `None` when the backend has nothing + /// publishable (no remotes, nothing described). + pub publish_plan: Option, } impl RepositorySnapshot { @@ -66,6 +70,7 @@ impl RepositorySnapshot { changes: snapshot.changes, operation_log: snapshot.operation_log, file_changes: snapshot.file_changes, + publish_plan: None, } } } @@ -240,6 +245,9 @@ pub enum RepositoryEvent { branch: String, message: String, }, + /// The VCS worker thread is gone, so dispatched repository commands are + /// being dropped. Surfaced so the user knows repo operations stopped. + WorkerStopped, } #[derive(Debug, Clone)] diff --git a/src/input/keyboard.rs b/src/input/keyboard.rs index e2598024..6f60d13a 100644 --- a/src/input/keyboard.rs +++ b/src/input/keyboard.rs @@ -145,7 +145,7 @@ fn global_shortcut_action(state: &AppState, chord: &KeyChord) -> Option } fn keymap_capture_actions(state: &AppState, chord: &KeyChord) -> Option> { - let command = state.keymap_capture.get(&state.store)?; + let command = state.ui.keymap_capture.get(&state.store)?; if chord.named() == Some(NamedKey::Escape) { return Some(vec![SettingsAction::CancelKeymapRebind.into()]); } @@ -552,11 +552,11 @@ fn workspace_key_actions_inner( Some(NamedKey::Escape) => { if state.overlays_top().is_some() { Some(vec![OverlayAction::CloseOverlay.into()]) - } else if state.app_view.get(&state.store) == AppView::Settings { + } else if state.ui.app_view.get(&state.store) == AppView::Settings { Some(vec![SettingsAction::CloseSettings.into()]) } else if state.editor.search.open.get(&state.store) { Some(vec![EditorAction::CloseSearch.into()]) - } else if state.focus.get(&state.store) == Some(FocusTarget::SidebarSearch) { + } else if state.ui.focus.get(&state.store) == Some(FocusTarget::SidebarSearch) { Some(vec![ FileListAction::ClearSidebarFilter.into(), AppAction::SetFocus(None).into(), @@ -579,7 +579,7 @@ fn workspace_key_actions_inner( } Some(NamedKey::Tab) => Some(vec![AppAction::SetFocus(cycle_focus_target(state)).into()]), Some(NamedKey::Enter) => { - if state.focus.get(&state.store) == Some(FocusTarget::SearchInput) { + if state.ui.focus.get(&state.store) == Some(FocusTarget::SearchInput) { Some(vec![if chord.shift() { EditorAction::SearchPrevious.into() } else { @@ -590,11 +590,11 @@ fn workspace_key_actions_inner( } } Some(NamedKey::ArrowDown) => { - if state.app_view.get(&state.store) == AppView::Settings { + if state.ui.app_view.get(&state.store) == AppView::Settings { Some(vec![ SettingsAction::SetSettingsSection(adjacent_settings_section(state, 1)).into(), ]) - } else if state.focus.get(&state.store) == Some(FocusTarget::Editor) { + } else if state.ui.focus.get(&state.store) == Some(FocusTarget::Editor) { Some(vec![EditorAction::ScrollViewportLines(1).into()]) } else if state.is_workspace_ready() { Some(vec![FileListAction::SelectNextFile.into()]) @@ -603,11 +603,11 @@ fn workspace_key_actions_inner( } } Some(NamedKey::ArrowUp) => { - if state.app_view.get(&state.store) == AppView::Settings { + if state.ui.app_view.get(&state.store) == AppView::Settings { Some(vec![ SettingsAction::SetSettingsSection(adjacent_settings_section(state, -1)).into(), ]) - } else if state.focus.get(&state.store) == Some(FocusTarget::Editor) { + } else if state.ui.focus.get(&state.store) == Some(FocusTarget::Editor) { Some(vec![EditorAction::ScrollViewportLines(-1).into()]) } else if state.is_workspace_ready() { Some(vec![FileListAction::SelectPreviousFile.into()]) @@ -616,14 +616,14 @@ fn workspace_key_actions_inner( } } Some(NamedKey::PageDown) if state.is_workspace_ready() => { - if state.focus.get(&state.store) == Some(FocusTarget::Editor) { + if state.ui.focus.get(&state.store) == Some(FocusTarget::Editor) { Some(vec![EditorAction::ScrollViewportPages(1).into()]) } else { Some(vec![FileListAction::ScrollFileList(10).into()]) } } Some(NamedKey::PageUp) if state.is_workspace_ready() => { - if state.focus.get(&state.store) == Some(FocusTarget::Editor) { + if state.ui.focus.get(&state.store) == Some(FocusTarget::Editor) { Some(vec![EditorAction::ScrollViewportPages(-1).into()]) } else { Some(vec![FileListAction::ScrollFileList(-10).into()]) @@ -648,11 +648,11 @@ fn workspace_key_actions_inner( _ => { let ch = chord.logical_char()?; let binding = chord.binding_string()?; - if state.app_view.get(&state.store) == AppView::Settings { + if state.ui.app_view.get(&state.store) == AppView::Settings { return settings_key_actions(state, &binding); } if state.overlays_top().is_some() - || state.workspace_mode.get(&state.store) != WorkspaceMode::Ready + || state.workspace.mode.get(&state.store) != WorkspaceMode::Ready { return None; } @@ -676,11 +676,11 @@ fn workspace_key_actions_inner( } else if matches_binding(overrides, ShortcutCommand::PreviousFile, &binding) { Some(vec![EditorAction::GoToPreviousFile.into()]) } else if matches_binding(overrides, ShortcutCommand::MoveDown, &binding) - && state.focus.get(&state.store) == Some(FocusTarget::FileList) + && state.ui.focus.get(&state.store) == Some(FocusTarget::FileList) { Some(vec![FileListAction::SelectNextFile.into()]) } else if matches_binding(overrides, ShortcutCommand::MoveUp, &binding) - && state.focus.get(&state.store) == Some(FocusTarget::FileList) + && state.ui.focus.get(&state.store) == Some(FocusTarget::FileList) { Some(vec![FileListAction::SelectPreviousFile.into()]) } else if matches_binding(overrides, ShortcutCommand::MoveDown, &binding) { @@ -695,7 +695,7 @@ fn workspace_key_actions_inner( Some(vec![EditorAction::ScrollViewportHalfPage(1).into()]) } else if matches_binding(overrides, ShortcutCommand::Unstage, &binding) && state.workspace.source.get(&state.store) == WorkspaceSource::Status - && state.focus.get(&state.store) == Some(FocusTarget::FileList) + && state.ui.focus.get(&state.store) == Some(FocusTarget::FileList) { Some(vec![RepositoryAction::UnstageSelectedFile.into()]) } else if matches_binding(overrides, ShortcutCommand::ScrollHalfPageUp, &binding) { @@ -871,7 +871,7 @@ fn settings_key_actions(state: &AppState, binding: &str) -> Option> } fn adjacent_settings_section(state: &AppState, delta: i32) -> SettingsSection { - let current = state.settings_section.get(&state.store); + let current = state.ui.settings_section.get(&state.store); let sections = SettingsSection::ALL; let current_index = sections .iter() @@ -926,7 +926,7 @@ fn status_operation_actions( if state.workspace.source.get(&state.store) != WorkspaceSource::Status { return None; } - if state.focus.get(&state.store) == Some(FocusTarget::FileList) { + if state.ui.focus.get(&state.store) == Some(FocusTarget::FileList) { return Some(vec![file_action.into()]); } if state @@ -943,17 +943,17 @@ fn status_operation_actions( fn cycle_focus_target(state: &AppState) -> Option { match state.overlays_top() { Some(OverlaySurface::RepoPicker | OverlaySurface::RefPicker) => { - match state.focus.get(&state.store) { + match state.ui.focus.get(&state.store) { Some(FocusTarget::PickerInput) => Some(FocusTarget::PickerList), _ => Some(FocusTarget::PickerInput), } } - Some(OverlaySurface::CommandPalette) => match state.focus.get(&state.store) { + Some(OverlaySurface::CommandPalette) => match state.ui.focus.get(&state.store) { Some(FocusTarget::CommandPaletteInput) => Some(FocusTarget::CommandPaletteList), _ => Some(FocusTarget::CommandPaletteInput), }, Some(OverlaySurface::ThemePicker | OverlaySurface::FontPicker) => { - match state.focus.get(&state.store) { + match state.ui.focus.get(&state.store) { Some(FocusTarget::PickerInput) => Some(FocusTarget::PickerList), _ => Some(FocusTarget::PickerInput), } @@ -966,7 +966,7 @@ fn cycle_focus_target(state: &AppState) -> Option { | OverlaySurface::PublishMenu | OverlaySurface::Confirmation, ) => None, - None => match state.focus.get(&state.store) { + None => match state.ui.focus.get(&state.store) { Some(FocusTarget::FileList) => Some(FocusTarget::Editor), Some(FocusTarget::Editor) => Some(FocusTarget::FileList), Some(FocusTarget::WorkspacePrimaryButton) => Some(FocusTarget::TitleBar), @@ -1007,7 +1007,7 @@ fn activate_current_focus_actions(state: &AppState) -> Option> { | OverlaySurface::AccountMenu | OverlaySurface::PublishMenu, ) => Some(Vec::new()), - None => match state.focus.get(&state.store) { + None => match state.ui.focus.get(&state.store) { Some(FocusTarget::WorkspacePrimaryButton) => { Some(vec![OverlayAction::OpenRepoPicker.into()]) } @@ -1049,7 +1049,7 @@ mod tests { #[test] fn viewport_copy_shortcut_copies_current_text_selection() { let state = AppState::default(); - state.focus.set(&state.store, Some(FocusTarget::Editor)); + state.ui.focus.set(&state.store, Some(FocusTarget::Editor)); state.editor.text_selection.set( &state.store, Some(ViewportTextSelection { diff --git a/src/input/mod.rs b/src/input/mod.rs index f9807c21..7dde56d9 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -538,6 +538,7 @@ impl InputSystem { pub fn resolve_input_context(state: &AppState, ime_active: bool) -> InputContext { let owner = if let Some(target) = state + .ui .focus .get(&state.store) .filter(|_| state.is_text_focused()) @@ -545,7 +546,7 @@ pub fn resolve_input_context(state: &AppState, ime_active: bool) -> InputContext InputOwner::TextField(target) } else if let Some(overlay) = state.overlays_top() { InputOwner::Overlay(overlay) - } else if state.focus.get(&state.store) == Some(FocusTarget::Editor) { + } else if state.ui.focus.get(&state.store) == Some(FocusTarget::Editor) { InputOwner::Editor } else { InputOwner::Workspace @@ -553,8 +554,8 @@ pub fn resolve_input_context(state: &AppState, ime_active: bool) -> InputContext InputContext { owner, overlay: state.overlays_top(), - focus: state.focus.get(&state.store), - workspace_mode: state.workspace_mode.get(&state.store), + focus: state.ui.focus.get(&state.store), + workspace_mode: state.workspace.mode.get(&state.store), ime_active, } } diff --git a/src/input/pointer.rs b/src/input/pointer.rs index 6d4b8610..98dc3f19 100644 --- a/src/input/pointer.rs +++ b/src/input/pointer.rs @@ -610,6 +610,7 @@ impl InputSystem { actions.push(OverlayAction::HoverOverlayEntry(hovered_overlay_entry).into()); } let current_hovered_toast = state + .ui .toasts .with(&state.store, |toasts| toasts.iter().position(|t| t.hovered)); if hovered_toast != current_hovered_toast { diff --git a/src/render/renderer.rs b/src/render/renderer.rs index 85813e5c..f3a0aee2 100644 --- a/src/render/renderer.rs +++ b/src/render/renderer.rs @@ -67,12 +67,20 @@ pub enum RenderError { PngWrite(String), } +/// GPU-resident images keyed by content hash (icons, avatars). +type ImageCache = HashMap; + // --------------------------------------------------------------------------- // TexturePool — reusable offscreen render targets // --------------------------------------------------------------------------- struct PooledTexture { view: wgpu::TextureView, + /// Lazily created bind group for sampling this texture. The view is + /// immutable for the entry's lifetime and the layout/sampler never change, + /// so the bind group is created once and reused across frames. It is + /// dropped together with the entry when `trim_unused` evicts it. + bind_group: Option, width: u32, height: u32, in_use: bool, @@ -206,6 +214,7 @@ impl TexturePool { let _ = texture; self.textures.push(PooledTexture { view, + bind_group: None, width: w, height: h, in_use: true, @@ -222,6 +231,24 @@ impl TexturePool { &self.textures[target.pool_index].view } + /// Get the cached bind group for sampling `target`, creating it on first + /// use. Returns an owned handle (`wgpu::BindGroup` is internally + /// refcounted) so multiple targets can be bound in the same pass. + fn bind_group( + &mut self, + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + sampler: &wgpu::Sampler, + target: &OffscreenTarget, + ) -> wgpu::BindGroup { + let entry = &mut self.textures[target.pool_index]; + let view = &entry.view; + entry + .bind_group + .get_or_insert_with(|| create_texture_bind_group(device, layout, view, sampler)) + .clone() + } + fn release(&mut self, target: OffscreenTarget) { self.textures[target.pool_index].in_use = false; self.textures[target.pool_index].last_used_frame = self.frame; @@ -254,7 +281,7 @@ pub struct Renderer { sampler: wgpu::Sampler, texture_pool: TexturePool, instance_buffer_pool: TransientBufferPool, - image_cache: HashMap, + image_cache: ImageCache, viewport_buffer: wgpu::Buffer, viewport_bind_group: wgpu::BindGroup, font_system: FontSystem, @@ -858,7 +885,7 @@ impl Renderer { }, ); - let flattened = flatten_scene(scene, viewport_rect); + let flattened = flatten_scene(scene, viewport_rect, &self.image_cache); // Owned target texture (COPY_SRC so we can read it back). Format matches // the surface format the pipelines were built against. @@ -1142,7 +1169,7 @@ impl Renderer { bytemuck::bytes_of(&viewport_uniform), ); - let flattened = flatten_scene(scene, viewport_rect); + let flattened = flatten_scene(scene, viewport_rect, &self.image_cache); let surface = self .surface @@ -1448,23 +1475,25 @@ impl Renderer { let h_target = self.texture_pool.acquire(&self.device, sw, sh); let v_target = self.texture_pool.acquire(&self.device, sw, sh); - let scene_bind = create_texture_bind_group( + // Bind groups are cached per pooled texture: layout and sampler + // never change, so a steady-state blur frame creates none. + let scene_bind = self.texture_pool.bind_group( &self.device, &self.texture_bind_group_layout, - self.texture_pool.view(&scene_target), &self.sampler, + &scene_target, ); - let h_bind = create_texture_bind_group( + let h_bind = self.texture_pool.bind_group( &self.device, &self.texture_bind_group_layout, - self.texture_pool.view(&h_target), &self.sampler, + &h_target, ); - let v_bind = create_texture_bind_group( + let v_bind = self.texture_pool.bind_group( &self.device, &self.texture_bind_group_layout, - self.texture_pool.view(&v_target), &self.sampler, + &v_target, ); let sigma = (blur.blur_radius * 0.5).max(0.5); @@ -1739,7 +1768,7 @@ fn draw_layers<'pass>( continue; }; pass.set_scissor_rect(sx, sy, sw, sh); - pass.draw(0..4, command.instance_range.clone()); + pass.draw(0..4, command.instance_range()); } } @@ -1753,7 +1782,7 @@ fn draw_layers<'pass>( continue; }; pass.set_scissor_rect(sx, sy, sw, sh); - pass.draw(0..4, command.instance_range.clone()); + pass.draw(0..4, command.instance_range()); } } @@ -1767,7 +1796,7 @@ fn draw_layers<'pass>( continue; }; pass.set_scissor_rect(sx, sy, sw, sh); - pass.draw(0..4, command.instance_range.clone()); + pass.draw(0..4, command.instance_range()); } } } @@ -1781,15 +1810,15 @@ fn draw_images<'pass>( queue: &wgpu::Queue, blit_pipeline: &'pass wgpu::RenderPipeline, viewport_bind_group: &'pass wgpu::BindGroup, - image_cache: &'pass HashMap, + image_cache: &'pass ImageCache, viewport_w: u32, viewport_h: u32, ) { for img in images { - if img.primitive.rgba.is_empty() || img.primitive.width == 0 || img.primitive.height == 0 { - continue; - } - + // The cache lookup is the gate: icons resolved from a prior frame + // carry an empty `rgba` (rasterization skipped) but still draw via + // their uploaded texture. Images that were never uploadable simply + // miss the cache. let bind_group = match image_cache.get(&img.primitive.cache_key) { Some((_, _, bg)) => bg, None => continue, @@ -2223,11 +2252,19 @@ pub(super) struct ClippedRichText { pub(super) clip: Rect, } +#[derive(Clone, Copy)] struct QuadDrawCommand { - instance_range: std::ops::Range, + instance_start: u32, + instance_end: u32, clip: Rect, } +impl QuadDrawCommand { + fn instance_range(&self) -> std::ops::Range { + self.instance_start..self.instance_end + } +} + #[derive(Debug)] pub(super) struct CachedTextBuffer { pub(super) buffer: Buffer, @@ -2303,7 +2340,7 @@ impl ActiveClip { } } -fn flatten_scene(scene: &Scene, viewport: Rect) -> FlattenedScene { +fn flatten_scene(scene: &Scene, viewport: Rect, image_cache: &ImageCache) -> FlattenedScene { use std::collections::BTreeMap; let mut clips = vec![ActiveClip::root(viewport)]; @@ -2515,8 +2552,14 @@ fn flatten_scene(scene: &Scene, viewport: Rect) -> FlattenedScene { let px_size = icon.rect.width.max(icon.rect.height).ceil() as u32; let cache_key = crate::ui::icons::cache_key(&icon.name, px_size, icon.color); - let (rgba, w, h) = - crate::ui::icons::rasterize_svg(&icon.name, px_size, icon.color); + // Only rasterize (and copy RGBA out of the icon cache) + // when the texture is not on the GPU yet; once + // uploaded, the cache key alone is enough to draw. + let (rgba, w, h) = if image_cache.contains_key(&cache_key) { + (Vec::new(), 0, 0) + } else { + crate::ui::icons::rasterize_svg(&icon.name, px_size, icon.color) + }; let zl = current_z!(); zl.images.push(ClippedImage { primitive: crate::render::scene::ImagePrimitive { @@ -2617,7 +2660,8 @@ fn build_quad_instances(quads: &[ClippedQuad]) -> (Vec, Vec = flat .z_layers .iter() diff --git a/src/ui/app.rs b/src/ui/app.rs index 465d1421..b4e51e19 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -75,6 +75,9 @@ pub fn run() -> Result<(), Box> { app.hot_reload_pending = Some(hot_reload_pending); } event_loop.run_app(&mut app)?; + if let Some(message) = app.startup_failure.take() { + return Err(message.into()); + } Ok(()) } @@ -98,6 +101,7 @@ struct NativeApp { next_update_check_at: Option, needs_redraw: bool, exit_requested: bool, + startup_failure: Option, has_seen_focus: bool, skip_next_focus_regain_rescan: bool, rescan_on_next_focus: bool, @@ -147,6 +151,7 @@ impl NativeApp { .then(|| Instant::now() + UPDATE_POLL_INTERVAL), needs_redraw: true, exit_requested: false, + startup_failure: None, has_seen_focus: false, skip_next_focus_regain_rescan: true, rescan_on_next_focus: false, @@ -162,6 +167,20 @@ impl NativeApp { self.needs_redraw = true; } + /// Window or renderer setup failed before anything can be drawn, so there + /// is no in-app surface for the error. Show a native message box, then + /// exit the loop; `run()` turns the stored failure into a non-zero exit. + fn fail_startup(&mut self, event_loop: &ActiveEventLoop, message: String) { + tracing::error!("startup failed: {message}"); + rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("Diffy failed to start") + .set_description(message.as_str()) + .show(); + self.startup_failure = Some(message); + event_loop.exit(); + } + fn paint_tooltip(&mut self) { use crate::render::{ BorderPrimitive, FontKind, FontWeight, Rect, RoundedRectPrimitive, ShadowPrimitive, @@ -617,7 +636,7 @@ impl NativeApp { let update = self .ui_frame .accessibility - .tree_update(self.state.focus.get(&self.state.store)); + .tree_update(self.state.ui.focus.get(&self.state.store)); if let Ok(mut latest) = self.accessibility_latest_tree.lock() { *latest = update.clone(); } @@ -784,7 +803,7 @@ impl NativeApp { &store, ) .with_text_measure_cache(&mut self.text_measure_cache) - .with_focus(store.read(self.state.focus)) + .with_focus(store.read(self.state.ui.focus)) .with_clock(self.state.clock_ms); cx.debug_wireframe = std::env::var("DIFFY_DEBUG_WIREFRAME").is_ok(); @@ -998,8 +1017,10 @@ impl ApplicationHandler for NativeApp { self.window = Some(window); } Err(error) => { - eprintln!("failed to create renderer: {error}"); - event_loop.exit(); + self.fail_startup( + event_loop, + format!("Could not initialize the GPU renderer: {error}"), + ); return; } } @@ -1009,8 +1030,10 @@ impl ApplicationHandler for NativeApp { self.position_traffic_lights(); } Err(error) => { - eprintln!("failed to create native window: {error}"); - event_loop.exit(); + self.fail_startup( + event_loop, + format!("Could not create the native window: {error}"), + ); } } } @@ -1141,6 +1164,7 @@ impl ApplicationHandler for NativeApp { Err(error) => { eprintln!("render failed: {error}"); self.state + .ui .last_error .set(&self.state.store, Some(error.to_string())); } @@ -1220,6 +1244,7 @@ impl ApplicationHandler for NativeApp { .map(|ms| self.launch_at + std::time::Duration::from_millis(ms)); let next_compare_progress_reveal = self.state + .workspace .compare_progress .with(&self.state.store, |progress| { progress.as_ref().and_then(|progress| { @@ -1427,7 +1452,7 @@ mod tests { #[test] fn file_list_scroll_region_wins_over_viewport_fallback() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state.workspace.files.set( &state.store, (0..32) @@ -1478,7 +1503,7 @@ mod tests { #[test] fn file_list_wheel_scroll_moves_sidebar_contents() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state.workspace.files.set( &state.store, (0..32) @@ -1527,7 +1552,7 @@ mod tests { #[test] fn overlay_blocks_viewport_scroll_fallback() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state.workspace.files.set( &state.store, vec![FileListEntry { @@ -1633,7 +1658,7 @@ mod tests { #[test] fn clicking_file_row_selects_exact_file() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state .workspace .source @@ -1696,7 +1721,7 @@ mod tests { Some("src/ui/state/text_edit.rs".to_owned()) ); assert_eq!( - app.state.focus.get(&app.state.store), + app.state.ui.focus.get(&app.state.store), Some(FocusTarget::FileList) ); } @@ -1705,7 +1730,7 @@ mod tests { fn clicking_continuous_file_header_selects_exact_file() { let mut state = AppState::default(); state.settings.continuous_scroll = true; - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state .workspace .source @@ -1760,7 +1785,7 @@ mod tests { Some("src/ui/state/text_edit.rs".to_owned()) ); assert_eq!( - app.state.focus.get(&app.state.store), + app.state.ui.focus.get(&app.state.store), Some(FocusTarget::Editor) ); @@ -1784,7 +1809,7 @@ mod tests { assert!(!app.state.editor.search.open.get(&app.state.store)); assert_eq!( - app.state.focus.get(&app.state.store), + app.state.ui.focus.get(&app.state.store), Some(FocusTarget::Editor) ); } @@ -1794,9 +1819,12 @@ mod tests { let mut app = test_app(AppState::default()); dispatch_input_event(&mut app, keypress("?", ModifiersState::empty())); - assert_eq!(app.state.app_view.get(&app.state.store), AppView::Settings); assert_eq!( - app.state.settings_section.get(&app.state.store), + app.state.ui.app_view.get(&app.state.store), + AppView::Settings + ); + assert_eq!( + app.state.ui.settings_section.get(&app.state.store), SettingsSection::Keymaps ); } @@ -1825,7 +1853,7 @@ mod tests { fn sidebar_tab_keys_switch_files_and_commits() { let repo_dir = TempDir::new().unwrap(); let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state .workspace .source @@ -2021,8 +2049,8 @@ mod tests { #[test] fn row_cursor_keys_move_visible_editor_cursor() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - state.focus.set(&state.store, Some(FocusTarget::Editor)); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); + state.ui.focus.set(&state.store, Some(FocusTarget::Editor)); state.editor.visible_row_start.set(&state.store, Some(4)); state.editor.visible_row_end.set(&state.store, Some(8)); let mut app = test_app(state); @@ -2040,12 +2068,12 @@ mod tests { #[test] fn line_selection_keys_dispatch_current_line_actions() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); state .workspace .source .set(&state.store, crate::ui::state::WorkspaceSource::Status); - state.focus.set(&state.store, Some(FocusTarget::Editor)); + state.ui.focus.set(&state.store, Some(FocusTarget::Editor)); let mut app = test_app(state); let toggle = route_input_event(&mut app, keypress("v", ModifiersState::empty())); @@ -2065,6 +2093,7 @@ mod tests { fn review_comment_editor_keyboard_submit_and_cancel() { let state = AppState::default(); state + .ui .focus .set(&state.store, Some(FocusTarget::ReviewCommentEditor)); let mut app = test_app(state); @@ -2091,18 +2120,18 @@ mod tests { #[test] fn settings_number_and_navigation_keys_switch_sections() { let state = AppState::default(); - state.app_view.set(&state.store, AppView::Settings); + state.ui.app_view.set(&state.store, AppView::Settings); let mut app = test_app(state); dispatch_input_event(&mut app, keypress("3", ModifiersState::empty())); assert_eq!( - app.state.settings_section.get(&app.state.store), + app.state.ui.settings_section.get(&app.state.store), SettingsSection::Behavior ); dispatch_input_event(&mut app, keypress("j", ModifiersState::empty())); assert_eq!( - app.state.settings_section.get(&app.state.store), + app.state.ui.settings_section.get(&app.state.store), SettingsSection::Keymaps ); @@ -2111,7 +2140,7 @@ mod tests { named_keypress(NamedKey::ArrowUp, ModifiersState::empty()), ); assert_eq!( - app.state.settings_section.get(&app.state.store), + app.state.ui.settings_section.get(&app.state.store), SettingsSection::Behavior ); } @@ -2119,7 +2148,7 @@ mod tests { #[test] fn settings_control_keys_dispatch_existing_actions() { let state = AppState::default(); - state.app_view.set(&state.store, AppView::Settings); + state.ui.app_view.set(&state.store, AppView::Settings); let mut app = test_app(state); dispatch_input_event(&mut app, keypress("w", ModifiersState::empty())); @@ -2138,11 +2167,12 @@ mod tests { #[test] fn keymap_rebind_overrides_default_shortcut() { let state = AppState::default(); - state.app_view.set(&state.store, AppView::Settings); + state.ui.app_view.set(&state.store, AppView::Settings); state + .ui .settings_section .set(&state.store, SettingsSection::Keymaps); - state.keymap_capture.set( + state.ui.keymap_capture.set( &state.store, Some(crate::input::ShortcutCommand::ToggleWrap), ); @@ -2161,19 +2191,22 @@ mod tests { #[test] fn vim_focus_keys_switch_file_list_and_editor_focus() { let state = AppState::default(); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - state.focus.set(&state.store, Some(FocusTarget::FileList)); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); + state + .ui + .focus + .set(&state.store, Some(FocusTarget::FileList)); let mut app = test_app(state); dispatch_input_event(&mut app, keypress("l", ModifiersState::empty())); assert_eq!( - app.state.focus.get(&app.state.store), + app.state.ui.focus.get(&app.state.store), Some(FocusTarget::Editor) ); dispatch_input_event(&mut app, keypress("h", ModifiersState::empty())); assert_eq!( - app.state.focus.get(&app.state.store), + app.state.ui.focus.get(&app.state.store), Some(FocusTarget::FileList) ); } diff --git a/src/ui/components/file_tree.rs b/src/ui/components/file_tree.rs index d79dd937..7b76f800 100644 --- a/src/ui/components/file_tree.rs +++ b/src/ui/components/file_tree.rs @@ -11,6 +11,7 @@ use crate::ui::element::{ use crate::ui::icons::lucide; use crate::ui::style::Styled; use crate::ui::theme::Color; +use crate::ui::virtual_list::virtual_row_wrapper_extent; pub struct FileTreeEntry { pub path: String, @@ -358,11 +359,12 @@ impl RenderOnce for FileTree { for (offset, row) in rows.into_iter().enumerate() { let global_index = window_start + offset; - let wrapper_height = if global_index + 1 == total_rows { - row_height - } else { - row_height + row_gap - }; + let wrapper_height = virtual_row_wrapper_extent( + global_index, + total_rows, + row_height, + row_height + row_gap, + ); let row_element = match row { FlatRow::Folder { name, diff --git a/src/ui/components/picker.rs b/src/ui/components/picker.rs index a5c3d409..14ba8eb5 100644 --- a/src/ui/components/picker.rs +++ b/src/ui/components/picker.rs @@ -6,6 +6,7 @@ use crate::ui::shell::CursorHint; use crate::ui::state::{PickerItem, PickerLabelStyle}; use crate::ui::style::Styled; use crate::ui::theme::{Color, Theme, ThemeColors}; +use crate::ui::virtual_list::virtual_list_total_extent; pub fn picker_list( entries: &[T], @@ -19,18 +20,9 @@ pub fn picker_list( let row_h = theme.metrics.ui_row_height.round(); let gap = (Sp::XS * scale).round(); let icon_size = Ico::XS; - let stride = row_h + gap; let visible_count = entries.len().min(max_visible); - let list_h = if visible_count == 0 { - 0.0 - } else { - visible_count as f32 * stride - gap - }; - let total_h = if entries.is_empty() { - 0.0 - } else { - entries.len() as f32 * stride - gap - }; + let list_h = virtual_list_total_extent(visible_count, row_h, gap); + let total_h = virtual_list_total_extent(entries.len(), row_h, gap); let scroll = scroll_top_px.min((total_h - list_h).max(0.0)); view! { scale, diff --git a/src/ui/components/sidebar.rs b/src/ui/components/sidebar.rs index f2701a0b..349b1676 100644 --- a/src/ui/components/sidebar.rs +++ b/src/ui/components/sidebar.rs @@ -99,7 +99,7 @@ impl<'a> Sidebar<'a> { }; let total_height = state.file_list_total_content_height(file_count); - let scroll_px = state.file_list.scroll_offset_px.get(&state.store); + let cx = &*state.store; view! { scale,
Sidebar<'a> { semantic_role={SemanticRole::ScrollArea} px={Sp::LG / Sp::XXS} gap={Sp::XS} - scroll_y={scroll_px} + scroll_y={@state.file_list.scroll_offset_px} scroll_total={total_height} on_scroll={ScrollActionBuilder::FileList}> for index in 0..file_count { diff --git a/src/ui/design.rs b/src/ui/design.rs index 363bbcd9..9b37941b 100644 --- a/src/ui/design.rs +++ b/src/ui/design.rs @@ -66,6 +66,15 @@ impl Sz { pub const COMMIT_BOX_H: f32 = 140.0; pub const MAIN_SURFACE_MIN_W: f32 = 320.0; pub const AUTH_MODAL_HEIGHT: f32 = 320.0; + pub const SETTINGS_NAV_W: f32 = 220.0; + pub const SETTINGS_CONTENT_MAX_W: f32 = 720.0; + pub const SETTINGS_KEYMAPS_MAX_W: f32 = 1500.0; + /// Unscaled height reserved for the open review-comment composer block. + pub const COMPOSER_H: f32 = 248.0; + /// Unscaled height of the editor/preview body region inside the inline + /// reply composer. Fixed (not flex) so the card's measured height is + /// determinate. + pub const INLINE_REPLY_BODY_H: f32 = 112.0; pub const PICKER_MAX_ROWS: usize = 8; } diff --git a/src/ui/harness.rs b/src/ui/harness.rs index 25946d52..512b118c 100644 --- a/src/ui/harness.rs +++ b/src/ui/harness.rs @@ -404,6 +404,7 @@ pub fn render_review_composer(width: f32, scale: f32, preview: bool) -> Rendered }, ); state + .ui .focus .set(&state.store, Some(FocusTarget::ReviewCommentEditor)); @@ -1261,7 +1262,7 @@ mod tests { // The card mousedown emitted FocusViewport (applied by the harness), so // input owner resolves to Editor and Cmd+C takes the card-copy branch. assert_eq!( - harness.state.focus.get(&harness.state.store), + harness.state.ui.focus.get(&harness.state.store), Some(FocusTarget::Editor), "card mousedown must focus the viewport/editor" ); @@ -1326,6 +1327,7 @@ mod tests { }, ); state + .ui .focus .set(&state.store, Some(FocusTarget::ReviewCommentEditor)); let small = theme.metrics.ui_small_font_size; diff --git a/src/ui/overlays/auth.rs b/src/ui/overlays/auth.rs index 481925cc..3bdf42c9 100644 --- a/src/ui/overlays/auth.rs +++ b/src/ui/overlays/auth.rs @@ -20,7 +20,7 @@ pub fn auth_modal( let token_present = state.github.auth.token_present.get(&state.store); let device_flow = state.github.auth.device_flow.get(&state.store); let status = state.github.auth.status.get(&state.store); - let last_error = state.last_error.get(&state.store); + let last_error = state.ui.last_error.get(&state.store); let phase = if token_present { AuthPhase::Success diff --git a/src/ui/overlays/picker.rs b/src/ui/overlays/picker.rs index e076ce07..c98d545c 100644 --- a/src/ui/overlays/picker.rs +++ b/src/ui/overlays/picker.rs @@ -107,7 +107,7 @@ pub fn picker_with_header(
{text_input("", query) .placeholder(placeholder) - .focused(state.focus.get(&state.store) == Some(focus_target)) + .focused(state.ui.focus.get(&state.store) == Some(focus_target)) .on_click( crate::actions::AppAction::SetFocus(Some(focus_target)).into(), ) diff --git a/src/ui/overlays/publish_menu.rs b/src/ui/overlays/publish_menu.rs index 3e26dee5..6861ca57 100644 --- a/src/ui/overlays/publish_menu.rs +++ b/src/ui/overlays/publish_menu.rs @@ -366,13 +366,5 @@ fn publish_ref_chips(state: &AppState) -> Vec { .repository .refs .with(&state.store, |refs| refs.clone()); - let has_remotes = state - .repository - .capabilities - .with(&state.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }); - profile - .publish_status_ui(&changes, &refs, has_remotes) - .ref_chips + profile.publish_status_ui(&changes, &refs, None).ref_chips } diff --git a/src/ui/settings_page.rs b/src/ui/settings_page.rs index 800f1ecf..19c633ea 100644 --- a/src/ui/settings_page.rs +++ b/src/ui/settings_page.rs @@ -22,13 +22,9 @@ use crate::ui::state::{AppState, FocusTarget, SettingsSection, UpdateState}; use crate::ui::style::Styled; use crate::ui::theme::{Color, Theme, ThemeMode}; -const NAV_WIDTH: f32 = 220.0; -const CONTENT_MAX_WIDTH: f32 = 720.0; -const KEYMAPS_MAX_WIDTH: f32 = 1500.0; - pub fn settings_page(state: &AppState, theme: &Theme) -> AnyElement { let tc = &theme.colors; - let active = state.settings_section.get(&state.store); + let active = state.ui.settings_section.get(&state.store); let nav = nav_panel(state, theme, active); let content = section_content(state, theme, active); @@ -46,7 +42,7 @@ pub fn settings_page(state: &AppState, theme: &Theme) -> AnyElement { fn nav_panel(_state: &AppState, theme: &Theme, active: SettingsSection) -> AnyElement { let tc = &theme.colors; let scale = theme.metrics.ui_scale(); - let nav_w = (NAV_WIDTH * scale).round(); + let nav_w = (Sz::SETTINGS_NAV_W * scale).round(); let entries: Vec = SettingsSection::ALL .iter() @@ -118,7 +114,7 @@ fn section_content(state: &AppState, theme: &Theme, section: SettingsSection) -> let tc = &theme.colors; let scale = theme.metrics.ui_scale(); - let max_w = (CONTENT_MAX_WIDTH * scale).round(); + let max_w = (Sz::SETTINGS_CONTENT_MAX_W * scale).round(); let (title, description, body) = match section { SettingsSection::Appearance => ( @@ -169,10 +165,9 @@ fn section_content(state: &AppState, theme: &Theme, section: SettingsSection) -> fn keymaps_layout(state: &AppState, theme: &Theme) -> AnyElement { let tc = &theme.colors; let scale = theme.metrics.ui_scale(); - let inner_max_w = (KEYMAPS_MAX_WIDTH * scale).round(); - let capture = state.keymap_capture.get(&state.store); - let scroll_px = state.keymaps_scroll_top_px.get(&state.store); - let total_h = state.keymaps_content_height_px.get(&state.store); + let inner_max_w = (Sz::SETTINGS_KEYMAPS_MAX_W * scale).round(); + let capture = state.ui.keymap_capture.get(&state.store); + let cx = &*state.store; let groups: Vec = shortcut_groups() .iter() @@ -193,8 +188,8 @@ fn keymaps_layout(state: &AppState, theme: &Theme) -> AnyElement {
AnyElement { let scale = theme.metrics.ui_scale(); let openai_focused = state + .ui .focus .get(&state.store) .is_some_and(|t| t == FocusTarget::SettingsOpenAiKey); let anthropic_focused = state + .ui .focus .get(&state.store) .is_some_and(|t| t == FocusTarget::SettingsAnthropicKey); let prompt_focused = state + .ui .focus .get(&state.store) .is_some_and(|t| t == FocusTarget::SettingsSteeringPrompt); @@ -941,7 +939,7 @@ fn about_section(state: &AppState, theme: &Theme) -> AnyElement { let tc = &theme.colors; let scale = theme.metrics.ui_scale(); let version = crate::APP_VERSION; - let update_state = state.update.get(&state.store); + let update_state = state.ui.update.get(&state.store); let auto_update_toggle = toggle(state.settings.auto_update) .on_toggle(crate::actions::SettingsAction::ToggleAutoUpdate.into()) .into_any(); diff --git a/src/ui/shell.rs b/src/ui/shell.rs index f7ed268a..fc9452b6 100644 --- a/src/ui/shell.rs +++ b/src/ui/shell.rs @@ -27,13 +27,6 @@ use crate::ui::window_chrome; pub use halogen::CursorHint; -/// Unscaled height reserved for the open review-comment composer block. -const COMPOSER_H_BASE: f32 = 248.0; - -/// Unscaled height of the editor/preview body region inside the inline reply -/// composer. Fixed (not flex) so the card's measured height is determinate. -const INLINE_REPLY_BODY_H: f32 = 112.0; - #[derive(Debug, Clone, Default)] pub struct UiFrame { pub scene: Scene, @@ -147,13 +140,15 @@ pub fn build_ui_frame( - Sp::LG * ui_scale) .max(0.0); state + .ui .keymaps_viewport_height_px .set(&state.store, keymaps_viewport_h); state + .ui .keymaps_content_height_px .set(&state.store, settings_page::keymaps_content_height(theme)); state.clamp_keymaps_scroll(); - let sidebar_width_factor = if state.sidebar_visible.get(&state.store) { + let sidebar_width_factor = if state.ui.sidebar_visible.get(&state.store) { 1.0 } else { 0.0 @@ -161,14 +156,14 @@ pub fn build_ui_frame( let sidebar_width = sidebar_mod::preferred_sidebar_width(state, theme, cx, width) * sidebar_width_factor; - let in_settings = state.app_view.get(&state.store) == AppView::Settings; + let in_settings = state.ui.app_view.get(&state.store) == AppView::Settings; // Once the reveal delay has elapsed we want the skeleton to take the // sidebar slot even if `workspace_mode` is still Ready — a re-compare // keeps the old file list around as scaffolding during the grace // window, but after the grace window we're committed to showing the // loading view, so blow the old sidebar away. - let progress_visible = state.compare_progress.with(&state.store, |p| { + let progress_visible = state.workspace.compare_progress.with(&state.store, |p| { p.as_ref().is_some_and(|p| state.clock_ms >= p.reveal_at_ms) }); let text_compare_source = @@ -247,7 +242,7 @@ pub fn build_ui_frame( root = root.child(edges); } - let toast_stack = state.toasts.with(&state.store, |toasts| { + let toast_stack = state.ui.toasts.with(&state.store, |toasts| { if toasts.is_empty() { None } else { @@ -435,7 +430,7 @@ pub fn build_ui_frame( editor.blocks_mut(), doc, &review_card_heights, - (COMPOSER_H_BASE * ui_scale).round() as u16, + (Sz::COMPOSER_H * ui_scale).round() as u16, ); editor.set_hunk_expand_caps(Vec::new()); } else if let Some(active_file) = active_file_snapshot.as_ref() { @@ -497,7 +492,7 @@ pub fn build_ui_frame( &active_file.render_doc, rside, line, - (COMPOSER_H_BASE * ui_scale).round() as u16, + (Sz::COMPOSER_H * ui_scale).round() as u16, ); } editor.set_hunk_expand_caps(caps); @@ -604,6 +599,10 @@ pub fn build_ui_frame( } }; // Write back every field prepare may have mutated. + state + .editor + .doc_generation + .set_if_changed(&state.store, editor_snap.doc_generation); state .editor .viewport_width_px @@ -1315,8 +1314,8 @@ fn build_review_add_button(theme: &Theme, ui_scale: f32, rect: Rect, strong: boo /// from `state.review_comment_editor`). Caller sizes it (`.flex_1()`) and converts. fn composer_text_editor(state: &AppState, theme: &Theme) -> TextEditorElement { let tc = &theme.colors; - let focused = - state.focus.get(&state.store) == Some(crate::ui::state::FocusTarget::ReviewCommentEditor); + let focused = state.ui.focus.get(&state.store) + == Some(crate::ui::state::FocusTarget::ReviewCommentEditor); text_editor_element() .placeholder("Leave a review comment") .editor_snapshot(&state.review_comment_editor) @@ -1422,8 +1421,8 @@ fn composer_editor_box( body_height: Option, ) -> AnyElement { let tc = &theme.colors; - let focused = - state.focus.get(&state.store) == Some(crate::ui::state::FocusTarget::ReviewCommentEditor); + let focused = state.ui.focus.get(&state.store) + == Some(crate::ui::state::FocusTarget::ReviewCommentEditor); let group_border = if focused { tc.accent } else { @@ -1518,7 +1517,7 @@ pub(crate) fn build_inline_reply_composer( .active_file .get(&state.store) .map(|file| file.path); - let body_h = (INLINE_REPLY_BODY_H * ui_scale).round(); + let body_h = (Sz::INLINE_REPLY_BODY_H * ui_scale).round(); view! { ui_scale,
{composer_editor_box(state, theme, ui_scale, width, preview, preview_path.as_deref(), Some(body_h))} diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 16c3330d..25b0f271 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -22,7 +22,9 @@ use crate::ui::state::{ use crate::ui::style::Styled; use crate::ui::theme::{Color, Theme}; use crate::ui::vcs::change_summary_label; -use crate::ui::virtual_list::virtual_list_window; +use crate::ui::virtual_list::{ + build_sectioned_rows, virtual_list_window, virtual_row_wrapper_extent, +}; pub(crate) struct SidebarResizeDrag { origin_x: f32, @@ -73,38 +75,16 @@ fn build_sidebar_rows<'a>( filtered_indices: &[usize], status_changes: Option<&[FileChange]>, ) -> Vec> { - let mut rows = Vec::with_capacity(filtered_indices.len()); - let mut last_bucket = None; - - for &index in filtered_indices { - let Some(entry) = all_files.get(index) else { - continue; - }; - let bucket = - status_changes.and_then(|changes| changes.get(index).map(|change| change.bucket)); - if bucket != last_bucket { - if let Some(bucket) = bucket { - rows.push(SidebarRow::Section(bucket)); - } - last_bucket = bucket; - } - rows.push(SidebarRow::File { index, entry }); - } - - rows -} - -fn sidebar_row_wrapper_height( - global_index: usize, - total_rows: usize, - row_height: f32, - stride: f32, -) -> f32 { - if global_index + 1 == total_rows { - row_height - } else { - stride - } + build_sectioned_rows( + filtered_indices, + |index| status_changes.and_then(|changes| changes.get(index).map(|change| change.bucket)), + |&bucket| SidebarRow::Section(bucket), + |index| { + all_files + .get(index) + .map(|entry| SidebarRow::File { index, entry }) + }, + ) } fn render_sidebar_row( @@ -647,7 +627,7 @@ pub(crate) fn sidebar( let rendered_rows: Vec = visible_files .iter() .map(|(index, entry)| { - let wrapper_height = sidebar_row_wrapper_height(*index, file_count, row_h, stride); + let wrapper_height = virtual_row_wrapper_extent(*index, file_count, row_h, stride); view! { scale,
{file_row(entry, *index, state, tc, scale, selected_index)} @@ -759,18 +739,7 @@ pub(crate) fn sidebar( let all_files = (0..file_count) .filter_map(|index| state.workspace_file_entry_at(index)) .collect::>(); - let rows = if grouped_status { - build_sidebar_rows(&all_files, &filtered_indices, status_rows.as_deref()) - } else { - filtered_indices - .iter() - .filter_map(|&index| { - all_files - .get(index) - .map(|entry| SidebarRow::File { index, entry }) - }) - .collect() - }; + let rows = build_sidebar_rows(&all_files, &filtered_indices, status_rows.as_deref()); let scroll_px = state.file_list.scroll_offset_px.get(&state.store); let stride = state.file_list_row_stride(); let viewport_height = state.file_list.viewport_height.get(&state.store); @@ -794,7 +763,7 @@ pub(crate) fn sidebar( .map(|(offset, row)| { let global_index = window.start + offset; let wrapper_height = - sidebar_row_wrapper_height(global_index, rows.len(), row_h, stride); + virtual_row_wrapper_extent(global_index, rows.len(), row_h, stride); view! { scale,
{render_sidebar_row(*row, state, tc, scale, row_h, selected_index)} diff --git a/src/ui/state/ai.rs b/src/ui/state/ai.rs index 6b04eb3f..24e7dc2e 100644 --- a/src/ui/state/ai.rs +++ b/src/ui/state/ai.rs @@ -111,7 +111,7 @@ impl AppState { }; if editing { self.set_focus(Some(target)); - } else if self.focus.get(&self.store) == Some(target) { + } else if self.ui.focus.get(&self.store) == Some(target) { self.set_focus(None); } Vec::new() @@ -207,3 +207,12 @@ impl AppState { ] } } + +impl AppState { + pub(super) fn ai_key_editable(&self, kind: AiKeyKind) -> bool { + match kind { + AiKeyKind::OpenAi => self.ai_openai_key.is_empty() || self.ai_openai_editing, + AiKeyKind::Anthropic => self.ai_anthropic_key.is_empty() || self.ai_anthropic_editing, + } + } +} diff --git a/src/ui/state/app.rs b/src/ui/state/app.rs index 6d074890..7dfcf011 100644 --- a/src/ui/state/app.rs +++ b/src/ui/state/app.rs @@ -42,7 +42,7 @@ impl AppState { Vec::new() } AppAction::DismissToast(index) => { - self.toasts.update(&self.store, |toasts| { + self.ui.toasts.update(&self.store, |toasts| { if index < toasts.len() { toasts.remove(index); } @@ -52,7 +52,7 @@ impl AppState { AppAction::HoverToast(index) => { let mut was_any_hovered = false; let mut is_any_hovered = false; - self.toasts.update(&self.store, |toasts| { + self.ui.toasts.update(&self.store, |toasts| { was_any_hovered = toasts.iter().any(|t| t.hovered); let hovered_id = index.and_then(|i| toasts.get(i)).map(|t| t.id); for toast in toasts.iter_mut() { @@ -81,3 +81,19 @@ impl AppState { } } } + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StartupState { + pub keyring_enabled: bool, + pub github_token_store: GitHubTokenStore, + pub auto_compare_pending: bool, + pub bootstrap_compare_started: bool, + pub pending_pr_url: Option, + pub preferred_file_index: Option, + pub preferred_file_path: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct DebugState { + pub overlay_visible: bool, +} diff --git a/src/ui/state/compare.rs b/src/ui/state/compare.rs index 91fb6b91..5fd76719 100644 --- a/src/ui/state/compare.rs +++ b/src/ui/state/compare.rs @@ -26,8 +26,8 @@ pub(super) fn reduce_event(state: &mut AppState, event: CompareEvent) -> Vec Vec, + pub left_ref: String, + pub right_ref: String, + pub mode: CompareMode, + pub layout: LayoutMode, + pub renderer: RendererKind, + pub resolved_left: Option, + pub resolved_right: Option, +} + +impl Default for CompareState { + fn default() -> Self { + Self { + repo_path: None, + left_ref: String::new(), + right_ref: String::new(), + mode: CompareMode::default(), + layout: LayoutMode::default(), + renderer: RendererKind::default(), + resolved_left: None, + resolved_right: None, + } + } +} + +pub use crate::core::compare::ComparePhase; + +/// What the progress panel is about. Drives chip rendering: compare +/// shows a left⇄right ref pair, repo-open shows a single folder chip. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoadingSubject { + Compare { + left_label: String, + right_label: String, + }, + RepoOpen { + name: String, + }, +} + +/// Transient progress state for a long-running workspace operation +/// (compare or repo open). Present iff something is in flight and the +/// reveal delay has either elapsed or was set to zero. Cleared when the +/// operation lands or the user cancels. +/// +/// `reveal_at_ms` controls when the panel is rendered. Compares show +/// immediately; repo-open still uses the short delay to avoid flashing a +/// loading panel for tiny repositories. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompareProgress { + pub generation: u64, + pub phase: ComparePhase, + pub subject: LoadingSubject, + pub started_at_ms: u64, + pub reveal_at_ms: u64, + /// Total file count — first known from a backend `LoadingFiles` + /// emission, re-confirmed by `CompareFinished`. Unused for RepoOpen. + pub file_count_total: Option, + /// Files read so far during `LoadingFiles`. Zero before, frozen + /// after. + pub files_loaded: u32, +} + +/// Delay between kicking off an op and revealing the loading UI — +/// fast ops under this threshold show no loading flash at all. +pub const COMPARE_REVEAL_DELAY_MS: u64 = 500; + +pub(super) fn vcs_compare_request( + mode: CompareMode, + left_ref: String, + right_ref: String, + layout: LayoutMode, + renderer: RendererKind, +) -> VcsCompareRequest { + let compare_spec = match mode { + CompareMode::SingleCommit => { + let revision = if right_ref.is_empty() { + left_ref + } else { + right_ref + }; + VcsCompareSpec::Change { revision } + } + CompareMode::TwoDot => VcsCompareSpec::Range { + from: left_ref, + to: right_ref, + }, + CompareMode::ThreeDot => VcsCompareSpec::MergeBaseRange { + base: left_ref, + head: right_ref, + }, + }; + VcsCompareRequest { + spec: compare_spec, + layout, + renderer, + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum CompareStatsHydrationState { + #[default] + Idle, + Running, + Failed, +} + +pub(super) fn matching_persisted_compare<'a>( + startup: &'a StartupOptions, + settings: &'a Settings, +) -> Option<&'a PersistedCompare> { + settings.last_compare.as_ref().filter(|compare| { + startup.args.repo.is_some() && compare.repo_path.as_ref() == startup.args.repo.as_ref() + }) +} + +pub(super) fn compare_refs_are_valid(mode: CompareMode, left_ref: &str, right_ref: &str) -> bool { + match mode { + CompareMode::SingleCommit => !left_ref.is_empty() || !right_ref.is_empty(), + CompareMode::TwoDot | CompareMode::ThreeDot => { + !left_ref.is_empty() && !right_ref.is_empty() + } + } +} + +pub(super) fn estimated_carbon_file_rows_with_overhead(file: &carbon::FileDiff) -> u32 { + if file.is_binary { + return 4; + } + estimated_carbon_file_rows(file).saturating_add(1).max(1) +} + +pub(super) fn estimated_carbon_file_rows(file: &carbon::FileDiff) -> u32 { + if file.hunks.is_empty() { + return file.additions.saturating_add(file.deletions).max(1); + } + + let mut rows = 0_u32; + for (hunk_index, hunk) in file.hunks.iter().enumerate() { + if !file.is_partial { + let gap_len = if hunk_index == 0 { + hunk.old_start_index().min(hunk.new_start_index()) + } else { + let prev = &file.hunks[hunk_index - 1]; + hunk.old_start_index() + .saturating_sub(prev.old_end_index()) + .min(hunk.new_start_index().saturating_sub(prev.new_end_index())) + }; + rows = rows.saturating_add((gap_len > 0) as u32); + } + + rows = rows.saturating_add(1); + for block in file.hunk_blocks(hunk) { + rows = rows.saturating_add(match block.kind { + carbon::BlockKind::Context => block.old.len.min(block.new.len), + carbon::BlockKind::Change => block.old.len.saturating_add(block.new.len), + }); + } + + if !file.is_partial && hunk_index + 1 == file.hunks.len() { + let old_end = file + .old_text + .as_ref() + .map(|text| text.line_count()) + .unwrap_or_else(|| hunk.old_end_index()); + let new_end = file + .new_text + .as_ref() + .map(|text| text.line_count()) + .unwrap_or_else(|| hunk.new_end_index()); + let gap_len = old_end + .saturating_sub(hunk.old_end_index()) + .min(new_end.saturating_sub(hunk.new_end_index())); + rows = rows.saturating_add((gap_len > 0) as u32); + } + } + rows +} + +pub(super) fn compare_summary_file_entry(summary: &CompareFileSummary) -> FileListEntry { + FileListEntry { + path: summary.paths.display_path_ref(), + } +} + +pub(super) fn compare_output_file_entry_meta( + output: &CompareOutput, + index: usize, +) -> Option { + if let Some(summary) = output.file_summaries.get(index) { + let (additions, deletions) = summary.fallback_stats(); + return Some(FileListEntryMeta { + status: carbon_list_status(summary.status), + additions, + deletions, + is_binary: summary.is_binary, + }); + } + output.carbon.files.get(index).map(carbon_file_entry_meta) +} + +pub(super) fn carbon_file_entry_meta(file: &carbon::FileDiff) -> FileListEntryMeta { + let (additions, deletions) = carbon_file_stats(file); + FileListEntryMeta { + status: carbon_list_status(file.status), + additions, + deletions, + is_binary: file.is_binary, + } +} + +pub(super) fn compare_output_summary_is_deferred(output: &CompareOutput, index: usize) -> bool { + if let Some(summary) = output.file_summaries.get(index) { + return summary.is_partial; + } + output + .carbon + .files + .get(index) + .is_some_and(|file| file.is_partial && file.hunks.is_empty()) +} + +pub(super) fn compare_output_deferred_summary( + output: &CompareOutput, + index: usize, +) -> Option { + if let Some(summary) = output.file_summaries.get(index) { + return summary.is_partial.then(|| summary.clone()); + } + output + .carbon + .files + .get(index) + .filter(|file| file.is_partial && file.hunks.is_empty()) + .map(CompareFileSummary::from_file) +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(super) struct CompareStatsSnapshot { + pub(super) hydrated_total: (i32, i32), + pub(super) deferred_count: usize, +} + +pub(super) fn compare_output_stats_snapshot(output: &CompareOutput) -> CompareStatsSnapshot { + let mut snapshot = CompareStatsSnapshot::default(); + output.for_each_summary(|_, summary| { + if summary.stats_deferred { + snapshot.deferred_count = snapshot.deferred_count.saturating_add(1); + } else { + let stats = summary.fallback_stats(); + snapshot.hydrated_total = ( + snapshot.hydrated_total.0.saturating_add(stats.0), + snapshot.hydrated_total.1.saturating_add(stats.1), + ); + } + }); + snapshot +} + +pub(super) fn compare_output_has_deferred_stats(output: &CompareOutput) -> bool { + if output.file_summaries.is_empty() { + output.carbon.files.iter().any(|file| file.stats_deferred) + } else { + output + .file_summaries + .iter() + .any(|summary| summary.stats_deferred) + } +} + +pub(super) fn carbon_file_stats(file: &carbon::FileDiff) -> (i32, i32) { + if file.additions > 0 || file.deletions > 0 || file.stats_deferred { + return ( + u32_to_i32_saturating(file.additions), + u32_to_i32_saturating(file.deletions), + ); + } + let mut additions = 0_i32; + let mut deletions = 0_i32; + for block in &file.blocks { + if block.kind == carbon::BlockKind::Change { + additions = additions.saturating_add(block.new.len.min(i32::MAX as u32) as i32); + deletions = deletions.saturating_add(block.old.len.min(i32::MAX as u32) as i32); + } + } + (additions, deletions) +} + +pub(super) fn u32_to_i32_saturating(value: u32) -> i32 { + i32::try_from(value).unwrap_or(i32::MAX) +} + +impl AppState { + pub(super) fn compare_file_is_large(&self, index: usize) -> bool { + if self.workspace.source.get(&self.store) == WorkspaceSource::TextCompare { + return false; + } + if self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .is_some_and(|output| compare_output_summary_is_deferred(output, index)) + }) { + return true; + } + + let meta = self.file_list_entry_meta(index); + !meta.is_binary && meta.additions.saturating_add(meta.deletions) >= LARGE_COMPARE_FILE_LINES + } + + pub(super) fn compare_refs(&self) -> (String, String) { + let left_ref = self + .compare + .resolved_left + .get(&self.store) + .unwrap_or_else(|| self.compare.left_ref.get(&self.store)); + let right_ref = self + .compare + .resolved_right + .get(&self.store) + .unwrap_or_else(|| self.compare.right_ref.get(&self.store)); + (left_ref, right_ref) + } +} + +impl AppState { + /// Clear the workspace back to a blank "no compare loaded" state. Replaces + /// the former `WorkspaceState::clear_compare(&mut self)` method. + pub(super) fn workspace_clear_compare(&mut self) { + self.workspace + .source + .set(&self.store, WorkspaceSource::None); + self.workspace.status.set(&self.store, AsyncStatus::Idle); + self.workspace + .status_operation_pending + .set(&self.store, false); + self.workspace.status_generation.set(&self.store, 0); + self.clear_syntax_inflight(); + self.workspace.files.set(&self.store, Vec::new()); + self.workspace + .status_file_changes + .set(&self.store, Vec::new()); + self.workspace.selected_file_index.set(&self.store, None); + self.workspace.selected_file_path.set(&self.store, None); + self.workspace.selected_change_bucket.set(&self.store, None); + self.workspace.compare_output.set(&self.store, None); + self.workspace.compare_total_stats.set(&self.store, None); + self.workspace.compare_hydrated_stats.set(&self.store, None); + self.workspace + .compare_deferred_stats_remaining + .set(&self.store, None); + self.workspace + .compare_deferred_stats_cursor + .set(&self.store, 0); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); + self.workspace.active_file.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + self.clear_file_cache(); + self.workspace.raw_diff_len.set(&self.store, 0); + self.workspace.used_fallback.set(&self.store, false); + self.workspace + .fallback_message + .set(&self.store, String::new()); + self.workspace.sidebar_auto_width.set(&self.store, None); + self.workspace.range_commits.set(&self.store, Vec::new()); + self.workspace + .compare_history_pending + .set(&self.store, None); + self.workspace.pre_drill_compare.set(&self.store, None); + self.workspace.expansions.update(&self.store, |m| m.clear()); + self.clear_file_scroll_layout(); + self.workspace.global_scroll_top_px.set(&self.store, 0); + } + + #[profiling::function] + pub(super) fn handle_compare_finished(&mut self, payload: CompareFinished) -> Vec { + if payload.generation != self.workspace.compare_generation.get(&self.store) { + return Vec::new(); + } + + let history_left = payload.resolved_left.clone(); + let history_right = self + .vcs_ui_profile() + .history_right_ref(&payload.resolved_right); + self.workspace + .status_operation_pending + .set(&self.store, false); + self.workspace + .source + .set(&self.store, WorkspaceSource::Compare); + self.workspace.status.set(&self.store, AsyncStatus::Ready); + self.workspace.mode.set(&self.store, WorkspaceMode::Ready); + self.compare.layout.set(&self.store, payload.request.layout); + self.compare + .renderer + .set(&self.store, payload.request.renderer); + self.compare + .resolved_left + .set(&self.store, Some(payload.resolved_left)); + self.compare + .resolved_right + .set(&self.store, Some(payload.resolved_right)); + self.workspace + .raw_diff_len + .set(&self.store, payload.output.raw_diff_len); + self.workspace + .used_fallback + .set(&self.store, payload.output.used_fallback); + self.workspace + .fallback_message + .set(&self.store, payload.output.fallback_message.clone()); + let total_files = payload.output.file_count() as u32; + let stats_snapshot = compare_output_stats_snapshot(&payload.output); + let has_deferred_stats = stats_snapshot.deferred_count > 0; + let eager_total_stats = (!has_deferred_stats).then_some(stats_snapshot.hydrated_total); + self.workspace + .compare_output + .set(&self.store, Some(payload.output)); + self.workspace.files.set(&self.store, Vec::new()); + self.workspace + .compare_total_stats + .set(&self.store, eager_total_stats); + self.workspace.compare_hydrated_stats.set( + &self.store, + has_deferred_stats.then_some(stats_snapshot.hydrated_total), + ); + self.workspace + .compare_deferred_stats_remaining + .set(&self.store, Some(stats_snapshot.deferred_count)); + self.workspace + .compare_deferred_stats_cursor + .set(&self.store, 0); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); + self.workspace.active_file_loading.set(&self.store, None); + self.workspace.sidebar_auto_width.set(&self.store, None); + self.clear_file_cache(); + self.reset_file_scroll_layout(); + self.workspace.global_scroll_top_px.set(&self.store, 0); + // Record the discovered file count + advance the phase. The progress + // panel stays up until the first file finishes mounting (or, for + // small-file fast paths, is cleared by install_compare_active_file). + self.workspace.compare_progress.update(&self.store, |slot| { + if let Some(p) = slot.as_mut() { + let p = Arc::make_mut(p); + p.file_count_total = Some(total_files); + p.phase = ComparePhase::PopulatingList; + } + }); + if self + .workspace + .pre_drill_compare + .with(&self.store, |p| p.is_none()) + { + self.workspace + .range_commits + .set(&self.store, payload.range_commits); + } + self.file_list.scroll_offset_px.set(&self.store, 0.0); + self.file_list + .commits_scroll_offset_px + .set(&self.store, 0.0); + self.editor_clear_document(); + // Clear overlays before claiming focus so the overlay restore target + // does not clobber the file list focus below. + self.clear_overlays(); + self.set_focus(Some(FocusTarget::FileList)); + + let preferred_index = self + .startup + .preferred_file_index + .or(self.workspace.selected_file_index.get(&self.store)); + let preferred_path = self + .startup + .preferred_file_path + .clone() + .or_else(|| self.workspace.selected_file_path.get(&self.store)); + + let file_count = self.workspace_file_count(); + let index_for_path = preferred_path + .as_deref() + .and_then(|path| self.workspace_file_index_for_path(path)); + + let mut effects = Vec::new(); + let mut selected_syntax_paths = Vec::new(); + let should_load_history = self + .workspace + .pre_drill_compare + .with(&self.store, |p| p.is_none()); + let history_effect = should_load_history + .then(|| self.compare_history_request(history_left, history_right)) + .flatten() + .and_then(|request| { + if has_deferred_stats { + self.workspace + .compare_history_pending + .set(&self.store, Some(request)); + None + } else { + Some(self.compare_history_effect(request)) + } + }); + if let Some(index) = index_for_path + .or(preferred_index.filter(|index| *index < file_count)) + .or_else(|| (file_count > 0).then_some(0)) + { + if let Some(path) = self.workspace_file_path_at(index) { + selected_syntax_paths.push(path); + } + effects.extend(self.select_file(index, true)); + if let Some(effect) = self.start_compare_stats_hydration_if_idle() { + effects.push(effect); + } + if let Some(effect) = self.start_compare_total_stats_if_needed() { + effects.push(effect); + } + } else { + self.workspace.selected_file_index.set(&self.store, None); + self.workspace.selected_file_path.set(&self.store, None); + self.workspace.selected_change_bucket.set(&self.store, None); + self.workspace.active_file.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + // No files to select — the compare succeeded but has no diffs. + // Tear down the progress panel; the "repo ready" hint takes over. + self.workspace.compare_progress.set(&self.store, None); + self.editor_clear_document(); + } + if let Some(effect) = self.syntax_pack_warmup_effect_for_compare(&selected_syntax_paths) { + effects.insert(0, effect); + } + if let Some(effect) = history_effect { + effects.push(effect); + } + + let (used_fallback, fallback_message) = ( + self.workspace.used_fallback.get(&self.store), + self.workspace.fallback_message.get(&self.store), + ); + if used_fallback && !fallback_message.is_empty() { + self.push_info(&fallback_message); + } + effects + } + + pub(super) fn handle_compare_history_ready( + &mut self, + payload: CompareHistoryReady, + ) -> Vec { + if payload.generation != self.workspace.compare_generation.get(&self.store) { + return Vec::new(); + } + if self + .workspace + .pre_drill_compare + .with(&self.store, |p| p.is_some()) + { + return Vec::new(); + } + self.workspace + .range_commits + .set(&self.store, payload.range_commits); + Vec::new() + } + + #[profiling::function] + pub(super) fn handle_compare_file_finished( + &mut self, + payload: CompareFileFinished, + ) -> Vec { + if payload.generation != self.workspace.compare_generation.get(&self.store) { + return Vec::new(); + } + + let matches_selected = self + .workspace + .selected_file_path + .get(&self.store) + .as_deref() + == Some(payload.path.as_str()); + let matches_loading = self + .workspace + .active_file_loading + .with(&self.store, |loading| { + loading.as_ref().is_some_and(|loading| { + loading.index == payload.index && loading.path == payload.path + }) + }); + let matches_cache_loading = + self.workspace + .file_cache_loading + .with(&self.store, |loading| { + loading + .get(&payload.index) + .is_some_and(|loading| loading.path == payload.path) + }); + if !matches_selected && !matches_cache_loading { + return Vec::new(); + } + + if matches_selected && matches_loading { + self.install_compare_active_file(payload.index, payload.path, payload.prepared); + } else { + let left_ref = self + .compare + .resolved_left + .get(&self.store) + .unwrap_or_else(|| self.compare.left_ref.get(&self.store)); + let right_ref = self + .compare + .resolved_right + .get(&self.store) + .unwrap_or_else(|| self.compare.right_ref.get(&self.store)); + let active_file = self.build_active_file( + payload.index, + payload.path, + payload.prepared, + left_ref, + right_ref, + ); + self.cache_active_file(active_file); + } + let mut effects = self.sync_editor_scroll_from_global(); + if matches_selected { + effects.extend(self.request_active_file_syntax_effect()); + } + if let Some(effect) = self.start_compare_stats_hydration_if_idle() { + effects.push(effect); + } else if let Some(effect) = self.start_compare_total_stats_if_needed() { + effects.push(effect); + } + effects + } + + pub(super) fn handle_compare_stats_ready(&mut self, payload: CompareStatsReady) -> Vec { + if payload.generation != self.workspace.compare_generation.get(&self.store) { + return Vec::new(); + } + + self.workspace + .compare_total_stats + .set(&self.store, Some((payload.additions, payload.deletions))); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + let mut effects = Vec::new(); + if let Some(effect) = self.start_compare_stats_hydration_if_idle() { + let is_background_stats = matches!( + &effect, + Effect::Compare(CompareEffect::LoadFileStats(task)) + if task.request.priority == CompareWorkPriority::Warmup + ); + effects.push(effect); + if is_background_stats && let Some(effect) = self.take_pending_compare_history_effect() + { + effects.push(effect); + } + } else if !self.compare_stats_hydration_running() + && let Some(effect) = self.take_pending_compare_history_effect() + { + effects.push(effect); + } + effects + } + + pub(super) fn handle_compare_file_stats_ready( + &mut self, + payload: CompareFileStatsReady, + ) -> Vec { + if payload.generation != self.workspace.compare_generation.get(&self.store) { + return Vec::new(); + } + + self.apply_compare_file_stats(&payload.stats); + let mut effects = self.sync_editor_scroll_from_global(); + if !payload.request_complete { + return effects; + } + if let Some(effect) = self.next_compare_stats_hydration_effect() { + effects.push(effect); + effects + } else { + self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); + let history_effect = self.take_pending_compare_history_effect(); + if let Some(stats) = self.exact_compare_total_stats_if_ready() { + if !self.workspace.compare_total_stats_loading.get(&self.store) { + self.workspace + .compare_total_stats + .set(&self.store, Some(stats)); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + } + if let Some(effect) = history_effect { + effects.push(effect); + } + return effects; + } + if let Some(effect) = self.start_compare_total_stats_if_needed() { + effects.push(effect); + } + if let Some(effect) = history_effect { + effects.push(effect); + } + effects + } + } + + pub(super) fn compare_stats_hydration_running(&self) -> bool { + self.workspace.compare_stats_hydration.get(&self.store) + == CompareStatsHydrationState::Running + } + + pub(super) fn compare_stats_hydration_failed(&self) -> bool { + self.workspace.compare_stats_hydration.get(&self.store) + == CompareStatsHydrationState::Failed + } + + pub(super) fn set_compare_stats_hydration(&self, state: CompareStatsHydrationState) { + self.workspace + .compare_stats_hydration + .set(&self.store, state); + } + + pub(super) fn start_compare_stats_hydration_if_idle(&mut self) -> Option { + if self.compare_stats_hydration_running() || self.compare_stats_hydration_failed() { + return None; + } + + let effect = self.next_compare_stats_hydration_effect()?; + self.set_compare_stats_hydration(CompareStatsHydrationState::Running); + Some(effect) + } + + pub(super) fn start_visible_compare_stats_hydration(&mut self) -> Option { + if self.compare_stats_hydration_failed() { + return None; + } + let prioritize_visible = self + .workspace + .compare_output + .with(&self.store, |maybe_output| { + maybe_output.as_ref().is_some_and(|output| { + output.file_count() > COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT + }) + }); + if !prioritize_visible { + return self.start_compare_stats_hydration_if_idle(); + } + let visible_files = self.visible_compare_stats_hydration_items(); + if visible_files.is_empty() { + return self.start_compare_stats_hydration_if_idle(); + } + let effect = self.compare_file_stats_hydration_effect( + visible_files, + CompareWorkPriority::VisibleSidebarStats, + )?; + self.set_compare_stats_hydration(CompareStatsHydrationState::Running); + Some(effect) + } + + pub(super) fn start_compare_total_stats_if_needed(&mut self) -> Option { + if self + .workspace + .compare_total_stats + .get(&self.store) + .is_some() + || self.workspace.compare_total_stats_loading.get(&self.store) + { + return None; + } + let repo_path = self.compare.repo_path.get(&self.store)?; + self.workspace + .compare_total_stats_loading + .set(&self.store, true); + + Some( + CompareEffect::LoadStats(Task { + generation: self.workspace.compare_generation.get(&self.store), + request: CompareStatsRequest { + repo_path, + request: vcs_compare_request( + self.compare.mode.get(&self.store), + self.compare.left_ref.get(&self.store), + self.compare.right_ref.get(&self.store), + self.compare.layout.get(&self.store), + self.compare.renderer.get(&self.store), + ), + priority: CompareWorkPriority::TotalStats, + }, + }) + .into(), + ) + } + + pub(super) fn next_compare_stats_hydration_effect(&self) -> Option { + let prioritize_visible = self + .workspace + .compare_output + .with(&self.store, |maybe_output| { + maybe_output.as_ref().is_some_and(|output| { + output.file_count() > COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT + }) + }); + let (files, priority) = if prioritize_visible { + let visible_files = self.visible_compare_stats_hydration_items(); + if !visible_files.is_empty() { + (visible_files, CompareWorkPriority::VisibleSidebarStats) + } else { + ( + self.next_deferred_compare_stats_items(COMPARE_STATS_BACKGROUND_CHUNK_SIZE), + CompareWorkPriority::Warmup, + ) + } + } else { + ( + self.next_deferred_compare_stats_items(COMPARE_STATS_BACKGROUND_CHUNK_SIZE), + CompareWorkPriority::Warmup, + ) + }; + if files.is_empty() { + return None; + } + + self.compare_file_stats_hydration_effect(files, priority) + } + + pub(super) fn compare_file_stats_hydration_effect( + &self, + files: Vec, + priority: CompareWorkPriority, + ) -> Option { + if files.is_empty() { + return None; + } + let repo_path = self.compare.repo_path.get(&self.store)?; + Some( + CompareEffect::LoadFileStats(Task { + generation: self.workspace.compare_generation.get(&self.store), + request: CompareFileStatsRequest { + repo_path, + request: vcs_compare_request( + self.compare.mode.get(&self.store), + self.compare.left_ref.get(&self.store), + self.compare.right_ref.get(&self.store), + self.compare.layout.get(&self.store), + self.compare.renderer.get(&self.store), + ), + files, + priority, + }, + }) + .into(), + ) + } + + pub(super) fn compare_history_request( + &self, + left_ref: String, + right_ref: String, + ) -> Option { + Some(CompareHistoryRequest { + repo_path: self.compare.repo_path.get(&self.store)?, + left_ref, + right_ref, + }) + } + + pub(super) fn compare_history_effect(&self, request: CompareHistoryRequest) -> Effect { + CompareEffect::LoadHistory(Task { + generation: self.workspace.compare_generation.get(&self.store), + request, + }) + .into() + } + + pub(super) fn take_pending_compare_history_effect(&mut self) -> Option { + if self + .workspace + .pre_drill_compare + .with(&self.store, |p| p.is_some()) + { + self.workspace + .compare_history_pending + .set(&self.store, None); + return None; + } + let pending = self.workspace.compare_history_pending.get(&self.store)?; + self.workspace + .compare_history_pending + .set(&self.store, None); + Some(self.compare_history_effect(pending)) + } + + pub(super) fn next_deferred_compare_stats_items( + &self, + limit: usize, + ) -> Vec { + if limit == 0 + || self + .workspace + .compare_deferred_stats_remaining + .get(&self.store) + == Some(0) + { + return Vec::new(); + } + + let cursor = self + .workspace + .compare_deferred_stats_cursor + .get(&self.store); + let (items, next_cursor) = + self.workspace + .compare_output + .with(&self.store, |maybe_output| { + let Some(output) = maybe_output.as_ref() else { + return (Vec::new(), None); + }; + let file_count = output.file_count(); + if file_count == 0 { + return (Vec::new(), None); + } + let mut items = Vec::new(); + let mut index = cursor.min(file_count - 1); + let mut scanned = 0_usize; + while scanned < file_count && items.len() < limit { + if let Some(target) = output.deferred_stats_target_at(index) { + items.push(CompareFileStatsItem { index, target }); + } + index = if index + 1 == file_count { + 0 + } else { + index + 1 + }; + scanned += 1; + } + (items, Some(index)) + }); + if let Some(next_cursor) = next_cursor { + self.workspace + .compare_deferred_stats_cursor + .set(&self.store, next_cursor); + } + items + } + + pub(super) fn visible_compare_stats_hydration_items(&self) -> Vec { + if self.workspace.source.get(&self.store) != WorkspaceSource::Compare + || self.file_list.tab.get(&self.store) != SidebarTab::Files + { + return Vec::new(); + } + + let stride = self.file_list_row_stride(); + if stride <= 0.0 { + return Vec::new(); + } + let scroll_px = self.file_list.scroll_offset_px.get(&self.store); + let viewport_px = self.file_list.viewport_height.get(&self.store); + let first = (scroll_px / stride).floor().max(0.0) as usize; + let visible = (viewport_px / stride).ceil().max(1.0) as usize; + let start = first.saturating_sub(COMPARE_STATS_VISIBLE_OVERSCAN_ROWS); + let end = first + .saturating_add(visible) + .saturating_add(COMPARE_STATS_VISIBLE_OVERSCAN_ROWS); + + let filter = self + .file_list + .filter + .with(&self.store, |filter| filter.clone()); + if !filter.is_empty() { + let filtered_indices = self.workspace_file_filter_matches(&filter); + let end = end.min(filtered_indices.len()); + if start >= end { + return Vec::new(); + } + return self.compare_stats_hydration_items_for_indices( + filtered_indices[start..end].iter().copied(), + ); + } + + if self.file_list.mode.get(&self.store) == SidebarMode::TreeView { + let expanded_folders = self.file_list.expanded_folders.get(&self.store); + let tree_indices = crate::ui::components::file_tree_visible_file_indices_by( + |visit| { + self.for_each_workspace_file_path(|index, path| visit(index, path)); + }, + &expanded_folders, + start..end, + ); + return self.compare_stats_hydration_items_for_indices(tree_indices); + } + + let end = end.min(self.workspace_file_count()); + if start >= end { + return Vec::new(); + } + self.compare_stats_hydration_items_for_indices(start..end) + } + + pub(super) fn compare_stats_hydration_items_for_indices( + &self, + indices: impl IntoIterator, + ) -> Vec { + self.workspace + .compare_output + .with(&self.store, |maybe_output| { + let Some(output) = maybe_output.as_ref() else { + return Vec::new(); + }; + let mut items = Vec::new(); + for index in indices { + if items.len() >= COMPARE_STATS_CHUNK_SIZE { + break; + } + if let Some(target) = output.deferred_stats_target_at(index) { + items.push(CompareFileStatsItem { index, target }); + } + } + items + }) + } + + pub(super) fn exact_compare_total_stats_if_ready(&self) -> Option<(i32, i32)> { + if let Some(remaining) = self + .workspace + .compare_deferred_stats_remaining + .get(&self.store) + { + if remaining > 0 { + return None; + } + if let Some(total) = self.workspace.compare_hydrated_stats.get(&self.store) { + return Some(total); + } + } + + let ready = self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .is_some_and(|output| !compare_output_has_deferred_stats(output)) + }); + if !ready { + return None; + } + self.workspace.compare_output.with(&self.store, |output| { + let output = output.as_ref()?; + let mut total = (0_i32, 0_i32); + output.for_each_summary(|_, summary| { + let stats = summary.fallback_stats(); + total = ( + total.0.saturating_add(stats.0), + total.1.saturating_add(stats.1), + ); + }); + Some(total) + }) + } + + pub(super) fn apply_compare_file_stats(&mut self, stats: &[CompareFileStat]) { + if stats.is_empty() { + return; + } + + let old_scroll_heights = stats + .iter() + .map(|stat| (stat.index, self.file_scroll_height_px(stat.index))) + .collect::>(); + + let mut stats_delta = (0_i32, 0_i32); + let mut newly_hydrated = 0_usize; + self.workspace + .compare_output + .update(&self.store, |maybe_output| { + let Some(output) = maybe_output.as_mut() else { + return; + }; + for stat in stats { + let additions = i32_to_u32_nonnegative(stat.additions); + let deletions = i32_to_u32_nonnegative(stat.deletions); + + if !output.file_summaries.is_empty() { + let Some(summary) = output.file_summaries.get_mut(stat.index) else { + continue; + }; + if summary.path() != stat.path { + continue; + } + let old_stats = summary.fallback_stats(); + let was_deferred = summary.stats_deferred; + summary.additions = additions; + summary.deletions = deletions; + summary.stats_deferred = false; + stats_delta = ( + stats_delta + .0 + .saturating_add(stat.additions.saturating_sub(old_stats.0)), + stats_delta + .1 + .saturating_add(stat.deletions.saturating_sub(old_stats.1)), + ); + newly_hydrated = newly_hydrated.saturating_add(was_deferred as usize); + continue; + } + + if let Some(file) = output.carbon.files.get_mut(stat.index) + && file.path() == stat.path + { + let old_stats = carbon_file_stats(file); + let was_deferred = file.stats_deferred; + file.additions = additions; + file.deletions = deletions; + file.stats_deferred = false; + stats_delta = ( + stats_delta + .0 + .saturating_add(stat.additions.saturating_sub(old_stats.0)), + stats_delta + .1 + .saturating_add(stat.deletions.saturating_sub(old_stats.1)), + ); + newly_hydrated = newly_hydrated.saturating_add(was_deferred as usize); + } + } + }); + + if stats_delta != (0, 0) { + self.workspace + .compare_hydrated_stats + .update(&self.store, |total| { + let current = total.get_or_insert((0, 0)); + *current = ( + current.0.saturating_add(stats_delta.0), + current.1.saturating_add(stats_delta.1), + ); + }); + } + if newly_hydrated > 0 { + self.workspace + .compare_deferred_stats_remaining + .update(&self.store, |remaining| { + if let Some(count) = remaining.as_mut() { + *count = count.saturating_sub(newly_hydrated); + } + }); + } + + let mut rebuilt_viewport_doc = false; + self.workspace.active_file.update(&self.store, |slot| { + let Some(active) = slot.as_mut() else { + return; + }; + for stat in stats { + if apply_compare_stat_to_active_file(active, stat) { + rebuilt_viewport_doc = true; + break; + } + } + }); + self.workspace.file_cache.update(&self.store, |files| { + for active in files.values_mut() { + for stat in stats { + if apply_compare_stat_to_active_file(active, stat) { + rebuilt_viewport_doc = true; + break; + } + } + } + }); + if rebuilt_viewport_doc { + self.viewport_document_cache = None; + } + + let dragging_scrollbar = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| drag.is_some()); + if dragging_scrollbar { + self.workspace + .file_scroll_recompute_pending + .set(&self.store, true); + } else { + self.update_file_scroll_heights(old_scroll_heights); + if self.settings.continuous_scroll { + self.clamp_global_scroll_top_px(); + } + } + } + + pub(super) fn kickoff_compare(&mut self) -> Vec { + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + self.push_error("Open a repository before starting a compare."); + return Vec::new(); + }; + + let mode = self.compare.mode.get(&self.store); + let left_ref = self.compare.left_ref.get(&self.store); + let right_ref = self.compare.right_ref.get(&self.store); + if !compare_refs_are_valid(mode, &left_ref, &right_ref) { + self.push_error("Provide the required refs for the selected mode."); + return Vec::new(); + } + + let active_pr = self.github.pull_request.active.get(&self.store); + let active_pr_still_matches = active_pr.as_ref().is_some_and(|key| { + self.github.pull_request.cache.with(&self.store, |cache| { + matches!( + cache.get(key).map(|entry| &entry.diff), + Some(PrPeekDiff::Ready { + left_ref: pr_left, + right_ref: pr_right, + .. + }) if pr_left == &left_ref && pr_right == &right_ref + ) + }) + }); + if !active_pr_still_matches { + self.github.pull_request.active.set(&self.store, None); + self.github + .pull_request + .review_composer + .set(&self.store, ReviewCommentComposerState::default()); + self.review_comment_editor.request_clear(); + } + + self.workspace + .source + .set(&self.store, WorkspaceSource::Compare); + let next_gen = self + .workspace + .compare_generation + .get(&self.store) + .saturating_add(1); + self.workspace.compare_generation.set(&self.store, next_gen); + let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); + self.workspace.compare_total_stats.set(&self.store, None); + self.workspace.compare_hydrated_stats.set(&self.store, None); + self.workspace + .compare_deferred_stats_remaining + .set(&self.store, None); + self.workspace + .compare_deferred_stats_cursor + .set(&self.store, 0); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); + self.workspace.expansions.update(&self.store, |m| m.clear()); + self.clear_overlays(); + self.sync_settings_snapshot(); + + let started_at_ms = self.clock_ms; + let reveal_at_ms = started_at_ms; + let has_prior_state = self.workspace_file_count() > 0 + || self + .workspace + .active_file + .with(&self.store, |af| af.is_some()); + + if !has_prior_state { + self.workspace.mode.set(&self.store, WorkspaceMode::Loading); + self.workspace.status.set(&self.store, AsyncStatus::Loading); + } + + let profile = self.vcs_ui_profile(); + let left_label = profile.compare_ref_display_label(&left_ref); + let right_label = profile.compare_ref_display_label(&right_ref); + self.workspace.compare_progress.set( + &self.store, + Some(Arc::new(CompareProgress { + generation: next_gen, + phase: ComparePhase::OpeningRepo, + subject: LoadingSubject::Compare { + left_label, + right_label, + }, + started_at_ms, + reveal_at_ms, + file_count_total: None, + files_loaded: 0, + })), + ); + + let renderer = self.compare.renderer.get(&self.store); + let layout = self.compare.layout.get(&self.store); + vec![ + syntax_epoch_effect, + SettingsEffect::SaveSettings(self.settings.clone()).into(), + CompareEffect::Run(Task { + generation: next_gen, + request: CompareRequest { + repo_path, + request: vcs_compare_request(mode, left_ref, right_ref, layout, renderer), + github_token: self.github_access_token.clone(), + }, + }) + .into(), + ] + } + + /// Soft-cancel an in-flight compare. Bumps the generation so any + /// result that eventually arrives is dropped by the guard, clears the + /// progress panel, and returns the viewport to the default empty state. + /// We do not attempt to interrupt backend work mid-flight; stale-result + /// guards keep late answers from mutating newer state. + pub(super) fn cancel_compare(&mut self) -> Vec { + if self + .workspace + .compare_progress + .with(&self.store, |p| p.is_none()) + { + return Vec::new(); + } + let next_gen = self + .workspace + .compare_generation + .get(&self.store) + .saturating_add(1); + self.workspace.compare_generation.set(&self.store, next_gen); + let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); + self.workspace.compare_progress.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + // Only revert the workspace mode if kickoff flipped it to Loading + // (i.e. no prior state was preserved). When the user cancels a + // re-compare, the old diff is still mounted and should stay visible. + if self.workspace.mode.get(&self.store) == WorkspaceMode::Loading { + self.workspace.mode.set(&self.store, WorkspaceMode::Empty); + self.workspace.status.set(&self.store, AsyncStatus::Idle); + } + vec![syntax_epoch_effect] + } + + pub(super) fn handle_compare_progress_update(&mut self, generation: u64, phase: ComparePhase) { + // Only apply when the progress slot matches the reporter's + // generation — stale workers silently lose their updates. + self.workspace.compare_progress.update(&self.store, |slot| { + if let Some(p) = slot.as_mut() + && p.generation == generation + { + let p = Arc::make_mut(p); + // Pull counts out of LoadingFiles so the determinate bar + // reads directly from durable struct fields (cheaper than + // pattern-matching in the render path, and lets the total + // survive the phase transition to PopulatingList). + if let ComparePhase::LoadingFiles { + files_seen, + files_total, + } = phase + { + p.files_loaded = files_seen; + if files_total > 0 { + p.file_count_total = Some(files_total); + } + } + p.phase = phase; + } + }); + } + + pub(super) fn swap_refs(&mut self) -> Vec { + let left = self.compare.left_ref.get(&self.store); + let right = self.compare.right_ref.get(&self.store); + let profile = self.vcs_ui_profile(); + if left.trim().is_empty() + || right.trim().is_empty() + || !profile.can_swap_ref(&right) + || !profile.can_swap_ref(&left) + { + return Vec::new(); + } + let resolved_left = self.compare.resolved_left.get(&self.store); + let resolved_right = self.compare.resolved_right.get(&self.store); + self.compare.left_ref.set(&self.store, right); + self.compare.right_ref.set(&self.store, left); + self.compare.resolved_left.set(&self.store, resolved_right); + self.compare.resolved_right.set(&self.store, resolved_left); + self.workspace.pre_drill_compare.set(&self.store, None); + let mut effects = self.persist_settings_effect(); + let has_repo = self.compare.repo_path.with(&self.store, |p| p.is_some()); + let not_loading = self.workspace.status.get(&self.store) != AsyncStatus::Loading; + let refs_valid = compare_refs_are_valid( + self.compare.mode.get(&self.store), + &self.compare.left_ref.get(&self.store), + &self.compare.right_ref.get(&self.store), + ); + if has_repo && not_loading && refs_valid { + effects.extend(self.kickoff_compare()); + } + effects + } + + pub(super) fn update_compare_field( + &mut self, + field: CompareField, + value: String, + ) -> Vec { + self.workspace.pre_drill_compare.set(&self.store, None); + match field { + CompareField::Left => { + self.compare.left_ref.set(&self.store, value); + self.compare.resolved_left.set(&self.store, None); + } + CompareField::Right => { + self.compare.right_ref.set(&self.store, value); + self.compare.resolved_right.set(&self.store, None); + } + } + self.auto_select_compare_mode(); + let active_field = self.overlays.ref_picker.active_field.get(&self.store); + let mut effects = if matches!(self.overlays_top(), Some(OverlaySurface::RefPicker)) + && active_field == field + { + self.rebuild_ref_picker(field) + } else { + Vec::new() + }; + effects.extend(self.rebuild_command_palette()); + effects + } + + pub(super) fn auto_select_compare_mode(&mut self) { + let profile = self.vcs_ui_profile(); + if !profile.should_auto_select_trunk_mode() { + return; + } + let left = self.compare.left_ref.get(&self.store); + let right = self.compare.right_ref.get(&self.store); + if left.is_empty() || right.is_empty() { + return; + } + if left == right && !profile.is_working_copy_ref(&right) { + self.compare + .mode + .set(&self.store, CompareMode::SingleCommit); + return; + } + let is_trunk = |r: &str| matches!(r, "main" | "master" | "develop" | "development"); + if is_trunk(&left) != is_trunk(&right) { + self.compare.mode.set(&self.store, CompareMode::ThreeDot); + } + } +} diff --git a/src/ui/state/editor.rs b/src/ui/state/editor.rs index b2d47ea5..3ffa43b7 100644 --- a/src/ui/state/editor.rs +++ b/src/ui/state/editor.rs @@ -127,7 +127,7 @@ impl AppState { Vec::new() } EditorClick(x, y) => { - match self.focus.get(&self.store) { + match self.ui.focus.get(&self.store) { Some(FocusTarget::SettingsSteeringPrompt) => { self.steering_prompt_editor.click(x, y); } @@ -147,7 +147,7 @@ impl AppState { Vec::new() } EditorDrag(x, y) => { - match self.focus.get(&self.store) { + match self.ui.focus.get(&self.store) { Some(FocusTarget::SettingsSteeringPrompt) => { self.steering_prompt_editor.drag(x, y); } @@ -167,7 +167,7 @@ impl AppState { Vec::new() } EditorScrollPx(delta) => { - match self.focus.get(&self.store) { + match self.ui.focus.get(&self.store) { Some(FocusTarget::SettingsSteeringPrompt) => { self.steering_prompt_editor.scroll(delta as f32); } @@ -230,3 +230,841 @@ impl AppState { } } } + +impl AppState { + pub(super) fn expand_context( + &mut self, + hunk_index: usize, + direction: crate::editor::diff::expansion::ExpandDirection, + amount: u32, + ) -> Vec { + use crate::editor::diff::expansion::ExpandDirection; + use crate::events::ContextDirection; + + if amount == 0 { + return Vec::new(); + } + + let ctx_direction = match direction { + ExpandDirection::Above => ContextDirection::Above, + ExpandDirection::Below => ContextDirection::Below, + }; + self.dispatch_context_expansion(hunk_index, ctx_direction, amount) + } + + pub(super) fn expand_all_context(&mut self) -> Vec { + use crate::events::ContextDirection; + self.dispatch_context_expansion(0, ContextDirection::All, 0) + } + + pub(super) fn dispatch_context_expansion( + &mut self, + hunk_index: usize, + direction: crate::events::ContextDirection, + amount: u32, + ) -> Vec { + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + + let generation = self.workspace.compare_generation.get(&self.store); + let Some(( + file_index, + path, + old_reference, + new_reference, + cached_old_lines, + cached_new_lines, + )) = self.workspace.active_file.with(&self.store, |af| { + let active = af.as_ref()?; + if active.carbon_file.hunks.is_empty() { + return None; + } + Some(( + active.index, + active.path.clone(), + active.left_ref.clone(), + if active.right_ref.is_empty() { + active.left_ref.clone() + } else { + active.right_ref.clone() + }, + active.old_file_lines.clone(), + active.file_lines.clone(), + )) + }) + else { + return Vec::new(); + }; + + if let (Some(old_lines), Some(new_lines)) = (cached_old_lines, cached_new_lines) { + self.apply_context_expansion(direction, hunk_index, amount, old_lines, new_lines); + let mut effects = vec![self.invalidate_syntax_epoch_effect()]; + effects.extend(self.request_active_file_syntax_effect()); + return effects; + } + + vec![ + RepositoryEffect::FetchContextLines(crate::effects::FetchContextLinesRequest { + repo_path, + old_reference, + new_reference, + path, + generation, + file_index, + hunk_index, + direction, + amount, + }) + .into(), + ] + } + + pub(super) fn handle_context_lines_ready( + &mut self, + payload: crate::events::ContextLinesReady, + ) -> Vec { + if payload.generation != self.workspace.compare_generation.get(&self.store) { + return Vec::new(); + } + + let matches_active = self.workspace.active_file.with(&self.store, |af| { + af.as_ref() + .is_some_and(|a| a.index == payload.file_index && a.path == payload.path) + }); + if !matches_active { + return Vec::new(); + } + + let old_lines = Arc::new(payload.old_lines); + let new_lines = Arc::new(payload.new_lines); + self.apply_context_expansion( + payload.direction, + payload.hunk_index, + payload.amount, + old_lines, + new_lines, + ); + let mut effects = vec![self.invalidate_syntax_epoch_effect()]; + effects.extend(self.request_active_file_syntax_effect()); + effects + } + + pub(super) fn apply_context_expansion( + &mut self, + direction: crate::events::ContextDirection, + hunk_index: usize, + amount: u32, + old_lines: Arc>, + new_lines: Arc>, + ) { + use crate::events::ContextDirection; + + let Some(( + active_index, + active_path, + mut carbon_file, + mut expansion, + mut carbon_overlays, + mut token_buffer, + )) = self.workspace.active_file.with(&self.store, |af| { + af.as_ref().map(|a| { + ( + a.index, + a.path.clone(), + (*a.carbon_file).clone(), + a.carbon_expansion.clone(), + a.carbon_overlays.clone(), + a.token_buffer.clone(), + ) + }) + }) + else { + return; + }; + + hydrate_carbon_full_text(&mut carbon_file, &old_lines, &new_lines); + match direction { + ContextDirection::Above => { + carbon::expand_context( + &carbon_file, + &mut expansion, + carbon::HunkId(hunk_index as u32), + carbon::ExpansionDirection::Above, + amount, + ); + } + ContextDirection::Below => { + carbon::expand_context( + &carbon_file, + &mut expansion, + carbon::HunkId(hunk_index as u32), + carbon::ExpansionDirection::Below, + amount, + ); + } + ContextDirection::All => { + let hunk_ids = carbon_file + .hunks + .iter() + .map(|hunk| hunk.id) + .collect::>(); + for hunk_id in hunk_ids { + let caps = carbon::expansion_caps(&carbon_file, hunk_id); + carbon::expand_context( + &carbon_file, + &mut expansion, + hunk_id, + carbon::ExpansionDirection::Above, + caps.above, + ); + carbon::expand_context( + &carbon_file, + &mut expansion, + hunk_id, + carbon::ExpansionDirection::Below, + caps.below, + ); + } + } + } + self.workspace.expansions.update(&self.store, |map| { + map.insert(active_path.clone(), expansion.clone()); + }); + + let preserve_change_tokens = carbon_overlays.has_change_tokens(); + carbon_overlays.clear_syntax(); + if !preserve_change_tokens { + token_buffer.clear(); + } + let render_doc = build_render_doc_from_carbon( + &carbon_file, + active_index, + &expansion, + &carbon_overlays, + &token_buffer, + ); + let total_lines = new_lines.len() as u32; + + let preserved_scroll = self.editor.scroll_top_px.get(&self.store); + + self.workspace.active_file.update(&self.store, |af| { + if let Some(active) = af.as_mut() { + active.carbon_file = Arc::new(carbon_file); + active.carbon_expansion = expansion; + active.carbon_overlays = carbon_overlays; + active.token_buffer = token_buffer; + active.render_doc = Arc::new(render_doc); + active.file_line_count = Some(total_lines); + active.old_file_lines = Some(old_lines); + active.file_lines = Some(new_lines); + active.syntax_pending.clear(); + active.syntax_covered.clear(); + } + }); + self.editor_clear_document(); + self.editor.scroll_top_px.set(&self.store, preserved_scroll); + } + + pub(super) fn current_hunk_index_from_hover(&self) -> Option { + self.editor + .hovered_hunk_index + .get(&self.store) + .or_else(|| self.editor_current_hunk_index().map(|(idx, _)| idx as i16)) + } + + pub(super) fn current_render_line_index_from_hover(&self) -> Option { + self.editor + .hovered_render_line_index + .get(&self.store) + .or_else(|| self.editor.hovered_row.get(&self.store)) + } + + pub(super) fn apply_hunk_operation( + &mut self, + operation: FileOperation, + explicit_hunk: Option, + ) -> Vec { + tracing::debug!( + ?operation, + ?explicit_hunk, + source = ?self.workspace.source.get(&self.store), + pending = self.workspace.status_operation_pending.get(&self.store), + hovered_row = ?self.editor.hovered_row.get(&self.store), + hovered_hunk_index = ?self.editor.hovered_hunk_index.get(&self.store), + "apply_hunk_operation: entered" + ); + if self.workspace.source.get(&self.store) != WorkspaceSource::Status { + tracing::debug!("apply_hunk_operation: bail: source != Status"); + return Vec::new(); + } + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.partial_hunk_mutation) + }) + { + self.push_error("This repository backend does not support hunk operations."); + return Vec::new(); + } + if self.workspace.status_operation_pending.get(&self.store) { + tracing::debug!("apply_hunk_operation: bail: status_operation_pending=true"); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + tracing::debug!("apply_hunk_operation: bail: no repo_path"); + return Vec::new(); + }; + let Some(bucket) = self.workspace.selected_change_bucket.get(&self.store) else { + tracing::debug!("apply_hunk_operation: bail: no selected_change_bucket"); + return Vec::new(); + }; + let resolved = explicit_hunk.or_else(|| self.current_hunk_index_from_hover()); + let hunk_index = match resolved { + Some(idx) if idx >= 0 => idx as usize, + _ => { + tracing::debug!(?resolved, "apply_hunk_operation: bail: no hunk_index"); + return Vec::new(); + } + }; + + let patch_text = self.workspace.active_file.with(&self.store, |af| { + let active = af.as_ref()?; + patch::format_carbon_hunk_patch( + &active.carbon_file, + hunk_index, + operation != FileOperation::Stage, + ) + }); + let Some(patch) = patch_text else { + tracing::debug!( + hunk_index, + "apply_hunk_operation: bail: format_hunk_patch returned None" + ); + return Vec::new(); + }; + + tracing::debug!( + ?operation, + hunk_index, + "apply_hunk_operation: dispatching ApplyPatchOperation" + ); + self.workspace + .status_operation_pending + .set(&self.store, true); + vec![ + RepositoryEffect::ApplyPatchOperation(PatchOperationRequest { + repo_path, + patch, + bucket, + operation, + }) + .into(), + ] + } + + pub(super) fn toggle_line_selection(&mut self, row: usize, _extend: bool) { + let line_opt = self.workspace.active_file.with(&self.store, |af| { + af.as_ref() + .and_then(|active| active.render_doc.lines.get(row).copied()) + }); + let Some(line) = line_opt else { + return; + }; + let kind = line.row_kind(); + if !matches!( + kind, + crate::editor::diff::render_doc::RenderRowKind::Added + | crate::editor::diff::render_doc::RenderRowKind::Removed + | crate::editor::diff::render_doc::RenderRowKind::Modified + ) { + return; + } + if line.hunk_index < 0 { + return; + } + let hunk_id = line.hunk_index as u32; + self.editor.line_selection.update(&self.store, |ls| { + if line.old_line_index >= 0 { + ls.toggle(hunk_id, carbon::DiffSide::Old, line.old_line_index as u32); + } + if line.new_line_index >= 0 { + ls.toggle(hunk_id, carbon::DiffSide::New, line.new_line_index as u32); + } + ls.last_toggled_row = Some(row); + }); + } + + pub(super) fn toggle_line_selection_range(&mut self, row: usize, anchor: usize) { + self.insert_line_selection_range(row, anchor, false); + } + + pub(super) fn set_line_selection_range(&mut self, row: usize, anchor: usize) { + self.insert_line_selection_range(row, anchor, true); + } + + pub(super) fn insert_line_selection_range( + &mut self, + row: usize, + anchor: usize, + clear_first: bool, + ) { + let (start, end) = if row <= anchor { + (row, anchor) + } else { + (anchor, row) + }; + let lines = self.workspace.active_file.with(&self.store, |af| { + let Some(active) = af.as_ref() else { + return Vec::new(); + }; + (start..=end) + .filter_map(|r| active.render_doc.lines.get(r).copied()) + .collect::>() + }); + if lines.is_empty() { + return; + } + // Staging only selects changed lines; in PR review mode a comment can anchor + // to any line (incl. context), like GitHub. + let review = self.pull_request_review_enabled(); + self.editor.line_selection.update(&self.store, |ls| { + if clear_first { + ls.clear(); + } + for line in &lines { + use crate::editor::diff::render_doc::RenderRowKind; + let kind = line.row_kind(); + if !kind.is_body() || line.hunk_index < 0 { + continue; + } + if !review + && !matches!( + kind, + RenderRowKind::Added | RenderRowKind::Removed | RenderRowKind::Modified + ) + { + continue; + } + let hunk_id = line.hunk_index as u32; + if line.old_line_index >= 0 { + ls.entries + .insert(crate::editor::diff::state::LineSelectionKey { + file_path: None, + hunk_id, + side: carbon::DiffSide::Old, + source_index: line.old_line_index as u32, + }); + } + if line.new_line_index >= 0 { + ls.entries + .insert(crate::editor::diff::state::LineSelectionKey { + file_path: None, + hunk_id, + side: carbon::DiffSide::New, + source_index: line.new_line_index as u32, + }); + } + } + ls.last_toggled_row = Some(row); + }); + } + + pub(super) fn toggle_current_line_selection(&mut self) { + let Some(row) = self.current_render_line_index_from_hover() else { + self.push_error("Move the row cursor to a changed line before selecting lines."); + return; + }; + self.toggle_line_selection(row, false); + } + + pub(super) fn toggle_current_line_selection_range(&mut self) { + let Some(row) = self.current_render_line_index_from_hover() else { + self.push_error("Move the row cursor to a changed line before selecting lines."); + return; + }; + let anchor = self + .editor + .line_selection + .with(&self.store, |ls| ls.last_toggled_row); + if let Some(anchor) = anchor { + self.toggle_line_selection_range(row, anchor); + } else { + self.toggle_line_selection(row, false); + } + } + + pub(super) fn apply_line_selection_operation( + &mut self, + operation: FileOperation, + ) -> Vec { + if self.workspace.source.get(&self.store) != WorkspaceSource::Status { + return Vec::new(); + } + if self.workspace.status_operation_pending.get(&self.store) { + return Vec::new(); + } + if self + .editor + .line_selection + .with(&self.store, |ls| ls.is_empty()) + { + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + let Some(bucket) = self.workspace.selected_change_bucket.get(&self.store) else { + return Vec::new(); + }; + let reverse = operation != FileOperation::Stage; + + let (hunk_indices, selection_snapshot) = + self.editor.line_selection.with(&self.store, |ls| { + let indices: Vec = ls + .entries + .iter() + .map(|key| key.hunk_id) + .collect::>() + .into_iter() + .collect(); + (indices, ls.clone()) + }); + + let patches = self.workspace.active_file.with(&self.store, |af| { + let Some(active) = af.as_ref() else { + return Vec::new(); + }; + let mut patches = Vec::new(); + for hunk_idx in hunk_indices { + let selected = selection_snapshot + .selected_lines_for_hunk(hunk_idx) + .into_iter() + .map(|key| patch::CarbonLineSelection { + side: key.side, + source_index: key.source_index, + }) + .collect::>(); + let patch = patch::format_carbon_lines_patch( + &active.carbon_file, + carbon::u32_to_usize_saturating(hunk_idx), + &selected, + reverse, + ); + if let Some(p) = patch { + patches.push(p); + } + } + patches + }); + + self.editor + .line_selection + .update(&self.store, |ls| ls.clear()); + + if patches.is_empty() { + return Vec::new(); + } + + self.workspace + .status_operation_pending + .set(&self.store, true); + patches + .into_iter() + .map(|p| { + RepositoryEffect::ApplyPatchOperation(PatchOperationRequest { + repo_path: repo_path.clone(), + patch: p, + bucket, + operation, + }) + .into() + }) + .collect() + } + + pub(super) fn navigate_to_hunk(&mut self, forward: bool) { + let current = self.editor.scroll_top_px.get(&self.store); + let target = self.editor.hunk_positions.with(&self.store, |positions| { + if positions.is_empty() { + return None; + } + if forward { + positions + .iter() + .find(|&&y| y > current) + .or_else(|| positions.first()) + .copied() + } else { + positions + .iter() + .rev() + .find(|&&y| y < current) + .or_else(|| positions.last()) + .copied() + } + }); + if let Some(y) = target { + self.editor.scroll_top_px.set(&self.store, y); + self.editor_clamp_scroll(); + } + } + + pub(super) fn navigate_to_file(&mut self, forward: bool) -> Vec { + let Some(current) = self.reconcile_selected_file_index_from_path() else { + return Vec::new(); + }; + let count = self.workspace_file_count(); + if count == 0 { + return Vec::new(); + } + let target = if forward { + current.saturating_add(1).min(count.saturating_sub(1)) + } else { + current.saturating_sub(1) + }; + if target == current { + return Vec::new(); + } + + if self.settings.continuous_scroll { + return self.select_file(target, true); + } + + self.select_file(target, true) + } + + pub(super) fn open_search(&mut self) { + self.editor.search.open.set(&self.store, true); + let len = self.editor.search.query.with(&self.store, |q| q.len()); + self.text_edit.cursor.set(&self.store, len); + self.text_edit.anchor.set(&self.store, 0); + self.text_edit + .cursor_moved_at_ms + .set(&self.store, self.clock_ms); + self.ui + .focus + .set(&self.store, Some(FocusTarget::SearchInput)); + self.editor.focused.set(&self.store, false); + self.recompute_search_matches(); + } + + pub(super) fn close_search(&mut self) { + self.editor.search.open.set(&self.store, false); + self.editor.search.matches.set(&self.store, Arc::default()); + self.editor.search.active_index.set(&self.store, None); + self.set_focus(Some(FocusTarget::Editor)); + } + + pub(super) fn recompute_search_matches(&mut self) { + use crate::editor::diff::state::MatchSide; + + self.editor.search.matches.set(&self.store, Arc::default()); + self.editor.search.active_index.set(&self.store, None); + + let query = self + .editor + .search + .query + .with(&self.store, |q| q.to_ascii_lowercase()); + if query.is_empty() { + return; + } + + let new_matches: Vec = self.workspace.active_file.with(&self.store, |af| { + let Some(active_file) = af.as_ref() else { + return Vec::new(); + }; + let doc = &active_file.render_doc; + let mut new_matches: Vec = Vec::new(); + for (line_idx, line) in doc.lines.iter().enumerate() { + let line_idx = line_idx as u32; + if line.left_text.is_valid() { + let text = doc.line_text(line.left_text); + let lower = text.to_ascii_lowercase(); + let mut start = 0; + while let Some(pos) = lower[start..].find(&query) { + let byte_start = (start + pos) as u32; + new_matches.push(SearchMatch { + line_index: line_idx, + byte_start, + byte_len: query.len() as u32, + side: MatchSide::Left, + }); + start += pos + query.len(); + } + } + if line.right_text.is_valid() { + let text = doc.line_text(line.right_text); + let lower = text.to_ascii_lowercase(); + let mut start = 0; + while let Some(pos) = lower[start..].find(&query) { + let byte_start = (start + pos) as u32; + new_matches.push(SearchMatch { + line_index: line_idx, + byte_start, + byte_len: query.len() as u32, + side: MatchSide::Right, + }); + start += pos + query.len(); + } + } + } + new_matches + }); + + let has_matches = !new_matches.is_empty(); + self.editor + .search + .matches + .set(&self.store, Arc::new(new_matches)); + if has_matches { + self.editor.search.active_index.set(&self.store, Some(0)); + } + } + + pub(super) fn search_navigate(&mut self, direction: i32) { + let count = self.editor.search.matches.with(&self.store, |m| m.len()); + if count == 0 { + return; + } + + let current = self + .editor + .search + .active_index + .get(&self.store) + .unwrap_or(0); + let next = if direction > 0 { + if current + 1 >= count { 0 } else { current + 1 } + } else { + if current == 0 { count - 1 } else { current - 1 } + }; + self.editor.search.active_index.set(&self.store, Some(next)); + self.scroll_to_search_match(next); + } + + pub(super) fn scroll_to_search_match(&mut self, match_index: usize) { + let y_pos = self + .editor + .search_match_y_positions + .with(&self.store, |v| v.get(match_index).copied()); + let target_y = if let Some(y) = y_pos { + y + } else { + let m = self + .editor + .search + .matches + .with(&self.store, |m| m.get(match_index).copied()); + let Some(m) = m else { + return; + }; + self.estimate_line_y(m.line_index) + }; + + let viewport_h = self.editor.viewport_height_px.get(&self.store); + let centered = target_y.saturating_sub(viewport_h / 3); + let max = self.editor_max_scroll_top_px(); + self.editor + .scroll_top_px + .set(&self.store, centered.min(max)); + } + + pub(super) fn estimate_line_y(&self, line_index: u32) -> u32 { + let content_height = self.editor.content_height_px.get(&self.store); + if content_height == 0 { + return 0; + } + let total_lines = self.workspace.active_file.with(&self.store, |af| { + af.as_ref() + .map(|active_file| active_file.render_doc.lines.len() as u32) + .unwrap_or(0) + }); + if total_lines == 0 { + return 0; + } + let avg_height = content_height / total_lines; + line_index.saturating_mul(avg_height) + } + + /// Clear document-specific editor state (scroll, content, hunks, etc.) + pub fn editor_clear_document(&mut self) { + self.editor.doc_generation.set(&self.store, 0); + self.editor.scroll_top_px.set(&self.store, 0); + self.editor.content_height_px.set(&self.store, 0); + self.editor.hovered_row.set(&self.store, None); + self.editor.hovered_render_line_index.set(&self.store, None); + self.editor.hovered_hunk_index.set(&self.store, None); + self.editor.visible_row_start.set(&self.store, None); + self.editor.visible_row_end.set(&self.store, None); + self.editor.hunk_positions.set(&self.store, Arc::default()); + self.editor.file_positions.set(&self.store, Arc::default()); + self.editor + .search_match_y_positions + .set(&self.store, Arc::default()); + self.editor + .line_selection + .update(&self.store, |ls| ls.clear()); + self.editor.text_selection.set(&self.store, None); + self.context_menu.close(); + } + + pub fn editor_max_scroll_top_px(&self) -> u32 { + let content = self.editor.content_height_px.get(&self.store); + let viewport = self.editor.viewport_height_px.get(&self.store); + content.saturating_sub(viewport.max(1)) + } + + pub fn editor_clamp_scroll(&mut self) { + let max = self.editor_max_scroll_top_px(); + let cur = self.editor.scroll_top_px.get(&self.store); + self.editor.scroll_top_px.set(&self.store, cur.min(max)); + } + + pub fn editor_current_hunk_index(&self) -> Option<(usize, usize)> { + let scroll = self.editor.scroll_top_px.get(&self.store); + self.editor.hunk_positions.with(&self.store, |positions| { + if positions.is_empty() { + return None; + } + let idx = positions + .partition_point(|&y| y <= scroll) + .saturating_sub(1); + Some((idx, positions.len())) + }) + } + + pub(super) fn move_editor_row_cursor(&mut self, delta: i32) { + let Some(start) = self.editor.visible_row_start.get(&self.store) else { + return; + }; + let Some(end) = self.editor.visible_row_end.get(&self.store) else { + return; + }; + if start >= end { + return; + } + let max = end.saturating_sub(1); + let Some(current) = self + .editor + .hovered_row + .get(&self.store) + .filter(|row| *row >= start && *row <= max) + else { + self.editor + .hovered_row + .set(&self.store, Some(if delta < 0 { max } else { start })); + return; + }; + let next = if delta < 0 { + current + .saturating_sub(delta.unsigned_abs() as usize) + .max(start) + } else { + current.saturating_add(delta as usize).min(max) + }; + self.editor.hovered_row.set(&self.store, Some(next)); + } +} diff --git a/src/ui/state/file_list.rs b/src/ui/state/file_list.rs index 49c63978..7ab65c36 100644 --- a/src/ui/state/file_list.rs +++ b/src/ui/state/file_list.rs @@ -112,7 +112,7 @@ impl AppState { .collect() } ToggleSidebar => { - self.store.update(self.sidebar_visible, |v| *v = !*v); + self.store.update(self.ui.sidebar_visible, |v| *v = !*v); Vec::new() } ToggleSidebarMode => { @@ -178,3 +178,860 @@ fn insert_folder_prefixes(path: &str, set: &mut HashSet) { set.insert(path[..index].to_owned()); } } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileListEntry { + pub path: ComparePath, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum FileListStatus { + #[default] + None, + Added, + Deleted, + Modified, + Renamed, + Copied, + Untracked, + Conflicted, + TypeChanged, + Binary, +} + +impl FileListStatus { + pub fn label(self) -> &'static str { + match self { + Self::None => "", + Self::Added => "A", + Self::Deleted => "D", + Self::Modified => "M", + Self::Renamed => "R", + Self::Copied => "C", + Self::Untracked => "U", + Self::Conflicted => "!", + Self::TypeChanged => "T", + Self::Binary => "B", + } + } + + pub fn is_empty(self) -> bool { + matches!(self, Self::None) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FileListEntryMeta { + pub status: FileListStatus, + pub additions: i32, + pub deletions: i32, + pub is_binary: bool, +} + +pub(super) fn file_change_list_status( + status: FileChangeStatus, + bucket: ChangeBucket, +) -> FileListStatus { + match (status, bucket) { + (FileChangeStatus::Added, _) => FileListStatus::Added, + (FileChangeStatus::Deleted, _) => FileListStatus::Deleted, + (FileChangeStatus::Renamed, _) => FileListStatus::Renamed, + (FileChangeStatus::Copied, _) => FileListStatus::Copied, + (FileChangeStatus::Untracked, _) => FileListStatus::Untracked, + (FileChangeStatus::Conflicted, _) | (_, ChangeBucket::Conflicted) => { + FileListStatus::Conflicted + } + (FileChangeStatus::TypeChanged, _) => FileListStatus::TypeChanged, + (FileChangeStatus::Binary, _) => FileListStatus::Binary, + (FileChangeStatus::Modified, _) => FileListStatus::Modified, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SidebarMode { + #[default] + FlatList, + TreeView, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SidebarTab { + #[default] + Files, + Commits, +} + +#[derive(Debug, Clone, PartialEq, Store)] +pub struct FileListState { + pub scroll_offset_px: f32, + pub commits_scroll_offset_px: f32, + pub hovered_index: Option, + pub row_height: f32, + pub gap: f32, + pub viewport_height: f32, + pub filter: String, + pub mode: SidebarMode, + pub tab: SidebarTab, + pub expanded_folders: HashSet, + pub viewed_files: HashSet, +} + +impl Default for FileListState { + fn default() -> Self { + Self { + scroll_offset_px: 0.0, + commits_scroll_offset_px: 0.0, + hovered_index: None, + row_height: 36.0, + gap: 4.0, + viewport_height: 0.0, + filter: String::new(), + mode: SidebarMode::FlatList, + tab: SidebarTab::Files, + expanded_folders: HashSet::new(), + viewed_files: HashSet::new(), + } + } +} + +pub(super) fn carbon_list_status(status: carbon::FileStatus) -> FileListStatus { + match status { + carbon::FileStatus::Added => FileListStatus::Added, + carbon::FileStatus::Deleted => FileListStatus::Deleted, + carbon::FileStatus::Renamed | carbon::FileStatus::RenamedModified => { + FileListStatus::Renamed + } + carbon::FileStatus::Binary => FileListStatus::Binary, + carbon::FileStatus::ModeChanged | carbon::FileStatus::Modified => FileListStatus::Modified, + } +} + +pub(super) fn build_status_file_entries(changes: &[FileChange]) -> Vec { + changes.iter().map(FileListEntry::from).collect() +} + +pub(super) fn status_section_count(changes: &[FileChange]) -> usize { + let mut last_bucket = None; + let mut count = 0; + for change in changes { + if Some(change.bucket) != last_bucket { + count += 1; + last_bucket = Some(change.bucket); + } + } + count +} + +pub(super) fn status_section_count_before(changes: &[FileChange], len: usize) -> usize { + status_section_count(&changes[..len.min(changes.len())]) +} + +impl From<&FileChange> for FileListEntry { + fn from(value: &FileChange) -> Self { + Self { + path: ComparePath::from(value.path.as_str()), + } + } +} + +pub(super) fn status_file_entry_meta(change: &FileChange) -> FileListEntryMeta { + FileListEntryMeta { + status: file_change_list_status(change.status, change.bucket), + additions: 0, + deletions: 0, + is_binary: matches!(change.status, FileChangeStatus::Binary), + } +} + +impl AppState { + pub fn workspace_file_entry_at(&self, index: usize) -> Option { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + if let Some(entry) = self.workspace.compare_output.with(&self.store, |output| { + output.as_ref().and_then(|output| { + output + .summary_at(index) + .map(|summary| compare_summary_file_entry(&summary)) + }) + }) { + return Some(entry); + } + self.workspace + .files + .with(&self.store, |files| files.get(index).cloned()) + } + WorkspaceSource::Status => self + .workspace + .status_file_changes + .with(&self.store, |changes| { + changes.get(index).map(FileListEntry::from) + }) + .or_else(|| { + self.workspace + .files + .with(&self.store, |files| files.get(index).cloned()) + }), + WorkspaceSource::None => self + .workspace + .files + .with(&self.store, |files| files.get(index).cloned()), + } + } + + pub fn for_each_workspace_file_path(&self, mut visit: impl FnMut(usize, &str)) { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + let visited = self.workspace.compare_output.with(&self.store, |output| { + let Some(output) = output.as_ref() else { + return false; + }; + output.for_each_path(|index, path| visit(index, path)); + true + }); + if !visited { + self.workspace.files.with(&self.store, |files| { + for (index, file) in files.iter().enumerate() { + let path = file.path.path(); + visit(index, path.as_ref()); + } + }); + } + } + WorkspaceSource::Status => { + self.workspace + .status_file_changes + .with(&self.store, |changes| { + for (index, change) in changes.iter().enumerate() { + visit(index, &change.path); + } + }); + } + WorkspaceSource::None => { + self.workspace.files.with(&self.store, |files| { + for (index, file) in files.iter().enumerate() { + let path = file.path.path(); + visit(index, path.as_ref()); + } + }); + } + } + } + + pub fn workspace_max_file_path_chars(&self) -> usize { + if matches!( + self.workspace.source.get(&self.store), + WorkspaceSource::Compare | WorkspaceSource::TextCompare + ) { + let chars = self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .map(CompareOutput::max_path_chars) + .unwrap_or(0) + }); + if chars > 0 { + return chars; + } + } + let mut max_chars = 0; + self.for_each_workspace_file_path(|_, path| { + max_chars = max_chars.max(path.chars().count()); + }); + max_chars + } + + pub fn workspace_file_filter_matches(&self, filter: &str) -> Vec { + let config = neo_frizbee::Config { + max_typos: Some(2), + sort: false, + ..Default::default() + }; + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + let matches = self.workspace.compare_output.with(&self.store, |output| { + let Some(output) = output.as_ref() else { + return None; + }; + let mut matcher = neo_frizbee::Matcher::new(filter, &config); + let mut matches = Vec::new(); + output.for_each_path(|index, path| { + if let Ok(offset) = u32::try_from(index) { + matcher.match_list_into( + std::slice::from_ref(&path), + offset, + &mut matches, + ); + } + }); + matches.sort_by(|a, b| b.score.cmp(&a.score)); + Some(matches.iter().map(|m| m.index as usize).collect()) + }); + if let Some(matches) = matches { + matches + } else { + self.workspace.files.with(&self.store, |files| { + let mut matcher = neo_frizbee::Matcher::new(filter, &config); + let mut matches = Vec::new(); + for (index, file) in files.iter().enumerate() { + if let Ok(offset) = u32::try_from(index) { + let path = file.path.path(); + let path_ref = path.as_ref(); + matcher.match_list_into( + std::slice::from_ref(&path_ref), + offset, + &mut matches, + ); + } + } + matches.sort_by(|a, b| b.score.cmp(&a.score)); + matches.iter().map(|m| m.index as usize).collect() + }) + } + } + WorkspaceSource::Status => { + self.workspace + .status_file_changes + .with(&self.store, |changes| { + let haystack = changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + let mut matches = neo_frizbee::match_list(filter, &haystack, &config); + matches.sort_by(|a, b| b.score.cmp(&a.score)); + matches.iter().map(|m| m.index as usize).collect() + }) + } + WorkspaceSource::None => self.workspace.files.with(&self.store, |files| { + let mut matcher = neo_frizbee::Matcher::new(filter, &config); + let mut matches = Vec::new(); + for (index, file) in files.iter().enumerate() { + if let Ok(offset) = u32::try_from(index) { + let path = file.path.path(); + let path_ref = path.as_ref(); + matcher.match_list_into( + std::slice::from_ref(&path_ref), + offset, + &mut matches, + ); + } + } + matches.sort_by(|a, b| b.score.cmp(&a.score)); + matches.iter().map(|m| m.index as usize).collect() + }), + } + } + + pub fn workspace_file_tree_visible_row_count( + &self, + expanded_folders: &HashSet, + ) -> usize { + crate::ui::components::file_tree_visible_row_count_by( + |visit| { + self.for_each_workspace_file_path(|_, path| visit(path)); + }, + expanded_folders, + ) + } + + pub fn workspace_file_index_for_path(&self, path: &str) -> Option { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + if let Some(index) = self.workspace.compare_output.with(&self.store, |output| { + let output = output.as_ref()?; + let mut found = None; + output.for_each_path(|index, candidate| { + if found.is_none() && candidate == path { + found = Some(index); + } + }); + found + }) { + return Some(index); + } + self.workspace.files.with(&self.store, |files| { + files.iter().position(|file| file.path == path) + }) + } + WorkspaceSource::Status => self + .workspace + .status_file_changes + .with(&self.store, |changes| { + changes.iter().position(|change| change.path == path) + }), + WorkspaceSource::None => self.workspace.files.with(&self.store, |files| { + files.iter().position(|file| file.path == path) + }), + } + } + + pub fn file_list_row_stride(&self) -> f32 { + self.file_list.row_height.get(&self.store) + self.file_list.gap.get(&self.store) + } + + pub fn file_list_total_content_height(&self, file_count: usize) -> f32 { + if file_count == 0 { + return 0.0; + } + file_count as f32 * self.file_list_row_stride() - self.file_list.gap.get(&self.store) + } + + pub fn file_list_max_scroll_px(&self, file_count: usize) -> f32 { + (self.file_list_total_content_height(file_count) + - self.file_list.viewport_height.get(&self.store)) + .max(0.0) + } + + pub fn file_list_clamp_scroll(&mut self, file_count: usize) { + let max = self.file_list_max_scroll_px(file_count); + let cur = self.file_list.scroll_offset_px.get(&self.store); + self.file_list + .scroll_offset_px + .set_if_changed(&self.store, cur.clamp(0.0, max)); + } + + /// Scroll by a number of rows (positive = down). + pub fn file_list_scroll_rows(&mut self, delta: i32, file_count: usize) { + let px_delta = delta as f32 * self.file_list_row_stride(); + let cur = self.file_list.scroll_offset_px.get(&self.store); + self.file_list + .scroll_offset_px + .set(&self.store, cur + px_delta); + self.file_list_clamp_scroll(file_count); + } + + /// Scroll by a raw pixel delta (positive = down). + pub fn file_list_scroll_px(&mut self, delta_px: f32, file_count: usize) { + let cur = self.file_list.scroll_offset_px.get(&self.store); + self.file_list + .scroll_offset_px + .set(&self.store, cur + delta_px); + self.file_list_clamp_scroll(file_count); + } + + /// Reset every file-list signal back to its default value. + pub fn reset_file_list(&mut self) { + let d = FileListState::default(); + self.file_list + .scroll_offset_px + .set(&self.store, d.scroll_offset_px); + self.file_list + .commits_scroll_offset_px + .set(&self.store, d.commits_scroll_offset_px); + self.file_list + .hovered_index + .set(&self.store, d.hovered_index); + self.file_list.row_height.set(&self.store, d.row_height); + self.file_list.gap.set(&self.store, d.gap); + self.file_list + .viewport_height + .set(&self.store, d.viewport_height); + self.file_list.filter.set(&self.store, d.filter); + self.file_list.mode.set(&self.store, d.mode); + self.file_list.tab.set(&self.store, d.tab); + self.file_list + .expanded_folders + .set(&self.store, d.expanded_folders); + self.file_list.viewed_files.set(&self.store, d.viewed_files); + } + + pub fn sidebar_row_count(&self) -> usize { + if matches!( + self.workspace.source.get(&self.store), + WorkspaceSource::Compare | WorkspaceSource::TextCompare + ) && self.file_list.tab.get(&self.store) == SidebarTab::Files + && self.file_list.mode.get(&self.store) == SidebarMode::TreeView + && self.file_list.filter.with(&self.store, |s| s.is_empty()) + { + let expanded_folders = self.file_list.expanded_folders.get(&self.store); + return self.workspace_file_tree_visible_row_count(&expanded_folders); + } + + if self.workspace.source.get(&self.store) == WorkspaceSource::Status + && self.file_list.filter.with(&self.store, |s| s.is_empty()) + { + self.workspace.files.with(&self.store, |f| f.len()) + + self + .workspace + .status_file_changes + .with(&self.store, |s| status_section_count(s)) + } else { + self.workspace_file_count() + } + } + + pub fn file_list_entry_meta(&self, index: usize) -> FileListEntryMeta { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .and_then(|output| compare_output_file_entry_meta(output, index)) + .unwrap_or_default() + }) + } + WorkspaceSource::Status => { + self.workspace + .status_file_changes + .with(&self.store, |changes| { + changes + .get(index) + .map(status_file_entry_meta) + .unwrap_or_default() + }) + } + WorkspaceSource::None => FileListEntryMeta::default(), + } + } + + pub(super) fn sidebar_row_index_for_file(&self, index: usize) -> usize { + if self.workspace.source.get(&self.store) != WorkspaceSource::Status + || !self.file_list.filter.with(&self.store, |s| s.is_empty()) + { + return index; + } + index + + self + .workspace + .status_file_changes + .with(&self.store, |s| status_section_count_before(s, index + 1)) + } +} + +impl AppState { + pub(super) fn clamp_sidebar_width_px(&self, width: u32) -> u32 { + let min_width = (280.0 * self.ui_scale_factor() * 0.64).round() as u32; + width.max(min_width.max(120)) + } + + pub(super) fn shift_loaded_file(&mut self, delta: isize) -> Vec { + let file_count = self.workspace_file_count(); + if file_count == 0 { + return Vec::new(); + } + let current = self.reconcile_selected_file_index_from_path().unwrap_or(0); + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current + .saturating_add(delta as usize) + .min(file_count.saturating_sub(1)) + }; + self.select_file(next, true) + } + + pub(super) fn select_file(&mut self, index: usize, reveal: bool) -> Vec { + if self.settings.continuous_scroll + && !matches!( + self.workspace.source.get(&self.store), + WorkspaceSource::None + ) + { + let target = self + .file_start_offset_px(index) + .min(self.global_max_scroll_top_px()); + self.set_viewport_anchor_for_global(target, ViewportAnchorBias::PreserveTop); + self.workspace.global_scroll_top_px.set(&self.store, target); + } + self.select_file_inner(index, reveal) + } + + pub(super) fn select_file_inner(&mut self, index: usize, reveal: bool) -> Vec { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare => self.select_compare_file(index, reveal), + WorkspaceSource::TextCompare => self.select_text_compare_file(index, reveal), + WorkspaceSource::Status => self.select_status_item(index, reveal), + WorkspaceSource::None => { + self.startup.preferred_file_index = Some(index); + Vec::new() + } + } + } + + pub(super) fn active_file_matches_workspace_file(&self, index: usize) -> bool { + let Some(path) = self.workspace_file_path_at(index) else { + return false; + }; + let source = self.workspace.source.get(&self.store); + let selected_bucket = self.workspace.selected_change_bucket.get(&self.store); + self.workspace.active_file.with(&self.store, |active| { + active.as_ref().is_some_and(|active| { + if active.index != index || active.path != path { + return false; + } + match source { + WorkspaceSource::Status => selected_bucket.is_some_and(|bucket| { + let (left_ref, right_ref) = self.status_refs_for_bucket(bucket); + active.left_ref == left_ref && active.right_ref == right_ref + }), + WorkspaceSource::Compare | WorkspaceSource::TextCompare => true, + WorkspaceSource::None => false, + } + }) + }) + } + + pub(super) fn select_text_compare_file(&mut self, index: usize, reveal: bool) -> Vec { + let Some(entry) = self.workspace_file_entry_at(index) else { + self.push_error("Selected file index is out of range."); + return Vec::new(); + }; + let mut effects = vec![ + SyntaxEffect::EnsureSyntaxPackForPath { + path: entry.path.to_string(), + } + .into(), + ]; + effects.extend(self.select_loaded_compare_file(index, reveal)); + effects + } + + pub(super) fn select_compare_file(&mut self, index: usize, reveal: bool) -> Vec { + let Some(entry) = self.workspace_file_entry_at(index) else { + self.push_error("Selected file index is out of range."); + return Vec::new(); + }; + + if !self.compare_file_is_large(index) { + let mut effects = vec![ + SyntaxEffect::EnsureSyntaxPackForPath { + path: entry.path.to_string(), + } + .into(), + ]; + effects.extend(self.select_loaded_compare_file(index, reveal)); + return effects; + } + + let entry_path = entry.path.to_string(); + + if let Some(mut active_file) = self.cached_compare_file_at(index, &entry_path) { + active_file.last_used_tick = self.next_file_working_set_tick(); + self.workspace + .selected_file_index + .set(&self.store, Some(index)); + self.workspace + .selected_file_path + .set(&self.store, Some(entry_path.clone())); + self.workspace.selected_change_bucket.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + self.workspace + .active_file + .set(&self.store, Some(active_file.clone())); + self.cache_active_file(active_file); + self.workspace.compare_progress.set(&self.store, None); + self.editor_clear_document(); + self.file_list.hovered_index.set(&self.store, Some(index)); + if reveal { + self.reveal_file_list_row(index); + } + let mut effects = self.sync_editor_scroll_from_global(); + effects.push(SyntaxEffect::EnsureSyntaxPackForPath { path: entry_path }.into()); + effects.extend(self.request_active_file_syntax_effect()); + return effects; + } + + let should_load = self.should_enqueue_file_load( + index, + &entry_path, + CompareWorkPriority::InteractiveSelectedFile, + ); + + // If we're mid-compare (first file selection post-CompareFinished), + // flip the phase so the progress panel reports "Preparing first + // file…". Subsequent selections don't touch compare_progress. + self.workspace.compare_progress.update(&self.store, |slot| { + if let Some(p) = slot.as_mut() { + Arc::make_mut(p).phase = ComparePhase::RenderingFirstFile; + } + }); + + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + self.push_error("Open a repository before selecting a compare file."); + return Vec::new(); + }; + let deferred_file = self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .and_then(|output| compare_output_deferred_summary(output, index)) + }); + + self.workspace + .selected_file_index + .set(&self.store, Some(index)); + self.workspace + .selected_file_path + .set(&self.store, Some(entry_path.clone())); + self.workspace.selected_change_bucket.set(&self.store, None); + self.workspace.active_file.set(&self.store, None); + self.workspace.active_file_loading.set( + &self.store, + Some(ActiveFileLoading { + index, + path: entry_path.clone(), + priority: CompareWorkPriority::InteractiveSelectedFile, + }), + ); + self.mark_file_cache_loading( + index, + entry_path.clone(), + CompareWorkPriority::InteractiveSelectedFile, + ); + self.editor_clear_document(); + self.file_list.hovered_index.set(&self.store, Some(index)); + if reveal { + self.reveal_file_list_row(index); + } + + let mut effects = vec![ + SyntaxEffect::EnsureSyntaxPackForPath { + path: entry_path.clone(), + } + .into(), + ]; + if should_load { + effects.push( + CompareEffect::LoadFile(Task { + generation: self.workspace.compare_generation.get(&self.store), + request: CompareFileRequest { + repo_path, + request: vcs_compare_request( + self.compare.mode.get(&self.store), + self.compare.left_ref.get(&self.store), + self.compare.right_ref.get(&self.store), + self.compare.layout.get(&self.store), + self.compare.renderer.get(&self.store), + ), + path: entry_path, + index, + deferred_file, + priority: CompareWorkPriority::InteractiveSelectedFile, + }, + }) + .into(), + ); + } + effects + } + + #[profiling::function] + pub(super) fn select_loaded_compare_file(&mut self, index: usize, reveal: bool) -> Vec { + let mut selected_path = None; + let mut prepared = None; + let mut oob = false; + self.workspace + .compare_output + .update(&self.store, |maybe_output| { + let Some(output) = maybe_output.as_mut() else { + return; + }; + let Some(carbon_file) = output.carbon.files.get(index) else { + oob = true; + return; + }; + selected_path = Some(carbon_file.path().to_owned()); + prepared = Some(prepare_active_file(index, carbon_file)); + }); + + let Some(prepared) = prepared else { + if oob { + self.push_error("Selected file index is out of range."); + return Vec::new(); + } + self.startup.preferred_file_index = Some(index); + return Vec::new(); + }; + + let Some(path) = selected_path else { + self.startup.preferred_file_index = Some(index); + return Vec::new(); + }; + + self.install_compare_active_file(index, path, prepared); + if reveal { + self.reveal_file_list_row(index); + } + let mut effects = self.sync_editor_scroll_from_global(); + effects.extend(self.request_active_file_syntax_effect()); + effects + } + + pub(super) fn reveal_file_list_row(&mut self, index: usize) { + let row_top = self.sidebar_row_index_for_file(index) as f32 * self.file_list_row_stride(); + let row_bottom = row_top + self.file_list.row_height.get(&self.store); + let scroll = self.file_list.scroll_offset_px.get(&self.store); + let viewport = self.file_list.viewport_height.get(&self.store); + if row_top < scroll { + self.file_list.scroll_offset_px.set(&self.store, row_top); + } else if row_bottom > scroll + viewport { + self.file_list + .scroll_offset_px + .set(&self.store, row_bottom - viewport); + } + self.file_list_clamp_scroll(self.sidebar_row_count()); + } + + pub fn workspace_file_count(&self) -> usize { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + let count = self.workspace.compare_output.with(&self.store, |output| { + output.as_ref().map(CompareOutput::file_count).unwrap_or(0) + }); + count.max(self.workspace.files.with(&self.store, |f| f.len())) + } + WorkspaceSource::Status => self + .workspace + .status_file_changes + .with(&self.store, |s| s.len()), + WorkspaceSource::None => self.workspace.files.with(&self.store, |f| f.len()), + } + } + + pub fn workspace_file_path_at(&self, index: usize) -> Option { + self.workspace_file_entry_at(index) + .map(|entry| entry.path.to_string()) + } + + pub fn selected_workspace_file_index(&self) -> Option { + let count = self.workspace_file_count(); + let selected_index = self + .workspace + .selected_file_index + .get(&self.store) + .filter(|index| *index < count); + + if let Some(path) = self.workspace.selected_file_path.get(&self.store) { + if let Some(index) = selected_index + && self + .workspace_file_entry_at(index) + .is_some_and(|entry| entry.path == path.as_str()) + { + return Some(index); + } + if let Some(index) = self.workspace_file_index_for_path(&path) { + return Some(index); + } + } + + selected_index + } + + pub(super) fn reconcile_selected_file_index_from_path(&mut self) -> Option { + let resolved = self.selected_workspace_file_index(); + if let Some(index) = resolved + && self.workspace.selected_file_index.get(&self.store) != Some(index) + { + self.workspace + .selected_file_index + .set(&self.store, Some(index)); + } + resolved + } + + pub fn workspace_render_generation(&self) -> u64 { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare => self.workspace.compare_generation.get(&self.store), + WorkspaceSource::TextCompare => self.text_compare.generation, + WorkspaceSource::Status => self.workspace.status_generation.get(&self.store), + WorkspaceSource::None => 0, + } + } +} diff --git a/src/ui/state/github.rs b/src/ui/state/github.rs index 8e44d5de..69b0394c 100644 --- a/src/ui/state/github.rs +++ b/src/ui/state/github.rs @@ -2143,3 +2143,342 @@ mod tests { assert_eq!(range.3, Some(5)); } } + +pub(super) fn build_pr_palette_entry( + cache: &HashMap, + key: &PrKey, + has_repo: bool, +) -> PaletteEntry { + let (owner, repo, number) = key; + let fallback_label = format!("#{number} in {owner}/{repo}"); + let entry = cache.get(key); + let (label, rhs, detail, disabled) = match entry.map(|e| (&e.meta, &e.diff)) { + None | Some((PrPeekMeta::Loading, _)) => ( + fallback_label, + Some("Resolving\u{2026}".to_owned()), + if has_repo { + "Fetching PR metadata".to_owned() + } else { + "Open a repo to view this diff".to_owned() + }, + false, + ), + Some((PrPeekMeta::Ready(info), diff)) => { + let label = format!("#{} {}", info.number, info.title); + let rhs = format!( + "{} \u{00B7} +{} \u{2212}{} \u{00B7} @{}", + info.state, info.additions, info.deletions, info.author_login + ); + let detail = match diff { + PrPeekDiff::Ready { .. } => "Ready \u{2014} press Enter to open".to_owned(), + PrPeekDiff::Loading => "Preparing diff\u{2026}".to_owned(), + PrPeekDiff::Failed(msg) => format!("Diff load failed: {msg}"), + PrPeekDiff::Idle => { + if has_repo { + "Queued".to_owned() + } else { + "Open a repo to view this diff".to_owned() + } + } + }; + let disabled = !has_repo; + (label, Some(rhs), detail, disabled) + } + Some((PrPeekMeta::Failed(msg), _)) => { + (fallback_label, Some("error".to_owned()), msg.clone(), true) + } + }; + PaletteEntry { + label, + detail, + kind: PaletteEntryKind::PullRequest(key.clone()), + highlights: Vec::new(), + rhs, + disabled, + } +} +pub type PrKey = (String, String, i32); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrPeekMeta { + Loading, + Ready(PullRequestInfo), + Failed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrPeekDiff { + Idle, + Loading, + Ready { + url: String, + left_ref: String, + right_ref: String, + info: PullRequestInfo, + }, + Failed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrCacheEntry { + pub meta: PrPeekMeta, + pub diff: PrPeekDiff, + pub last_peek_ms: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PrReviewCommentsEntry { + pub status: AsyncStatus, + pub comments: Vec, + pub message: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReviewCommentDraft { + pub key: PrKey, + pub request: CreatePullRequestReviewComment, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ReviewCommentComposerState { + pub draft: Option, + pub status: AsyncStatus, + pub message: Option, + /// When set, submitting the composer replies to this thread instead of + /// creating a new inline draft. + pub reply_target: Option, + /// When set, submitting the composer edits this comment (by GraphQL node id) + /// instead of creating a new draft. + pub edit_target: Option, + /// Write (false) vs Preview (true) tab — Preview renders the markdown. + pub preview: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveReviewStatus { + pub status: ReviewSessionStatus, + pub message: Option, + pub unresolved_threads: usize, + pub resolved_threads: usize, + pub outdated_threads: usize, + pub pending_drafts: usize, + pub failed_drafts: usize, + pub review_decision: Option, + pub viewer_latest_review_state: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct PullRequestState { + pub status: AsyncStatus, + pub cache: HashMap, + pub pending_confirm: Option, + pub active: Option, + pub review_comments: HashMap, + pub review_sessions: HashMap, + pub review_composer: ReviewCommentComposerState, + /// Ephemeral, UI-only expand/collapse override per thread. Takes precedence + /// over the default (unresolved=expanded, resolved=collapsed). Not persisted + /// and intentionally separate from the backend `ReviewThreadStatus.collapsed`. + pub review_thread_expanded: HashMap, + /// Fetched comment-author avatars, keyed by `avatar_cache_key` of the sized + /// URL. Shared across PRs (avatars are immutable per URL); populated by the + /// shared `AvatarFetched` handler and read by the review card overlay. + pub review_avatars: HashMap, + /// Active drag-selection within a single review comment body, or `None`. + /// Mutually exclusive with the editor's viewport text selection. + pub card_text_selection: Option, +} + +/// Drag-selection within one review comment body. Offsets are byte indices into +/// `text` (a snapshot of the cleaned, wrapped-source body), so they remain valid +/// across re-wrap; `text` is stored so copy never has to re-derive it. Only the +/// comment whose `source_key` matches renders the highlight. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CardTextSelection { + pub source_key: u64, + pub text: String, + pub anchor: usize, + pub focus: usize, +} + +impl CardTextSelection { + pub fn new(source_key: u64, text: String, byte: usize) -> Self { + let byte = byte.min(text.len()); + Self { + source_key, + text, + anchor: byte, + focus: byte, + } + } + + pub fn normalized(&self) -> (usize, usize) { + (self.anchor.min(self.focus), self.anchor.max(self.focus)) + } + + pub fn is_collapsed(&self) -> bool { + self.anchor == self.focus + } + + /// The selected substring, or `None` when the selection is empty/invalid. + pub fn selected_text(&self) -> Option { + let (lo, hi) = self.normalized(); + if lo >= hi { + return None; + } + self.text.get(lo..hi).map(str::to_owned) + } +} + +/// Lifecycle of a single comment-author avatar fetch. `Failed` is terminal (no +/// retry) so a persistently-broken URL falls back to initials without re-fetching. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReviewAvatar { + Fetching, + Ready(AvatarBitmap), + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AvatarBitmap { + pub url: String, + pub rgba: Arc>, + pub width: u32, + pub height: u32, + pub cache_key: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct GitHubAuthState { + pub status: AsyncStatus, + pub device_flow: Option, + pub token_present: bool, + pub user: Option, + pub avatar: Option, + pub avatar_fetching: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct GitHubState { + pub client_id: String, + #[store(flatten)] + pub auth: GitHubAuthState, + #[store(flatten)] + pub pull_request: PullRequestState, +} + +/// Request a fixed-size avatar from GitHub by rewriting (or appending) the `s=` query +/// parameter. Returns `None` if the input URL is empty. +pub(crate) fn avatar_url_sized(base: &str, size: u32) -> Option { + let base = base.trim(); + if base.is_empty() { + return None; + } + let (path, query) = match base.split_once('?') { + Some((p, q)) => (p, q), + None => (base, ""), + }; + let mut parts: Vec = query + .split('&') + .filter(|part| !part.is_empty() && !part.starts_with("s=")) + .map(|part| part.to_owned()) + .collect(); + parts.push(format!("s={size}")); + Some(format!("{path}?{}", parts.join("&"))) +} + +/// Deterministic cache key for an avatar URL so the GPU texture cache dedupes it. +pub(crate) fn avatar_cache_key(url: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + "avatar".hash(&mut h); + url.hash(&mut h); + h.finish() +} + +impl AppState { + pub(super) fn preview_pull_request(&mut self) -> Vec { + let profile = self.vcs_ui_profile(); + if !profile.accepts_compare_mode(CompareMode::ThreeDot) + || self.repository.location.with(&self.store, |location| { + !location + .as_ref() + .is_some_and(|location| location.profile == VCS_PROFILE_GIT) + }) + { + self.push_error("PR preview is only available for Git repositories."); + return Vec::new(); + } + let Some(base_ref) = self.default_pull_request_base_ref() else { + self.push_error("No default branch found for PR preview."); + return Vec::new(); + }; + let (_, workdir_ref, _) = profile.working_copy_compare(); + self.workspace.pre_drill_compare.set(&self.store, None); + self.compare.left_ref.set(&self.store, base_ref); + self.compare + .right_ref + .set(&self.store, workdir_ref.to_owned()); + self.compare.resolved_left.set(&self.store, None); + self.compare.resolved_right.set(&self.store, None); + self.compare.mode.set(&self.store, CompareMode::ThreeDot); + let mut effects = self.persist_settings_effect(); + effects.extend(self.kickoff_compare()); + effects + } + + pub(super) fn default_pull_request_base_ref(&self) -> Option { + let refs = self.repository.refs.get(&self.store); + let active = refs + .iter() + .find(|reference| reference.active && reference.kind == RefKind::Branch) + .map(|reference| reference.name.as_str()); + let branch_ref = |name: &str| { + refs.iter() + .find(|reference| { + reference.name == name + && active != Some(reference.name.as_str()) + && matches!(reference.kind, RefKind::Branch | RefKind::RemoteBranch) + }) + .map(|reference| reference.name.clone()) + }; + for name in [ + "origin/main", + "origin/master", + "upstream/main", + "upstream/master", + "origin/develop", + "origin/development", + "main", + "master", + "develop", + "development", + ] { + if let Some(reference) = branch_ref(name) { + return Some(reference); + } + } + for trunk in ["main", "master", "develop", "development"] { + let suffix = format!("/{trunk}"); + if let Some(reference) = refs + .iter() + .find(|reference| { + reference.name.ends_with(&suffix) + && active != Some(reference.name.as_str()) + && reference.kind == RefKind::RemoteBranch + }) + .map(|reference| reference.name.clone()) + { + return Some(reference); + } + } + None + } + + pub(super) fn apply_pr_compare(&mut self, left: String, right: String) -> Vec { + let _ = self.update_compare_field(CompareField::Left, left); + let _ = self.update_compare_field(CompareField::Right, right); + self.compare.mode.set(&self.store, CompareMode::ThreeDot); + self.kickoff_compare() + } +} diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index 2e1217dc..9219ed88 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -5,15 +5,32 @@ mod editor; mod file_list; mod github; mod overlay; +mod presentation; mod repository; mod settings; mod syntax; mod text_compare; mod text_edit; +mod ui; mod update; mod working_set; mod workspace; +pub use self::app::*; +pub use self::compare::*; +pub use self::file_list::*; +pub use self::github::*; +pub use self::overlay::*; +pub use self::presentation::*; +pub use self::repository::*; +use self::syntax::*; +pub use self::text_compare::*; +pub use self::text_edit::*; +pub use self::ui::*; +pub use self::update::*; +pub use self::working_set::*; +pub use self::workspace::*; + use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::rc::Rc; @@ -33,7 +50,7 @@ use crate::core::forge::github::{ PullRequestReviewComment, }; use crate::core::frecency::FrecencyStore; -use crate::core::review::{ReviewSession, ReviewSessionStatus, ReviewTarget, ReviewThreadId}; +use crate::core::review::{ReviewSession, ReviewSessionStatus, ReviewTarget}; use crate::core::syntax::Highlighter; use crate::core::syntax::annotator::{SyntaxLineTokens, SyntaxRowWindow}; use crate::core::text::TokenBuffer; @@ -54,9 +71,9 @@ use crate::effects::{ AiEffect, BatchFileOperationRequest, CommitRequest, CompareEffect, CompareFileRequest, CompareFileStatsItem, CompareFileStatsRequest, CompareHistoryRequest, CompareRequest, CompareStatsRequest, CompareWorkPriority, Effect, FetchRemoteRequest, FileOperationRequest, - GitHubEffect, LoadFileSyntaxRequest, PatchOperationRequest, PublishPlanRequest, PublishRequest, - PullFfRequest, PushRequest, RepositoryEffect, SettingsEffect, StatusDiffRequest, SyntaxEffect, - Task, TextCompareRequest, UiEffect, UpdateEffect, VcsOperationRequest, + GitHubEffect, PatchOperationRequest, PublishPlanRequest, PublishRequest, PullFfRequest, + PushRequest, RepositoryEffect, SettingsEffect, StatusDiffRequest, SyntaxEffect, Task, + TextCompareRequest, UiEffect, UpdateEffect, VcsOperationRequest, }; use crate::events::{ AppEvent, CompareFileFinished, CompareFileStat, CompareFileStatsReady, CompareFinished, @@ -71,181 +88,7 @@ use crate::ui::components::ContextMenuState; use crate::ui::design::{Sp, Sz}; use crate::ui::icons::lucide; use crate::ui::theme::ThemeMode; - -const MAX_VISIBLE_TOASTS: usize = 5; -const TOAST_LIFETIME_MS: u64 = 5_000; -const TOAST_ANIM_MS: u64 = 150; -const CURSOR_BLINK_INTERVAL_MS: u64 = 530; -const LARGE_COMPARE_FILE_LINES: i32 = 1_500; -const COMPARE_STATS_CHUNK_SIZE: usize = 64; -const COMPARE_STATS_BACKGROUND_CHUNK_SIZE: usize = 128 * 1024; -const COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT: usize = 10_000; -const COMPARE_STATS_VISIBLE_OVERSCAN_ROWS: usize = 32; -const SYNTAX_INITIAL_ROWS: usize = 200; -const SYNTAX_OVERSCAN_ROWS: usize = 160; -const MAX_PENDING_SYNTAX_WINDOWS: usize = 96; -const COMPARE_WORKING_SET_MAX_FILES: usize = 96; -const COMPARE_WORKING_SET_MIN_FILES: usize = 24; -const COMPARE_WORKING_SET_BYTE_BUDGET: usize = 64 * 1024 * 1024; -const COMPARE_WORKING_SET_PREFETCH_PAGES: u32 = 3; -const COMPARE_WORKING_SET_TRAILING_PAGES: u32 = 1; -const CONTINUOUS_BOTTOM_ANCHOR_TOLERANCE_PX: u32 = 2; - -fn build_pr_palette_entry( - cache: &HashMap, - key: &PrKey, - has_repo: bool, -) -> PaletteEntry { - let (owner, repo, number) = key; - let fallback_label = format!("#{number} in {owner}/{repo}"); - let entry = cache.get(key); - let (label, rhs, detail, disabled) = match entry.map(|e| (&e.meta, &e.diff)) { - None | Some((PrPeekMeta::Loading, _)) => ( - fallback_label, - Some("Resolving\u{2026}".to_owned()), - if has_repo { - "Fetching PR metadata".to_owned() - } else { - "Open a repo to view this diff".to_owned() - }, - false, - ), - Some((PrPeekMeta::Ready(info), diff)) => { - let label = format!("#{} {}", info.number, info.title); - let rhs = format!( - "{} \u{00B7} +{} \u{2212}{} \u{00B7} @{}", - info.state, info.additions, info.deletions, info.author_login - ); - let detail = match diff { - PrPeekDiff::Ready { .. } => "Ready \u{2014} press Enter to open".to_owned(), - PrPeekDiff::Loading => "Preparing diff\u{2026}".to_owned(), - PrPeekDiff::Failed(msg) => format!("Diff load failed: {msg}"), - PrPeekDiff::Idle => { - if has_repo { - "Queued".to_owned() - } else { - "Open a repo to view this diff".to_owned() - } - } - }; - let disabled = !has_repo; - (label, Some(rhs), detail, disabled) - } - Some((PrPeekMeta::Failed(msg), _)) => { - (fallback_label, Some("error".to_owned()), msg.clone(), true) - } - }; - PaletteEntry { - label, - detail, - kind: PaletteEntryKind::PullRequest(key.clone()), - highlights: Vec::new(), - rhs, - disabled, - } -} - -/// Request a fixed-size avatar from GitHub by rewriting (or appending) the `s=` query -/// parameter. Returns `None` if the input URL is empty. -pub(crate) fn avatar_url_sized(base: &str, size: u32) -> Option { - let base = base.trim(); - if base.is_empty() { - return None; - } - let (path, query) = match base.split_once('?') { - Some((p, q)) => (p, q), - None => (base, ""), - }; - let mut parts: Vec = query - .split('&') - .filter(|part| !part.is_empty() && !part.starts_with("s=")) - .map(|part| part.to_owned()) - .collect(); - parts.push(format!("s={size}")); - Some(format!("{path}?{}", parts.join("&"))) -} - -/// Deterministic cache key for an avatar URL so the GPU texture cache dedupes it. -pub(crate) fn avatar_cache_key(url: &str) -> u64 { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - "avatar".hash(&mut h); - url.hash(&mut h); - h.finish() -} - -const DEFAULT_UI_SCALE_PCT: u16 = 100; -const MIN_UI_SCALE_PCT: u16 = 70; -const MAX_UI_SCALE_PCT: u16 = 180; -const UI_SCALE_STEP_PCT: u16 = 10; - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum WorkspaceMode { - #[default] - Empty, - Loading, - Ready, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum AppView { - #[default] - Workspace, - Settings, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum SettingsSection { - #[default] - Appearance, - Editor, - Behavior, - Keymaps, - Clankers, - About, -} - -impl SettingsSection { - pub fn label(self) -> &'static str { - match self { - Self::Appearance => "Appearance", - Self::Editor => "Editor", - Self::Behavior => "Behavior", - Self::Keymaps => "Keymaps", - Self::Clankers => "Clankers", - Self::About => "About", - } - } - - pub fn icon(self) -> &'static str { - match self { - Self::Appearance => lucide::SUN, - Self::Editor => lucide::FILE_CODE, - Self::Behavior => lucide::SETTINGS, - Self::Keymaps => lucide::KEY, - Self::Clankers => lucide::SPARKLES, - Self::About => lucide::INFO, - } - } - - pub const ALL: [Self; 6] = [ - Self::Appearance, - Self::Editor, - Self::Behavior, - Self::Keymaps, - Self::Clankers, - Self::About, - ]; -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum WorkspaceSource { - #[default] - None, - Status, - Compare, - TextCompare, -} +use crate::ui::virtual_list::{build_sectioned_rows, step_selection}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum AsyncStatus { @@ -256,15325 +99,421 @@ pub enum AsyncStatus { Failed, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum CompareField { - #[default] - Left, - Right, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum TextCompareView { - #[default] - Edit, - Diff, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TextCompareSide { - Left, - Right, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum TextCompareLanguage { - #[default] - Auto, - PlainText, - Rust, - TypeScript, - JavaScript, - Python, - Go, - Json, - Toml, - Shell, - Nix, - C, - Cpp, - Zig, -} - -impl TextCompareLanguage { - pub const OPTIONS: &'static [Self] = &[ - Self::Auto, - Self::PlainText, - Self::Rust, - Self::TypeScript, - Self::JavaScript, - Self::Python, - Self::Go, - Self::Json, - Self::Toml, - Self::Shell, - Self::Nix, - Self::C, - Self::Cpp, - Self::Zig, - ]; - - pub fn label(self) -> &'static str { - match self { - Self::Auto => "Auto", - Self::PlainText => "Plain text", - Self::Rust => "Rust", - Self::TypeScript => "TypeScript", - Self::JavaScript => "JavaScript", - Self::Python => "Python", - Self::Go => "Go", - Self::Json => "JSON", - Self::Toml => "TOML", - Self::Shell => "Shell", - Self::Nix => "Nix", - Self::C => "C", - Self::Cpp => "C++", - Self::Zig => "Zig", - } - } - - pub fn short_label(self) -> &'static str { - match self { - Self::Auto => "Auto", - Self::PlainText => "Text", - Self::Rust => "Rust", - Self::TypeScript => "TS", - Self::JavaScript => "JS", - Self::Python => "Py", - Self::Go => "Go", - Self::Json => "JSON", - Self::Toml => "TOML", - Self::Shell => "Sh", - Self::Nix => "Nix", - Self::C => "C", - Self::Cpp => "C++", - Self::Zig => "Zig", - } - } - - pub fn scratch_path(self) -> &'static str { - match self { - Self::Auto | Self::PlainText => "text.txt", - Self::Rust => "scratch.rs", - Self::TypeScript => "scratch.ts", - Self::JavaScript => "scratch.js", - Self::Python => "scratch.py", - Self::Go => "scratch.go", - Self::Json => "scratch.json", - Self::Toml => "scratch.toml", - Self::Shell => "scratch.sh", - Self::Nix => "scratch.nix", - Self::C => "scratch.c", - Self::Cpp => "scratch.cpp", - Self::Zig => "scratch.zig", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FocusTarget { - WorkspacePrimaryButton, - TitleBar, - ThemeToggle, - FileList, - Editor, - PickerInput, - PickerList, - CommandPaletteInput, - CommandPaletteList, - AuthPrimaryAction, - SidebarSearch, - SearchInput, - CommitEditor, - ReviewCommentEditor, - TextCompareLeft, - TextCompareRight, - SettingsOpenAiKey, - SettingsAnthropicKey, - SettingsSteeringPrompt, -} - -impl FocusTarget { - pub fn is_text_field(self) -> bool { - matches!( - self, - Self::PickerInput - | Self::CommandPaletteInput - | Self::SidebarSearch - | Self::SearchInput - | Self::CommitEditor - | Self::ReviewCommentEditor - | Self::TextCompareLeft - | Self::TextCompareRight - | Self::SettingsOpenAiKey - | Self::SettingsAnthropicKey - | Self::SettingsSteeringPrompt - ) - } -} - -// Focus is stored directly as a Signal on AppState — no wrapper struct. - -/// Cursor/selection state for the currently focused text field. -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct TextEditState { - /// Byte offset of the caret. - pub cursor: usize, - /// Byte offset of the selection anchor. Equal to `cursor` when nothing is selected. - pub anchor: usize, - /// Timestamp (clock_ms) when the cursor last moved — used to reset blink phase. - pub cursor_moved_at_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Store)] -pub struct CompareState { - pub repo_path: Option, - pub left_ref: String, - pub right_ref: String, - pub mode: CompareMode, - pub layout: LayoutMode, - pub renderer: RendererKind, - pub resolved_left: Option, - pub resolved_right: Option, -} - -impl Default for CompareState { - fn default() -> Self { - Self { - repo_path: None, - left_ref: String::new(), - right_ref: String::new(), - mode: CompareMode::default(), - layout: LayoutMode::default(), - renderer: RendererKind::default(), - resolved_left: None, - resolved_right: None, - } - } -} +// App-chrome signals (focus, toasts, view routing, ...) live in the +// derived `UiStateStore` at `AppState::ui`. -#[derive(Debug, Clone)] -pub struct TextCompareState { - pub left_editor: Editor, - pub right_editor: Editor, - pub language: TextCompareLanguage, - pub detected_language: Option, - pub path_hint: String, - pub view: TextCompareView, - pub generation: u64, - pub last_compared_generation: Option, - pub status: AsyncStatus, +#[derive(Debug)] +pub struct AppState { + pub ui: UiStateStore, + pub compare: CompareStateStore, + pub repository: RepositoryStateStore, + pub workspace: WorkspaceStateStore, + pub file_list: FileListStateStore, + pub overlays: OverlayStackStateStore, + pub text_edit: TextEditStateStore, + pub editor: EditorStateStore, + pub github: GitHubStateStore, + pub settings: Settings, + pub startup: StartupState, + pub context_menu: ContextMenuState, + /// Memoized: `true` when `ui.focus` targets a text-editing field. + pub text_focused: Signal, + pub animation: crate::ui::animation::AnimationState, + pub commit_editor: Editor, + pub review_comment_editor: Editor, + pub steering_prompt_editor: Editor, + pub text_compare: TextCompareState, + pub ai_openai_key: String, + pub ai_anthropic_key: String, + pub ai_openai_editing: bool, + pub ai_anthropic_editing: bool, + pub ai_generation_id: u64, + pub ai_generation_active: bool, + pub ai_generation_error: Option, + /// Shared reactive store. Signals (like `ui.sidebar_visible`) are handles + /// into this store. Kept in `AppState` so state methods (apply_action etc.) + /// can freely read/write signals without threading a store parameter. + pub store: Rc, + pub debug: DebugStateStore, + pub clock_ms: u64, + pub next_toast_id: u64, + pub frecency: Option, + pub theme_names: Vec, + pub theme_variants: Vec, + pub github_access_token: Option, + viewport_document_cache: Option, + virtual_diff_document: VirtualDiffDocument, + virtual_scroll: VirtualScrollModel, + file_working_set: FileWorkingSet, + syntax_requests: SyntaxRequestTracker, + last_virtual_scroll_top_px: Option, } -impl Default for TextCompareState { +impl Default for AppState { fn default() -> Self { - let mut left_editor = Editor::new(EditorMode::CodeInput); - let mut right_editor = Editor::new(EditorMode::CodeInput); - left_editor.set_syntax_path("text.txt"); - right_editor.set_syntax_path("text.txt"); + let store = Rc::new(SignalStore::default()); + let ui = UiStateStore::new_default(&store); + let focus = ui.focus; + let text_focused = + store.create_memo(move |s| s.read(focus).is_some_and(|t| t.is_text_field())); + let debug = DebugStateStore::new(&store, DebugState::default()); + let file_list = FileListStateStore::new_default(&store); + let editor = EditorStateStore::new_default(&store); + let overlays = OverlayStackStateStore::new_default(&store); + let compare = CompareStateStore::new_default(&store); + let repository = RepositoryStateStore::new_default(&store); + let workspace = WorkspaceStateStore::new_default(&store); + let text_edit = TextEditStateStore::new_default(&store); + let github = GitHubStateStore::new_default(&store); Self { - left_editor, - right_editor, - language: TextCompareLanguage::Auto, - detected_language: None, - path_hint: "text.txt".to_owned(), - view: TextCompareView::default(), - generation: 0, - last_compared_generation: None, - status: AsyncStatus::Idle, - } - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct RepositoryState { - pub status: AsyncStatus, - pub location: Option, - pub capabilities: Option, - pub refs: Vec, - pub changes: Vec, - pub operation_log: Vec, - pub file_changes: Vec, - pub publish_plan: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FileListEntry { - pub path: ComparePath, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum FileListStatus { - #[default] - None, - Added, - Deleted, - Modified, - Renamed, - Copied, - Untracked, - Conflicted, - TypeChanged, - Binary, -} - -impl FileListStatus { - pub fn label(self) -> &'static str { - match self { - Self::None => "", - Self::Added => "A", - Self::Deleted => "D", - Self::Modified => "M", - Self::Renamed => "R", - Self::Copied => "C", - Self::Untracked => "U", - Self::Conflicted => "!", - Self::TypeChanged => "T", - Self::Binary => "B", + ui, + compare, + repository, + workspace, + file_list, + overlays, + text_edit, + editor, + github, + settings: Settings::default(), + startup: StartupState::default(), + context_menu: ContextMenuState::default(), + text_focused, + animation: crate::ui::animation::AnimationState::default(), + commit_editor: Editor::default(), + review_comment_editor: Editor::default(), + steering_prompt_editor: Editor::default(), + text_compare: TextCompareState::default(), + ai_openai_key: String::new(), + ai_anthropic_key: String::new(), + ai_openai_editing: false, + ai_anthropic_editing: false, + ai_generation_id: 0, + ai_generation_active: false, + ai_generation_error: None, + debug, + store, + clock_ms: 0, + next_toast_id: 1, + frecency: None, + theme_names: Vec::new(), + theme_variants: Vec::new(), + github_access_token: None, + viewport_document_cache: None, + virtual_diff_document: VirtualDiffDocument::default(), + virtual_scroll: VirtualScrollModel::default(), + file_working_set: FileWorkingSet::default(), + syntax_requests: SyntaxRequestTracker::default(), + last_virtual_scroll_top_px: None, } } - - pub fn is_empty(self) -> bool { - matches!(self, Self::None) - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct FileListEntryMeta { - pub status: FileListStatus, - pub additions: i32, - pub deletions: i32, - pub is_binary: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActiveFileLoading { - pub index: usize, - pub path: String, - pub priority: CompareWorkPriority, -} - -pub use crate::core::compare::ComparePhase; - -/// What the progress panel is about. Drives chip rendering: compare -/// shows a left⇄right ref pair, repo-open shows a single folder chip. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum LoadingSubject { - Compare { - left_label: String, - right_label: String, - }, - RepoOpen { - name: String, - }, -} - -/// Transient progress state for a long-running workspace operation -/// (compare or repo open). Present iff something is in flight and the -/// reveal delay has either elapsed or was set to zero. Cleared when the -/// operation lands or the user cancels. -/// -/// `reveal_at_ms` controls when the panel is rendered. Compares show -/// immediately; repo-open still uses the short delay to avoid flashing a -/// loading panel for tiny repositories. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CompareProgress { - pub generation: u64, - pub phase: ComparePhase, - pub subject: LoadingSubject, - pub started_at_ms: u64, - pub reveal_at_ms: u64, - /// Total file count — first known from a backend `LoadingFiles` - /// emission, re-confirmed by `CompareFinished`. Unused for RepoOpen. - pub file_count_total: Option, - /// Files read so far during `LoadingFiles`. Zero before, frozen - /// after. - pub files_loaded: u32, } -/// Delay between kicking off an op and revealing the loading UI — -/// fast ops under this threshold show no loading flash at all. -pub const COMPARE_REVEAL_DELAY_MS: u64 = 500; - -#[derive(Debug, Clone)] -pub struct PreparedActiveFile { - pub carbon_file: carbon::FileDiff, - pub carbon_expansion: carbon::ExpansionState, - pub carbon_overlays: CarbonStyleOverlays, - pub render_doc: Arc, - pub token_buffer: TokenBuffer, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ViewportDocumentMode { - Single, - Continuous, -} - -#[derive(Debug, Clone)] -pub struct ViewportDocument { - pub doc: Arc, - pub mode: ViewportDocumentMode, - pub generation: u64, - pub start_index: usize, - pub start_offset_px: u32, - pub scroll_top_px: u32, - pub slot_indices: Vec, - pub slot_item_ids: Vec, - pub stream_items: Vec, - pub slot_loading: Vec, - pub path: String, -} - -impl ViewportDocument { - pub fn single(doc: Arc, generation: u64, file_index: usize, path: String) -> Self { - Self { - doc, - mode: ViewportDocumentMode::Single, - generation, - start_index: file_index, - start_offset_px: 0, - scroll_top_px: 0, - slot_indices: vec![file_index], - slot_item_ids: vec![VirtualDiffItemId::file( - WorkspaceSource::None, - generation, - file_index, - )], - stream_items: Vec::new(), - slot_loading: vec![false], - path, - } - } - - pub fn is_continuous(&self) -> bool { - self.mode == ViewportDocumentMode::Continuous - } - - pub fn insert_stream_item(&mut self, item: VirtualDiffStreamItem) { - let index = self - .stream_items - .partition_point(|existing| existing.sort_key <= item.sort_key); - self.stream_items.insert(index, item); - } -} +impl AppState { + pub fn bootstrap(startup: StartupOptions, settings: Settings) -> (Self, Vec) { + let persisted = matching_persisted_compare(&startup, &settings).cloned(); + let repo_path = startup.args.repo.clone(); + let left_ref = startup + .args + .left + .clone() + .or_else(|| persisted.as_ref().map(|compare| compare.left_ref.clone())) + .unwrap_or_default(); + let right_ref = startup + .args + .right + .clone() + .or_else(|| persisted.as_ref().map(|compare| compare.right_ref.clone())) + .unwrap_or_default(); + let mode = startup + .args + .compare_mode + .or_else(|| persisted.as_ref().map(|compare| compare.mode)) + .unwrap_or_default(); + let layout = startup + .args + .layout + .or_else(|| persisted.as_ref().map(|compare| compare.layout)) + .unwrap_or(settings.viewport.layout); + let renderer = startup + .args + .renderer + .or_else(|| persisted.as_ref().map(|compare| compare.renderer)) + .unwrap_or_default(); + let auto_compare_pending = startup.wants_compare(mode, &left_ref, &right_ref); + let bootstrap_compare_started = repo_path.is_some() + && startup.args.open_pr.is_none() + && auto_compare_pending + && (startup.args.left.is_some() + || startup.args.right.is_some() + || startup.args.compare_mode.is_some()); -fn virtual_stream_item_kind( - slot: &ViewportSlotKey, - line: &RenderLine, -) -> Option { - match line.row_kind() { - RenderRowKind::FileHeader => Some(VirtualDiffItemKind::FileHeader), - RenderRowKind::HunkSeparator - if matches!(slot.kind, ViewportSlotKind::Loading) || line.hunk_index < 0 => - { - Some(VirtualDiffItemKind::LoadingPlaceholder) - } - RenderRowKind::HunkSeparator => Some(VirtualDiffItemKind::Hunk), - RenderRowKind::Context - | RenderRowKind::Added - | RenderRowKind::Removed - | RenderRowKind::Modified => Some(VirtualDiffItemKind::DiffRow), - RenderRowKind::Block => None, - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VirtualDiffItemKind { - File, - FileHeader, - Hunk, - DiffRow, - ReviewThread, - ReviewComment, - Composer, - LoadingPlaceholder, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct VirtualDiffItemId { - pub source: WorkspaceSource, - pub generation: u64, - pub kind: VirtualDiffItemKind, - pub index: usize, - pub ordinal: u32, - pub stable_key: u64, -} - -impl VirtualDiffItemId { - fn file(source: WorkspaceSource, generation: u64, index: usize) -> Self { - Self { - source, - generation, - kind: VirtualDiffItemKind::File, - index, - ordinal: 0, - stable_key: 0, - } - } - - pub fn new( - source: WorkspaceSource, - generation: u64, - kind: VirtualDiffItemKind, - index: usize, - ordinal: u32, - stable_key: u64, - ) -> Self { - Self { - source, - generation, - kind, - index, - ordinal, - stable_key, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct VirtualDiffStreamItem { - pub id: VirtualDiffItemId, - pub sort_key: u64, - pub estimated_height_px: u32, - pub measured_height_px: Option, -} - -impl VirtualDiffStreamItem { - pub fn new( - id: VirtualDiffItemId, - sort_key: u64, - estimated_height_px: u32, - measured_height_px: Option, - ) -> Self { - Self { - id, - sort_key, - estimated_height_px, - measured_height_px, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ViewportAnchorBias { - PreserveTop, - PreserveBottom, - FollowEnd, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ViewportAnchor { - pub item_id: VirtualDiffItemId, - pub intra_item_offset_px: u32, - pub bias: ViewportAnchorBias, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ViewportSlotKey { - source: WorkspaceSource, - index: usize, - path: String, - left_ref: String, - right_ref: String, - kind: ViewportSlotKind, -} - -impl ViewportSlotKey { - fn working_set_key(&self) -> Option { - if self.source == WorkspaceSource::None { - return None; - } - Some(WorkingSetFileKey::new( - self.index, - self.path.clone(), - self.left_ref.clone(), - self.right_ref.clone(), - )) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ViewportSlotKind { - Text { - line_count: usize, - text_len: usize, - style_run_count: usize, - syntax_covered_count: usize, - }, - Binary, - Loading, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ViewportDocumentKey { - source: WorkspaceSource, - generation: u64, - start_index: usize, - slots: Vec, -} - -#[derive(Debug, Clone)] -struct ViewportDocumentCache { - key: ViewportDocumentKey, - doc: Arc, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ScrollDirection { - Backward, - Forward, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SyntaxPendingWindow { - request_id: u64, - window: SyntaxRowWindow, -} - -fn file_change_list_status(status: FileChangeStatus, bucket: ChangeBucket) -> FileListStatus { - match (status, bucket) { - (FileChangeStatus::Added, _) => FileListStatus::Added, - (FileChangeStatus::Deleted, _) => FileListStatus::Deleted, - (FileChangeStatus::Renamed, _) => FileListStatus::Renamed, - (FileChangeStatus::Copied, _) => FileListStatus::Copied, - (FileChangeStatus::Untracked, _) => FileListStatus::Untracked, - (FileChangeStatus::Conflicted, _) | (_, ChangeBucket::Conflicted) => { - FileListStatus::Conflicted - } - (FileChangeStatus::TypeChanged, _) => FileListStatus::TypeChanged, - (FileChangeStatus::Binary, _) => FileListStatus::Binary, - (FileChangeStatus::Modified, _) => FileListStatus::Modified, - } -} - -fn vcs_compare_request( - mode: CompareMode, - left_ref: String, - right_ref: String, - layout: LayoutMode, - renderer: RendererKind, -) -> VcsCompareRequest { - let compare_spec = match mode { - CompareMode::SingleCommit => { - let revision = if right_ref.is_empty() { - left_ref - } else { - right_ref - }; - VcsCompareSpec::Change { revision } - } - CompareMode::TwoDot => VcsCompareSpec::Range { - from: left_ref, - to: right_ref, - }, - CompareMode::ThreeDot => VcsCompareSpec::MergeBaseRange { - base: left_ref, - head: right_ref, - }, - }; - VcsCompareRequest { - spec: compare_spec, - layout, - renderer, - } -} - -fn append_active_file_doc(out: &mut RenderDoc, active: &ActiveFile) { - if active.carbon_file.is_binary { - out.append_doc(&build_placeholder_render_doc( - &active.path, - "Binary file. Diffy only shows text diffs here.", - )); - } else { - out.append_doc(&active.render_doc); - } -} - -fn request_syntax_for_active_file( - active: &mut ActiveFile, - repo_path: PathBuf, - generation: u64, - syntax_epoch: u64, - window: SyntaxRowWindow, - request_id: u64, -) -> Option { - let window = next_missing_syntax_tile(active, window)?; - if active - .syntax_pending - .iter() - .any(|pending| pending.window.contains(window)) - || active - .syntax_covered - .iter() - .any(|covered| covered.contains(window)) - { - return None; - } - - active - .syntax_pending - .push(SyntaxPendingWindow { request_id, window }); - Some(LoadFileSyntaxRequest { - repo_path, - file_index: active.index, - path: active.path.clone(), - carbon_file: active.carbon_file.clone(), - carbon_expansion: active.carbon_expansion.clone(), - left_ref: active.left_ref.clone(), - right_ref: active.right_ref.clone(), - window, - request_id, - cache_generation: generation, - syntax_epoch, - }) -} - -fn next_missing_syntax_tile( - active: &ActiveFile, - requested: SyntaxRowWindow, -) -> Option { - let line_count = active.render_doc.lines.len(); - let start = requested.start.min(line_count); - let end = requested.end.min(line_count); - if line_count == 0 || end <= start { - return None; - } - - let tile_rows = SYNTAX_INITIAL_ROWS.max(1); - let mut tile_start = (start / tile_rows) * tile_rows; - while tile_start < end { - let tile_end = tile_start.saturating_add(tile_rows).min(line_count); - let candidate = SyntaxRowWindow { - start: tile_start, - end: tile_end, - }; - let already_requested = active - .syntax_pending - .iter() - .any(|pending| pending.window.contains(candidate)) - || active - .syntax_covered - .iter() - .any(|covered| covered.contains(candidate)); - if !already_requested { - return Some(candidate); - } - if tile_end == line_count { - break; - } - tile_start = tile_end; - } - None -} - -fn apply_syntax_tokens_to_file( - carbon_overlays: &mut CarbonStyleOverlays, - token_buffer: &mut TokenBuffer, - updates: &[SyntaxLineTokens], -) { - for update in updates { - if let (Some(side), Some(source_index)) = (update.side, update.source_index) { - if update.tokens.is_empty() { - continue; - } - let range = token_buffer.append(&update.tokens); - carbon_overlays.insert_syntax(update.hunk_index as u32, side, source_index, range); - } - } -} - -fn active_file_matches_language( - active: &ActiveFile, - highlighter: &Highlighter, - language: &str, -) -> bool { - !active.carbon_file.is_binary - && [ - Some(active.path.as_str()), - active.carbon_file.old_path.as_deref(), - active.carbon_file.new_path.as_deref(), - ] - .into_iter() - .flatten() - .any(|path| { - highlighter - .resolve_language(path) - .is_some_and(|resolved| resolved.name() == language) - }) -} - -fn file_change_syntax_paths(change: &FileChange) -> Vec { - let mut paths = Vec::with_capacity(2); - if let Some(old_path) = change.old_path.as_ref() { - paths.push(old_path.clone()); - } - if !paths.iter().any(|path| path == &change.path) { - paths.push(change.path.clone()); - } - paths -} - -fn ensure_syntax_packs_for_file_change_effect(change: &FileChange) -> Effect { - let mut paths = file_change_syntax_paths(change); - if paths.len() == 1 { - return SyntaxEffect::EnsureSyntaxPackForPath { - path: paths.pop().unwrap_or_else(|| change.path.clone()), - } - .into(); - } - SyntaxEffect::EnsureSyntaxPacksForPaths { paths }.into() -} - -fn reset_active_file_syntax(active: &mut ActiveFile) { - active.syntax_pending.clear(); - active.syntax_covered.clear(); - let preserve_change_tokens = active.carbon_overlays.has_change_tokens(); - active.carbon_overlays.clear_syntax(); - if !preserve_change_tokens { - active.token_buffer.clear(); - } - active.render_doc = Arc::new(build_render_doc_from_carbon( - &active.carbon_file, - active.index, - &active.carbon_expansion, - &active.carbon_overlays, - &active.token_buffer, - )); -} - -fn apply_compare_stat_to_active_file(active: &mut ActiveFile, stat: &CompareFileStat) -> bool { - if active.index != stat.index || active.path != stat.path { - return false; - } - - let additions = i32_to_u32_nonnegative(stat.additions); - let deletions = i32_to_u32_nonnegative(stat.deletions); - let carbon_file = Arc::make_mut(&mut active.carbon_file); - if carbon_file.additions == additions - && carbon_file.deletions == deletions - && !carbon_file.stats_deferred - { - return false; - } - - carbon_file.additions = additions; - carbon_file.deletions = deletions; - carbon_file.stats_deferred = false; - active.render_doc = Arc::new(build_render_doc_from_carbon( - &active.carbon_file, - active.index, - &active.carbon_expansion, - &active.carbon_overlays, - &active.token_buffer, - )); - true -} - -fn push_syntax_covered_window(windows: &mut Vec, window: SyntaxRowWindow) { - if window.end <= window.start { - return; - } - windows.push(window); - windows.sort_by_key(|window| window.start); - let mut merged: Vec = Vec::with_capacity(windows.len()); - for window in windows.drain(..) { - if let Some(last) = merged.last_mut() - && window.start <= last.end - { - last.end = last.end.max(window.end); - continue; - } - merged.push(window); - } - *windows = merged; -} - -fn remove_pending_syntax_window( - pending: &mut Vec, - request_id: u64, - window: SyntaxRowWindow, -) -> bool { - let Some(index) = pending - .iter() - .position(|pending| pending.request_id == request_id && pending.window == window) - else { - return false; - }; - pending.swap_remove(index); - true -} - -fn hydrate_carbon_full_text( - file: &mut carbon::FileDiff, - old_lines: &[String], - new_lines: &[String], -) { - if !old_lines.is_empty() { - file.old_text = Some(carbon::TextStore::from_text(lines_to_text(old_lines))); - } - if !new_lines.is_empty() { - file.new_text = Some(carbon::TextStore::from_text(lines_to_text(new_lines))); - } - for block in &mut file.blocks { - block.old.start = block.old_line_start.saturating_sub(1); - block.new.start = block.new_line_start.saturating_sub(1); - } - file.is_partial = false; -} - -fn lines_to_text(lines: &[String]) -> String { - if lines.is_empty() { - return String::new(); - } - let mut text = - String::with_capacity(lines.iter().map(|line| line.len().saturating_add(1)).sum()); - for line in lines { - text.push_str(line); - text.push('\n'); - } - text -} - -fn text_store_estimated_bytes(text: &carbon::TextStore) -> usize { - text.as_bytes() - .len() - .saturating_add(text.line_count() as usize * std::mem::size_of::()) -} - -fn render_doc_estimated_bytes(doc: &RenderDoc) -> usize { - doc.text_bytes - .len() - .saturating_add( - doc.style_runs.len() * std::mem::size_of::(), - ) - .saturating_add( - doc.lines.len() * std::mem::size_of::(), - ) - .saturating_add( - doc.file_metadata - .iter() - .map(|meta| { - meta.path - .len() - .saturating_add(meta.old_path.as_ref().map_or(0, String::len)) - }) - .sum::(), - ) -} - -fn carbon_file_estimated_bytes(file: &carbon::FileDiff) -> usize { - file.old_path - .as_ref() - .map_or(0, String::len) - .saturating_add(file.new_path.as_ref().map_or(0, String::len)) - .saturating_add(file.old_oid.as_ref().map_or(0, |oid| oid.0.len())) - .saturating_add(file.new_oid.as_ref().map_or(0, |oid| oid.0.len())) - .saturating_add(file.old_mode.as_ref().map_or(0, |mode| mode.0.len())) - .saturating_add(file.new_mode.as_ref().map_or(0, |mode| mode.0.len())) - .saturating_add(file.old_text.as_ref().map_or(0, text_store_estimated_bytes)) - .saturating_add(file.new_text.as_ref().map_or(0, text_store_estimated_bytes)) - .saturating_add(file.hunks.len() * std::mem::size_of::()) - .saturating_add( - file.hunks - .iter() - .map(|hunk| hunk.header.len()) - .sum::(), - ) - .saturating_add(file.blocks.len() * std::mem::size_of::()) - .saturating_add( - file.blocks - .iter() - .map(|block| { - block.old_inline.len() * std::mem::size_of::() - + block.new_inline.len() * std::mem::size_of::() - }) - .sum::(), - ) -} - -fn line_vec_estimated_bytes(lines: &Arc>) -> usize { - lines - .iter() - .map(|line| { - std::mem::size_of::() - .saturating_add(line.len()) - .saturating_add(1) - }) - .fold(0usize, usize::saturating_add) -} - -fn i32_to_u32_nonnegative(value: i32) -> u32 { - u32::try_from(value).unwrap_or_default() -} - -#[derive(Debug, Clone)] -pub struct ActiveFile { - pub index: usize, - pub path: String, - pub carbon_file: Arc, - pub carbon_expansion: carbon::ExpansionState, - pub carbon_overlays: CarbonStyleOverlays, - pub render_doc: Arc, - pub token_buffer: TokenBuffer, - pub left_ref: String, - pub right_ref: String, - pub file_line_count: Option, - pub old_file_lines: Option>>, - pub file_lines: Option>>, - pub syntax_pending: Vec, - pub syntax_covered: Vec, - pub last_used_tick: u64, -} - -impl ActiveFile { - fn working_set_key(&self) -> WorkingSetFileKey { - WorkingSetFileKey::new( - self.index, - self.path.clone(), - self.left_ref.clone(), - self.right_ref.clone(), - ) - } - - fn working_set_bytes(&self) -> usize { - self.path - .len() - .saturating_add(self.left_ref.len()) - .saturating_add(self.right_ref.len()) - .saturating_add(render_doc_estimated_bytes(&self.render_doc)) - .saturating_add( - self.token_buffer - .len() - .saturating_mul(std::mem::size_of::()), - ) - .saturating_add(carbon_file_estimated_bytes(&self.carbon_file)) - .saturating_add( - self.old_file_lines - .as_ref() - .map_or(0, line_vec_estimated_bytes), - ) - .saturating_add(self.file_lines.as_ref().map_or(0, line_vec_estimated_bytes)) - } -} - -pub(crate) fn prepare_active_file( - file_index: usize, - carbon_file: &carbon::FileDiff, -) -> PreparedActiveFile { - let token_buffer = TokenBuffer::default(); - let carbon_overlays = CarbonStyleOverlays::default(); - - let carbon_expansion = carbon::ExpansionState::default(); - let render_doc = build_render_doc_from_carbon( - carbon_file, - file_index, - &carbon_expansion, - &carbon_overlays, - &token_buffer, - ); - PreparedActiveFile { - carbon_file: carbon_file.clone(), - carbon_expansion, - carbon_overlays, - render_doc: Arc::new(render_doc), - token_buffer, - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct SidebarWidthCache { - pub compare_generation: u64, - pub ui_scale_pct: u16, - pub intrinsic_width_px: f32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ViewportScrollbarMetrics { - pub content_height_px: u32, - pub viewport_height_px: u32, - pub scroll_top_px: u32, - pub max_scroll_top_px: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ViewportScrollbarDragState { - pub metrics: ViewportScrollbarMetrics, - pub file_heights_px: Vec, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum CompareStatsHydrationState { - #[default] - Idle, - Running, - Failed, -} - -#[derive(Debug, Clone, Default, Store)] -pub struct WorkspaceState { - pub source: WorkspaceSource, - pub status: AsyncStatus, - pub status_operation_pending: bool, - pub compare_generation: u64, - pub status_generation: u64, - pub files: Vec, - pub status_file_changes: Vec, - pub selected_file_index: Option, - pub selected_file_path: Option, - pub selected_change_bucket: Option, - pub compare_output: Option, - pub compare_total_stats: Option<(i32, i32)>, - pub compare_hydrated_stats: Option<(i32, i32)>, - pub compare_deferred_stats_remaining: Option, - pub compare_deferred_stats_cursor: usize, - pub compare_total_stats_loading: bool, - pub compare_stats_hydration: CompareStatsHydrationState, - pub active_file: Option, - pub active_file_loading: Option, - pub file_cache: HashMap, - pub file_cache_loading: HashMap, - pub raw_diff_len: usize, - pub used_fallback: bool, - pub fallback_message: String, - pub sidebar_auto_width: Option, - pub range_commits: Vec, - pub compare_history_pending: Option, - pub pre_drill_compare: Option<(String, String, CompareMode)>, - pub expansions: HashMap, - pub file_content_heights: Vec>, - pub file_scroll_total_height_px: u32, - pub pending_file_content_heights: HashMap, - pub file_scroll_recompute_pending: bool, - pub global_scroll_top_px: u32, - pub measured_px_per_row_q16: u32, - pub viewport_scrollbar_drag: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum SidebarMode { - #[default] - FlatList, - TreeView, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum SidebarTab { - #[default] - Files, - Commits, -} - -#[derive(Debug, Clone, PartialEq, Store)] -pub struct FileListState { - pub scroll_offset_px: f32, - pub commits_scroll_offset_px: f32, - pub hovered_index: Option, - pub row_height: f32, - pub gap: f32, - pub viewport_height: f32, - pub filter: String, - pub mode: SidebarMode, - pub tab: SidebarTab, - pub expanded_folders: HashSet, - pub viewed_files: HashSet, -} - -impl Default for FileListState { - fn default() -> Self { - Self { - scroll_offset_px: 0.0, - commits_scroll_offset_px: 0.0, - hovered_index: None, - row_height: 36.0, - gap: 4.0, - viewport_height: 0.0, - filter: String::new(), - mode: SidebarMode::FlatList, - tab: SidebarTab::Files, - expanded_folders: HashSet::new(), - viewed_files: HashSet::new(), - } - } -} - -impl AppState { - pub fn workspace_file_entry_at(&self, index: usize) -> Option { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - if let Some(entry) = self.workspace.compare_output.with(&self.store, |output| { - output.as_ref().and_then(|output| { - output - .summary_at(index) - .map(|summary| compare_summary_file_entry(&summary)) - }) - }) { - return Some(entry); - } - self.workspace - .files - .with(&self.store, |files| files.get(index).cloned()) - } - WorkspaceSource::Status => self - .workspace - .status_file_changes - .with(&self.store, |changes| { - changes.get(index).map(FileListEntry::from) - }) - .or_else(|| { - self.workspace - .files - .with(&self.store, |files| files.get(index).cloned()) - }), - WorkspaceSource::None => self - .workspace - .files - .with(&self.store, |files| files.get(index).cloned()), - } - } - - pub fn for_each_workspace_file_path(&self, mut visit: impl FnMut(usize, &str)) { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - let visited = self.workspace.compare_output.with(&self.store, |output| { - let Some(output) = output.as_ref() else { - return false; - }; - output.for_each_path(|index, path| visit(index, path)); - true - }); - if !visited { - self.workspace.files.with(&self.store, |files| { - for (index, file) in files.iter().enumerate() { - let path = file.path.path(); - visit(index, path.as_ref()); - } - }); - } - } - WorkspaceSource::Status => { - self.workspace - .status_file_changes - .with(&self.store, |changes| { - for (index, change) in changes.iter().enumerate() { - visit(index, &change.path); - } - }); - } - WorkspaceSource::None => { - self.workspace.files.with(&self.store, |files| { - for (index, file) in files.iter().enumerate() { - let path = file.path.path(); - visit(index, path.as_ref()); - } - }); - } - } - } - - pub fn workspace_max_file_path_chars(&self) -> usize { - if matches!( - self.workspace.source.get(&self.store), - WorkspaceSource::Compare | WorkspaceSource::TextCompare - ) { - let chars = self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .map(CompareOutput::max_path_chars) - .unwrap_or(0) - }); - if chars > 0 { - return chars; - } - } - let mut max_chars = 0; - self.for_each_workspace_file_path(|_, path| { - max_chars = max_chars.max(path.chars().count()); - }); - max_chars - } - - pub fn workspace_file_filter_matches(&self, filter: &str) -> Vec { - let config = neo_frizbee::Config { - max_typos: Some(2), - sort: false, - ..Default::default() - }; - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - let matches = self.workspace.compare_output.with(&self.store, |output| { - let Some(output) = output.as_ref() else { - return None; - }; - let mut matcher = neo_frizbee::Matcher::new(filter, &config); - let mut matches = Vec::new(); - output.for_each_path(|index, path| { - if let Ok(offset) = u32::try_from(index) { - matcher.match_list_into( - std::slice::from_ref(&path), - offset, - &mut matches, - ); - } - }); - matches.sort_by(|a, b| b.score.cmp(&a.score)); - Some(matches.iter().map(|m| m.index as usize).collect()) - }); - if let Some(matches) = matches { - matches - } else { - self.workspace.files.with(&self.store, |files| { - let mut matcher = neo_frizbee::Matcher::new(filter, &config); - let mut matches = Vec::new(); - for (index, file) in files.iter().enumerate() { - if let Ok(offset) = u32::try_from(index) { - let path = file.path.path(); - let path_ref = path.as_ref(); - matcher.match_list_into( - std::slice::from_ref(&path_ref), - offset, - &mut matches, - ); - } - } - matches.sort_by(|a, b| b.score.cmp(&a.score)); - matches.iter().map(|m| m.index as usize).collect() - }) - } - } - WorkspaceSource::Status => { - self.workspace - .status_file_changes - .with(&self.store, |changes| { - let haystack = changes - .iter() - .map(|change| change.path.as_str()) - .collect::>(); - let mut matches = neo_frizbee::match_list(filter, &haystack, &config); - matches.sort_by(|a, b| b.score.cmp(&a.score)); - matches.iter().map(|m| m.index as usize).collect() - }) - } - WorkspaceSource::None => self.workspace.files.with(&self.store, |files| { - let mut matcher = neo_frizbee::Matcher::new(filter, &config); - let mut matches = Vec::new(); - for (index, file) in files.iter().enumerate() { - if let Ok(offset) = u32::try_from(index) { - let path = file.path.path(); - let path_ref = path.as_ref(); - matcher.match_list_into( - std::slice::from_ref(&path_ref), - offset, - &mut matches, - ); - } - } - matches.sort_by(|a, b| b.score.cmp(&a.score)); - matches.iter().map(|m| m.index as usize).collect() - }), - } - } - - pub fn workspace_file_tree_visible_row_count( - &self, - expanded_folders: &HashSet, - ) -> usize { - crate::ui::components::file_tree_visible_row_count_by( - |visit| { - self.for_each_workspace_file_path(|_, path| visit(path)); - }, - expanded_folders, - ) - } - - pub fn workspace_file_index_for_path(&self, path: &str) -> Option { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - if let Some(index) = self.workspace.compare_output.with(&self.store, |output| { - let output = output.as_ref()?; - let mut found = None; - output.for_each_path(|index, candidate| { - if found.is_none() && candidate == path { - found = Some(index); - } - }); - found - }) { - return Some(index); - } - self.workspace.files.with(&self.store, |files| { - files.iter().position(|file| file.path == path) - }) - } - WorkspaceSource::Status => self - .workspace - .status_file_changes - .with(&self.store, |changes| { - changes.iter().position(|change| change.path == path) - }), - WorkspaceSource::None => self.workspace.files.with(&self.store, |files| { - files.iter().position(|file| file.path == path) - }), - } - } - - pub fn file_list_row_stride(&self) -> f32 { - self.file_list.row_height.get(&self.store) + self.file_list.gap.get(&self.store) - } - - pub fn file_list_total_content_height(&self, file_count: usize) -> f32 { - if file_count == 0 { - return 0.0; - } - file_count as f32 * self.file_list_row_stride() - self.file_list.gap.get(&self.store) - } - - pub fn file_list_max_scroll_px(&self, file_count: usize) -> f32 { - (self.file_list_total_content_height(file_count) - - self.file_list.viewport_height.get(&self.store)) - .max(0.0) - } - - pub fn file_list_clamp_scroll(&mut self, file_count: usize) { - let max = self.file_list_max_scroll_px(file_count); - let cur = self.file_list.scroll_offset_px.get(&self.store); - self.file_list - .scroll_offset_px - .set_if_changed(&self.store, cur.clamp(0.0, max)); - } - - pub fn keymaps_max_scroll_px(&self) -> f32 { - let content = self.keymaps_content_height_px.get(&self.store); - let viewport = self.keymaps_viewport_height_px.get(&self.store); - (content - viewport).max(0.0) - } - - pub fn clamp_keymaps_scroll(&mut self) { - let max = self.keymaps_max_scroll_px(); - let cur = self.keymaps_scroll_top_px.get(&self.store); - self.keymaps_scroll_top_px - .set(&self.store, cur.clamp(0.0, max)); - } - - /// Scroll by a number of rows (positive = down). - pub fn file_list_scroll_rows(&mut self, delta: i32, file_count: usize) { - let px_delta = delta as f32 * self.file_list_row_stride(); - let cur = self.file_list.scroll_offset_px.get(&self.store); - self.file_list - .scroll_offset_px - .set(&self.store, cur + px_delta); - self.file_list_clamp_scroll(file_count); - } - - /// Scroll by a raw pixel delta (positive = down). - pub fn file_list_scroll_px(&mut self, delta_px: f32, file_count: usize) { - let cur = self.file_list.scroll_offset_px.get(&self.store); - self.file_list - .scroll_offset_px - .set(&self.store, cur + delta_px); - self.file_list_clamp_scroll(file_count); - } - - /// Reset every file-list signal back to its default value. - pub fn reset_file_list(&mut self) { - let d = FileListState::default(); - self.file_list - .scroll_offset_px - .set(&self.store, d.scroll_offset_px); - self.file_list - .commits_scroll_offset_px - .set(&self.store, d.commits_scroll_offset_px); - self.file_list - .hovered_index - .set(&self.store, d.hovered_index); - self.file_list.row_height.set(&self.store, d.row_height); - self.file_list.gap.set(&self.store, d.gap); - self.file_list - .viewport_height - .set(&self.store, d.viewport_height); - self.file_list.filter.set(&self.store, d.filter); - self.file_list.mode.set(&self.store, d.mode); - self.file_list.tab.set(&self.store, d.tab); - self.file_list - .expanded_folders - .set(&self.store, d.expanded_folders); - self.file_list.viewed_files.set(&self.store, d.viewed_files); - } - - pub fn sidebar_row_count(&self) -> usize { - if matches!( - self.workspace.source.get(&self.store), - WorkspaceSource::Compare | WorkspaceSource::TextCompare - ) && self.file_list.tab.get(&self.store) == SidebarTab::Files - && self.file_list.mode.get(&self.store) == SidebarMode::TreeView - && self.file_list.filter.with(&self.store, |s| s.is_empty()) - { - let expanded_folders = self.file_list.expanded_folders.get(&self.store); - return self.workspace_file_tree_visible_row_count(&expanded_folders); - } - - if self.workspace.source.get(&self.store) == WorkspaceSource::Status - && self.file_list.filter.with(&self.store, |s| s.is_empty()) - { - self.workspace.files.with(&self.store, |f| f.len()) - + self - .workspace - .status_file_changes - .with(&self.store, |s| status_section_count(s)) - } else { - self.workspace_file_count() - } - } - - pub fn file_list_entry_meta(&self, index: usize) -> FileListEntryMeta { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .and_then(|output| compare_output_file_entry_meta(output, index)) - .unwrap_or_default() - }) - } - WorkspaceSource::Status => { - self.workspace - .status_file_changes - .with(&self.store, |changes| { - changes - .get(index) - .map(status_file_entry_meta) - .unwrap_or_default() - }) - } - WorkspaceSource::None => FileListEntryMeta::default(), - } - } - - fn sidebar_row_index_for_file(&self, index: usize) -> usize { - if self.workspace.source.get(&self.store) != WorkspaceSource::Status - || !self.file_list.filter.with(&self.store, |s| s.is_empty()) - { - return index; - } - index - + self - .workspace - .status_file_changes - .with(&self.store, |s| status_section_count_before(s, index + 1)) - } - - fn compare_file_is_large(&self, index: usize) -> bool { - if self.workspace.source.get(&self.store) == WorkspaceSource::TextCompare { - return false; - } - if self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .is_some_and(|output| compare_output_summary_is_deferred(output, index)) - }) { - return true; - } - - let meta = self.file_list_entry_meta(index); - !meta.is_binary && meta.additions.saturating_add(meta.deletions) >= LARGE_COMPARE_FILE_LINES - } - - fn build_active_file( - &self, - index: usize, - path: String, - prepared: PreparedActiveFile, - left_ref: String, - right_ref: String, - ) -> ActiveFile { - ActiveFile { - index, - path, - carbon_file: Arc::new(prepared.carbon_file), - carbon_expansion: prepared.carbon_expansion.clone(), - carbon_overlays: prepared.carbon_overlays, - render_doc: prepared.render_doc, - token_buffer: prepared.token_buffer, - left_ref, - right_ref, - file_line_count: None, - old_file_lines: None, - file_lines: None, - syntax_pending: Vec::new(), - syntax_covered: Vec::new(), - last_used_tick: 0, - } - } - - fn clear_file_cache(&mut self) { - self.workspace.file_cache.set(&self.store, HashMap::new()); - self.workspace - .file_cache_loading - .set(&self.store, HashMap::new()); - self.viewport_document_cache = None; - self.last_virtual_scroll_top_px = None; - self.file_working_set.reset(); - } - - fn next_file_working_set_tick(&mut self) -> u64 { - self.file_working_set.next_tick() - } - - fn syntax_pending_window_count(&self) -> usize { - let active_count = self.workspace.active_file.with(&self.store, |active| { - active - .as_ref() - .map_or(0, |active| active.syntax_pending.len()) - }); - let cache_count = self.workspace.file_cache.with(&self.store, |files| { - files - .values() - .map(|file| file.syntax_pending.len()) - .sum::() - }); - active_count.saturating_add(cache_count) - } - - fn syntax_outstanding_window_count(&self) -> usize { - self.syntax_requests - .outstanding_count(self.syntax_pending_window_count()) - } - - fn syntax_request_budget_available(&self) -> bool { - self.syntax_requests - .budget_available(self.syntax_pending_window_count()) - } - - fn track_syntax_request(&mut self, request: &LoadFileSyntaxRequest) { - self.syntax_requests.track(request); - } - - fn finish_syntax_request(&mut self, generation: u64, request_id: u64) { - self.syntax_requests.finish(generation, request_id); - } - - fn clear_syntax_pending_windows(&mut self) { - self.workspace.active_file.update(&self.store, |active| { - if let Some(active) = active.as_mut() { - active.syntax_pending.clear(); - } - }); - self.workspace.file_cache.update(&self.store, |files| { - for active in files.values_mut() { - active.syntax_pending.clear(); - } - }); - } - - fn clear_syntax_inflight(&mut self) { - self.clear_syntax_pending_windows(); - self.syntax_requests.invalidate(); - } - - fn syntax_epoch_effect(&self) -> Effect { - SyntaxEffect::SetFileSyntaxEpoch { - epoch: self.syntax_requests.epoch(), - } - .into() - } - - fn invalidate_syntax_epoch_effect(&mut self) -> Effect { - self.clear_syntax_inflight(); - self.syntax_epoch_effect() - } - - fn protect_working_set_slots(&mut self, slots: &[ViewportSlotKey]) { - self.file_working_set.protect_slots(slots); - } - - fn cache_active_file(&mut self, mut active_file: ActiveFile) -> ActiveFile { - let index = active_file.index; - active_file.last_used_tick = self.next_file_working_set_tick(); - let cached = active_file.clone(); - self.workspace.file_cache.update(&self.store, |files| { - files.insert(index, cached); - }); - self.workspace - .file_cache_loading - .update(&self.store, |files| { - files.remove(&index); - }); - self.trim_file_working_set(); - active_file - } - - fn touch_viewport_slot(&mut self, key: &ViewportSlotKey) { - let tick = self.next_file_working_set_tick(); - self.workspace.active_file.update(&self.store, |slot| { - if let Some(active) = slot.as_mut() - && active.index == key.index - && active.path == key.path - && active.left_ref == key.left_ref - && active.right_ref == key.right_ref - { - active.last_used_tick = tick; - } - }); - self.workspace.file_cache.update(&self.store, |files| { - if let Some(active) = files.get_mut(&key.index) - && active.index == key.index - && active.path == key.path - && active.left_ref == key.left_ref - && active.right_ref == key.right_ref - { - active.last_used_tick = tick; - } - }); - } - - fn trim_file_working_set(&mut self) { - let mut keep = self.file_working_set.protected_snapshot(); - if let Some(active) = self.workspace.active_file.with(&self.store, |active| { - active.as_ref().map(ActiveFile::working_set_key) - }) { - keep.insert(active); - } - if let Some(cache) = self.viewport_document_cache.as_ref() { - keep.extend( - cache - .key - .slots - .iter() - .filter_map(ViewportSlotKey::working_set_key), - ); - } - - self.workspace.file_cache.update(&self.store, |files| { - let mut bytes = files - .values() - .map(ActiveFile::working_set_bytes) - .fold(0usize, usize::saturating_add); - if files.len() <= COMPARE_WORKING_SET_MAX_FILES - && bytes <= COMPARE_WORKING_SET_BYTE_BUDGET - { - return; - } - - let mut victims = files - .iter() - .filter(|(_, file)| !keep.contains(&file.working_set_key())) - .map(|(index, file)| (*index, file.last_used_tick)) - .collect::>(); - victims.sort_by_key(|(_, last_used)| *last_used); - - for (index, _) in victims { - if files.len() <= COMPARE_WORKING_SET_MAX_FILES - && (files.len() <= COMPARE_WORKING_SET_MIN_FILES - || bytes <= COMPARE_WORKING_SET_BYTE_BUDGET) - { - break; - } - if let Some(file) = files.remove(&index) { - bytes = bytes.saturating_sub(file.working_set_bytes()); - } - } - }); - } - - fn cached_file_at(&self, index: usize) -> Option { - self.workspace - .file_cache - .with(&self.store, |files| files.get(&index).cloned()) - } - - pub(crate) fn viewport_file_snapshot(&self, index: usize) -> Option { - if let Some(active) = self.workspace.active_file.with(&self.store, |file| { - file.as_ref() - .filter(|active| active.index == index) - .cloned() - }) { - return Some(active); - } - self.cached_file_at(index) - } - - fn file_load_pending_priority(&self, index: usize, path: &str) -> Option { - self.workspace - .active_file_loading - .with(&self.store, |loading| { - loading - .as_ref() - .filter(|loading| loading.index == index && loading.path == path) - .map(|loading| loading.priority) - }) - .or_else(|| { - self.workspace - .file_cache_loading - .with(&self.store, |loading| { - loading - .get(&index) - .filter(|loading| loading.path == path) - .map(|loading| loading.priority) - }) - }) - } - - fn should_enqueue_file_load( - &self, - index: usize, - path: &str, - priority: CompareWorkPriority, - ) -> bool { - self.file_load_pending_priority(index, path) - .is_none_or(|pending| priority.rank() > pending.rank()) - } - - fn mark_file_cache_loading( - &mut self, - index: usize, - path: String, - priority: CompareWorkPriority, - ) { - self.workspace - .file_cache_loading - .update(&self.store, |loading| { - loading.insert( - index, - ActiveFileLoading { - index, - path, - priority, - }, - ); - }); - } - - fn clear_file_cache_loading(&mut self, index: usize) { - self.workspace - .file_cache_loading - .update(&self.store, |loading| { - loading.remove(&index); - }); - } - - fn compare_refs(&self) -> (String, String) { - let left_ref = self - .compare - .resolved_left - .get(&self.store) - .unwrap_or_else(|| self.compare.left_ref.get(&self.store)); - let right_ref = self - .compare - .resolved_right - .get(&self.store) - .unwrap_or_else(|| self.compare.right_ref.get(&self.store)); - (left_ref, right_ref) - } - - fn cached_compare_file_at(&self, index: usize, path: &str) -> Option { - let (left_ref, right_ref) = self.compare_refs(); - if let Some(active_file) = self.workspace.active_file.with(&self.store, |file| { - file.as_ref() - .filter(|file| { - file.index == index - && file.path == path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - .cloned() - }) { - return Some(active_file); - } - self.cached_file_at(index).filter(|file| { - file.index == index - && file.path == path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - } - - fn cached_status_file_at(&self, index: usize, change: &FileChange) -> Option { - let (left_ref, right_ref) = self.status_refs_for_bucket(change.bucket); - if let Some(active_file) = self.workspace.active_file.with(&self.store, |file| { - file.as_ref() - .filter(|file| { - file.index == index - && file.path == change.path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - .cloned() - }) { - return Some(active_file); - } - self.cached_file_at(index).filter(|file| { - file.index == index - && file.path == change.path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - } - - fn status_refs_for_bucket(&self, bucket: ChangeBucket) -> (String, String) { - self.vcs_ui_profile().status_compare_refs(bucket) - } - - fn vcs_ui_profile(&self) -> crate::ui::vcs::VcsUiProfile { - self.repository.location.with(&self.store, |location| { - crate::ui::vcs::profile(location.as_ref()) - }) - } - - fn active_file_slot_key( - &self, - source: WorkspaceSource, - active: &ActiveFile, - ) -> ViewportSlotKey { - let kind = if active.carbon_file.is_binary { - ViewportSlotKind::Binary - } else { - ViewportSlotKind::Text { - line_count: active.render_doc.lines.len(), - text_len: active.render_doc.text_bytes.len(), - style_run_count: active.render_doc.style_runs.len(), - syntax_covered_count: active.syntax_covered.len(), - } - }; - ViewportSlotKey { - source, - index: active.index, - path: active.path.clone(), - left_ref: active.left_ref.clone(), - right_ref: active.right_ref.clone(), - kind, - } - } - - fn loading_slot_key( - &self, - source: WorkspaceSource, - index: usize, - path: &str, - left_ref: String, - right_ref: String, - ) -> ViewportSlotKey { - ViewportSlotKey { - source, - index, - path: path.to_owned(), - left_ref, - right_ref, - kind: ViewportSlotKind::Loading, - } - } - - fn compare_slot_key_at(&self, index: usize, path: &str) -> ViewportSlotKey { - let source = match self.workspace.source.get(&self.store) { - WorkspaceSource::TextCompare => WorkspaceSource::TextCompare, - _ => WorkspaceSource::Compare, - }; - let (left_ref, right_ref) = self.compare_refs(); - if let Some(key) = self.workspace.active_file.with(&self.store, |file| { - file.as_ref() - .filter(|file| { - file.index == index - && file.path == path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - .map(|file| self.active_file_slot_key(source, file)) - }) { - return key; - } - if let Some(key) = self.workspace.file_cache.with(&self.store, |files| { - files - .get(&index) - .filter(|file| { - file.index == index - && file.path == path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - .map(|file| self.active_file_slot_key(source, file)) - }) { - return key; - } - self.loading_slot_key(source, index, path, left_ref, right_ref) - } - - fn status_slot_key_at(&self, index: usize, change: &FileChange) -> ViewportSlotKey { - let (left_ref, right_ref) = self.status_refs_for_bucket(change.bucket); - if let Some(key) = self.workspace.active_file.with(&self.store, |file| { - file.as_ref() - .filter(|file| { - file.index == index - && file.path == change.path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - .map(|file| self.active_file_slot_key(WorkspaceSource::Status, file)) - }) { - return key; - } - if let Some(key) = self.workspace.file_cache.with(&self.store, |files| { - files - .get(&index) - .filter(|file| { - file.index == index - && file.path == change.path - && file.left_ref == left_ref - && file.right_ref == right_ref - }) - .map(|file| self.active_file_slot_key(WorkspaceSource::Status, file)) - }) { - return key; - } - self.loading_slot_key( - WorkspaceSource::Status, - index, - &change.path, - left_ref, - right_ref, - ) - } - - fn append_viewport_slot_doc( - &self, - out: &mut RenderDoc, - key: &ViewportSlotKey, - loading_message: &str, - ) { - if let ViewportSlotKind::Loading = key.kind { - out.append_doc(&build_placeholder_render_doc(&key.path, loading_message)); - return; - } - - let mut appended = false; - self.workspace.active_file.with(&self.store, |file| { - let Some(active) = file.as_ref() else { - return; - }; - if active.index == key.index - && active.path == key.path - && active.left_ref == key.left_ref - && active.right_ref == key.right_ref - { - append_active_file_doc(out, active); - appended = true; - } - }); - if appended { - return; - } - - self.workspace.file_cache.with(&self.store, |files| { - let Some(active) = files.get(&key.index).filter(|active| { - active.index == key.index - && active.path == key.path - && active.left_ref == key.left_ref - && active.right_ref == key.right_ref - }) else { - return; - }; - append_active_file_doc(out, active); - appended = true; - }); - - if !appended { - out.append_doc(&build_placeholder_render_doc(&key.path, loading_message)); - } - } - - fn viewport_slot_syntax_window( - &self, - key: &ViewportSlotKey, - slot_top_px: u32, - slot_height_px: u32, - viewport_top_px: u32, - viewport_height_px: u32, - ) -> Option { - let ViewportSlotKind::Text { line_count, .. } = key.kind else { - return None; - }; - if line_count == 0 { - return None; - } - - let slot_bottom_px = slot_top_px.saturating_add(slot_height_px.max(1)); - let viewport_bottom_px = viewport_top_px.saturating_add(viewport_height_px.max(1)); - let visible_top_px = slot_top_px.max(viewport_top_px); - let visible_bottom_px = slot_bottom_px.min(viewport_bottom_px); - if visible_bottom_px <= visible_top_px { - return None; - } - - let row_height_q16 = self.workspace.measured_px_per_row_q16.get(&self.store); - let row_height_q16 = if row_height_q16 == 0 { - 24_u32 << 16 - } else { - row_height_q16 - }; - let row_height_q16 = u64::from(row_height_q16.max(1)); - let start_px = visible_top_px.saturating_sub(slot_top_px); - let end_px = visible_bottom_px.saturating_sub(slot_top_px); - let row_floor = |px: u32| ((u64::from(px) << 16) / row_height_q16) as usize; - let row_ceil = |px: u32| { - (((u64::from(px) << 16).saturating_add(row_height_q16 - 1)) / row_height_q16) as usize - }; - - let start = row_floor(start_px) - .saturating_sub(SYNTAX_OVERSCAN_ROWS) - .min(line_count); - let mut end = row_ceil(end_px) - .saturating_add(SYNTAX_OVERSCAN_ROWS) - .min(line_count); - if end <= start { - end = start.saturating_add(SYNTAX_INITIAL_ROWS).min(line_count); - } - Some(SyntaxRowWindow { start, end }) - } - - fn request_viewport_slot_syntax_window( - &mut self, - key: &ViewportSlotKey, - window: SyntaxRowWindow, - ) -> Option { - if window.end <= window.start { - return None; - } - if !self.syntax_request_budget_available() { - return None; - } - let repo_path = self.compare.repo_path.get(&self.store)?; - let generation = self.active_syntax_generation(); - let syntax_epoch = self.syntax_requests.epoch(); - let mut request = None; - let request_id = self.syntax_requests.next_request_id(); - let mut matched_active = false; - let mut active_to_cache = None; - - self.workspace.active_file.update(&self.store, |slot| { - let Some(active) = slot.as_mut() else { - return; - }; - if active.index != key.index - || active.path != key.path - || active.left_ref != key.left_ref - || active.right_ref != key.right_ref - { - return; - } - matched_active = true; - if let Some(next_request) = request_syntax_for_active_file( - active, - repo_path.clone(), - generation, - syntax_epoch, - window, - request_id, - ) { - active_to_cache = Some(active.clone()); - request = Some(next_request); - } - }); - if let Some(active_file) = active_to_cache { - self.cache_active_file(active_file); - } - if matched_active { - if let Some(request) = request { - self.track_syntax_request(&request); - return Some( - SyntaxEffect::LoadFileSyntax(Task { - generation, - request, - }) - .into(), - ); - } - return None; - } - - let request_id = self.syntax_requests.next_request_id(); - self.workspace.file_cache.update(&self.store, |files| { - let Some(active) = files.get_mut(&key.index).filter(|active| { - active.index == key.index - && active.path == key.path - && active.left_ref == key.left_ref - && active.right_ref == key.right_ref - }) else { - return; - }; - request = request_syntax_for_active_file( - active, - repo_path, - generation, - syntax_epoch, - window, - request_id, - ); - }); - - request.map(|request| { - self.track_syntax_request(&request); - SyntaxEffect::LoadFileSyntax(Task { - generation, - request, - }) - .into() - }) - } - - fn cache_compare_file_from_output(&mut self, index: usize, path: &str) -> Option { - let carbon_file = self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .and_then(|output| output.carbon.files.get(index)) - .filter(|file| file.path() == path) - .filter(|file| !(file.is_partial && file.hunks.is_empty())) - .cloned() - })?; - let prepared = prepare_active_file(index, &carbon_file); - let (left_ref, right_ref) = self.compare_refs(); - let active_file = - self.build_active_file(index, path.to_owned(), prepared, left_ref, right_ref); - let active_file = self.cache_active_file(active_file); - Some(active_file) - } - - fn ensure_compare_file_cached_for_viewport( - &mut self, - index: usize, - path: &str, - priority: CompareWorkPriority, - ) -> Vec { - if self.cached_compare_file_at(index, path).is_some() { - return Vec::new(); - } - if self.workspace.source.get(&self.store) == WorkspaceSource::TextCompare { - if self.cache_compare_file_from_output(index, path).is_some() { - return vec![ - SyntaxEffect::EnsureSyntaxPackForPath { - path: path.to_owned(), - } - .into(), - ]; - } - return Vec::new(); - } - if !self.compare_file_is_large(index) - && self.cache_compare_file_from_output(index, path).is_some() - { - return vec![ - SyntaxEffect::EnsureSyntaxPackForPath { - path: path.to_owned(), - } - .into(), - ]; - } - if !self.should_enqueue_file_load(index, path, priority) { - return Vec::new(); - } - - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - let deferred_file = self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .and_then(|output| compare_output_deferred_summary(output, index)) - .filter(|summary| summary.path() == path) - }); - self.mark_file_cache_loading(index, path.to_owned(), priority); - vec![ - SyntaxEffect::EnsureSyntaxPackForPath { - path: path.to_owned(), - } - .into(), - CompareEffect::LoadFile(Task { - generation: self.workspace.compare_generation.get(&self.store), - request: CompareFileRequest { - repo_path, - request: vcs_compare_request( - self.compare.mode.get(&self.store), - self.compare.left_ref.get(&self.store), - self.compare.right_ref.get(&self.store), - self.compare.layout.get(&self.store), - self.compare.renderer.get(&self.store), - ), - path: path.to_owned(), - index, - deferred_file, - priority, - }, - }) - .into(), - ] - } - - fn ensure_status_file_cached_for_viewport(&mut self, index: usize) -> Vec { - let Some(file_change) = self - .workspace - .status_file_changes - .with(&self.store, |changes| changes.get(index).cloned()) - else { - return Vec::new(); - }; - if self.cached_status_file_at(index, &file_change).is_some() { - return Vec::new(); - } - if !self.should_enqueue_file_load( - index, - &file_change.path, - CompareWorkPriority::VisibleViewportDiff, - ) { - return Vec::new(); - } - - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - self.mark_file_cache_loading( - index, - file_change.path.clone(), - CompareWorkPriority::VisibleViewportDiff, - ); - let generation = self.workspace.status_generation.get(&self.store); - let renderer = self.compare.renderer.get(&self.store); - vec![ - ensure_syntax_packs_for_file_change_effect(&file_change), - RepositoryEffect::LoadStatusDiff { - task: Task { - generation, - request: StatusDiffRequest { - repo_path, - file_change, - renderer, - }, - }, - index, - } - .into(), - ] - } - - fn install_compare_active_file( - &mut self, - index: usize, - path: String, - prepared: PreparedActiveFile, - ) { - let left_ref = self - .compare - .resolved_left - .get(&self.store) - .unwrap_or_else(|| self.compare.left_ref.get(&self.store)); - let right_ref = self - .compare - .resolved_right - .get(&self.store) - .unwrap_or_else(|| self.compare.right_ref.get(&self.store)); - let active_file = - self.build_active_file(index, path.clone(), prepared, left_ref, right_ref); - let active_file = self.cache_active_file(active_file); - let stats = CompareFileStat { - index, - path: path.clone(), - additions: u32_to_i32_saturating(active_file.carbon_file.additions), - deletions: u32_to_i32_saturating(active_file.carbon_file.deletions), - }; - - self.workspace - .selected_file_index - .set(&self.store, Some(index)); - self.workspace - .selected_file_path - .set(&self.store, Some(path)); - self.workspace.selected_change_bucket.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - self.workspace - .active_file - .set(&self.store, Some(active_file)); - self.apply_compare_file_stats(&[stats]); - // The first real file has landed — tear down the progress panel. - // Subsequent file loads use the sidebar row spinner, not this. - self.compare_progress.set(&self.store, None); - self.editor_clear_document(); - self.editor - .line_selection - .update(&self.store, |ls| ls.clear()); - if self.editor.search.open.get(&self.store) { - self.recompute_search_matches(); - } - self.file_list.hovered_index.set(&self.store, Some(index)); - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OverlayListState { - pub scroll_top_px: u32, - pub viewport_height_px: u32, - pub row_height_px: u32, - pub gap_px: u32, -} - -impl Default for OverlayListState { - fn default() -> Self { - Self { - scroll_top_px: 0, - viewport_height_px: 0, - row_height_px: 36, - gap_px: 0, - } - } -} - -impl OverlayListState { - pub fn stride_px(&self) -> u32 { - self.row_height_px + self.gap_px - } - - pub fn total_content_height_px(&self, entry_count: usize) -> u32 { - if entry_count == 0 { - return 0; - } - self.stride_px() - .saturating_mul(entry_count as u32) - .saturating_sub(self.gap_px) - } - - pub fn viewport_for_max_rows(&self, max_rows: usize, entry_count: usize) -> u32 { - let visible = entry_count.min(max_rows); - if visible == 0 { - return 0; - } - self.stride_px() - .saturating_mul(visible as u32) - .saturating_sub(self.gap_px) - } - - pub fn max_scroll_top_px(&self, entry_count: usize) -> u32 { - self.total_content_height_px(entry_count) - .saturating_sub(self.viewport_height_px) - } - - pub fn clamp_scroll(&mut self, entry_count: usize) { - self.scroll_top_px = self.scroll_top_px.min(self.max_scroll_top_px(entry_count)); - } - - pub fn scroll_px(&mut self, delta_px: i32, entry_count: usize) { - self.scroll_top_px = apply_scroll_delta_px( - self.scroll_top_px, - delta_px, - self.max_scroll_top_px(entry_count), - ); - } - - pub fn reveal_index(&mut self, index: usize, entry_count: usize) { - let stride = self.stride_px().max(1); - let item_top = stride.saturating_mul(index as u32); - let item_bottom = item_top.saturating_add(self.row_height_px); - let viewport_bottom = self.scroll_top_px.saturating_add(self.viewport_height_px); - - if item_top < self.scroll_top_px { - self.scroll_top_px = item_top; - } else if item_bottom > viewport_bottom { - self.scroll_top_px = item_bottom.saturating_sub(self.viewport_height_px); - } - - self.clamp_scroll(entry_count); - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum PickerKind { - #[default] - Repository, - LeftRef, - RightRef, - Theme, - UiFont, - MonoFont, -} - -pub trait PickerItem { - fn label(&self) -> &str; - fn detail(&self) -> Option<&str>; - fn label_style(&self) -> PickerLabelStyle { - PickerLabelStyle::Default - } - fn highlight_ranges(&self) -> &[(usize, usize)] { - &[] - } - fn icon_svg(&self) -> Option<&'static str> { - None - } - fn is_section_header(&self) -> bool { - false - } - fn rhs(&self) -> Option<&str> { - None - } - fn is_disabled(&self) -> bool { - false - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum PickerLabelStyle { - #[default] - Default, - JjChangeId { - prefix_len: usize, - working_copy: bool, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PickerEntry { - pub label: String, - pub detail: String, - pub value: String, - pub highlights: Vec<(usize, usize)>, - pub label_style: PickerLabelStyle, - pub icon: Option<&'static str>, - pub section_header: bool, -} - -impl PickerItem for PickerEntry { - fn label(&self) -> &str { - &self.label - } - fn detail(&self) -> Option<&str> { - Some(&self.detail) - } - fn label_style(&self) -> PickerLabelStyle { - self.label_style - } - fn highlight_ranges(&self) -> &[(usize, usize)] { - &self.highlights - } - fn icon_svg(&self) -> Option<&'static str> { - self.icon - } - fn is_section_header(&self) -> bool { - self.section_header - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct PickerState { - pub kind: PickerKind, - pub query: String, - pub entries: Vec, - pub selected_index: usize, - pub hovered_index: Option, - pub list: OverlayListState, - pub browse_path: Option, - pub ref_resolve_generation: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PaletteCommand { - OpenRepoPicker, - NewTextCompare, - OpenGitHubAuthModal, - OpenGitHubAccountMenu, - SignOutGitHub, - FocusFileList, - FocusViewport, - ShowWorkingTree, - RefreshRepository, - OpenBaseRefPicker, - OpenHeadRefPicker, - SwapRefs, - StartCompare, - OpenCompareMenu, - ShowKeyboardShortcuts, - RestoreCompare, - ToggleSidebar, - ToggleFileTree, - ExpandAllFolders, - CollapseAllFolders, - ToggleWrap, - ToggleContinuousScroll, - SetSettingsSection(SettingsSection), - SetThemeMode(ThemeMode), - SetUiScalePct(u16), - SetWrapColumn(u32), - SetWheelScrollLines(u8), - ToggleAutoUpdate, - ToggleThemeMode, - ChangeTheme, - SetLayout(LayoutMode), - SetRenderer(RendererKind), - SetTheme(String), - ExpandAllContext, - ClearLineSelection, - GenerateCommitMessage, - OpenReviewComment, - OpenPullRequestInGitHub, - CheckForUpdates, - InstallUpdate, - RestartToUpdate, - RunOperation(VcsOperation), - FetchOrigin, - FetchAllRemotes, - PushCurrentBranch, - PublishOptions, - PushCurrentBranchForceWithLease, - PullCurrentBranch, - OpenSettings, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PaletteEntryKind { - Command(PaletteCommand), - File(usize), - Commit(String), - Repo(PathBuf), - Ref(CompareField, String), - PullRequest(PrKey), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PaletteEntry { - pub label: String, - pub detail: String, - pub kind: PaletteEntryKind, - pub highlights: Vec<(usize, usize)>, - /// Extra right-aligned summary (e.g. "+12 −3 · open"). - pub rhs: Option, - /// Disables the entry when set; `detail` usually explains why. - pub disabled: bool, -} - -fn palette_command_available( - command: &PaletteCommand, - capabilities: Option, -) -> bool { - match command { - PaletteCommand::FetchOrigin - | PaletteCommand::FetchAllRemotes - | PaletteCommand::PushCurrentBranch - | PaletteCommand::PublishOptions => { - capabilities.is_some_and(|capabilities| capabilities.remotes) - } - PaletteCommand::PushCurrentBranchForceWithLease => { - capabilities.is_some_and(|capabilities| capabilities.remotes && capabilities.branches) - } - PaletteCommand::PullCurrentBranch => { - capabilities.is_some_and(|capabilities| capabilities.pull_fast_forward) - } - _ => true, - } -} - -fn vcs_operation_available_for_location( - operation: &VcsOperation, - location: Option<&RepoLocation>, -) -> bool { - match operation { - VcsOperation::Jj(_) => location.is_some_and(|location| location.profile == VCS_PROFILE_JJ), - VcsOperation::JjRebaseCurrentChangeOnto { .. } => { - location.is_some_and(|location| location.profile == VCS_PROFILE_JJ) - } - VcsOperation::JjEditRevision { .. } => { - location.is_some_and(|location| location.profile == VCS_PROFILE_JJ) - } - VcsOperation::JjRestoreOperation { .. } => { - location.is_some_and(|location| location.profile == VCS_PROFILE_JJ) - } - } -} - -fn operation_log_entry_detail(entry: &VcsOperationLogEntry) -> String { - match ( - entry.description.is_empty(), - entry.user.is_empty(), - entry.time.is_empty(), - ) { - (false, false, false) => format!("{} - {} - {}", entry.description, entry.user, entry.time), - (false, false, true) => format!("{} - {}", entry.description, entry.user), - (false, true, false) => format!("{} - {}", entry.description, entry.time), - (false, true, true) => entry.description.clone(), - (true, false, false) => format!("{} - {}", entry.user, entry.time), - (true, false, true) => entry.user.clone(), - (true, true, false) => entry.time.clone(), - (true, true, true) => "jj operation log entry".to_owned(), - } -} - -impl PickerItem for PaletteEntry { - fn label(&self) -> &str { - &self.label - } - fn detail(&self) -> Option<&str> { - Some(&self.detail) - } - fn highlight_ranges(&self) -> &[(usize, usize)] { - &self.highlights - } - fn rhs(&self) -> Option<&str> { - self.rhs.as_deref() - } - fn is_disabled(&self) -> bool { - self.disabled - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct CommandPaletteState { - pub query: String, - pub entries: Vec, - pub selected_index: usize, - pub list: OverlayListState, -} - -/// Ephemeral ref-picker overlay state. `active_field` tracks which chip the -/// search input currently drives; `original_*` snapshots the refs at the moment -/// the picker opened so we can revert cleanly on cancel/backdrop. -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct RefPickerState { - pub active_field: CompareField, - pub original_left: String, - pub original_right: String, -} - -pub type PrKey = (String, String, i32); - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PrPeekMeta { - Loading, - Ready(PullRequestInfo), - Failed(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PrPeekDiff { - Idle, - Loading, - Ready { - url: String, - left_ref: String, - right_ref: String, - info: PullRequestInfo, - }, - Failed(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PrCacheEntry { - pub meta: PrPeekMeta, - pub diff: PrPeekDiff, - pub last_peek_ms: u64, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct PrReviewCommentsEntry { - pub status: AsyncStatus, - pub comments: Vec, - pub message: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReviewCommentDraft { - pub key: PrKey, - pub request: CreatePullRequestReviewComment, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ReviewCommentComposerState { - pub draft: Option, - pub status: AsyncStatus, - pub message: Option, - /// When set, submitting the composer replies to this thread instead of - /// creating a new inline draft. - pub reply_target: Option, - /// When set, submitting the composer edits this comment (by GraphQL node id) - /// instead of creating a new draft. - pub edit_target: Option, - /// Write (false) vs Preview (true) tab — Preview renders the markdown. - pub preview: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActiveReviewStatus { - pub status: ReviewSessionStatus, - pub message: Option, - pub unresolved_threads: usize, - pub resolved_threads: usize, - pub outdated_threads: usize, - pub pending_drafts: usize, - pub failed_drafts: usize, - pub review_decision: Option, - pub viewer_latest_review_state: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct PullRequestState { - pub status: AsyncStatus, - pub cache: HashMap, - pub pending_confirm: Option, - pub active: Option, - pub review_comments: HashMap, - pub review_sessions: HashMap, - pub review_composer: ReviewCommentComposerState, - /// Ephemeral, UI-only expand/collapse override per thread. Takes precedence - /// over the default (unresolved=expanded, resolved=collapsed). Not persisted - /// and intentionally separate from the backend `ReviewThreadStatus.collapsed`. - pub review_thread_expanded: HashMap, - /// Fetched comment-author avatars, keyed by `avatar_cache_key` of the sized - /// URL. Shared across PRs (avatars are immutable per URL); populated by the - /// shared `AvatarFetched` handler and read by the review card overlay. - pub review_avatars: HashMap, - /// Active drag-selection within a single review comment body, or `None`. - /// Mutually exclusive with the editor's viewport text selection. - pub card_text_selection: Option, -} - -/// Drag-selection within one review comment body. Offsets are byte indices into -/// `text` (a snapshot of the cleaned, wrapped-source body), so they remain valid -/// across re-wrap; `text` is stored so copy never has to re-derive it. Only the -/// comment whose `source_key` matches renders the highlight. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CardTextSelection { - pub source_key: u64, - pub text: String, - pub anchor: usize, - pub focus: usize, -} - -impl CardTextSelection { - pub fn new(source_key: u64, text: String, byte: usize) -> Self { - let byte = byte.min(text.len()); - Self { - source_key, - text, - anchor: byte, - focus: byte, - } - } - - pub fn normalized(&self) -> (usize, usize) { - (self.anchor.min(self.focus), self.anchor.max(self.focus)) - } - - pub fn is_collapsed(&self) -> bool { - self.anchor == self.focus - } - - /// The selected substring, or `None` when the selection is empty/invalid. - pub fn selected_text(&self) -> Option { - let (lo, hi) = self.normalized(); - if lo >= hi { - return None; - } - self.text.get(lo..hi).map(str::to_owned) - } -} - -/// Lifecycle of a single comment-author avatar fetch. `Failed` is terminal (no -/// retry) so a persistently-broken URL falls back to initials without re-fetching. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReviewAvatar { - Fetching, - Ready(AvatarBitmap), - Failed, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AvatarBitmap { - pub url: String, - pub rgba: Arc>, - pub width: u32, - pub height: u32, - pub cache_key: u64, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct GitHubAuthState { - pub status: AsyncStatus, - pub device_flow: Option, - pub token_present: bool, - pub user: Option, - pub avatar: Option, - pub avatar_fetching: bool, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct GitHubState { - pub client_id: String, - #[store(flatten)] - pub auth: GitHubAuthState, - #[store(flatten)] - pub pull_request: PullRequestState, -} - -/// Overlays live as normal elements in the main tree with a z-index above the -/// viewport. Occluding the viewport is the overlay's own responsibility: modal -/// surfaces (pickers, auth, shortcuts) render a full-screen `overlay_scrim` -/// backdrop; anchored dropdowns (AccountMenu, CompareMenu) render a transparent -/// backdrop and let the viewport show through. Do NOT gate viewport rendering -/// on overlay presence — let z-index handle layering. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OverlaySurface { - RepoPicker, - RefPicker, - CommandPalette, - Confirmation, - GitHubAuthModal, - KeyboardShortcuts, - ThemePicker, - FontPicker, - CompareMenu, - AccountMenu, - PublishMenu, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OverlayEntry { - pub surface: OverlaySurface, - pub focus_return: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct ConfirmationState { - pub title: String, - pub message: String, - pub confirm_label: String, - pub action: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct OverlayStackState { - pub stack: Vec, - #[store(flatten)] - pub picker: PickerState, - #[store(flatten)] - pub command_palette: CommandPaletteState, - #[store(flatten)] - pub ref_picker: RefPickerState, - #[store(flatten)] - pub confirmation: ConfirmationState, -} - -impl AppState { - pub fn overlays_top(&self) -> Option { - self.overlays - .stack - .with(&self.store, |stack| stack.last().map(|e| e.surface)) - } - - pub fn overlays_active_name(&self) -> Option<&'static str> { - self.overlays_top().map(overlay_name) - } - - /// `(pending, failed)` draft counts for the active pull request's review - /// session, for the submit bar. Zeroes when no PR/session is active. - pub fn active_review_draft_metrics(&self) -> (usize, usize) { - let Some(key) = self.active_pull_request_key() else { - return (0, 0); - }; - self.github - .pull_request - .review_sessions - .with(&self.store, |sessions| { - sessions - .get(&key) - .map(|session| { - let metrics = session.metrics(); - (metrics.pending_drafts, metrics.failed_drafts) - }) - .unwrap_or((0, 0)) - }) - } - - pub fn reset_picker(&mut self) { - let d = PickerState::default(); - self.overlays.picker.kind.set(&self.store, d.kind); - self.overlays.picker.query.set(&self.store, d.query); - self.overlays.picker.entries.set(&self.store, d.entries); - self.overlays - .picker - .selected_index - .set(&self.store, d.selected_index); - self.overlays - .picker - .hovered_index - .set(&self.store, d.hovered_index); - self.overlays.picker.list.set(&self.store, d.list); - self.overlays - .picker - .browse_path - .set(&self.store, d.browse_path); - self.overlays - .picker - .ref_resolve_generation - .set(&self.store, d.ref_resolve_generation); - } - - pub fn reset_command_palette(&mut self) { - let d = CommandPaletteState::default(); - self.overlays - .command_palette - .query - .set(&self.store, d.query); - self.overlays - .command_palette - .entries - .set(&self.store, d.entries); - self.overlays - .command_palette - .selected_index - .set(&self.store, d.selected_index); - self.overlays.command_palette.list.set(&self.store, d.list); - } - - pub fn reset_confirmation(&mut self) { - let d = ConfirmationState::default(); - self.overlays.confirmation.title.set(&self.store, d.title); - self.overlays - .confirmation - .message - .set(&self.store, d.message); - self.overlays - .confirmation - .confirm_label - .set(&self.store, d.confirm_label); - self.overlays.confirmation.action.set(&self.store, d.action); - } - - pub fn clear_overlays(&mut self) { - self.overlays - .stack - .update(&self.store, |stack| stack.clear()); - self.reset_picker(); - self.reset_command_palette(); - self.reset_confirmation(); - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Toast { - pub id: u64, - pub kind: ToastKind, - pub message: String, - pub description: Option, - pub created_at_ms: u64, - pub hovered: bool, - /// When `Some`, the toast renders an externally-driven progress bar in - /// place of the time-based one and is pinned (not auto-dismissed). - pub progress: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToastKind { - Info, - Error, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub enum UpdateState { - #[default] - Idle, - Checking, - Available(AvailableUpdate), - Downloading(AvailableUpdate), - ReadyToRestart(StagedUpdate), - Restarting(StagedUpdate), - Failed(String), -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct StartupState { - pub keyring_enabled: bool, - pub github_token_store: GitHubTokenStore, - pub auto_compare_pending: bool, - pub bootstrap_compare_started: bool, - pub pending_pr_url: Option, - pub preferred_file_index: Option, - pub preferred_file_path: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] -pub struct DebugState { - pub overlay_visible: bool, -} - -const FILE_HEIGHT_SPARSE_MIN_COUNT: usize = 4096; - -#[derive(Debug)] -enum FileHeightIndex { - Empty, - Dense { - heights: Vec, - tree: Vec, - }, - Sparse { - count: usize, - default_height: u32, - total: u64, - overrides: BTreeMap, - tree: Vec, - }, -} - -impl Default for FileHeightIndex { - fn default() -> Self { - Self::Empty - } -} - -impl FileHeightIndex { - fn rebuild(&mut self, heights: Vec) { - if heights.is_empty() { - self.clear(); - return; - } - - if let Some((default_height, overrides, total)) = sparse_height_index_parts(&heights) { - let mut tree = vec![0; heights.len() + 1]; - for (index, height) in heights.iter().copied().enumerate() { - height_tree_add(&mut tree, index, u64::from(height)); - } - *self = Self::Sparse { - count: heights.len(), - default_height, - total, - overrides, - tree, - }; - return; - } - - let mut tree = vec![0; heights.len() + 1]; - for (index, height) in heights.iter().copied().enumerate() { - dense_tree_add(&mut tree, index, height); - } - *self = Self::Dense { heights, tree }; - } - - fn clear(&mut self) { - *self = Self::Empty; - } - - fn len(&self) -> usize { - match self { - Self::Empty => 0, - Self::Dense { heights, .. } => heights.len(), - Self::Sparse { count, .. } => *count, - } - } - - fn total_u64(&self) -> u64 { - match self { - Self::Empty => 0, - Self::Dense { heights, .. } => self.prefix_u64(heights.len()), - Self::Sparse { total, .. } => *total, - } - } - - fn total_u32(&self) -> u32 { - self.total_u64().min(u64::from(u32::MAX)) as u32 - } - - fn prefix_u32(&self, index: usize) -> u32 { - self.prefix_u64(index).min(u64::from(u32::MAX)) as u32 - } - - fn update(&mut self, index: usize, height: u32) { - match self { - Self::Empty => {} - Self::Dense { heights, tree } => { - if index >= heights.len() { - return; - } - let old = heights[index]; - if old == height { - return; - } - heights[index] = height; - if height >= old { - dense_tree_add(tree, index, height - old); - } else { - dense_tree_sub(tree, index, old - height); - } - } - Self::Sparse { - count, - default_height, - total, - overrides, - tree, - } => { - if index >= *count { - return; - } - let old = overrides.get(&index).copied().unwrap_or(*default_height); - if old == height { - return; - } - if height == *default_height { - overrides.remove(&index); - } else { - overrides.insert(index, height); - } - *total = total - .saturating_sub(u64::from(old)) - .saturating_add(u64::from(height)); - if height >= old { - height_tree_add(tree, index, u64::from(height - old)); - } else { - height_tree_sub(tree, index, u64::from(old - height)); - } - if overrides.len() > *count / 4 { - self.promote_sparse_to_dense(); - } - } - } - } - - fn locate(&self, target_px: u32) -> Option<(usize, u32)> { - match self { - Self::Empty => None, - Self::Dense { heights, tree } => locate_dense_height(heights, tree, target_px), - Self::Sparse { - count, total, tree, .. - } => locate_sparse_height(self, *count, *total, tree, target_px), - } - } - - fn prefix_u64(&self, index: usize) -> u64 { - match self { - Self::Empty => 0, - Self::Dense { heights, tree } => dense_prefix_u64(heights, tree, index), - Self::Sparse { count, tree, .. } => height_tree_prefix_u64(tree, index.min(*count)), - } - } - - fn height_at(&self, index: usize) -> u32 { - match self { - Self::Empty => 0, - Self::Dense { heights, .. } => heights.get(index).copied().unwrap_or(0), - Self::Sparse { - count, - default_height, - overrides, - .. - } => { - if index >= *count { - 0 - } else { - overrides.get(&index).copied().unwrap_or(*default_height) - } - } - } - } - - fn promote_sparse_to_dense(&mut self) { - let Self::Sparse { - count, - default_height, - overrides, - .. - } = self - else { - return; - }; - let mut heights = vec![*default_height; *count]; - for (index, height) in overrides.iter() { - if let Some(slot) = heights.get_mut(*index) { - *slot = *height; - } - } - self.rebuild(heights); - } -} - -#[derive(Debug, Default)] -struct VirtualDiffDocument { - source: WorkspaceSource, - generation: u64, - file_count: usize, - height_index: FileHeightIndex, -} - -impl VirtualDiffDocument { - fn sync_identity( - &mut self, - source: WorkspaceSource, - generation: u64, - file_count: usize, - ) -> bool { - let changed = - self.source != source || self.generation != generation || self.file_count != file_count; - if changed { - self.source = source; - self.generation = generation; - self.file_count = file_count; - self.height_index.clear(); - } - changed - } - - fn clear(&mut self) { - self.source = WorkspaceSource::None; - self.generation = 0; - self.file_count = 0; - self.height_index.clear(); - } - - fn rebuild_heights(&mut self, heights: Vec) { - self.file_count = heights.len(); - self.height_index.rebuild(heights); - } - - fn item_id(&self, index: usize) -> Option { - (index < self.file_count) - .then(|| VirtualDiffItemId::file(self.source, self.generation, index)) - } - - fn anchor_is_current(&self, anchor: ViewportAnchor) -> bool { - anchor.item_id.source == self.source - && anchor.item_id.generation == self.generation - && anchor.item_id.kind == VirtualDiffItemKind::File - && anchor.item_id.index < self.file_count - } - - fn len(&self) -> usize { - self.height_index.len() - } - - fn total_u32(&self) -> u32 { - self.height_index.total_u32() - } - - fn prefix_u32(&self, index: usize) -> u32 { - self.height_index.prefix_u32(index) - } - - fn locate(&self, target_px: u32) -> Option<(usize, u32)> { - self.height_index.locate(target_px) - } - - fn height_at(&self, index: usize) -> u32 { - self.height_index.height_at(index) - } - - fn update_height(&mut self, index: usize, height: u32) { - self.height_index.update(index, height); - } -} - -#[derive(Debug, Default)] -struct VirtualScrollModel { - anchor: Option, -} - -impl VirtualScrollModel { - fn clear(&mut self) { - self.anchor = None; - } - - fn set_anchor(&mut self, anchor: ViewportAnchor) { - self.anchor = Some(anchor); - } -} - -const VIRTUAL_STREAM_SORT_STRIDE: u64 = 1024; -const VIRTUAL_STREAM_ROW_OFFSET: u64 = 512; -const VIRTUAL_STREAM_BLOCK_BELOW_OFFSET: u64 = 768; - -fn virtual_row_sort_key(line_index: usize) -> u64 { - (line_index as u64) - .saturating_mul(VIRTUAL_STREAM_SORT_STRIDE) - .saturating_add(VIRTUAL_STREAM_ROW_OFFSET) -} - -pub fn virtual_block_below_sort_key(anchor_line_index: u32, block_order: usize) -> u64 { - u64::from(anchor_line_index) - .saturating_mul(VIRTUAL_STREAM_SORT_STRIDE) - .saturating_add(VIRTUAL_STREAM_BLOCK_BELOW_OFFSET) - .saturating_add(block_order.min(255) as u64) -} - -pub fn stable_virtual_key(text: &str) -> u64 { - let mut key = 0xcbf2_9ce4_8422_2325_u64; - for byte in text.as_bytes() { - key ^= u64::from(*byte); - key = key.wrapping_mul(0x100_0000_01b3); - } - key -} - -fn estimated_virtual_item_height_px(kind: VirtualDiffItemKind) -> u32 { - match kind { - VirtualDiffItemKind::File => 192, - VirtualDiffItemKind::FileHeader => 40, - VirtualDiffItemKind::Hunk => 28, - VirtualDiffItemKind::DiffRow => 24, - VirtualDiffItemKind::ReviewThread => 160, - VirtualDiffItemKind::ReviewComment => 96, - VirtualDiffItemKind::Composer => 248, - VirtualDiffItemKind::LoadingPlaceholder => 48, - } -} - -fn virtual_row_stable_key(line: &RenderLine, local_ordinal: u32) -> u64 { - let mut key = u64::from(line.kind); - key = key - .wrapping_mul(1_099_511_628_211) - .wrapping_add(line.hunk_index as i64 as u64); - key = key - .wrapping_mul(1_099_511_628_211) - .wrapping_add(u64::from(line.old_line_no)); - key = key - .wrapping_mul(1_099_511_628_211) - .wrapping_add(u64::from(line.new_line_no)); - key = key - .wrapping_mul(1_099_511_628_211) - .wrapping_add(line.line_index as i64 as u64); - key.wrapping_mul(1_099_511_628_211) - .wrapping_add(u64::from(local_ordinal)) -} - -fn sparse_height_index_parts(heights: &[u32]) -> Option<(u32, BTreeMap, u64)> { - if heights.len() < FILE_HEIGHT_SPARSE_MIN_COUNT { - return None; - } - let default_height = most_common_height(heights); - let mut overrides = BTreeMap::new(); - let mut total = 0_u64; - for (index, height) in heights.iter().copied().enumerate() { - total = total.saturating_add(u64::from(height)); - if height != default_height { - overrides.insert(index, height); - } - } - - if overrides.len() <= heights.len() / 4 { - Some((default_height, overrides, total)) - } else { - None - } -} - -fn most_common_height(heights: &[u32]) -> u32 { - let mut counts: HashMap = HashMap::new(); - let mut best_height = heights[0]; - let mut best_count = 0; - for height in heights { - let count = counts - .entry(*height) - .and_modify(|count| *count += 1) - .or_insert(1); - if *count > best_count { - best_height = *height; - best_count = *count; - } - } - best_height -} - -fn dense_tree_add(tree: &mut [u32], index: usize, delta: u32) { - let mut idx = index + 1; - while idx < tree.len() { - tree[idx] = tree[idx].saturating_add(delta); - idx += idx & idx.wrapping_neg(); - } -} - -fn dense_tree_sub(tree: &mut [u32], index: usize, delta: u32) { - let mut idx = index + 1; - while idx < tree.len() { - tree[idx] = tree[idx].saturating_sub(delta); - idx += idx & idx.wrapping_neg(); - } -} - -fn height_tree_add(tree: &mut [u64], index: usize, delta: u64) { - let mut idx = index + 1; - while idx < tree.len() { - tree[idx] = tree[idx].saturating_add(delta); - idx += idx & idx.wrapping_neg(); - } -} - -fn height_tree_sub(tree: &mut [u64], index: usize, delta: u64) { - let mut idx = index + 1; - while idx < tree.len() { - tree[idx] = tree[idx].saturating_sub(delta); - idx += idx & idx.wrapping_neg(); - } -} - -fn dense_prefix_u64(heights: &[u32], tree: &[u32], index: usize) -> u64 { - let mut idx = index.min(heights.len()); - let mut sum = 0_u64; - while idx > 0 { - sum = sum.saturating_add(u64::from(tree[idx])); - idx &= idx - 1; - } - sum -} - -fn height_tree_prefix_u64(tree: &[u64], index: usize) -> u64 { - let mut idx = index.min(tree.len().saturating_sub(1)); - let mut sum = 0_u64; - while idx > 0 { - sum = sum.saturating_add(tree[idx]); - idx &= idx - 1; - } - sum -} - -fn locate_dense_height(heights: &[u32], tree: &[u32], target_px: u32) -> Option<(usize, u32)> { - if heights.is_empty() { - return None; - } - let target = u64::from(target_px); - let total = dense_prefix_u64(heights, tree, heights.len()); - if target >= total { - let index = heights.len() - 1; - return Some((index, heights[index].saturating_sub(1))); - } - - let mut idx = 0_usize; - let mut bit = 1_usize; - while bit < tree.len() { - bit <<= 1; - } - let mut sum = 0_u64; - while bit > 0 { - let next = idx + bit; - if next < tree.len() { - let next_sum = sum.saturating_add(u64::from(tree[next])); - if next_sum <= target { - idx = next; - sum = next_sum; - } - } - bit >>= 1; - } - let index = idx.min(heights.len().saturating_sub(1)); - Some(( - index, - target.saturating_sub(sum).min(u64::from(u32::MAX)) as u32, - )) -} - -fn locate_sparse_height( - index: &FileHeightIndex, - count: usize, - total: u64, - tree: &[u64], - target_px: u32, -) -> Option<(usize, u32)> { - if count == 0 { - return None; - } - let target = u64::from(target_px); - if target >= total { - let slot = count - 1; - return Some((slot, index.height_at(slot).saturating_sub(1))); - } - - let mut slot = 0_usize; - let mut bit = 1_usize; - while bit < tree.len() { - bit <<= 1; - } - let mut sum = 0_u64; - while bit > 0 { - let next = slot + bit; - if next < tree.len() { - let next_sum = sum.saturating_add(tree[next]); - if next_sum <= target { - slot = next; - sum = next_sum; - } - } - bit >>= 1; - } - let slot = slot.min(count.saturating_sub(1)); - Some(( - slot, - target.saturating_sub(sum).min(u64::from(u32::MAX)) as u32, - )) -} - -#[derive(Debug)] -pub struct AppState { - pub workspace_mode: Signal, - pub compare_progress: Signal>, - pub app_view: Signal, - pub settings_section: Signal, - pub keymap_capture: Signal>, - pub keymaps_scroll_top_px: Signal, - pub keymaps_viewport_height_px: Signal, - pub keymaps_content_height_px: Signal, - pub compare: CompareStateStore, - pub repository: RepositoryStateStore, - pub workspace: WorkspaceStateStore, - pub file_list: FileListStateStore, - pub overlays: OverlayStackStateStore, - pub focus: Signal>, - pub text_edit: TextEditStateStore, - pub editor: EditorStateStore, - pub github: GitHubStateStore, - pub settings: Settings, - pub startup: StartupState, - pub last_error: Signal>, - pub toasts: Signal>, - pub syntax_pack_installs: Signal>, - pub update: Signal, - pub context_menu: ContextMenuState, - /// Memoized: `true` when `focus` targets a text-editing field. - pub text_focused: Signal, - pub animation: crate::ui::animation::AnimationState, - pub commit_editor: Editor, - pub review_comment_editor: Editor, - pub steering_prompt_editor: Editor, - pub text_compare: TextCompareState, - pub ai_openai_key: String, - pub ai_anthropic_key: String, - pub ai_openai_editing: bool, - pub ai_anthropic_editing: bool, - pub ai_generation_id: u64, - pub ai_generation_active: bool, - pub ai_generation_error: Option, - /// Shared reactive store. Signals (like `sidebar_visible`) are handles - /// into this store. Kept in `AppState` so state methods (apply_action etc.) - /// can freely read/write signals without threading a store parameter. - pub store: Rc, - pub sidebar_visible: Signal, - pub debug: DebugStateStore, - pub clock_ms: u64, - pub next_toast_id: u64, - pub frecency: Option, - pub theme_names: Vec, - pub theme_variants: Vec, - pub theme_preview_original: Signal>, - pub github_access_token: Option, - viewport_document_cache: Option, - virtual_diff_document: VirtualDiffDocument, - virtual_scroll: VirtualScrollModel, - file_working_set: FileWorkingSet, - syntax_requests: SyntaxRequestTracker, - last_virtual_scroll_top_px: Option, -} - -impl Default for AppState { - fn default() -> Self { - let store = Rc::new(SignalStore::default()); - let sidebar_visible = store.create(true); - let focus = store.create(None::); - let text_focused = - store.create_memo(move |s| s.read(focus).is_some_and(|t| t.is_text_field())); - let workspace_mode = store.create(WorkspaceMode::default()); - let compare_progress = store.create(None::); - let app_view = store.create(AppView::default()); - let settings_section = store.create(SettingsSection::default()); - let keymap_capture = store.create(None::); - let keymaps_scroll_top_px = store.create(0.0_f32); - let keymaps_viewport_height_px = store.create(0.0_f32); - let keymaps_content_height_px = store.create(0.0_f32); - let last_error = store.create(None::); - let theme_preview_original = store.create(None::); - let toasts = store.create(Vec::::new()); - let syntax_pack_installs = store.create(Vec::::new()); - let update = store.create(UpdateState::default()); - let debug = DebugStateStore::new(&store, DebugState::default()); - let file_list = FileListStateStore::new_default(&store); - let editor = EditorStateStore::new_default(&store); - let overlays = OverlayStackStateStore::new_default(&store); - let compare = CompareStateStore::new_default(&store); - let repository = RepositoryStateStore::new_default(&store); - let workspace = WorkspaceStateStore::new_default(&store); - let text_edit = TextEditStateStore::new_default(&store); - let github = GitHubStateStore::new_default(&store); - Self { - workspace_mode, - compare_progress, - app_view, - settings_section, - keymap_capture, - keymaps_scroll_top_px, - keymaps_viewport_height_px, - keymaps_content_height_px, - compare, - repository, - workspace, - file_list, - overlays, - focus, - text_edit, - editor, - github, - settings: Settings::default(), - startup: StartupState::default(), - last_error, - toasts, - syntax_pack_installs, - update, - context_menu: ContextMenuState::default(), - text_focused, - animation: crate::ui::animation::AnimationState::default(), - commit_editor: Editor::default(), - review_comment_editor: Editor::default(), - steering_prompt_editor: Editor::default(), - text_compare: TextCompareState::default(), - ai_openai_key: String::new(), - ai_anthropic_key: String::new(), - ai_openai_editing: false, - ai_anthropic_editing: false, - ai_generation_id: 0, - ai_generation_active: false, - ai_generation_error: None, - sidebar_visible, - debug, - store, - clock_ms: 0, - next_toast_id: 1, - frecency: None, - theme_names: Vec::new(), - theme_variants: Vec::new(), - theme_preview_original, - github_access_token: None, - viewport_document_cache: None, - virtual_diff_document: VirtualDiffDocument::default(), - virtual_scroll: VirtualScrollModel::default(), - file_working_set: FileWorkingSet::default(), - syntax_requests: SyntaxRequestTracker::default(), - last_virtual_scroll_top_px: None, - } - } -} - -impl AppState { - pub fn bootstrap(startup: StartupOptions, settings: Settings) -> (Self, Vec) { - let persisted = matching_persisted_compare(&startup, &settings).cloned(); - let repo_path = startup.args.repo.clone(); - let left_ref = startup - .args - .left - .clone() - .or_else(|| persisted.as_ref().map(|compare| compare.left_ref.clone())) - .unwrap_or_default(); - let right_ref = startup - .args - .right - .clone() - .or_else(|| persisted.as_ref().map(|compare| compare.right_ref.clone())) - .unwrap_or_default(); - let mode = startup - .args - .compare_mode - .or_else(|| persisted.as_ref().map(|compare| compare.mode)) - .unwrap_or_default(); - let layout = startup - .args - .layout - .or_else(|| persisted.as_ref().map(|compare| compare.layout)) - .unwrap_or(settings.viewport.layout); - let renderer = startup - .args - .renderer - .or_else(|| persisted.as_ref().map(|compare| compare.renderer)) - .unwrap_or_default(); - let auto_compare_pending = startup.wants_compare(mode, &left_ref, &right_ref); - let bootstrap_compare_started = repo_path.is_some() - && startup.args.open_pr.is_none() - && auto_compare_pending - && (startup.args.left.is_some() - || startup.args.right.is_some() - || startup.args.compare_mode.is_some()); - - let store = Rc::new(SignalStore::default()); - let sidebar_visible = store.create(true); - let focus = store.create(if repo_path.is_some() { - Some(FocusTarget::TitleBar) - } else { - Some(FocusTarget::WorkspacePrimaryButton) - }); - let text_focused = - store.create_memo(move |s| s.read(focus).is_some_and(|t| t.is_text_field())); - let workspace_mode = store.create(if repo_path.is_some() && auto_compare_pending { - WorkspaceMode::Loading - } else { - WorkspaceMode::Empty - }); - let compare_progress = store.create(None::); - let app_view = store.create(AppView::default()); - let settings_section = store.create(SettingsSection::default()); - let keymap_capture = store.create(None::); - let keymaps_scroll_top_px = store.create(0.0_f32); - let keymaps_viewport_height_px = store.create(0.0_f32); - let keymaps_content_height_px = store.create(0.0_f32); - let last_error = store.create(None::); - let theme_preview_original = store.create(None::); - let toasts = store.create(Vec::::new()); - let syntax_pack_installs = store.create(Vec::::new()); - let update = store.create(UpdateState::default()); - let debug = DebugStateStore::new(&store, DebugState::default()); - let file_list = FileListStateStore::new_default(&store); - let editor = EditorStateStore::new( - &store, - EditorState { - layout, - wrap_enabled: settings.viewport.wrap_enabled, - wrap_column: settings.viewport.wrap_column, - ..EditorState::default() - }, - ); - let overlays = OverlayStackStateStore::new_default(&store); - let compare = CompareStateStore::new( - &store, - CompareState { - repo_path: repo_path.clone(), - left_ref, - right_ref, - mode, - layout, - renderer, - resolved_left: None, - resolved_right: None, - }, - ); - let repository = RepositoryStateStore::new_default(&store); - let workspace = WorkspaceStateStore::new_default(&store); - let text_edit = TextEditStateStore::new_default(&store); - let initial_token_present = settings.github_user.is_some(); - let github = GitHubStateStore::new( - &store, - GitHubState { - client_id: startup.github_client_id.clone(), - auth: GitHubAuthState { - token_present: initial_token_present, - user: settings.github_user.clone(), - ..GitHubAuthState::default() - }, - pull_request: PullRequestState::default(), - }, - ); - let mut state = Self { - workspace_mode, - compare_progress, - app_view, - settings_section, - keymap_capture, - keymaps_scroll_top_px, - keymaps_viewport_height_px, - keymaps_content_height_px, - compare, - repository, - workspace, - file_list, - overlays, - focus, - text_edit, - editor, - github, - settings, - startup: StartupState { - keyring_enabled: startup.keyring_enabled, - github_token_store: startup.github_token_store, - auto_compare_pending: auto_compare_pending && !bootstrap_compare_started, - bootstrap_compare_started, - pending_pr_url: startup.args.open_pr.clone(), - preferred_file_index: startup.args.file_index, - preferred_file_path: startup.args.file_path.clone(), - }, - last_error, - toasts, - syntax_pack_installs, - update, - context_menu: ContextMenuState::default(), - text_focused, - animation: crate::ui::animation::AnimationState::default(), - commit_editor: Editor::default(), - review_comment_editor: Editor::default(), - steering_prompt_editor: Editor::default(), - text_compare: TextCompareState::default(), - ai_openai_key: String::new(), - ai_anthropic_key: String::new(), - ai_openai_editing: false, - ai_anthropic_editing: false, - ai_generation_id: 0, - ai_generation_active: false, - ai_generation_error: None, - sidebar_visible, - debug, - store, - clock_ms: 0, - next_toast_id: 1, - frecency: crate::core::frecency::open_default_store(), - theme_names: Vec::new(), - theme_variants: Vec::new(), - theme_preview_original, - github_access_token: None, - viewport_document_cache: None, - virtual_diff_document: VirtualDiffDocument::default(), - virtual_scroll: VirtualScrollModel::default(), - file_working_set: FileWorkingSet::default(), - syntax_requests: SyntaxRequestTracker::default(), - last_virtual_scroll_top_px: None, - }; - let seed_prompt = if state.settings.ai_steering_prompt.trim().is_empty() { - crate::ai::DEFAULT_STEERING_PROMPT - } else { - state.settings.ai_steering_prompt.as_str() - }; - state.steering_prompt_editor.set_text(seed_prompt); - state.sync_settings_snapshot(); - - let mut effects = Vec::new(); - if let Some(path) = repo_path { - state - .repository - .status - .set(&state.store, AsyncStatus::Loading); - - // Bootstrap: seed the loading panel so a slow cold-boot open - // shows staged progress. Reveal is gated by the same 500ms - // threshold as user-initiated opens — if the whole bootstrap - // open completes within the threshold the panel never appears - // and the user lands straight in the ready UI. - let boot_gen = state - .workspace - .compare_generation - .get(&state.store) - .saturating_add(1); - state - .workspace - .compare_generation - .set(&state.store, boot_gen); - effects.push(state.invalidate_syntax_epoch_effect()); - let repo_name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("repository") - .to_owned(); - state.compare_progress.set( - &state.store, - Some(CompareProgress { - generation: boot_gen, - phase: ComparePhase::OpeningRepo, - subject: if bootstrap_compare_started { - LoadingSubject::Compare { - left_label: state.vcs_ui_profile().compare_ref_display_label( - &state.compare.left_ref.get(&state.store), - ), - right_label: state.vcs_ui_profile().compare_ref_display_label( - &state.compare.right_ref.get(&state.store), - ), - } - } else { - LoadingSubject::RepoOpen { name: repo_name } - }, - started_at_ms: 0, - reveal_at_ms: COMPARE_REVEAL_DELAY_MS, - file_count_total: None, - files_loaded: 0, - }), - ); - - effects.push( - RepositoryEffect::SyncRepository { - path: path.clone(), - reason: RepositorySyncReason::Open, - reporter_generation: (!bootstrap_compare_started).then_some(boot_gen), - } - .into(), - ); - effects.push(RepositoryEffect::WatchRepository { path: Some(path) }.into()); - if bootstrap_compare_started { - effects.push( - CompareEffect::Run(Task { - generation: boot_gen, - request: CompareRequest { - repo_path: state.compare.repo_path.get(&state.store).unwrap(), - request: vcs_compare_request( - state.compare.mode.get(&state.store), - state.compare.left_ref.get(&state.store), - state.compare.right_ref.get(&state.store), - state.compare.layout.get(&state.store), - state.compare.renderer.get(&state.store), - ), - github_token: startup.github_token.clone(), - }, - }) - .into(), - ); - } - } - if let Some(token) = startup.github_token.clone() { - state.github_access_token = Some(token.clone()); - state.github.auth.token_present.set(&state.store, true); - if startup.github_token_store.is_enabled() { - effects.push(GitHubEffect::SaveGitHubToken(token).into()); - } - } else if startup.github_token_store.is_enabled() { - effects.push(GitHubEffect::LoadGitHubToken.into()); - } - - // Show the cached user + avatar optimistically while the token loads. - if let Some(user) = state.settings.github_user.as_ref() - && let Some(url) = avatar_url_sized(&user.avatar_url, 128) - { - state.github.auth.avatar_fetching.set(&state.store, true); - effects.push(GitHubEffect::FetchAvatar { url }.into()); - } - - effects.push(SyntaxEffect::InstallCommonSyntaxPacks.into()); - if startup.keyring_enabled { - effects.push(AiEffect::LoadAiKeys.into()); - } - if state.update_polling_enabled() { - effects.push(UpdateEffect::CheckForUpdates { silent: true }.into()); - } - (state, effects) - } - - pub fn apply_action>(&mut self, action: A) -> Vec { - let action = action.into(); - match action { - Action::App(action) => app::reduce_action(self, action), - Action::Workspace(action) => workspace::reduce_action(self, action), - Action::TextCompare(action) => text_compare::reduce_action(self, action), - Action::Compare(action) => compare::reduce_action(self, action), - Action::Repository(action) => repository::reduce_action(self, action), - Action::FileList(action) => file_list::reduce_action(self, action), - Action::Overlay(action) => overlay::reduce_action(self, action), - Action::Editor(action) => editor::reduce_action(self, action), - Action::TextEdit(action) => text_edit::reduce_action(self, action), - Action::Settings(action) => settings::reduce_action(self, action), - Action::GitHub(action) => github::reduce_action(self, action), - Action::Update(action) => update::reduce_action(self, action), - Action::Window(_) => Vec::new(), - Action::Syntax(action) => syntax::reduce_action(self, action), - Action::Ai(action) => ai::reduce_action(self, action), - Action::Noop => Vec::new(), - } - } - - pub fn apply_event(&mut self, event: AppEvent) -> Vec { - match event { - AppEvent::Ui(event) => app::reduce_event(self, event), - AppEvent::Repository(event) => repository::reduce_event(self, event), - AppEvent::Compare(event) => compare::reduce_event(self, event), - AppEvent::GitHub(event) => github::reduce_event(self, event), - AppEvent::Settings(event) => settings::reduce_event(self, event), - AppEvent::Update(event) => update::reduce_event(self, event), - AppEvent::Syntax(event) => syntax::reduce_event(self, event), - AppEvent::Ai(event) => ai::reduce_event(self, event), - } - } - - pub fn window_title(&self) -> String { - let workspace_mode = if self.compare_progress.with(&self.store, |p| p.is_some()) { - "loading" - } else { - workspace_mode_name(self.workspace_mode.get(&self.store)) - }; - let title_prefix = crate::platform::startup::window_title_prefix(); - if self.workspace.source.get(&self.store) == WorkspaceSource::TextCompare { - return format!("{title_prefix} - Text Compare [{workspace_mode}]"); - } - let repo = self.compare.repo_path.with(&self.store, |p| { - p.as_deref() - .and_then(Path::file_name) - .and_then(|value| value.to_str()) - .unwrap_or("native") - .to_owned() - }); - let selected_path = self.workspace.selected_file_path.get(&self.store); - if let Some(path) = selected_path.as_deref() { - format!("{title_prefix} - {repo} [{workspace_mode}] {path}") - } else { - format!("{title_prefix} - {repo} [{workspace_mode}]") - } - } - - pub fn update_time(&mut self, now_ms: u64) { - self.clock_ms = now_ms; - self.animation.tick(now_ms); - let has_expired_toast = self.toasts.with(&self.store, |toasts| { - toasts.iter().any(|toast| { - !toast.hovered - && toast.progress.is_none() - && now_ms.saturating_sub(toast.created_at_ms) >= TOAST_LIFETIME_MS - }) - }); - if has_expired_toast { - self.toasts.update(&self.store, |toasts| { - toasts.retain(|toast| { - toast.hovered - || toast.progress.is_some() - || now_ms.saturating_sub(toast.created_at_ms) < TOAST_LIFETIME_MS - }); - }); - } - } - - pub fn update_polling_enabled(&self) -> bool { - self.settings.auto_update - && crate::core::update::updates_configured() - && !cfg!(debug_assertions) - && !matches!( - self.update.get(&self.store), - UpdateState::Downloading(_) - | UpdateState::ReadyToRestart(_) - | UpdateState::Restarting(_) - ) - } - - pub fn cursor_blink_epoch(&self) -> Option { - self.is_text_focused().then(|| { - self.clock_ms - .saturating_sub(self.text_edit.cursor_moved_at_ms.get(&self.store)) - / CURSOR_BLINK_INTERVAL_MS - }) - } - - pub fn next_cursor_blink_at_ms(&self) -> Option { - self.is_text_focused().then(|| { - let moved_at = self.text_edit.cursor_moved_at_ms.get(&self.store); - let elapsed = self.clock_ms.saturating_sub(moved_at); - let next_epoch = elapsed / CURSOR_BLINK_INTERVAL_MS + 1; - moved_at.saturating_add(next_epoch.saturating_mul(CURSOR_BLINK_INTERVAL_MS)) - }) - } - - pub fn next_toast_expiry_at_ms(&self) -> Option { - self.toasts.with(&self.store, |toasts| { - toasts - .iter() - .filter(|toast| !toast.hovered && toast.progress.is_none()) - .map(|toast| toast.created_at_ms.saturating_add(TOAST_LIFETIME_MS)) - .min() - }) - } - - pub fn active_overlay_name(&self) -> Option<&'static str> { - self.overlays_active_name() - } - - fn open_repository(&mut self, path: PathBuf) -> Vec { - let path = normalize_repository_open_path(path); - self.workspace_mode.set(&self.store, WorkspaceMode::Loading); - self.compare.repo_path.set(&self.store, Some(path.clone())); - self.compare.left_ref.set(&self.store, String::new()); - self.compare.right_ref.set(&self.store, String::new()); - self.compare.mode.set(&self.store, CompareMode::default()); - self.compare.resolved_left.set(&self.store, None); - self.compare.resolved_right.set(&self.store, None); - self.repository - .status - .set(&self.store, AsyncStatus::Loading); - self.repository.location.set(&self.store, None); - self.repository.capabilities.set(&self.store, None); - self.repository.refs.set(&self.store, Vec::new()); - self.repository.changes.set(&self.store, Vec::new()); - self.repository.operation_log.set(&self.store, Vec::new()); - self.repository.file_changes.set(&self.store, Vec::new()); - self.repository.publish_plan.set(&self.store, None); - self.workspace_clear_compare(); - self.reset_file_list(); - self.editor_clear_document(); - self.editor.focused.set(&self.store, false); - self.last_error.set(&self.store, None); - self.github.pull_request.cache.update(&self.store, |c| { - c.clear(); - }); - self.github - .pull_request - .pending_confirm - .set(&self.store, None); - self.clear_overlays(); - self.focus.set(&self.store, Some(FocusTarget::TitleBar)); - self.sync_settings_snapshot(); - - // Seed the progress panel with a repo-open subject. We piggy-back - // on `compare_generation` as the loading generation — any in-flight - // compare is invalidated when the user opens a new repo anyway, - // and `handle_compare_progress_update` just matches on whatever - // generation the panel records. - let next_gen = self - .workspace - .compare_generation - .get(&self.store) - .saturating_add(1); - self.workspace.compare_generation.set(&self.store, next_gen); - let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); - let repo_name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("repository") - .to_owned(); - // Always delay the panel reveal — a tiny repo that opens in under - // the threshold should finish without ever flashing a loading UI. - // Unlike re-compare (which can preserve the old diff during the - // grace window), repo-open has nothing to fall back to visually; - // the empty background / previous workspace is what the user sees - // for 500ms, which is a cheap price for zero flash on fast ops. - let started_at_ms = self.clock_ms; - let reveal_at_ms = started_at_ms.saturating_add(COMPARE_REVEAL_DELAY_MS); - self.compare_progress.set( - &self.store, - Some(CompareProgress { - generation: next_gen, - phase: ComparePhase::OpeningRepo, - subject: LoadingSubject::RepoOpen { name: repo_name }, - started_at_ms, - reveal_at_ms, - file_count_total: None, - files_loaded: 0, - }), - ); - - vec![ - syntax_epoch_effect, - SettingsEffect::SaveSettings(self.settings.clone()).into(), - RepositoryEffect::SyncRepository { - path: path.clone(), - reason: RepositorySyncReason::Open, - reporter_generation: Some(next_gen), - } - .into(), - RepositoryEffect::WatchRepository { path: Some(path) }.into(), - ] - } - - /// Clear the workspace back to a blank "no compare loaded" state. Replaces - /// the former `WorkspaceState::clear_compare(&mut self)` method. - fn workspace_clear_compare(&mut self) { - self.workspace - .source - .set(&self.store, WorkspaceSource::None); - self.workspace.status.set(&self.store, AsyncStatus::Idle); - self.workspace - .status_operation_pending - .set(&self.store, false); - self.workspace.status_generation.set(&self.store, 0); - self.clear_syntax_inflight(); - self.workspace.files.set(&self.store, Vec::new()); - self.workspace - .status_file_changes - .set(&self.store, Vec::new()); - self.workspace.selected_file_index.set(&self.store, None); - self.workspace.selected_file_path.set(&self.store, None); - self.workspace.selected_change_bucket.set(&self.store, None); - self.workspace.compare_output.set(&self.store, None); - self.workspace.compare_total_stats.set(&self.store, None); - self.workspace.compare_hydrated_stats.set(&self.store, None); - self.workspace - .compare_deferred_stats_remaining - .set(&self.store, None); - self.workspace - .compare_deferred_stats_cursor - .set(&self.store, 0); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); - self.workspace.active_file.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - self.clear_file_cache(); - self.workspace.raw_diff_len.set(&self.store, 0); - self.workspace.used_fallback.set(&self.store, false); - self.workspace - .fallback_message - .set(&self.store, String::new()); - self.workspace.sidebar_auto_width.set(&self.store, None); - self.workspace.range_commits.set(&self.store, Vec::new()); - self.workspace - .compare_history_pending - .set(&self.store, None); - self.workspace.pre_drill_compare.set(&self.store, None); - self.workspace.expansions.update(&self.store, |m| m.clear()); - self.clear_file_scroll_layout(); - self.workspace.global_scroll_top_px.set(&self.store, 0); - } - - fn handle_repository_snapshot(&mut self, payload: RepositorySnapshot) -> Vec { - tracing::debug!( - path = %payload.path.display(), - reason = ?payload.reason, - change_kind = ?payload.change_kind, - pending = self.workspace.status_operation_pending.get(&self.store), - status_gen = self.workspace.status_generation.get(&self.store), - "handle_repository_snapshot: entered" - ); - if self - .compare - .repo_path - .with(&self.store, |p| p.as_ref() != Some(&payload.path)) - { - tracing::warn!("handle_repository_snapshot: path mismatch, ignored"); - return Vec::new(); - } - - self.repository.status.set(&self.store, AsyncStatus::Ready); - self.repository - .location - .set(&self.store, Some(payload.location.clone())); - self.repository - .capabilities - .set(&self.store, Some(payload.capabilities)); - let file_changes = payload.file_changes; - self.repository.refs.set(&self.store, payload.refs); - self.repository.changes.set(&self.store, payload.changes); - self.repository - .operation_log - .set(&self.store, payload.operation_log); - self.repository - .file_changes - .set(&self.store, file_changes.clone()); - self.repository.publish_plan.set(&self.store, None); - self.workspace - .status_file_changes - .set(&self.store, file_changes); - - // Tear down a repo-open progress panel. Compare-subject progress - // survives — a kickoff_compare may be queued below and will - // replace it atomically via its own seeding path. - self.compare_progress.update(&self.store, |slot| { - if let Some(p) = slot.as_ref() - && matches!(p.subject, LoadingSubject::RepoOpen { .. }) - { - *slot = None; - } - }); - - match payload.reason { - RepositorySyncReason::Open => { - if let Some(ref store) = self.frecency { - store.record_access(&format!("repo:{}", payload.path.display())); - } - let mut effects = self.persist_settings_effect(); - if let Some(url) = self.startup.pending_pr_url.clone() { - self.startup.pending_pr_url = None; - self.startup.auto_compare_pending = false; - self.github - .pull_request - .status - .set(&self.store, AsyncStatus::Loading); - if let Some(parsed) = crate::core::forge::github::parse_pr_url(&url) { - let key: PrKey = (parsed.owner, parsed.repo, parsed.number); - self.github.pull_request.cache.update(&self.store, |c| { - c.entry(key.clone()).or_insert_with(|| PrCacheEntry { - meta: PrPeekMeta::Loading, - diff: PrPeekDiff::Loading, - last_peek_ms: 0, - }); - }); - self.github - .pull_request - .pending_confirm - .set(&self.store, Some(key)); - } - effects.push( - GitHubEffect::LoadPullRequest { - url, - repo_path: payload.path, - github_token: self.github_access_token.clone(), - } - .into(), - ); - } else if self.startup.auto_compare_pending { - self.startup.auto_compare_pending = false; - effects.extend(self.kickoff_compare()); - } else if self.startup.bootstrap_compare_started { - self.startup.bootstrap_compare_started = false; - } else if let Some(persisted) = self.settings.last_compare.as_ref().filter(|c| { - c.repo_path.as_ref() == Some(&payload.path) - && compare_refs_are_valid(c.mode, &c.left_ref, &c.right_ref) - }) { - self.compare - .left_ref - .set(&self.store, persisted.left_ref.clone()); - self.compare - .right_ref - .set(&self.store, persisted.right_ref.clone()); - self.compare.mode.set(&self.store, persisted.mode); - effects.extend(self.kickoff_compare()); - } else { - let profile = crate::ui::vcs::profile(Some(&payload.location)); - let (left, right, mode) = profile.default_compare(); - self.compare.left_ref.set(&self.store, left.to_owned()); - self.compare.right_ref.set(&self.store, right.to_owned()); - self.compare.mode.set(&self.store, mode); - effects.extend(self.activate_status_view(true)); - } - effects - } - RepositorySyncReason::Dirty | RepositorySyncReason::Rescan => { - if self.workspace.source.get(&self.store) == WorkspaceSource::Status { - return self.activate_status_view(false); - } - - let (mode, left_ref, right_ref) = ( - self.compare.mode.get(&self.store), - self.compare.left_ref.get(&self.store), - self.compare.right_ref.get(&self.store), - ); - if !compare_refs_are_valid(mode, &left_ref, &right_ref) { - return Vec::new(); - } - - match payload.change_kind { - Some(RepositoryChangeKind::Metadata | RepositoryChangeKind::Both) => { - self.kickoff_compare() - } - Some(RepositoryChangeKind::Worktree) - if self.vcs_ui_profile().is_working_copy_ref(&right_ref) => - { - self.kickoff_compare() - } - _ => Vec::new(), - } - } - } - } - - fn expand_context( - &mut self, - hunk_index: usize, - direction: crate::editor::diff::expansion::ExpandDirection, - amount: u32, - ) -> Vec { - use crate::editor::diff::expansion::ExpandDirection; - use crate::events::ContextDirection; - - if amount == 0 { - return Vec::new(); - } - - let ctx_direction = match direction { - ExpandDirection::Above => ContextDirection::Above, - ExpandDirection::Below => ContextDirection::Below, - }; - self.dispatch_context_expansion(hunk_index, ctx_direction, amount) - } - - fn expand_all_context(&mut self) -> Vec { - use crate::events::ContextDirection; - self.dispatch_context_expansion(0, ContextDirection::All, 0) - } - - fn dispatch_context_expansion( - &mut self, - hunk_index: usize, - direction: crate::events::ContextDirection, - amount: u32, - ) -> Vec { - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - - let generation = self.workspace.compare_generation.get(&self.store); - let Some(( - file_index, - path, - old_reference, - new_reference, - cached_old_lines, - cached_new_lines, - )) = self.workspace.active_file.with(&self.store, |af| { - let active = af.as_ref()?; - if active.carbon_file.hunks.is_empty() { - return None; - } - Some(( - active.index, - active.path.clone(), - active.left_ref.clone(), - if active.right_ref.is_empty() { - active.left_ref.clone() - } else { - active.right_ref.clone() - }, - active.old_file_lines.clone(), - active.file_lines.clone(), - )) - }) - else { - return Vec::new(); - }; - - if let (Some(old_lines), Some(new_lines)) = (cached_old_lines, cached_new_lines) { - self.apply_context_expansion(direction, hunk_index, amount, old_lines, new_lines); - let mut effects = vec![self.invalidate_syntax_epoch_effect()]; - effects.extend(self.request_active_file_syntax_effect()); - return effects; - } - - vec![ - RepositoryEffect::FetchContextLines(crate::effects::FetchContextLinesRequest { - repo_path, - old_reference, - new_reference, - path, - generation, - file_index, - hunk_index, - direction, - amount, - }) - .into(), - ] - } - - fn handle_context_lines_ready( - &mut self, - payload: crate::events::ContextLinesReady, - ) -> Vec { - if payload.generation != self.workspace.compare_generation.get(&self.store) { - return Vec::new(); - } - - let matches_active = self.workspace.active_file.with(&self.store, |af| { - af.as_ref() - .is_some_and(|a| a.index == payload.file_index && a.path == payload.path) - }); - if !matches_active { - return Vec::new(); - } - - let old_lines = Arc::new(payload.old_lines); - let new_lines = Arc::new(payload.new_lines); - self.apply_context_expansion( - payload.direction, - payload.hunk_index, - payload.amount, - old_lines, - new_lines, - ); - let mut effects = vec![self.invalidate_syntax_epoch_effect()]; - effects.extend(self.request_active_file_syntax_effect()); - effects - } - - fn apply_context_expansion( - &mut self, - direction: crate::events::ContextDirection, - hunk_index: usize, - amount: u32, - old_lines: Arc>, - new_lines: Arc>, - ) { - use crate::events::ContextDirection; - - let Some(( - active_index, - active_path, - mut carbon_file, - mut expansion, - mut carbon_overlays, - mut token_buffer, - )) = self.workspace.active_file.with(&self.store, |af| { - af.as_ref().map(|a| { - ( - a.index, - a.path.clone(), - (*a.carbon_file).clone(), - a.carbon_expansion.clone(), - a.carbon_overlays.clone(), - a.token_buffer.clone(), - ) - }) - }) - else { - return; - }; - - hydrate_carbon_full_text(&mut carbon_file, &old_lines, &new_lines); - match direction { - ContextDirection::Above => { - carbon::expand_context( - &carbon_file, - &mut expansion, - carbon::HunkId(hunk_index as u32), - carbon::ExpansionDirection::Above, - amount, - ); - } - ContextDirection::Below => { - carbon::expand_context( - &carbon_file, - &mut expansion, - carbon::HunkId(hunk_index as u32), - carbon::ExpansionDirection::Below, - amount, - ); - } - ContextDirection::All => { - let hunk_ids = carbon_file - .hunks - .iter() - .map(|hunk| hunk.id) - .collect::>(); - for hunk_id in hunk_ids { - let caps = carbon::expansion_caps(&carbon_file, hunk_id); - carbon::expand_context( - &carbon_file, - &mut expansion, - hunk_id, - carbon::ExpansionDirection::Above, - caps.above, - ); - carbon::expand_context( - &carbon_file, - &mut expansion, - hunk_id, - carbon::ExpansionDirection::Below, - caps.below, - ); - } - } - } - self.workspace.expansions.update(&self.store, |map| { - map.insert(active_path.clone(), expansion.clone()); - }); - - let preserve_change_tokens = carbon_overlays.has_change_tokens(); - carbon_overlays.clear_syntax(); - if !preserve_change_tokens { - token_buffer.clear(); - } - let render_doc = build_render_doc_from_carbon( - &carbon_file, - active_index, - &expansion, - &carbon_overlays, - &token_buffer, - ); - let total_lines = new_lines.len() as u32; - - let preserved_scroll = self.editor.scroll_top_px.get(&self.store); - - self.workspace.active_file.update(&self.store, |af| { - if let Some(active) = af.as_mut() { - active.carbon_file = Arc::new(carbon_file); - active.carbon_expansion = expansion; - active.carbon_overlays = carbon_overlays; - active.token_buffer = token_buffer; - active.render_doc = Arc::new(render_doc); - active.file_line_count = Some(total_lines); - active.old_file_lines = Some(old_lines); - active.file_lines = Some(new_lines); - active.syntax_pending.clear(); - active.syntax_covered.clear(); - } - }); - self.editor_clear_document(); - self.editor.scroll_top_px.set(&self.store, preserved_scroll); - } - - #[profiling::function] - fn handle_compare_finished(&mut self, payload: CompareFinished) -> Vec { - if payload.generation != self.workspace.compare_generation.get(&self.store) { - return Vec::new(); - } - - let history_left = payload.resolved_left.clone(); - let history_right = self - .vcs_ui_profile() - .history_right_ref(&payload.resolved_right); - self.workspace - .status_operation_pending - .set(&self.store, false); - self.workspace - .source - .set(&self.store, WorkspaceSource::Compare); - self.workspace.status.set(&self.store, AsyncStatus::Ready); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); - self.compare.layout.set(&self.store, payload.request.layout); - self.compare - .renderer - .set(&self.store, payload.request.renderer); - self.compare - .resolved_left - .set(&self.store, Some(payload.resolved_left)); - self.compare - .resolved_right - .set(&self.store, Some(payload.resolved_right)); - self.workspace - .raw_diff_len - .set(&self.store, payload.output.raw_diff_len); - self.workspace - .used_fallback - .set(&self.store, payload.output.used_fallback); - self.workspace - .fallback_message - .set(&self.store, payload.output.fallback_message.clone()); - let total_files = payload.output.file_count() as u32; - let stats_snapshot = compare_output_stats_snapshot(&payload.output); - let has_deferred_stats = stats_snapshot.deferred_count > 0; - let eager_total_stats = (!has_deferred_stats).then_some(stats_snapshot.hydrated_total); - self.workspace - .compare_output - .set(&self.store, Some(payload.output)); - self.workspace.files.set(&self.store, Vec::new()); - self.workspace - .compare_total_stats - .set(&self.store, eager_total_stats); - self.workspace.compare_hydrated_stats.set( - &self.store, - has_deferred_stats.then_some(stats_snapshot.hydrated_total), - ); - self.workspace - .compare_deferred_stats_remaining - .set(&self.store, Some(stats_snapshot.deferred_count)); - self.workspace - .compare_deferred_stats_cursor - .set(&self.store, 0); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); - self.workspace.active_file_loading.set(&self.store, None); - self.workspace.sidebar_auto_width.set(&self.store, None); - self.clear_file_cache(); - self.reset_file_scroll_layout(); - self.workspace.global_scroll_top_px.set(&self.store, 0); - // Record the discovered file count + advance the phase. The progress - // panel stays up until the first file finishes mounting (or, for - // small-file fast paths, is cleared by install_compare_active_file). - self.compare_progress.update(&self.store, |slot| { - if let Some(p) = slot.as_mut() { - p.file_count_total = Some(total_files); - p.phase = ComparePhase::PopulatingList; - } - }); - if self - .workspace - .pre_drill_compare - .with(&self.store, |p| p.is_none()) - { - self.workspace - .range_commits - .set(&self.store, payload.range_commits); - } - self.file_list.scroll_offset_px.set(&self.store, 0.0); - self.file_list - .commits_scroll_offset_px - .set(&self.store, 0.0); - self.set_focus(Some(FocusTarget::FileList)); - self.editor_clear_document(); - self.clear_overlays(); - - let preferred_index = self - .startup - .preferred_file_index - .or(self.workspace.selected_file_index.get(&self.store)); - let preferred_path = self - .startup - .preferred_file_path - .clone() - .or_else(|| self.workspace.selected_file_path.get(&self.store)); - - let file_count = self.workspace_file_count(); - let index_for_path = preferred_path - .as_deref() - .and_then(|path| self.workspace_file_index_for_path(path)); - - let mut effects = Vec::new(); - let mut selected_syntax_paths = Vec::new(); - let should_load_history = self - .workspace - .pre_drill_compare - .with(&self.store, |p| p.is_none()); - let history_effect = should_load_history - .then(|| self.compare_history_request(history_left, history_right)) - .flatten() - .and_then(|request| { - if has_deferred_stats { - self.workspace - .compare_history_pending - .set(&self.store, Some(request)); - None - } else { - Some(self.compare_history_effect(request)) - } - }); - if let Some(index) = index_for_path - .or(preferred_index.filter(|index| *index < file_count)) - .or_else(|| (file_count > 0).then_some(0)) - { - if let Some(path) = self.workspace_file_path_at(index) { - selected_syntax_paths.push(path); - } - effects.extend(self.select_file(index, true)); - if let Some(effect) = self.start_compare_stats_hydration_if_idle() { - effects.push(effect); - } - if let Some(effect) = self.start_compare_total_stats_if_needed() { - effects.push(effect); - } - } else { - self.workspace.selected_file_index.set(&self.store, None); - self.workspace.selected_file_path.set(&self.store, None); - self.workspace.selected_change_bucket.set(&self.store, None); - self.workspace.active_file.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - // No files to select — the compare succeeded but has no diffs. - // Tear down the progress panel; the "repo ready" hint takes over. - self.compare_progress.set(&self.store, None); - self.editor_clear_document(); - } - if let Some(effect) = self.syntax_pack_warmup_effect_for_compare(&selected_syntax_paths) { - effects.insert(0, effect); - } - if let Some(effect) = history_effect { - effects.push(effect); - } - - let (used_fallback, fallback_message) = ( - self.workspace.used_fallback.get(&self.store), - self.workspace.fallback_message.get(&self.store), - ); - if used_fallback && !fallback_message.is_empty() { - self.push_info(&fallback_message); - } - effects - } - - fn handle_compare_history_ready(&mut self, payload: CompareHistoryReady) -> Vec { - if payload.generation != self.workspace.compare_generation.get(&self.store) { - return Vec::new(); - } - if self - .workspace - .pre_drill_compare - .with(&self.store, |p| p.is_some()) - { - return Vec::new(); - } - self.workspace - .range_commits - .set(&self.store, payload.range_commits); - Vec::new() - } - - fn handle_status_diff_finished(&mut self, payload: StatusDiffFinished) -> Vec { - let current_gen = self.workspace.status_generation.get(&self.store); - tracing::debug!( - payload_gen = payload.generation, - current_gen, - payload_index = payload.index, - payload_path = %payload.file_change.path, - payload_bucket = ?payload.file_change.bucket, - "handle_status_diff_finished: entered" - ); - if payload.generation != current_gen { - tracing::debug!( - "handle_status_diff_finished: generation mismatch, discarding (pending NOT cleared)" - ); - return Vec::new(); - } - let matches = self.repository.file_changes.with(&self.store, |changes| { - match changes.get(payload.index) { - Some(current) => current == &payload.file_change, - None => false, - } - }); - if !matches { - let current_change_at_idx = self.repository.file_changes.with(&self.store, |changes| { - changes - .get(payload.index) - .map(|change| format!("{}:{:?}", change.path, change.bucket)) - .unwrap_or_else(|| "".to_owned()) - }); - tracing::debug!( - current_change_at_idx, - "handle_status_diff_finished: file change mismatch, discarding (pending NOT cleared)" - ); - return Vec::new(); - } - let matches_selection = self.workspace.selected_file_index.get(&self.store) - == Some(payload.index) - && self - .workspace - .selected_file_path - .get(&self.store) - .as_deref() - == Some(payload.file_change.path.as_str()) - && self.workspace.selected_change_bucket.get(&self.store) - == Some(payload.file_change.bucket); - let output = payload.output; - - let Some(carbon_file) = output.carbon.files.first() else { - self.clear_file_cache_loading(payload.index); - if matches_selection { - self.workspace.active_file.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - self.editor_clear_document(); - } - return Vec::new(); - }; - let prepared = prepare_active_file(payload.index, carbon_file); - let bucket = payload.file_change.bucket; - let (left_ref, right_ref) = self.status_refs_for_bucket(bucket); - let active_file = self.build_active_file( - payload.index, - payload.file_change.path.clone(), - prepared, - left_ref, - right_ref, - ); - let active_file = self.cache_active_file(active_file); - if !matches_selection { - return Vec::new(); - } - - tracing::debug!("handle_status_diff_finished: clearing status_operation_pending"); - self.workspace - .source - .set(&self.store, WorkspaceSource::Status); - self.workspace - .status_operation_pending - .set(&self.store, false); - self.workspace.status.set(&self.store, AsyncStatus::Ready); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); - self.workspace - .used_fallback - .set(&self.store, output.used_fallback); - self.workspace - .fallback_message - .set(&self.store, output.fallback_message.clone()); - self.workspace - .raw_diff_len - .set(&self.store, output.raw_diff_len); - self.workspace.compare_output.set(&self.store, None); - self.workspace.compare_total_stats.set(&self.store, None); - self.workspace.compare_hydrated_stats.set(&self.store, None); - self.workspace - .compare_deferred_stats_remaining - .set(&self.store, None); - self.workspace - .compare_deferred_stats_cursor - .set(&self.store, 0); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); - self.workspace.active_file_loading.set(&self.store, None); - - self.workspace - .selected_file_index - .set(&self.store, Some(payload.index)); - self.workspace - .selected_file_path - .set(&self.store, Some(payload.file_change.path.clone())); - self.workspace - .selected_change_bucket - .set(&self.store, Some(bucket)); - // Preserve scroll/hover/positional editor state when refreshing the - // same file (e.g. after staging a hunk). Only reset when the path - // changed (navigating to a different file). - let same_file = self.workspace.active_file.with(&self.store, |af| { - af.as_ref().is_some_and(|a| { - a.path == payload.file_change.path - && a.left_ref == active_file.left_ref - && a.right_ref == active_file.right_ref - }) - }); - self.workspace - .active_file - .set(&self.store, Some(active_file)); - if !same_file { - self.editor_clear_document(); - self.editor - .line_selection - .update(&self.store, |ls| ls.clear()); - } - if self.editor.search.open.get(&self.store) { - self.recompute_search_matches(); - } - let mut effects = self.sync_editor_scroll_from_global(); - effects.extend(self.request_active_file_syntax_effect()); - effects - } - - #[profiling::function] - fn handle_compare_file_finished(&mut self, payload: CompareFileFinished) -> Vec { - if payload.generation != self.workspace.compare_generation.get(&self.store) { - return Vec::new(); - } - - let matches_selected = self - .workspace - .selected_file_path - .get(&self.store) - .as_deref() - == Some(payload.path.as_str()); - let matches_loading = self - .workspace - .active_file_loading - .with(&self.store, |loading| { - loading.as_ref().is_some_and(|loading| { - loading.index == payload.index && loading.path == payload.path - }) - }); - let matches_cache_loading = - self.workspace - .file_cache_loading - .with(&self.store, |loading| { - loading - .get(&payload.index) - .is_some_and(|loading| loading.path == payload.path) - }); - if !matches_selected && !matches_cache_loading { - return Vec::new(); - } - - if matches_selected && matches_loading { - self.install_compare_active_file(payload.index, payload.path, payload.prepared); - } else { - let left_ref = self - .compare - .resolved_left - .get(&self.store) - .unwrap_or_else(|| self.compare.left_ref.get(&self.store)); - let right_ref = self - .compare - .resolved_right - .get(&self.store) - .unwrap_or_else(|| self.compare.right_ref.get(&self.store)); - let active_file = self.build_active_file( - payload.index, - payload.path, - payload.prepared, - left_ref, - right_ref, - ); - self.cache_active_file(active_file); - } - let mut effects = self.sync_editor_scroll_from_global(); - if matches_selected { - effects.extend(self.request_active_file_syntax_effect()); - } - if let Some(effect) = self.start_compare_stats_hydration_if_idle() { - effects.push(effect); - } else if let Some(effect) = self.start_compare_total_stats_if_needed() { - effects.push(effect); - } - effects - } - - fn handle_compare_stats_ready(&mut self, payload: CompareStatsReady) -> Vec { - if payload.generation != self.workspace.compare_generation.get(&self.store) { - return Vec::new(); - } - - self.workspace - .compare_total_stats - .set(&self.store, Some((payload.additions, payload.deletions))); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - let mut effects = Vec::new(); - if let Some(effect) = self.start_compare_stats_hydration_if_idle() { - let is_background_stats = matches!( - &effect, - Effect::Compare(CompareEffect::LoadFileStats(task)) - if task.request.priority == CompareWorkPriority::Warmup - ); - effects.push(effect); - if is_background_stats && let Some(effect) = self.take_pending_compare_history_effect() - { - effects.push(effect); - } - } else if !self.compare_stats_hydration_running() - && let Some(effect) = self.take_pending_compare_history_effect() - { - effects.push(effect); - } - effects - } - - fn handle_compare_file_stats_ready(&mut self, payload: CompareFileStatsReady) -> Vec { - if payload.generation != self.workspace.compare_generation.get(&self.store) { - return Vec::new(); - } - - self.apply_compare_file_stats(&payload.stats); - let mut effects = self.sync_editor_scroll_from_global(); - if !payload.request_complete { - return effects; - } - if let Some(effect) = self.next_compare_stats_hydration_effect() { - effects.push(effect); - effects - } else { - self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); - let history_effect = self.take_pending_compare_history_effect(); - if let Some(stats) = self.exact_compare_total_stats_if_ready() { - if !self.workspace.compare_total_stats_loading.get(&self.store) { - self.workspace - .compare_total_stats - .set(&self.store, Some(stats)); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - } - if let Some(effect) = history_effect { - effects.push(effect); - } - return effects; - } - if let Some(effect) = self.start_compare_total_stats_if_needed() { - effects.push(effect); - } - if let Some(effect) = history_effect { - effects.push(effect); - } - effects - } - } - - fn compare_stats_hydration_running(&self) -> bool { - self.workspace.compare_stats_hydration.get(&self.store) - == CompareStatsHydrationState::Running - } - - fn compare_stats_hydration_failed(&self) -> bool { - self.workspace.compare_stats_hydration.get(&self.store) - == CompareStatsHydrationState::Failed - } - - fn set_compare_stats_hydration(&self, state: CompareStatsHydrationState) { - self.workspace - .compare_stats_hydration - .set(&self.store, state); - } - - fn start_compare_stats_hydration_if_idle(&mut self) -> Option { - if self.compare_stats_hydration_running() || self.compare_stats_hydration_failed() { - return None; - } - - let effect = self.next_compare_stats_hydration_effect()?; - self.set_compare_stats_hydration(CompareStatsHydrationState::Running); - Some(effect) - } - - fn start_visible_compare_stats_hydration(&mut self) -> Option { - if self.compare_stats_hydration_failed() { - return None; - } - let prioritize_visible = self - .workspace - .compare_output - .with(&self.store, |maybe_output| { - maybe_output.as_ref().is_some_and(|output| { - output.file_count() > COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT - }) - }); - if !prioritize_visible { - return self.start_compare_stats_hydration_if_idle(); - } - let visible_files = self.visible_compare_stats_hydration_items(); - if visible_files.is_empty() { - return self.start_compare_stats_hydration_if_idle(); - } - let effect = self.compare_file_stats_hydration_effect( - visible_files, - CompareWorkPriority::VisibleSidebarStats, - )?; - self.set_compare_stats_hydration(CompareStatsHydrationState::Running); - Some(effect) - } - - fn start_compare_total_stats_if_needed(&mut self) -> Option { - if self - .workspace - .compare_total_stats - .get(&self.store) - .is_some() - || self.workspace.compare_total_stats_loading.get(&self.store) - { - return None; - } - let repo_path = self.compare.repo_path.get(&self.store)?; - self.workspace - .compare_total_stats_loading - .set(&self.store, true); - - Some( - CompareEffect::LoadStats(Task { - generation: self.workspace.compare_generation.get(&self.store), - request: CompareStatsRequest { - repo_path, - request: vcs_compare_request( - self.compare.mode.get(&self.store), - self.compare.left_ref.get(&self.store), - self.compare.right_ref.get(&self.store), - self.compare.layout.get(&self.store), - self.compare.renderer.get(&self.store), - ), - priority: CompareWorkPriority::TotalStats, - }, - }) - .into(), - ) - } - - fn next_compare_stats_hydration_effect(&self) -> Option { - let prioritize_visible = self - .workspace - .compare_output - .with(&self.store, |maybe_output| { - maybe_output.as_ref().is_some_and(|output| { - output.file_count() > COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT - }) - }); - let (files, priority) = if prioritize_visible { - let visible_files = self.visible_compare_stats_hydration_items(); - if !visible_files.is_empty() { - (visible_files, CompareWorkPriority::VisibleSidebarStats) - } else { - ( - self.next_deferred_compare_stats_items(COMPARE_STATS_BACKGROUND_CHUNK_SIZE), - CompareWorkPriority::Warmup, - ) - } - } else { - ( - self.next_deferred_compare_stats_items(COMPARE_STATS_BACKGROUND_CHUNK_SIZE), - CompareWorkPriority::Warmup, - ) - }; - if files.is_empty() { - return None; - } - - self.compare_file_stats_hydration_effect(files, priority) - } - - fn compare_file_stats_hydration_effect( - &self, - files: Vec, - priority: CompareWorkPriority, - ) -> Option { - if files.is_empty() { - return None; - } - let repo_path = self.compare.repo_path.get(&self.store)?; - Some( - CompareEffect::LoadFileStats(Task { - generation: self.workspace.compare_generation.get(&self.store), - request: CompareFileStatsRequest { - repo_path, - request: vcs_compare_request( - self.compare.mode.get(&self.store), - self.compare.left_ref.get(&self.store), - self.compare.right_ref.get(&self.store), - self.compare.layout.get(&self.store), - self.compare.renderer.get(&self.store), - ), - files, - priority, - }, - }) - .into(), - ) - } - - fn compare_history_request( - &self, - left_ref: String, - right_ref: String, - ) -> Option { - Some(CompareHistoryRequest { - repo_path: self.compare.repo_path.get(&self.store)?, - left_ref, - right_ref, - }) - } - - fn compare_history_effect(&self, request: CompareHistoryRequest) -> Effect { - CompareEffect::LoadHistory(Task { - generation: self.workspace.compare_generation.get(&self.store), - request, - }) - .into() - } - - fn take_pending_compare_history_effect(&mut self) -> Option { - if self - .workspace - .pre_drill_compare - .with(&self.store, |p| p.is_some()) - { - self.workspace - .compare_history_pending - .set(&self.store, None); - return None; - } - let pending = self.workspace.compare_history_pending.get(&self.store)?; - self.workspace - .compare_history_pending - .set(&self.store, None); - Some(self.compare_history_effect(pending)) - } - - fn next_deferred_compare_stats_items(&self, limit: usize) -> Vec { - if limit == 0 - || self - .workspace - .compare_deferred_stats_remaining - .get(&self.store) - == Some(0) - { - return Vec::new(); - } - - let cursor = self - .workspace - .compare_deferred_stats_cursor - .get(&self.store); - let (items, next_cursor) = - self.workspace - .compare_output - .with(&self.store, |maybe_output| { - let Some(output) = maybe_output.as_ref() else { - return (Vec::new(), None); - }; - let file_count = output.file_count(); - if file_count == 0 { - return (Vec::new(), None); - } - let mut items = Vec::new(); - let mut index = cursor.min(file_count - 1); - let mut scanned = 0_usize; - while scanned < file_count && items.len() < limit { - if let Some(target) = output.deferred_stats_target_at(index) { - items.push(CompareFileStatsItem { index, target }); - } - index = if index + 1 == file_count { - 0 - } else { - index + 1 - }; - scanned += 1; - } - (items, Some(index)) - }); - if let Some(next_cursor) = next_cursor { - self.workspace - .compare_deferred_stats_cursor - .set(&self.store, next_cursor); - } - items - } - - fn visible_compare_stats_hydration_items(&self) -> Vec { - if self.workspace.source.get(&self.store) != WorkspaceSource::Compare - || self.file_list.tab.get(&self.store) != SidebarTab::Files - { - return Vec::new(); - } - - let stride = self.file_list_row_stride(); - if stride <= 0.0 { - return Vec::new(); - } - let scroll_px = self.file_list.scroll_offset_px.get(&self.store); - let viewport_px = self.file_list.viewport_height.get(&self.store); - let first = (scroll_px / stride).floor().max(0.0) as usize; - let visible = (viewport_px / stride).ceil().max(1.0) as usize; - let start = first.saturating_sub(COMPARE_STATS_VISIBLE_OVERSCAN_ROWS); - let end = first - .saturating_add(visible) - .saturating_add(COMPARE_STATS_VISIBLE_OVERSCAN_ROWS); - - let filter = self - .file_list - .filter - .with(&self.store, |filter| filter.clone()); - if !filter.is_empty() { - let filtered_indices = self.workspace_file_filter_matches(&filter); - let end = end.min(filtered_indices.len()); - if start >= end { - return Vec::new(); - } - return self.compare_stats_hydration_items_for_indices( - filtered_indices[start..end].iter().copied(), - ); - } - - if self.file_list.mode.get(&self.store) == SidebarMode::TreeView { - let expanded_folders = self.file_list.expanded_folders.get(&self.store); - let tree_indices = crate::ui::components::file_tree_visible_file_indices_by( - |visit| { - self.for_each_workspace_file_path(|index, path| visit(index, path)); - }, - &expanded_folders, - start..end, - ); - return self.compare_stats_hydration_items_for_indices(tree_indices); - } - - let end = end.min(self.workspace_file_count()); - if start >= end { - return Vec::new(); - } - self.compare_stats_hydration_items_for_indices(start..end) - } - - fn compare_stats_hydration_items_for_indices( - &self, - indices: impl IntoIterator, - ) -> Vec { - self.workspace - .compare_output - .with(&self.store, |maybe_output| { - let Some(output) = maybe_output.as_ref() else { - return Vec::new(); - }; - let mut items = Vec::new(); - for index in indices { - if items.len() >= COMPARE_STATS_CHUNK_SIZE { - break; - } - if let Some(target) = output.deferred_stats_target_at(index) { - items.push(CompareFileStatsItem { index, target }); - } - } - items - }) - } - - fn exact_compare_total_stats_if_ready(&self) -> Option<(i32, i32)> { - if let Some(remaining) = self - .workspace - .compare_deferred_stats_remaining - .get(&self.store) - { - if remaining > 0 { - return None; - } - if let Some(total) = self.workspace.compare_hydrated_stats.get(&self.store) { - return Some(total); - } - } - - let ready = self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .is_some_and(|output| !compare_output_has_deferred_stats(output)) - }); - if !ready { - return None; - } - self.workspace.compare_output.with(&self.store, |output| { - let output = output.as_ref()?; - let mut total = (0_i32, 0_i32); - output.for_each_summary(|_, summary| { - let stats = summary.fallback_stats(); - total = ( - total.0.saturating_add(stats.0), - total.1.saturating_add(stats.1), - ); - }); - Some(total) - }) - } - - fn apply_compare_file_stats(&mut self, stats: &[CompareFileStat]) { - if stats.is_empty() { - return; - } - - let old_scroll_heights = stats - .iter() - .map(|stat| (stat.index, self.file_scroll_height_px(stat.index))) - .collect::>(); - - let mut stats_delta = (0_i32, 0_i32); - let mut newly_hydrated = 0_usize; - self.workspace - .compare_output - .update(&self.store, |maybe_output| { - let Some(output) = maybe_output.as_mut() else { - return; - }; - for stat in stats { - let additions = i32_to_u32_nonnegative(stat.additions); - let deletions = i32_to_u32_nonnegative(stat.deletions); - - if !output.file_summaries.is_empty() { - let Some(summary) = output.file_summaries.get_mut(stat.index) else { - continue; - }; - if summary.path() != stat.path { - continue; - } - let old_stats = summary.fallback_stats(); - let was_deferred = summary.stats_deferred; - summary.additions = additions; - summary.deletions = deletions; - summary.stats_deferred = false; - stats_delta = ( - stats_delta - .0 - .saturating_add(stat.additions.saturating_sub(old_stats.0)), - stats_delta - .1 - .saturating_add(stat.deletions.saturating_sub(old_stats.1)), - ); - newly_hydrated = newly_hydrated.saturating_add(was_deferred as usize); - continue; - } - - if let Some(file) = output.carbon.files.get_mut(stat.index) - && file.path() == stat.path - { - let old_stats = carbon_file_stats(file); - let was_deferred = file.stats_deferred; - file.additions = additions; - file.deletions = deletions; - file.stats_deferred = false; - stats_delta = ( - stats_delta - .0 - .saturating_add(stat.additions.saturating_sub(old_stats.0)), - stats_delta - .1 - .saturating_add(stat.deletions.saturating_sub(old_stats.1)), - ); - newly_hydrated = newly_hydrated.saturating_add(was_deferred as usize); - } - } - }); - - if stats_delta != (0, 0) { - self.workspace - .compare_hydrated_stats - .update(&self.store, |total| { - let current = total.get_or_insert((0, 0)); - *current = ( - current.0.saturating_add(stats_delta.0), - current.1.saturating_add(stats_delta.1), - ); - }); - } - if newly_hydrated > 0 { - self.workspace - .compare_deferred_stats_remaining - .update(&self.store, |remaining| { - if let Some(count) = remaining.as_mut() { - *count = count.saturating_sub(newly_hydrated); - } - }); - } - - let mut rebuilt_viewport_doc = false; - self.workspace.active_file.update(&self.store, |slot| { - let Some(active) = slot.as_mut() else { - return; - }; - for stat in stats { - if apply_compare_stat_to_active_file(active, stat) { - rebuilt_viewport_doc = true; - break; - } - } - }); - self.workspace.file_cache.update(&self.store, |files| { - for active in files.values_mut() { - for stat in stats { - if apply_compare_stat_to_active_file(active, stat) { - rebuilt_viewport_doc = true; - break; - } - } - } - }); - if rebuilt_viewport_doc { - self.viewport_document_cache = None; - } - - let dragging_scrollbar = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| drag.is_some()); - if dragging_scrollbar { - self.workspace - .file_scroll_recompute_pending - .set(&self.store, true); - } else { - self.update_file_scroll_heights(old_scroll_heights); - if self.settings.continuous_scroll { - self.clamp_global_scroll_top_px(); - } - } - } - - fn handle_file_syntax_ready(&mut self, payload: FileSyntaxReady) -> Vec { - self.finish_syntax_request(payload.generation, payload.request_id); - if payload.generation != self.active_syntax_generation() { - return Vec::new(); - } - - let mut applied_file = None; - let mut applied_active = false; - let mut matched_active = false; - self.workspace.active_file.update(&self.store, |slot| { - let Some(active) = slot.as_mut() else { - return; - }; - if active.index != payload.file_index || active.path != payload.path { - return; - } - matched_active = true; - - if !remove_pending_syntax_window( - &mut active.syntax_pending, - payload.request_id, - payload.window, - ) { - return; - } - if active - .syntax_covered - .iter() - .any(|covered| covered.contains(payload.window)) - { - return; - } - push_syntax_covered_window(&mut active.syntax_covered, payload.window); - apply_syntax_tokens_to_file( - &mut active.carbon_overlays, - &mut active.token_buffer, - &payload.tokens, - ); - active.render_doc = Arc::new(build_render_doc_from_carbon( - &active.carbon_file, - active.index, - &active.carbon_expansion, - &active.carbon_overlays, - &active.token_buffer, - )); - applied_file = Some(active.clone()); - applied_active = true; - }); - if matched_active && applied_file.is_none() { - tracing::debug!( - file_index = payload.file_index, - path = %payload.path, - request_id = payload.request_id, - "stale active syntax response dropped" - ); - return Vec::new(); - } - - if applied_file.is_none() { - self.workspace.file_cache.update(&self.store, |files| { - let Some(active) = files.get_mut(&payload.file_index) else { - return; - }; - if active.index != payload.file_index || active.path != payload.path { - return; - } - - if !remove_pending_syntax_window( - &mut active.syntax_pending, - payload.request_id, - payload.window, - ) { - return; - } - if active - .syntax_covered - .iter() - .any(|covered| covered.contains(payload.window)) - { - return; - } - push_syntax_covered_window(&mut active.syntax_covered, payload.window); - apply_syntax_tokens_to_file( - &mut active.carbon_overlays, - &mut active.token_buffer, - &payload.tokens, - ); - active.render_doc = Arc::new(build_render_doc_from_carbon( - &active.carbon_file, - active.index, - &active.carbon_expansion, - &active.carbon_overlays, - &active.token_buffer, - )); - applied_file = Some(active.clone()); - }); - } - - let Some(active_file) = applied_file else { - return Vec::new(); - }; - self.cache_active_file(active_file); - self.viewport_document_cache = None; - - if applied_active { - self.request_active_file_syntax_effect() - .into_iter() - .collect() - } else { - Vec::new() - } - } - - fn handle_syntax_pack_install_started(&mut self, language: &str) { - self.syntax_pack_installs.update(&self.store, |active| { - if !active.iter().any(|item| item == language) { - active.push(language.to_owned()); - } - }); - } - - fn handle_syntax_pack_install_finished(&mut self, language: &str) { - self.syntax_pack_installs - .update(&self.store, |active| active.retain(|item| item != language)); - } - - pub fn syntax_pack_install_active(&self) -> bool { - self.syntax_pack_installs - .with(&self.store, |active| !active.is_empty()) - } - - fn syntax_pack_warmup_effect_for_compare(&self, exclude_paths: &[String]) -> Option { - let highlighter = phosphor::Highlighter::new(); - let excluded_languages = exclude_paths - .iter() - .filter_map(|path| highlighter.guess_language(Path::new(path))) - .collect::>(); - let active_languages = self.syntax_pack_installs.with(&self.store, |active| { - active.iter().cloned().collect::>() - }); - - self.workspace.compare_output.with(&self.store, |output| { - let output = output.as_ref()?; - let mut seen = HashSet::new(); - let mut warmup_paths = Vec::new(); - output.for_each_summary(|_, summary| { - for path in [summary.paths.old_path(), summary.paths.new_path()] - .into_iter() - .flatten() - { - let Some(language) = highlighter.guess_language(Path::new(path.as_ref())) - else { - continue; - }; - if excluded_languages.contains(&language) - || active_languages.contains(language.name()) - || highlighter.is_parser_available(language) - { - continue; - } - if seen.insert(language) { - warmup_paths.push(path.into_owned()); - } - } - }); - - (!warmup_paths.is_empty()).then_some( - SyntaxEffect::EnsureSyntaxPacksForPaths { - paths: warmup_paths, - } - .into(), - ) - }) - } - - fn syntax_pack_warmup_effect_for_paths( - &self, - paths: &[String], - exclude_paths: &[String], - ) -> Option { - let highlighter = phosphor::Highlighter::new(); - let excluded_languages = exclude_paths - .iter() - .filter_map(|path| highlighter.guess_language(Path::new(path))) - .collect::>(); - let active_languages = self.syntax_pack_installs.with(&self.store, |active| { - active.iter().cloned().collect::>() - }); - - let mut seen = HashSet::new(); - let mut warmup_paths = Vec::new(); - for path in paths { - let Some(language) = highlighter.guess_language(Path::new(path)) else { - continue; - }; - if excluded_languages.contains(&language) - || active_languages.contains(language.name()) - || highlighter.is_parser_available(language) - { - continue; - } - if seen.insert(language) { - warmup_paths.push(path.clone()); - } - } - - (!warmup_paths.is_empty()).then_some( - SyntaxEffect::EnsureSyntaxPacksForPaths { - paths: warmup_paths, - } - .into(), - ) - } - - fn handle_syntax_packs_installed(&mut self, languages: &[String]) -> Vec { - if languages.is_empty() { - return Vec::new(); - } - let mut effects = vec![self.invalidate_syntax_epoch_effect()]; - for language in languages { - effects.extend(self.refresh_active_file_syntax_for_language(language)); - effects.extend(self.request_cached_file_syntax_effects_for_language(language)); - } - effects - } - - fn refresh_active_file_syntax_for_language(&mut self, language: &str) -> Vec { - let highlighter = Highlighter::new(); - let mut refreshed = false; - self.workspace.active_file.update(&self.store, |slot| { - let Some(active) = slot.as_mut() else { - return; - }; - if !active_file_matches_language(active, &highlighter, language) { - return; - } - reset_active_file_syntax(active); - refreshed = true; - }); - if !refreshed { - return Vec::new(); - } - self.viewport_document_cache = None; - self.request_active_file_syntax_effect() - .into_iter() - .collect() - } - - fn request_cached_file_syntax_effects_for_language(&mut self, language: &str) -> Vec { - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - let generation = self.active_syntax_generation(); - let syntax_epoch = self.syntax_requests.epoch(); - let mut remaining_budget = - MAX_PENDING_SYNTAX_WINDOWS.saturating_sub(self.syntax_outstanding_window_count()); - if remaining_budget == 0 { - return Vec::new(); - } - let active_key = self.workspace.active_file.with(&self.store, |active| { - active.as_ref().map(ActiveFile::working_set_key) - }); - let highlighter = Highlighter::new(); - let mut requests = Vec::new(); - let mut next_request_id = self.syntax_requests.last_request_id(); - - self.workspace.file_cache.update(&self.store, |files| { - for active in files.values_mut() { - if remaining_budget == 0 { - break; - } - if active_key - .as_ref() - .is_some_and(|key| key == &active.working_set_key()) - { - continue; - } - if !active_file_matches_language(active, &highlighter, language) { - continue; - } - let line_count = active.render_doc.lines.len(); - if line_count == 0 { - continue; - } - reset_active_file_syntax(active); - let window = SyntaxRowWindow { - start: 0, - end: line_count.min(SYNTAX_INITIAL_ROWS), - }; - next_request_id = next_request_id.saturating_add(1); - if let Some(request) = request_syntax_for_active_file( - active, - repo_path.clone(), - generation, - syntax_epoch, - window, - next_request_id, - ) { - requests.push(request); - remaining_budget = remaining_budget.saturating_sub(1); - } - } - }); - self.syntax_requests.set_last_request_id(next_request_id); - - requests - .into_iter() - .map(|request| { - self.track_syntax_request(&request); - SyntaxEffect::LoadFileSyntax(Task { - generation, - request, - }) - .into() - }) - .collect() - } - - fn activate_status_view(&mut self, reset_scroll: bool) -> Vec { - tracing::debug!( - reset_scroll, - pending = self.workspace.status_operation_pending.get(&self.store), - status_gen = self.workspace.status_generation.get(&self.store), - status_file_changes_count = self - .workspace - .status_file_changes - .with(&self.store, |i| i.len()), - "activate_status_view: entered" - ); - self.workspace - .source - .set(&self.store, WorkspaceSource::Status); - self.workspace.status.set(&self.store, AsyncStatus::Ready); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); - self.workspace.compare_output.set(&self.store, None); - self.workspace.compare_total_stats.set(&self.store, None); - self.workspace.compare_hydrated_stats.set(&self.store, None); - self.workspace - .compare_deferred_stats_remaining - .set(&self.store, None); - self.workspace - .compare_deferred_stats_cursor - .set(&self.store, 0); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); - self.workspace.active_file_loading.set(&self.store, None); - let new_files = self - .workspace - .status_file_changes - .with(&self.store, |changes| build_status_file_entries(changes)); - self.workspace.files.set(&self.store, new_files); - let next_status_gen = self - .workspace - .status_generation - .get(&self.store) - .saturating_add(1); - self.workspace - .status_generation - .set(&self.store, next_status_gen); - let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); - self.clear_file_cache(); - self.workspace.sidebar_auto_width.set(&self.store, None); - self.workspace.used_fallback.set(&self.store, false); - self.workspace - .fallback_message - .set(&self.store, String::new()); - self.workspace.raw_diff_len.set(&self.store, 0); - self.reset_file_scroll_layout(); - if reset_scroll { - self.file_list.scroll_offset_px.set(&self.store, 0.0); - self.workspace.global_scroll_top_px.set(&self.store, 0); - } else if self.settings.continuous_scroll { - self.clamp_global_scroll_top_px(); - } - - let current_path = self.workspace.selected_file_path.get(&self.store); - let current_bucket = self.workspace.selected_change_bucket.get(&self.store); - let (status_syntax_paths, selected_index, selected_syntax_paths) = self - .workspace - .status_file_changes - .with(&self.store, |changes| { - let paths = changes - .iter() - .flat_map(file_change_syntax_paths) - .collect::>(); - let selected_index = - if let Some((path, bucket)) = current_path.clone().zip(current_bucket) { - if let Some(idx) = changes - .iter() - .position(|change| change.path == path && change.bucket == bucket) - { - Some(idx) - } else { - None - } - } else if let Some(path) = current_path.as_deref() { - if let Some(idx) = changes.iter().position(|change| change.path == path) { - Some(idx) - } else { - None - } - } else { - None - } - .or_else(|| (!changes.is_empty()).then_some(0)); - let selected_paths = selected_index - .and_then(|index| changes.get(index)) - .map(file_change_syntax_paths) - .unwrap_or_default(); - (paths, selected_index, selected_paths) - }); - - tracing::debug!( - ?selected_index, - "activate_status_view: resolved selected_index" - ); - match selected_index { - Some(index) => { - let mut effects = self.select_status_item(index, false); - effects.insert(0, syntax_epoch_effect); - if let Some(effect) = self.syntax_pack_warmup_effect_for_paths( - &status_syntax_paths, - &selected_syntax_paths, - ) { - effects.insert(0, effect); - } - effects - } - None => { - tracing::debug!("activate_status_view: no selection, clearing pending"); - self.workspace - .status_operation_pending - .set(&self.store, false); - self.workspace.selected_file_index.set(&self.store, None); - self.workspace.selected_file_path.set(&self.store, None); - self.workspace.selected_change_bucket.set(&self.store, None); - self.workspace.active_file.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - self.editor_clear_document(); - vec![syntax_epoch_effect] - } - } - } - - fn kickoff_compare(&mut self) -> Vec { - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - self.push_error("Open a repository before starting a compare."); - return Vec::new(); - }; - - let mode = self.compare.mode.get(&self.store); - let left_ref = self.compare.left_ref.get(&self.store); - let right_ref = self.compare.right_ref.get(&self.store); - if !compare_refs_are_valid(mode, &left_ref, &right_ref) { - self.push_error("Provide the required refs for the selected mode."); - return Vec::new(); - } - - let active_pr = self.github.pull_request.active.get(&self.store); - let active_pr_still_matches = active_pr.as_ref().is_some_and(|key| { - self.github.pull_request.cache.with(&self.store, |cache| { - matches!( - cache.get(key).map(|entry| &entry.diff), - Some(PrPeekDiff::Ready { - left_ref: pr_left, - right_ref: pr_right, - .. - }) if pr_left == &left_ref && pr_right == &right_ref - ) - }) - }); - if !active_pr_still_matches { - self.github.pull_request.active.set(&self.store, None); - self.github - .pull_request - .review_composer - .set(&self.store, ReviewCommentComposerState::default()); - self.review_comment_editor.request_clear(); - } - - self.workspace - .source - .set(&self.store, WorkspaceSource::Compare); - let next_gen = self - .workspace - .compare_generation - .get(&self.store) - .saturating_add(1); - self.workspace.compare_generation.set(&self.store, next_gen); - let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); - self.workspace.compare_total_stats.set(&self.store, None); - self.workspace.compare_hydrated_stats.set(&self.store, None); - self.workspace - .compare_deferred_stats_remaining - .set(&self.store, None); - self.workspace - .compare_deferred_stats_cursor - .set(&self.store, 0); - self.workspace - .compare_total_stats_loading - .set(&self.store, false); - self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); - self.workspace.expansions.update(&self.store, |m| m.clear()); - self.clear_overlays(); - self.sync_settings_snapshot(); - - let started_at_ms = self.clock_ms; - let reveal_at_ms = started_at_ms; - let has_prior_state = self.workspace_file_count() > 0 - || self - .workspace - .active_file - .with(&self.store, |af| af.is_some()); - - if !has_prior_state { - self.workspace_mode.set(&self.store, WorkspaceMode::Loading); - self.workspace.status.set(&self.store, AsyncStatus::Loading); - } - - let profile = self.vcs_ui_profile(); - let left_label = profile.compare_ref_display_label(&left_ref); - let right_label = profile.compare_ref_display_label(&right_ref); - self.compare_progress.set( - &self.store, - Some(CompareProgress { - generation: next_gen, - phase: ComparePhase::OpeningRepo, - subject: LoadingSubject::Compare { - left_label, - right_label, - }, - started_at_ms, - reveal_at_ms, - file_count_total: None, - files_loaded: 0, - }), - ); - - let renderer = self.compare.renderer.get(&self.store); - let layout = self.compare.layout.get(&self.store); - vec![ - syntax_epoch_effect, - SettingsEffect::SaveSettings(self.settings.clone()).into(), - CompareEffect::Run(Task { - generation: next_gen, - request: CompareRequest { - repo_path, - request: vcs_compare_request(mode, left_ref, right_ref, layout, renderer), - github_token: self.github_access_token.clone(), - }, - }) - .into(), - ] - } - - /// Soft-cancel an in-flight compare. Bumps the generation so any - /// result that eventually arrives is dropped by the guard, clears the - /// progress panel, and returns the viewport to the default empty state. - /// We do not attempt to interrupt backend work mid-flight; stale-result - /// guards keep late answers from mutating newer state. - fn cancel_compare(&mut self) -> Vec { - if self.compare_progress.with(&self.store, |p| p.is_none()) { - return Vec::new(); - } - let next_gen = self - .workspace - .compare_generation - .get(&self.store) - .saturating_add(1); - self.workspace.compare_generation.set(&self.store, next_gen); - let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); - self.compare_progress.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - // Only revert the workspace mode if kickoff flipped it to Loading - // (i.e. no prior state was preserved). When the user cancels a - // re-compare, the old diff is still mounted and should stay visible. - if self.workspace_mode.get(&self.store) == WorkspaceMode::Loading { - self.workspace_mode.set(&self.store, WorkspaceMode::Empty); - self.workspace.status.set(&self.store, AsyncStatus::Idle); - } - vec![syntax_epoch_effect] - } - - fn handle_compare_progress_update(&mut self, generation: u64, phase: ComparePhase) { - // Only apply when the progress slot matches the reporter's - // generation — stale workers silently lose their updates. - self.compare_progress.update(&self.store, |slot| { - if let Some(p) = slot.as_mut() - && p.generation == generation - { - // Pull counts out of LoadingFiles so the determinate bar - // reads directly from durable struct fields (cheaper than - // pattern-matching in the render path, and lets the total - // survive the phase transition to PopulatingList). - if let ComparePhase::LoadingFiles { - files_seen, - files_total, - } = phase - { - p.files_loaded = files_seen; - if files_total > 0 { - p.file_count_total = Some(files_total); - } - } - p.phase = phase; - } - }); - } - - fn show_working_tree(&mut self) -> Vec { - let (left, right, mode) = self.vcs_ui_profile().working_copy_compare(); - self.compare.left_ref.set(&self.store, left.to_owned()); - self.compare.right_ref.set(&self.store, right.to_owned()); - self.compare.mode.set(&self.store, mode); - let mut effects = self.persist_settings_effect(); - effects.extend(self.activate_status_view(true)); - effects - } - - fn preview_pull_request(&mut self) -> Vec { - let profile = self.vcs_ui_profile(); - if !profile.accepts_compare_mode(CompareMode::ThreeDot) - || self.repository.location.with(&self.store, |location| { - !location - .as_ref() - .is_some_and(|location| location.profile == VCS_PROFILE_GIT) - }) - { - self.push_error("PR preview is only available for Git repositories."); - return Vec::new(); - } - let Some(base_ref) = self.default_pull_request_base_ref() else { - self.push_error("No default branch found for PR preview."); - return Vec::new(); - }; - let (_, workdir_ref, _) = profile.working_copy_compare(); - self.workspace.pre_drill_compare.set(&self.store, None); - self.compare.left_ref.set(&self.store, base_ref); - self.compare - .right_ref - .set(&self.store, workdir_ref.to_owned()); - self.compare.resolved_left.set(&self.store, None); - self.compare.resolved_right.set(&self.store, None); - self.compare.mode.set(&self.store, CompareMode::ThreeDot); - let mut effects = self.persist_settings_effect(); - effects.extend(self.kickoff_compare()); - effects - } - - fn default_pull_request_base_ref(&self) -> Option { - let refs = self.repository.refs.get(&self.store); - let active = refs - .iter() - .find(|reference| reference.active && reference.kind == RefKind::Branch) - .map(|reference| reference.name.as_str()); - let branch_ref = |name: &str| { - refs.iter() - .find(|reference| { - reference.name == name - && active != Some(reference.name.as_str()) - && matches!(reference.kind, RefKind::Branch | RefKind::RemoteBranch) - }) - .map(|reference| reference.name.clone()) - }; - for name in [ - "origin/main", - "origin/master", - "upstream/main", - "upstream/master", - "origin/develop", - "origin/development", - "main", - "master", - "develop", - "development", - ] { - if let Some(reference) = branch_ref(name) { - return Some(reference); - } - } - for trunk in ["main", "master", "develop", "development"] { - let suffix = format!("/{trunk}"); - if let Some(reference) = refs - .iter() - .find(|reference| { - reference.name.ends_with(&suffix) - && active != Some(reference.name.as_str()) - && reference.kind == RefKind::RemoteBranch - }) - .map(|reference| reference.name.clone()) - { - return Some(reference); - } - } - None - } - - fn swap_refs(&mut self) -> Vec { - let left = self.compare.left_ref.get(&self.store); - let right = self.compare.right_ref.get(&self.store); - let profile = self.vcs_ui_profile(); - if left.trim().is_empty() - || right.trim().is_empty() - || !profile.can_swap_ref(&right) - || !profile.can_swap_ref(&left) - { - return Vec::new(); - } - let resolved_left = self.compare.resolved_left.get(&self.store); - let resolved_right = self.compare.resolved_right.get(&self.store); - self.compare.left_ref.set(&self.store, right); - self.compare.right_ref.set(&self.store, left); - self.compare.resolved_left.set(&self.store, resolved_right); - self.compare.resolved_right.set(&self.store, resolved_left); - self.workspace.pre_drill_compare.set(&self.store, None); - let mut effects = self.persist_settings_effect(); - let has_repo = self.compare.repo_path.with(&self.store, |p| p.is_some()); - let not_loading = self.workspace.status.get(&self.store) != AsyncStatus::Loading; - let refs_valid = compare_refs_are_valid( - self.compare.mode.get(&self.store), - &self.compare.left_ref.get(&self.store), - &self.compare.right_ref.get(&self.store), - ); - if has_repo && not_loading && refs_valid { - effects.extend(self.kickoff_compare()); - } - effects - } - - fn persist_settings_effect(&mut self) -> Vec { - self.sync_settings_snapshot(); - vec![SettingsEffect::SaveSettings(self.settings.clone()).into()] - } - - fn sync_settings_snapshot(&mut self) { - self.settings.ui_scale_pct = self.clamp_ui_scale_pct(self.settings.ui_scale_pct); - self.settings.fonts = self.settings.fonts.normalized(); - self.settings.sidebar_width_px = self - .settings - .sidebar_width_px - .map(|width| self.clamp_sidebar_width_px(width)); - self.settings.viewport.wrap_enabled = self.editor.wrap_enabled.get(&self.store); - self.settings.viewport.wrap_column = self.editor.wrap_column.get(&self.store); - self.settings.viewport.layout = self.compare.layout.get(&self.store); - self.settings.last_compare = Some(PersistedCompare { - repo_path: self.compare.repo_path.get(&self.store), - left_ref: self.compare.left_ref.get(&self.store), - right_ref: self.compare.right_ref.get(&self.store), - mode: self.compare.mode.get(&self.store), - layout: self.compare.layout.get(&self.store), - renderer: self.compare.renderer.get(&self.store), - }); - } - - pub fn ui_scale_factor(&self) -> f32 { - self.clamp_ui_scale_pct(self.settings.ui_scale_pct) as f32 / DEFAULT_UI_SCALE_PCT as f32 - } - - fn clamp_ui_scale_pct(&self, scale_pct: u16) -> u16 { - scale_pct.clamp(MIN_UI_SCALE_PCT, MAX_UI_SCALE_PCT) - } - - fn adjust_ui_scale(&mut self, delta_pct: i16) -> Vec { - let current = i32::from(self.clamp_ui_scale_pct(self.settings.ui_scale_pct)); - let updated = (current + i32::from(delta_pct)) - .clamp(i32::from(MIN_UI_SCALE_PCT), i32::from(MAX_UI_SCALE_PCT)) - as u16; - if updated == self.settings.ui_scale_pct { - return Vec::new(); - } - self.settings.ui_scale_pct = updated; - self.persist_settings_effect() - } - - fn clamp_sidebar_width_px(&self, width: u32) -> u32 { - let min_width = (280.0 * self.ui_scale_factor() * 0.64).round() as u32; - width.max(min_width.max(120)) - } - - fn set_focus(&mut self, target: Option) { - if target != self.focus.get(&self.store) { - // Reset cursor to end of the new field - let len = target - .and_then(|t| self.with_text_for_focus(t, |s| s.len())) - .unwrap_or(0); - self.reset_text_edit(len); - } - self.focus.set(&self.store, target); - self.editor - .focused - .set(&self.store, target == Some(FocusTarget::Editor)); - } - - /// Set cursor and anchor to the same offset and refresh the blink timestamp. - pub(super) fn reset_text_edit(&mut self, offset: usize) { - self.text_edit.cursor.set(&self.store, offset); - self.text_edit.anchor.set(&self.store, offset); - self.text_edit - .cursor_moved_at_ms - .set(&self.store, self.clock_ms); - } - - /// Run `f` against the text string for the given focus target, if it's a text field. - pub(super) fn with_text_for_focus( - &self, - target: FocusTarget, - f: impl FnOnce(&str) -> R, - ) -> Option { - match target { - FocusTarget::PickerInput => match self.overlays.picker.kind.get(&self.store) { - PickerKind::Repository - | PickerKind::Theme - | PickerKind::UiFont - | PickerKind::MonoFont => { - Some(self.overlays.picker.query.with(&self.store, |s| f(s))) - } - PickerKind::LeftRef => Some(self.compare.left_ref.with(&self.store, |s| f(s))), - PickerKind::RightRef => Some(self.compare.right_ref.with(&self.store, |s| f(s))), - }, - FocusTarget::CommandPaletteInput => Some( - self.overlays - .command_palette - .query - .with(&self.store, |s| f(s)), - ), - FocusTarget::SidebarSearch => Some(self.file_list.filter.with(&self.store, |s| f(s))), - FocusTarget::SearchInput => Some(self.editor.search.query.with(&self.store, |s| f(s))), - FocusTarget::CommitEditor => None, - FocusTarget::SettingsOpenAiKey => Some(f(&self.ai_openai_key)), - FocusTarget::SettingsAnthropicKey => Some(f(&self.ai_anthropic_key)), - FocusTarget::SettingsSteeringPrompt => None, - FocusTarget::TextCompareLeft | FocusTarget::TextCompareRight => None, - _ => None, - } - } - - pub(super) fn ai_key_editable(&self, kind: AiKeyKind) -> bool { - match kind { - AiKeyKind::OpenAi => self.ai_openai_key.is_empty() || self.ai_openai_editing, - AiKeyKind::Anthropic => self.ai_anthropic_key.is_empty() || self.ai_anthropic_editing, - } - } - - pub(super) fn with_focused_text(&self, f: impl FnOnce(&str) -> R) -> Option { - let target = self.focus.get(&self.store)?; - self.with_text_for_focus(target, f) - } - - pub(super) fn update_focused_text(&mut self, f: impl FnOnce(&mut String) -> R) -> Option { - match self.focus.get(&self.store) { - Some(FocusTarget::PickerInput) => match self.overlays.picker.kind.get(&self.store) { - PickerKind::Repository - | PickerKind::Theme - | PickerKind::UiFont - | PickerKind::MonoFont => { - let mut out = None; - self.overlays - .picker - .query - .update(&self.store, |s| out = Some(f(s))); - out - } - PickerKind::LeftRef => { - let mut out = None; - self.compare - .left_ref - .update(&self.store, |s| out = Some(f(s))); - out - } - PickerKind::RightRef => { - let mut out = None; - self.compare - .right_ref - .update(&self.store, |s| out = Some(f(s))); - out - } - }, - Some(FocusTarget::CommandPaletteInput) => { - let mut out = None; - self.overlays - .command_palette - .query - .update(&self.store, |s| out = Some(f(s))); - out - } - Some(FocusTarget::SidebarSearch) => { - let mut out = None; - self.file_list - .filter - .update(&self.store, |s| out = Some(f(s))); - out - } - Some(FocusTarget::SearchInput) => { - let mut out = None; - self.editor - .search - .query - .update(&self.store, |s| out = Some(f(s))); - out - } - Some(FocusTarget::CommitEditor) => None, - Some(FocusTarget::SettingsOpenAiKey) => { - if !self.ai_key_editable(AiKeyKind::OpenAi) { - return None; - } - let result = f(&mut self.ai_openai_key); - Some(result) - } - Some(FocusTarget::SettingsAnthropicKey) => { - if !self.ai_key_editable(AiKeyKind::Anthropic) { - return None; - } - let result = f(&mut self.ai_anthropic_key); - Some(result) - } - Some(FocusTarget::SettingsSteeringPrompt) => None, - _ => None, - } - } - - /// Returns true if the current focus target is a text editing field. - /// Backed by a memo; `focus` writes invalidate it automatically. - pub fn is_text_focused(&self) -> bool { - self.text_focused.get(&self.store) - } - - /// Returns true when the workspace is in `Ready` mode. - pub fn is_workspace_ready(&self) -> bool { - self.workspace_mode.get(&self.store) == WorkspaceMode::Ready - } - - fn touch_cursor(&mut self) { - self.text_edit - .cursor_moved_at_ms - .set(&self.store, self.clock_ms); - } - - fn clamp_cursor(&mut self) { - let cursor_now = self.text_edit.cursor.get(&self.store); - let anchor_now = self.text_edit.anchor.get(&self.store); - let Some((cursor, anchor)) = self.with_focused_text(|text| { - let len = text.len(); - let mut cursor = cursor_now.min(len); - while cursor > 0 && !text.is_char_boundary(cursor) { - cursor -= 1; - } - let mut anchor = anchor_now.min(len); - while anchor > 0 && !text.is_char_boundary(anchor) { - anchor -= 1; - } - (cursor, anchor) - }) else { - return; - }; - self.text_edit.cursor.set(&self.store, cursor); - self.text_edit.anchor.set(&self.store, anchor); - } - - // Text editing methods are in text_edit.rs - - fn update_compare_field(&mut self, field: CompareField, value: String) -> Vec { - self.workspace.pre_drill_compare.set(&self.store, None); - match field { - CompareField::Left => { - self.compare.left_ref.set(&self.store, value); - self.compare.resolved_left.set(&self.store, None); - } - CompareField::Right => { - self.compare.right_ref.set(&self.store, value); - self.compare.resolved_right.set(&self.store, None); - } - } - self.auto_select_compare_mode(); - let active_field = self.overlays.ref_picker.active_field.get(&self.store); - let mut effects = if matches!(self.overlays_top(), Some(OverlaySurface::RefPicker)) - && active_field == field - { - self.rebuild_ref_picker(field) - } else { - Vec::new() - }; - effects.extend(self.rebuild_command_palette()); - effects - } - - fn auto_select_compare_mode(&mut self) { - let profile = self.vcs_ui_profile(); - if !profile.should_auto_select_trunk_mode() { - return; - } - let left = self.compare.left_ref.get(&self.store); - let right = self.compare.right_ref.get(&self.store); - if left.is_empty() || right.is_empty() { - return; - } - if left == right && !profile.is_working_copy_ref(&right) { - self.compare - .mode - .set(&self.store, CompareMode::SingleCommit); - return; - } - let is_trunk = |r: &str| matches!(r, "main" | "master" | "develop" | "development"); - if is_trunk(&left) != is_trunk(&right) { - self.compare.mode.set(&self.store, CompareMode::ThreeDot); - } - } - - fn apply_pr_compare(&mut self, left: String, right: String) -> Vec { - let _ = self.update_compare_field(CompareField::Left, left); - let _ = self.update_compare_field(CompareField::Right, right); - self.compare.mode.set(&self.store, CompareMode::ThreeDot); - self.kickoff_compare() - } - - fn open_repo_picker(&mut self) { - let scale = self.ui_scale_factor(); - self.overlays - .picker - .kind - .set(&self.store, PickerKind::Repository); - self.overlays.picker.list.update(&self.store, |l| { - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - l.scroll_top_px = 0; - }); - self.overlays.picker.browse_path.set(&self.store, None); - self.overlays.picker.selected_index.set(&self.store, 0); - - let has_recents = crate::core::frecency::recent_repo_paths(self.frecency.as_ref(), 1) - .first() - .is_some(); - - if has_recents { - self.overlays.picker.query.set(&self.store, String::new()); - } else { - let home = dirs::home_dir() - .map(|p| format!("{}/", p.display())) - .unwrap_or_else(|| "/".to_owned()); - let home_len = home.len(); - self.overlays.picker.query.set(&self.store, home); - self.reset_text_edit(home_len); - } - - self.rebuild_repo_picker(); - self.push_overlay(OverlaySurface::RepoPicker, Some(FocusTarget::PickerInput)); - } - - fn open_theme_picker(&mut self) { - let scale = self.ui_scale_factor(); - self.theme_preview_original - .set(&self.store, Some(self.settings.theme_name.clone())); - self.overlays - .picker - .kind - .set(&self.store, PickerKind::Theme); - self.overlays.picker.query.set(&self.store, String::new()); - self.overlays.picker.list.update(&self.store, |l| { - l.scroll_top_px = 0; - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - }); - let entries = self.build_theme_entries_grouped(); - let selected = entries.iter().position(|e| !e.section_header).unwrap_or(0); - self.overlays.picker.entries.set(&self.store, entries); - self.overlays - .picker - .selected_index - .set(&self.store, selected); - self.push_overlay(OverlaySurface::ThemePicker, Some(FocusTarget::PickerInput)); - } - - fn build_theme_entries_grouped(&self) -> Vec { - use crate::core::themes::ThemeVariant; - - let original = self - .theme_preview_original - .get(&self.store) - .unwrap_or_else(|| self.settings.theme_name.clone()); - let make_entry = |name: &String| PickerEntry { - label: name.clone(), - detail: if *name == original { - "\u{2713}".to_owned() - } else { - String::new() - }, - value: name.clone(), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - icon: None, - section_header: false, - }; - let make_header = |label: &str| PickerEntry { - label: label.to_owned(), - detail: String::new(), - value: String::new(), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - icon: None, - section_header: true, - }; - - let mut dual = Vec::new(); - let mut dark = Vec::new(); - let mut light = Vec::new(); - for (i, name) in self.theme_names.iter().enumerate() { - let variant = self - .theme_variants - .get(i) - .copied() - .unwrap_or(ThemeVariant::Dark); - match variant { - ThemeVariant::Dual => dual.push(make_entry(name)), - ThemeVariant::Dark => dark.push(make_entry(name)), - ThemeVariant::Light => light.push(make_entry(name)), - } - } - - let mut entries = Vec::with_capacity(dual.len() + dark.len() + light.len() + 3); - if !dual.is_empty() { - entries.push(make_header("Dark & Light")); - entries.extend(dual); - } - if !dark.is_empty() { - entries.push(make_header("Dark")); - entries.extend(dark); - } - if !light.is_empty() { - entries.push(make_header("Light")); - entries.extend(light); - } - entries - } - - fn rebuild_theme_picker(&mut self) { - let query = self - .overlays - .picker - .query - .with(&self.store, |q| q.trim().to_owned()); - let original = self - .theme_preview_original - .get(&self.store) - .unwrap_or_else(|| self.settings.theme_name.clone()); - let (entries, selected) = if query.is_empty() { - let entries = self.build_theme_entries_grouped(); - let selected = entries.iter().position(|e| !e.section_header).unwrap_or(0); - (entries, selected) - } else { - let haystack: Vec<&str> = self.theme_names.iter().map(|s| s.as_str()).collect(); - let config = neo_frizbee::Config { - max_typos: Some(2), - sort: false, - ..Default::default() - }; - let mut matches = neo_frizbee::match_list_indices(&query, &haystack, &config); - matches.sort_by(|a, b| b.score.cmp(&a.score)); - let entries: Vec = matches - .iter() - .map(|m| { - let name = &self.theme_names[m.index as usize]; - PickerEntry { - label: name.clone(), - detail: if *name == *original { - "\u{2713}".to_owned() - } else { - String::new() - }, - value: name.clone(), - highlights: highlight_ranges_from_match_indices(name, &m.indices), - label_style: PickerLabelStyle::Default, - icon: None, - section_header: false, - } - }) - .collect(); - (entries, 0) - }; - if let Some(entry) = entries.get(selected) { - if !entry.section_header { - self.settings.theme_name = entry.value.clone(); - } - } - let entry_count = entries.len(); - self.overlays.picker.entries.set(&self.store, entries); - self.overlays - .picker - .selected_index - .set(&self.store, selected); - self.overlays.picker.list.update(&self.store, |l| { - l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); - l.scroll_top_px = 0; - }); - } - - fn open_font_picker(&mut self, role: FontRole) { - let scale = self.ui_scale_factor(); - self.overlays.picker.kind.set( - &self.store, - match role { - FontRole::Ui => PickerKind::UiFont, - FontRole::Mono => PickerKind::MonoFont, - }, - ); - self.overlays.picker.query.set(&self.store, String::new()); - self.overlays.picker.list.update(&self.store, |l| { - l.scroll_top_px = 0; - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - }); - self.rebuild_font_picker(); - self.reset_text_edit(0); - self.push_overlay(OverlaySurface::FontPicker, Some(FocusTarget::PickerInput)); - } - - fn rebuild_font_picker(&mut self) { - let Some(role) = self.font_picker_role() else { - return; - }; - let query = self - .overlays - .picker - .query - .with(&self.store, |q| q.trim().to_owned()); - let selected_family = self.selected_font_family(role); - let font_entries = crate::fonts::font_family_entries(role); - let entries: Vec = if query.is_empty() { - font_entries - .iter() - .map(|entry| font_picker_entry(entry, &selected_family, Vec::new())) - .collect() - } else { - let search_texts: Vec = font_entries - .iter() - .map(|entry| { - if entry.label == entry.family { - entry.label.clone() - } else { - format!("{} {}", entry.label, entry.family) - } - }) - .collect(); - let haystack: Vec<&str> = search_texts.iter().map(|s| s.as_str()).collect(); - let config = neo_frizbee::Config { - max_typos: Some(2), - sort: false, - ..Default::default() - }; - let mut matches = neo_frizbee::match_list_indices(&query, &haystack, &config); - matches.sort_by(|a, b| b.score.cmp(&a.score).then(a.index.cmp(&b.index))); - matches - .into_iter() - .map(|m| { - let entry = &font_entries[m.index as usize]; - let highlights = highlight_ranges_for_visible_match( - &query, - &entry.label, - &m.indices, - &config, - ); - font_picker_entry(entry, &selected_family, highlights) - }) - .collect() - }; - - let selected = entries - .iter() - .position(|entry| entry.value == selected_family) - .unwrap_or(0); - let entry_count = entries.len(); - self.overlays.picker.entries.set(&self.store, entries); - self.overlays - .picker - .selected_index - .set(&self.store, selected); - self.overlays.picker.list.update(&self.store, |l| { - l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); - l.scroll_top_px = 0; - }); - } - - fn font_picker_role(&self) -> Option { - match self.overlays.picker.kind.get(&self.store) { - PickerKind::UiFont => Some(FontRole::Ui), - PickerKind::MonoFont => Some(FontRole::Mono), - _ => None, - } - } - - fn selected_font_family(&self, role: FontRole) -> String { - match role { - FontRole::Ui => { - crate::fonts::normalize_font_selection(role, &self.settings.fonts.ui_family) - } - FontRole::Mono => { - crate::fonts::normalize_font_selection(role, &self.settings.fonts.mono_family) - } - } - } - - fn open_ref_picker(&mut self, field: CompareField) -> Vec { - let scale = self.ui_scale_factor(); - let already_open = self.overlays_top() == Some(OverlaySurface::RefPicker); - // Snapshot originals only on first open; switching chips shouldn't - // refresh the revert baseline. - if !already_open { - let left = self.compare.left_ref.get(&self.store); - let right = self.compare.right_ref.get(&self.store); - self.overlays - .ref_picker - .original_left - .set(&self.store, left); - self.overlays - .ref_picker - .original_right - .set(&self.store, right); - } - self.overlays - .ref_picker - .active_field - .set(&self.store, field); - self.overlays.picker.kind.set( - &self.store, - match field { - CompareField::Left => PickerKind::LeftRef, - CompareField::Right => PickerKind::RightRef, - }, - ); - self.overlays.picker.selected_index.set(&self.store, 0); - self.overlays.picker.list.update(&self.store, |l| { - l.scroll_top_px = 0; - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - }); - let effects = self.rebuild_ref_picker(field); - self.push_overlay(OverlaySurface::RefPicker, Some(FocusTarget::PickerInput)); - // Move cursor to end of the active field's current value so typing - // continues from where the label ends. - let len = match field { - CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), - CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), - }; - self.reset_text_edit(len); - effects - } - - fn open_command_palette(&mut self) -> Vec { - let scale = self.ui_scale_factor(); - self.overlays.command_palette.list.update(&self.store, |l| { - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - l.scroll_top_px = 0; - }); - let effects = self.rebuild_command_palette(); - self.push_overlay( - OverlaySurface::CommandPalette, - Some(FocusTarget::CommandPaletteInput), - ); - effects - } - - fn push_overlay(&mut self, surface: OverlaySurface, focus_target: Option) { - if self.overlays_top() == Some(surface) { - self.set_focus(focus_target); - return; - } - let focus_return = self.focus.get(&self.store); - self.overlays.stack.update(&self.store, |stack| { - stack.push(OverlayEntry { - surface, - focus_return, - }); - }); - self.set_focus(focus_target); - } - - fn pop_overlay(&mut self) { - let mut popped: Option = None; - self.overlays.stack.update(&self.store, |stack| { - popped = stack.pop(); - }); - let Some(entry) = popped else { - return; - }; - match entry.surface { - OverlaySurface::ThemePicker => { - let original = self.theme_preview_original.get(&self.store); - self.theme_preview_original.set(&self.store, None); - if let Some(original) = original { - self.settings.theme_name = original; - } - self.reset_picker(); - } - OverlaySurface::RepoPicker | OverlaySurface::RefPicker | OverlaySurface::FontPicker => { - self.reset_picker(); - } - OverlaySurface::CommandPalette => { - self.reset_command_palette(); - } - OverlaySurface::Confirmation => { - self.reset_confirmation(); - } - _ => {} - } - self.set_focus(entry.focus_return); - } - - fn open_confirmation( - &mut self, - title: impl Into, - message: impl Into, - confirm_label: impl Into, - action: Action, - ) { - self.overlays - .confirmation - .title - .set(&self.store, title.into()); - self.overlays - .confirmation - .message - .set(&self.store, message.into()); - self.overlays - .confirmation - .confirm_label - .set(&self.store, confirm_label.into()); - self.overlays - .confirmation - .action - .set(&self.store, Some(action)); - self.set_focus(None); - self.push_overlay(OverlaySurface::Confirmation, None); - } - - fn move_overlay_selection(&mut self, delta: i32) { - match self.overlays_top() { - Some(OverlaySurface::ThemePicker) => { - let current = self.overlays.picker.selected_index.get(&self.store); - let (idx, len, value) = self.overlays.picker.entries.with(&self.store, |entries| { - let len = entries.len(); - if len == 0 { - return (current, len, None); - } - let max = len.saturating_sub(1) as i32; - let mut idx = (current as i32 + delta).clamp(0, max) as usize; - while idx < len && entries[idx].section_header { - if delta > 0 { - idx = (idx + 1).min(len.saturating_sub(1)); - } else { - if idx == 0 { - break; - } - idx -= 1; - } - } - let value = entries - .get(idx) - .filter(|e| !e.section_header) - .map(|e| e.value.clone()); - (idx, len, value) - }); - if len == 0 { - return; - } - self.overlays.picker.selected_index.set(&self.store, idx); - self.overlays - .picker - .list - .update(&self.store, |l| l.reveal_index(idx, len)); - if let Some(value) = value { - tracing::debug!(theme = %value, "theme preview"); - self.settings.theme_name = value; - } - } - Some( - OverlaySurface::RepoPicker | OverlaySurface::RefPicker | OverlaySurface::FontPicker, - ) => { - let current = self.overlays.picker.selected_index.get(&self.store); - let (idx, len) = self.overlays.picker.entries.with(&self.store, |entries| { - let len = entries.len(); - if len == 0 { - return (current, len); - } - let max = len.saturating_sub(1) as i32; - let mut idx = (current as i32 + delta).clamp(0, max) as usize; - while idx < len && entries[idx].section_header { - if delta > 0 { - idx = (idx + 1).min(len.saturating_sub(1)); - } else { - if idx == 0 { - break; - } - idx -= 1; - } - } - (idx, len) - }); - if len == 0 { - return; - } - self.overlays.picker.selected_index.set(&self.store, idx); - self.overlays - .picker - .list - .update(&self.store, |l| l.reveal_index(idx, len)); - } - Some(OverlaySurface::CommandPalette) => { - let entry_count = self - .overlays - .command_palette - .entries - .with(&self.store, |e| e.len()); - let max = entry_count.saturating_sub(1) as i32; - let current = self - .overlays - .command_palette - .selected_index - .get(&self.store); - let idx = (current as i32 + delta).clamp(0, max.max(0)) as usize; - self.overlays - .command_palette - .selected_index - .set(&self.store, idx); - self.overlays - .command_palette - .list - .update(&self.store, |l| l.reveal_index(idx, entry_count)); - } - _ => {} - } - } - - fn select_overlay_entry(&mut self, index: usize) { - match self.overlays_top() { - Some(OverlaySurface::ThemePicker) => { - let (clamped, len, value) = - self.overlays.picker.entries.with(&self.store, |entries| { - let len = entries.len(); - let clamped = index.min(len.saturating_sub(1)); - let value = entries.get(clamped).map(|e| e.value.clone()); - (clamped, len, value) - }); - self.overlays - .picker - .selected_index - .set(&self.store, clamped); - if let Some(value) = value { - self.settings.theme_name = value; - } - self.overlays - .picker - .list - .update(&self.store, |l| l.reveal_index(clamped, len)); - } - Some( - OverlaySurface::RepoPicker | OverlaySurface::RefPicker | OverlaySurface::FontPicker, - ) => { - let (clamped, len, is_header) = - self.overlays.picker.entries.with(&self.store, |entries| { - let len = entries.len(); - let clamped = index.min(len.saturating_sub(1)); - let is_header = entries.get(clamped).map_or(false, |e| e.section_header); - (clamped, len, is_header) - }); - if is_header { - return; - } - self.overlays - .picker - .selected_index - .set(&self.store, clamped); - self.overlays - .picker - .list - .update(&self.store, |l| l.reveal_index(clamped, len)); - } - Some(OverlaySurface::CommandPalette) => { - let len = self - .overlays - .command_palette - .entries - .with(&self.store, |e| e.len()); - let clamped = index.min(len.saturating_sub(1)); - self.overlays - .command_palette - .selected_index - .set(&self.store, clamped); - self.overlays - .command_palette - .list - .update(&self.store, |l| l.reveal_index(clamped, len)); - } - _ => {} - } - } - - fn confirm_overlay_selection(&mut self) -> Vec { - match self.overlays_top() { - Some(OverlaySurface::ThemePicker) => { - let selected = self.overlays.picker.selected_index.get(&self.store); - let value = self.overlays.picker.entries.with(&self.store, |entries| { - entries.get(selected).map(|e| e.value.clone()) - }); - if let Some(value) = value { - tracing::info!(theme = %value, "theme confirmed"); - self.settings.theme_name = value; - } - self.theme_preview_original.set(&self.store, None); - self.pop_overlay(); - self.persist_settings_effect() - } - Some(OverlaySurface::FontPicker) => self.confirm_font_picker(), - Some(OverlaySurface::RepoPicker) => self.confirm_repo_picker(), - Some(OverlaySurface::RefPicker) => { - let field = self.overlays.ref_picker.active_field.get(&self.store); - self.confirm_ref_picker(field) - } - Some(OverlaySurface::CommandPalette) => self.confirm_command_palette(), - Some(OverlaySurface::Confirmation) => { - let action = self.overlays.confirmation.action.get(&self.store); - self.pop_overlay(); - if let Some(action) = action { - self.apply_action(action) - } else { - Vec::new() - } - } - Some(OverlaySurface::GitHubAuthModal) => { - if self - .github - .auth - .device_flow - .with(&self.store, |opt| opt.is_some()) - { - self.apply_action(crate::actions::GitHubAction::OpenDeviceFlowBrowser) - } else { - self.apply_action(crate::actions::GitHubAction::StartGitHubDeviceFlow) - } - } - Some( - OverlaySurface::KeyboardShortcuts - | OverlaySurface::CompareMenu - | OverlaySurface::AccountMenu - | OverlaySurface::PublishMenu, - ) => Vec::new(), - None => Vec::new(), - } - } - - fn confirm_font_picker(&mut self) -> Vec { - let Some(role) = self.font_picker_role() else { - return Vec::new(); - }; - let selected = self.overlays.picker.selected_index.get(&self.store); - let family = self.overlays.picker.entries.with(&self.store, |entries| { - entries.get(selected).map(|entry| entry.value.clone()) - }); - let Some(family) = family else { - return Vec::new(); - }; - let family = crate::fonts::normalize_font_selection(role, &family); - let changed = match role { - FontRole::Ui => { - if self.settings.fonts.ui_family == family { - false - } else { - self.settings.fonts.ui_family = family; - true - } - } - FontRole::Mono => { - if self.settings.fonts.mono_family == family { - false - } else { - self.settings.fonts.mono_family = family; - true - } - } - }; - self.pop_overlay(); - if changed { - self.persist_settings_effect() - } else { - Vec::new() - } - } - - fn confirm_repo_picker(&mut self) -> Vec { - let selected = self.overlays.picker.selected_index.get(&self.store); - let entry = self - .overlays - .picker - .entries - .with(&self.store, |entries| entries.get(selected).cloned()); - - let Some(entry) = entry else { - let query = self - .overlays - .picker - .query - .with(&self.store, |q| q.trim().to_owned()); - if !query.is_empty() { - let expanded = expand_tilde(&query); - let path = PathBuf::from(&expanded); - if path.is_dir() && path_looks_like_repository(&path) { - self.pop_overlay(); - return self.open_repository(path); - } - if path.is_dir() { - self.navigate_picker_to_dir(&path); - return Vec::new(); - } - } - return Vec::new(); - }; - - if entry.section_header { - return Vec::new(); - } - - if entry.value.starts_with("open:") { - let path = PathBuf::from(&entry.value[5..]); - self.pop_overlay(); - return self.open_repository(path); - } - - let path = PathBuf::from(&entry.value); - - let browsing = self - .overlays - .picker - .browse_path - .with(&self.store, |p| p.is_some()); - if browsing { - if entry.label == ".." { - self.navigate_picker_to_dir(&path); - return Vec::new(); - } - if path.is_dir() && path_looks_like_repository(&path) { - self.pop_overlay(); - return self.open_repository(path); - } - if path.is_dir() { - self.navigate_picker_to_dir(&path); - return Vec::new(); - } - return Vec::new(); - } - - self.pop_overlay(); - self.open_repository(path) - } - - fn tab_complete_picker_dir(&mut self) { - if self.overlays.picker.kind.get(&self.store) != PickerKind::Repository { - return; - } - let selected = self.overlays.picker.selected_index.get(&self.store); - let entry = self - .overlays - .picker - .entries - .with(&self.store, |entries| entries.get(selected).cloned()); - let Some(entry) = entry else { return }; - if entry.section_header || entry.value.is_empty() { - return; - } - let path = PathBuf::from(&entry.value); - if path.is_dir() { - self.navigate_picker_to_dir(&path); - } - } - - fn navigate_picker_to_dir(&mut self, path: &Path) { - let display = path.display().to_string(); - let new_query = if display.ends_with('/') || display.ends_with('\\') { - display - } else { - format!("{}/", display) - }; - let new_len = new_query.len(); - self.overlays.picker.query.set(&self.store, new_query); - self.reset_text_edit(new_len); - self.rebuild_repo_picker(); - } - - fn confirm_ref_picker(&mut self, field: CompareField) -> Vec { - let selected = self.overlays.picker.selected_index.get(&self.store); - let entry = self - .overlays - .picker - .entries - .with(&self.store, |entries| entries.get(selected).cloned()) - .or_else(|| { - let query = match field { - CompareField::Left => self - .compare - .left_ref - .with(&self.store, |s| s.trim().to_owned()), - CompareField::Right => self - .compare - .right_ref - .with(&self.store, |s| s.trim().to_owned()), - }; - (!query.is_empty()).then(|| PickerEntry { - label: query.clone(), - detail: "Use typed ref".to_owned(), - value: query.clone(), - highlights: vec![(0, query.len())], - label_style: PickerLabelStyle::Default, - icon: None, - section_header: false, - }) - }); - let Some(entry) = entry else { - return Vec::new(); - }; - // Presets apply both refs at once; treat them as an explicit commit. - if let Some(rest) = entry.value.strip_prefix("@preset:") { - return self.apply_compare_preset(rest); - } - if let Some(ref store) = self.frecency { - store.record_access(&format!("ref:{}", entry.value)); - } - let _ = self.update_compare_field(field, entry.value); - // Auto-advance to the other chip if it's still at its snapshot — the - // user is likely changing both refs. Only commit when both chips have - // diverged from their snapshots (or neither, which is a no-op). - let other = match field { - CompareField::Left => CompareField::Right, - CompareField::Right => CompareField::Left, - }; - let other_current = match other { - CompareField::Left => self.compare.left_ref.get(&self.store), - CompareField::Right => self.compare.right_ref.get(&self.store), - }; - let other_original = match other { - CompareField::Left => self.overlays.ref_picker.original_left.get(&self.store), - CompareField::Right => self.overlays.ref_picker.original_right.get(&self.store), - }; - if other_current == other_original { - let scale = self.ui_scale_factor(); - self.overlays - .ref_picker - .active_field - .set(&self.store, other); - self.overlays.picker.kind.set( - &self.store, - match other { - CompareField::Left => PickerKind::LeftRef, - CompareField::Right => PickerKind::RightRef, - }, - ); - self.overlays.picker.selected_index.set(&self.store, 0); - self.overlays.picker.list.update(&self.store, |l| { - l.scroll_top_px = 0; - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - }); - let effects = self.rebuild_ref_picker(other); - let len = match other { - CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), - CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), - }; - self.reset_text_edit(len); - return effects; - } - // Both chips changed — commit. - self.commit_ref_picker() - } - - fn commit_ref_picker(&mut self) -> Vec { - let original_left = self.overlays.ref_picker.original_left.get(&self.store); - let original_right = self.overlays.ref_picker.original_right.get(&self.store); - let current_left = self.compare.left_ref.get(&self.store); - let current_right = self.compare.right_ref.get(&self.store); - let changed = current_left != original_left || current_right != original_right; - self.pop_overlay(); - let mut effects = self.persist_settings_effect(); - if !changed { - return effects; - } - let has_repo = self.compare.repo_path.with(&self.store, |p| p.is_some()); - let not_loading = self.workspace.status.get(&self.store) != AsyncStatus::Loading; - let refs_valid = compare_refs_are_valid( - self.compare.mode.get(&self.store), - ¤t_left, - ¤t_right, - ); - if has_repo && not_loading && refs_valid { - effects.extend(self.kickoff_compare()); - } - effects - } - - fn cancel_ref_picker(&mut self) -> Vec { - let left = self.overlays.ref_picker.original_left.get(&self.store); - let right = self.overlays.ref_picker.original_right.get(&self.store); - self.compare.left_ref.set(&self.store, left); - self.compare.right_ref.set(&self.store, right); - self.compare.resolved_left.set(&self.store, None); - self.compare.resolved_right.set(&self.store, None); - self.pop_overlay(); - Vec::new() - } - - fn set_active_ref_field(&mut self, field: CompareField) -> Vec { - if self.overlays_top() != Some(OverlaySurface::RefPicker) { - return Vec::new(); - } - let scale = self.ui_scale_factor(); - self.overlays - .ref_picker - .active_field - .set(&self.store, field); - self.overlays.picker.kind.set( - &self.store, - match field { - CompareField::Left => PickerKind::LeftRef, - CompareField::Right => PickerKind::RightRef, - }, - ); - self.overlays.picker.selected_index.set(&self.store, 0); - self.overlays.picker.list.update(&self.store, |l| { - l.scroll_top_px = 0; - l.row_height_px = (Sz::ROW * scale).round() as u32; - l.gap_px = (Sp::XS * scale).round() as u32; - }); - let effects = self.rebuild_ref_picker(field); - let len = match field { - CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), - CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), - }; - self.reset_text_edit(len); - effects - } - - fn swap_draft_refs(&mut self) -> Vec { - if self.overlays_top() != Some(OverlaySurface::RefPicker) { - return Vec::new(); - } - let left = self.compare.left_ref.get(&self.store); - let right = self.compare.right_ref.get(&self.store); - self.compare.left_ref.set(&self.store, right); - self.compare.right_ref.set(&self.store, left); - self.compare.resolved_left.set(&self.store, None); - self.compare.resolved_right.set(&self.store, None); - // Re-sync the search input to the active chip's new value. - let field = self.overlays.ref_picker.active_field.get(&self.store); - let len = match field { - CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), - CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), - }; - self.reset_text_edit(len); - self.rebuild_ref_picker(field) - } - - fn apply_compare_preset(&mut self, preset: &str) -> Vec { - let parts: Vec<&str> = preset.splitn(3, ':').collect(); - if parts.len() != 3 { - return Vec::new(); - } - let (left, right, mode_str) = (parts[0], parts[1], parts[2]); - let mode = match mode_str { - "commit" => CompareMode::SingleCommit, - "diff" => CompareMode::TwoDot, - _ => CompareMode::ThreeDot, - }; - let profile = self.vcs_ui_profile(); - let mode = if profile.accepts_compare_mode(mode) { - mode - } else { - profile.compare_modes()[0].mode - }; - self.workspace.pre_drill_compare.set(&self.store, None); - self.compare.left_ref.set(&self.store, left.to_owned()); - self.compare.right_ref.set(&self.store, right.to_owned()); - self.compare.resolved_left.set(&self.store, None); - self.compare.resolved_right.set(&self.store, None); - self.compare.mode.set(&self.store, mode); - self.pop_overlay(); - let mut effects = self.persist_settings_effect(); - if self.compare.repo_path.with(&self.store, |p| p.is_some()) { - effects.extend(self.kickoff_compare()); - } - effects - } - - fn confirm_command_palette(&mut self) -> Vec { - let selected = self - .overlays - .command_palette - .selected_index - .get(&self.store); - let Some(entry) = self - .overlays - .command_palette - .entries - .with(&self.store, |entries| entries.get(selected).cloned()) - else { - return Vec::new(); - }; - if entry.disabled { - return Vec::new(); - } - self.clear_overlays(); - match entry.kind { - PaletteEntryKind::Command(command) => { - match command { - PaletteCommand::OpenRepoPicker => { - self.open_repo_picker(); - Vec::new() - } - PaletteCommand::NewTextCompare => { - self.apply_action(crate::actions::WorkspaceAction::NewTextCompare) - } - PaletteCommand::OpenGitHubAuthModal => { - self.push_overlay( - OverlaySurface::GitHubAuthModal, - Some(FocusTarget::AuthPrimaryAction), - ); - Vec::new() - } - PaletteCommand::OpenGitHubAccountMenu => { - self.apply_action(crate::actions::GitHubAction::OpenAccountMenu) - } - PaletteCommand::SignOutGitHub => { - self.apply_action(crate::actions::GitHubAction::SignOutGitHub) - } - PaletteCommand::FocusFileList => { - self.set_focus(Some(FocusTarget::FileList)); - Vec::new() - } - PaletteCommand::FocusViewport => { - self.set_focus(Some(FocusTarget::Editor)); - Vec::new() - } - PaletteCommand::ShowWorkingTree => { - self.apply_action(crate::actions::WorkspaceAction::ShowWorkingTree) - } - PaletteCommand::RefreshRepository => { - self.apply_action(crate::actions::WorkspaceAction::RefreshRepository) - } - PaletteCommand::OpenBaseRefPicker => self.apply_action( - crate::actions::OverlayAction::OpenRefPicker(CompareField::Left), - ), - PaletteCommand::OpenHeadRefPicker => self.apply_action( - crate::actions::OverlayAction::OpenRefPicker(CompareField::Right), - ), - PaletteCommand::SwapRefs => { - self.apply_action(crate::actions::CompareAction::SwapRefs) - } - PaletteCommand::StartCompare => { - self.apply_action(crate::actions::CompareAction::StartCompare) - } - PaletteCommand::OpenCompareMenu => { - self.apply_action(crate::actions::CompareAction::OpenCompareMenu) - } - PaletteCommand::ShowKeyboardShortcuts => { - self.apply_action(crate::actions::SettingsAction::OpenKeymaps) - } - PaletteCommand::RestoreCompare => { - self.apply_action(crate::actions::CompareAction::ClearSidebarCommit) - } - PaletteCommand::ToggleSidebar => { - self.apply_action(crate::actions::FileListAction::ToggleSidebar) - } - PaletteCommand::ToggleFileTree => { - self.apply_action(crate::actions::FileListAction::ToggleSidebarMode) - } - PaletteCommand::ExpandAllFolders => { - self.apply_action(crate::actions::FileListAction::ExpandAllFolders) - } - PaletteCommand::CollapseAllFolders => { - self.apply_action(crate::actions::FileListAction::CollapseAllFolders) - } - PaletteCommand::ToggleWrap => { - self.apply_action(crate::actions::SettingsAction::ToggleWrap) - } - PaletteCommand::ToggleContinuousScroll => { - self.apply_action(crate::actions::SettingsAction::ToggleContinuousScroll) - } - PaletteCommand::SetSettingsSection(section) => self - .apply_action(crate::actions::SettingsAction::SetSettingsSection(section)), - PaletteCommand::SetThemeMode(mode) => { - self.apply_action(crate::actions::SettingsAction::SetThemeMode(mode)) - } - PaletteCommand::SetUiScalePct(pct) => { - self.apply_action(crate::actions::SettingsAction::SetUiScalePct(pct)) - } - PaletteCommand::SetWrapColumn(column) => { - self.apply_action(crate::actions::SettingsAction::SetWrapColumn(column)) - } - PaletteCommand::SetWheelScrollLines(lines) => self - .apply_action(crate::actions::SettingsAction::SetWheelScrollLines(lines)), - PaletteCommand::ToggleAutoUpdate => { - self.apply_action(crate::actions::SettingsAction::ToggleAutoUpdate) - } - PaletteCommand::ToggleThemeMode => { - self.apply_action(crate::actions::SettingsAction::ToggleThemeMode) - } - PaletteCommand::SetLayout(layout) => { - self.apply_action(crate::actions::CompareAction::SetLayoutMode(layout)) - } - PaletteCommand::SetRenderer(renderer) => { - self.apply_action(crate::actions::CompareAction::SetRenderer(renderer)) - } - PaletteCommand::ChangeTheme => { - self.apply_action(crate::actions::SettingsAction::OpenThemePicker) - } - PaletteCommand::SetTheme(name) => { - self.apply_action(crate::actions::SettingsAction::SetThemeName(name)) - } - PaletteCommand::ExpandAllContext => { - self.apply_action(crate::actions::EditorAction::ExpandAllContext) - } - PaletteCommand::ClearLineSelection => { - self.apply_action(crate::actions::RepositoryAction::ClearLineSelection) - } - PaletteCommand::GenerateCommitMessage => { - self.apply_action(crate::actions::AiAction::GenerateCommitMessage) - } - PaletteCommand::OpenReviewComment => { - self.apply_action(crate::actions::GitHubAction::OpenReviewCommentComposer) - } - PaletteCommand::OpenPullRequestInGitHub => { - self.apply_action(crate::actions::GitHubAction::OpenPullRequestInBrowser) - } - PaletteCommand::CheckForUpdates => { - self.apply_action(crate::actions::UpdateAction::CheckForUpdates) - } - PaletteCommand::InstallUpdate => { - self.apply_action(crate::actions::UpdateAction::InstallUpdate) - } - PaletteCommand::RestartToUpdate => { - self.apply_action(crate::actions::UpdateAction::RestartToUpdate) - } - PaletteCommand::RunOperation(operation) => { - self.confirm_or_run_vcs_operation(operation) - } - PaletteCommand::FetchOrigin => self.apply_action( - crate::actions::RepositoryAction::FetchRemote("origin".to_owned()), - ), - PaletteCommand::FetchAllRemotes => { - self.apply_action(crate::actions::RepositoryAction::FetchAllRemotes) - } - PaletteCommand::PushCurrentBranch => { - self.apply_action(crate::actions::RepositoryAction::PushCurrentBranch { - force_with_lease: false, - }) - } - PaletteCommand::PublishOptions => { - self.apply_action(crate::actions::RepositoryAction::OpenPublishMenu) - } - PaletteCommand::PushCurrentBranchForceWithLease => { - self.apply_action(crate::actions::RepositoryAction::PushCurrentBranch { - force_with_lease: true, - }) - } - PaletteCommand::PullCurrentBranch => { - self.apply_action(crate::actions::RepositoryAction::PullCurrentBranch) - } - PaletteCommand::OpenSettings => { - self.apply_action(crate::actions::SettingsAction::OpenSettings) - } - } - } - PaletteEntryKind::File(index) => self.select_file(index, true), - PaletteEntryKind::Commit(oid) => { - self.apply_action(crate::actions::CompareAction::SelectSidebarCommit(oid)) - } - PaletteEntryKind::Repo(path) => self.open_repository(path), - PaletteEntryKind::Ref(field, value) => { - let _ = self.update_compare_field(field, value); - self.persist_settings_effect() - } - PaletteEntryKind::PullRequest(key) => self.confirm_pr_entry(key), - } - } - - fn confirm_pr_entry(&mut self, key: PrKey) -> Vec { - if self.compare.repo_path.with(&self.store, |p| p.is_none()) { - self.push_error("Open a repository before loading a pull request."); - return Vec::new(); - } - let diff_state = self - .github - .pull_request - .cache - .with(&self.store, |c| c.get(&key).map(|e| e.diff.clone())); - match diff_state { - Some(PrPeekDiff::Ready { - left_ref, - right_ref, - .. - }) => { - self.github - .pull_request - .pending_confirm - .set(&self.store, None); - self.github.pull_request.active.set(&self.store, Some(key)); - self.apply_pr_compare(left_ref, right_ref) - } - Some(PrPeekDiff::Loading) | Some(PrPeekDiff::Idle) => { - self.github - .pull_request - .pending_confirm - .set(&self.store, Some(key.clone())); - self.push_info(&format!("Preparing PR #{}\u{2026}", key.2)); - Vec::new() - } - Some(PrPeekDiff::Failed(message)) => { - self.push_error(&message); - Vec::new() - } - None => { - self.push_error("Pull request not available."); - Vec::new() - } - } - } - - fn confirm_or_run_vcs_operation(&mut self, operation: VcsOperation) -> Vec { - let action = crate::actions::RepositoryAction::RunOperation(operation.clone()); - if let Some(message) = operation.confirmation_message() { - self.open_confirmation( - format!("Confirm {}", operation.label()), - message, - operation.label(), - action.into(), - ); - Vec::new() - } else { - self.apply_action(action) - } - } - - fn rebuild_repo_picker(&mut self) { - let query = self.overlays.picker.query.with(&self.store, |q| q.clone()); - let trimmed = query.trim(); - - if query_looks_like_path(trimmed) { - self.rebuild_repo_picker_browse(trimmed); - } else { - self.overlays.picker.browse_path.set(&self.store, None); - self.rebuild_repo_picker_recent(trimmed); - } - - let current_selected = self.overlays.picker.selected_index.get(&self.store); - let (entry_count, new_selected) = - self.overlays.picker.entries.with(&self.store, |entries| { - let entry_count = entries.len(); - let new_selected = if entries.is_empty() { - 0 - } else { - let first_selectable = - entries.iter().position(|e| !e.section_header).unwrap_or(0); - current_selected - .max(first_selectable) - .min(entries.len().saturating_sub(1)) - }; - (entry_count, new_selected) - }); - self.overlays - .picker - .selected_index - .set(&self.store, new_selected); - self.overlays.picker.list.update(&self.store, |l| { - l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); - l.clamp_scroll(entry_count); - }); - } - - fn rebuild_repo_picker_recent(&mut self, query: &str) { - let mut entries = Vec::new(); - - let all_repos = crate::core::frecency::recent_repo_paths(self.frecency.as_ref(), 20); - - let mut seen = HashSet::new(); - let mut unique_repos = Vec::new(); - for repo in &all_repos { - if seen.insert(repo.clone()) { - unique_repos.push(repo.clone()); - } - } - - if !unique_repos.is_empty() { - entries.push(PickerEntry { - label: "Recent".to_owned(), - detail: String::new(), - value: String::new(), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - icon: None, - section_header: true, - }); - } - - if query.is_empty() { - for repo in &unique_repos { - let display = repo.display().to_string(); - let is_repo = path_looks_like_repository(repo); - entries.push(PickerEntry { - label: repo - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(&display) - .to_owned(), - detail: display.clone(), - value: repo.display().to_string(), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - icon: Some(if is_repo { - lucide::FOLDER_GIT - } else { - lucide::FOLDER - }), - section_header: false, - }); - } - } else { - let haystack: Vec = unique_repos - .iter() - .map(|r| r.display().to_string()) - .collect(); - let haystack_refs: Vec<&str> = haystack.iter().map(|s| s.as_str()).collect(); - let config = neo_frizbee::Config { - max_typos: Some(2), - sort: false, - ..Default::default() - }; - let mut matches = neo_frizbee::match_list_indices(query, &haystack_refs, &config); - matches.sort_by(|a, b| b.score.cmp(&a.score)); - if matches.is_empty() { - entries.clear(); - } - for m in matches { - let repo = &unique_repos[m.index as usize]; - let display = &haystack[m.index as usize]; - let label = repo - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(display) - .to_owned(); - let highlights = - highlight_ranges_for_visible_match(query, &label, &m.indices, &config); - let is_repo = path_looks_like_repository(repo); - entries.push(PickerEntry { - label, - detail: display.clone(), - value: repo.display().to_string(), - highlights, - label_style: PickerLabelStyle::Default, - icon: Some(if is_repo { - lucide::FOLDER_GIT - } else { - lucide::FOLDER - }), - section_header: false, - }); - } - } - self.overlays.picker.entries.set(&self.store, entries); - } - - fn rebuild_repo_picker_browse(&mut self, query: &str) { - let expanded = expand_tilde(query); - let (dir_path, filter) = split_browse_query(&expanded); - - let dir = PathBuf::from(&dir_path); - if !dir.is_dir() { - self.overlays.picker.browse_path.set(&self.store, None); - self.overlays.picker.entries.set(&self.store, Vec::new()); - return; - } - - self.overlays - .picker - .browse_path - .set(&self.store, Some(dir.clone())); - - let mut entries = Vec::new(); - - if path_looks_like_repository(&dir) { - entries.push(PickerEntry { - label: "open this directory".to_owned(), - detail: String::new(), - value: format!("open:{}", dir.display()), - icon: Some(lucide::CORNER_UP_LEFT), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - section_header: false, - }); - } - - if dir.parent().is_some() { - entries.push(PickerEntry { - label: "..".to_owned(), - detail: String::new(), - value: dir - .parent() - .map(|p| p.display().to_string()) - .unwrap_or_default(), - icon: Some(lucide::CORNER_UP_LEFT), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - section_header: false, - }); - } - - let mut dirs: Vec<(String, PathBuf, bool)> = Vec::new(); - if let Ok(read) = std::fs::read_dir(&dir) { - for entry in read.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let name = entry.file_name().to_str().unwrap_or_default().to_owned(); - if name.starts_with('.') { - continue; - } - let is_repo = path_looks_like_repository(&path); - dirs.push((name, path, is_repo)); - } - } - - dirs.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); - - if filter.is_empty() { - for (name, path, is_repo) in &dirs { - entries.push(PickerEntry { - label: name.clone(), - detail: String::new(), - value: path.display().to_string(), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - icon: Some(if *is_repo { - lucide::FOLDER_GIT - } else { - lucide::FOLDER - }), - section_header: false, - }); - } - } else { - let haystack: Vec<&str> = dirs.iter().map(|(n, _, _)| n.as_str()).collect(); - let config = neo_frizbee::Config { - max_typos: Some(1), - sort: false, - ..Default::default() - }; - let mut matches = neo_frizbee::match_list_indices(filter, &haystack, &config); - matches.sort_by(|a, b| b.score.cmp(&a.score)); - for m in matches { - let (name, path, is_repo) = &dirs[m.index as usize]; - entries.push(PickerEntry { - label: name.clone(), - detail: String::new(), - value: path.display().to_string(), - highlights: highlight_ranges_from_match_indices(name, &m.indices), - label_style: PickerLabelStyle::Default, - icon: Some(if *is_repo { - lucide::FOLDER_GIT - } else { - lucide::FOLDER - }), - section_header: false, - }); - } - } - - self.overlays.picker.entries.set(&self.store, entries); - } - - fn rebuild_ref_picker(&mut self, field: CompareField) -> Vec { - let query_owned = match field { - CompareField::Left => self - .compare - .left_ref - .with(&self.store, |s| s.trim().to_owned()), - CompareField::Right => self - .compare - .right_ref - .with(&self.store, |s| s.trim().to_owned()), - }; - let query = query_owned.as_str(); - let mut seen = HashSet::new(); - - struct RefCandidate { - search_text: String, - label: String, - detail: String, - value: String, - icon: Option<&'static str>, - default_highlights: Vec<(usize, usize)>, - label_style: PickerLabelStyle, - ordinal: usize, - } - - let mut all_candidates = Vec::new(); - let mut ordinal = 0_usize; - - let mut push = |search_text: String, - label: String, - detail: String, - value: String, - icon: Option<&'static str>, - default_highlights: Vec<(usize, usize)>, - label_style: PickerLabelStyle| { - if !seen.insert(value.clone()) { - return; - } - all_candidates.push(RefCandidate { - search_text, - label, - detail, - value, - icon, - default_highlights, - label_style, - ordinal, - }); - ordinal += 1; - }; - - let profile = self.vcs_ui_profile(); - let refs = self.repository.refs.get(&self.store); - let changes = self.repository.changes.get(&self.store); - - for reference in &refs { - let value = reference.name.clone(); - let (kind_label, icon) = profile.ref_kind_label_and_icon(reference.kind); - let mut detail = kind_label.to_owned(); - if reference.active { - detail.push_str(" \u{2022} current"); - } - let mut search_text = format!("{} {detail}", reference.name); - if reference.target.id != reference.name { - search_text.push(' '); - search_text.push_str(&reference.target.id); - } - if reference.kind == RefKind::WorkingCopy - && let Some((detail_suffix, search_suffix)) = - profile.working_copy_ref_suffix(&changes) - { - detail.push_str(&detail_suffix); - search_text.push_str(&search_suffix); - } - push( - search_text, - reference.name.clone(), - detail, - value, - icon, - Vec::new(), - PickerLabelStyle::Default, - ); - } - - for change in &changes { - let entry = profile.change_ref_entry(change); - let label_style = entry - .prefix_len - .map(|prefix_len| PickerLabelStyle::JjChangeId { - prefix_len, - working_copy: entry.working_copy, - }) - .unwrap_or_default(); - push( - entry.search_text, - entry.label, - entry.detail, - entry.value, - Some(lucide::HASH), - entry.default_highlights, - label_style, - ); - } - - let mut needs_resolve = false; - - if query.is_empty() { - let entries = all_candidates - .into_iter() - .take(10) - .map(|c| PickerEntry { - label: c.label, - detail: c.detail, - value: c.value, - highlights: c.default_highlights, - label_style: c.label_style, - icon: c.icon, - section_header: false, - }) - .collect(); - self.overlays.picker.entries.set(&self.store, entries); - } else { - let haystack: Vec<&str> = all_candidates - .iter() - .map(|c| c.search_text.as_str()) - .collect(); - let config = neo_frizbee::Config { - max_typos: Some(2), - sort: false, - ..Default::default() - }; - let matches = neo_frizbee::match_list_indices(query, &haystack, &config); - let mut scored: Vec<_> = matches - .into_iter() - .map(|m| { - let c = &all_candidates[m.index as usize]; - ( - m.score, - c.ordinal, - PickerEntry { - label: c.label.clone(), - detail: c.detail.clone(), - value: c.value.clone(), - highlights: highlight_ranges_for_visible_match( - query, &c.label, &m.indices, &config, - ), - label_style: c.label_style, - icon: c.icon, - section_header: false, - }, - ) - }) - .collect(); - scored.sort_by(|a, b| { - b.0.cmp(&a.0) - .then(a.1.cmp(&b.1)) - .then(a.2.label.cmp(&b.2.label)) - }); - let mut entries = Vec::new(); - entries.extend(scored.into_iter().map(|(_, _, entry)| entry).take(10)); - if !entries.iter().any(|entry| entry.value == query) { - entries.insert( - 0, - PickerEntry { - label: query.to_owned(), - detail: "Resolving\u{2026}".to_owned(), - value: query.to_owned(), - highlights: vec![(0, query.len())], - label_style: PickerLabelStyle::Default, - icon: None, - section_header: false, - }, - ); - needs_resolve = true; - } - self.overlays.picker.entries.set(&self.store, entries); - } - - self.overlays.picker.entries.update(&self.store, |e| { - e.truncate(10); - }); - let entry_count = self.overlays.picker.entries.with(&self.store, |e| e.len()); - let current_selected = self.overlays.picker.selected_index.get(&self.store); - self.overlays.picker.selected_index.set( - &self.store, - current_selected.min(entry_count.saturating_sub(1)), - ); - self.overlays.picker.list.update(&self.store, |l| { - l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); - l.clamp_scroll(entry_count); - }); - - if needs_resolve { - if let Some(repo_path) = self.compare.repo_path.get(&self.store) { - let new_gen = self.overlays.picker.ref_resolve_generation.get(&self.store) + 1; - self.overlays - .picker - .ref_resolve_generation - .set(&self.store, new_gen); - return vec![ - CompareEffect::ResolveRef { - repo_path, - query: query.to_owned(), - generation: new_gen, - } - .into(), - ]; - } - } - Vec::new() - } - - fn rebuild_command_palette_if_open(&mut self) -> Vec { - if self.overlays_top() == Some(OverlaySurface::CommandPalette) { - self.rebuild_command_palette() - } else { - Vec::new() - } - } - - fn rebuild_command_palette(&mut self) -> Vec { - let query_owned = self - .overlays - .command_palette - .query - .with(&self.store, |q| q.trim().to_owned()); - let query = query_owned.as_str(); - - let mut out_effects = Vec::new(); - let mut pr_entry: Option = None; - - if let Some(parsed) = crate::core::forge::github::parse_pr_url(query) { - let key: PrKey = (parsed.owner.clone(), parsed.repo.clone(), parsed.number); - let token = self.github_access_token.clone(); - let repo_path = self.compare.repo_path.get(&self.store); - let supports_github_prs = repo_path.is_some() - && self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.github_pull_requests) - }); - - let already_cached = self - .github - .pull_request - .cache - .with(&self.store, |c| c.contains_key(&key)); - if !already_cached { - self.github.pull_request.cache.update(&self.store, |c| { - c.insert( - key.clone(), - PrCacheEntry { - meta: PrPeekMeta::Loading, - diff: PrPeekDiff::Idle, - last_peek_ms: self.clock_ms, - }, - ); - }); - out_effects.push( - GitHubEffect::PeekPullRequest { - owner: parsed.owner.clone(), - repo: parsed.repo.clone(), - number: parsed.number, - github_token: token.clone(), - } - .into(), - ); - } - - // Speculative diff load — kick off as soon as we know the key, provided - // a repo is open. Dedupe via the cache's diff state. - if supports_github_prs && let Some(repo_path) = repo_path.clone() { - let diff_idle = self.github.pull_request.cache.with(&self.store, |c| { - matches!(c.get(&key).map(|e| &e.diff), Some(PrPeekDiff::Idle) | None) - }); - if diff_idle { - self.github.pull_request.cache.update(&self.store, |c| { - if let Some(e) = c.get_mut(&key) { - e.diff = PrPeekDiff::Loading; - } - }); - let url = format!( - "https://github.com/{}/{}/pull/{}", - parsed.owner, parsed.repo, parsed.number - ); - out_effects.push( - GitHubEffect::LoadPullRequest { - url, - repo_path, - github_token: token, - } - .into(), - ); - } - } - - pr_entry = Some(build_pr_palette_entry( - &self.github.pull_request.cache.get(&self.store), - &key, - supports_github_prs, - )); - } - - struct PaletteCandidate { - search_text: String, - label: String, - detail: String, - kind: PaletteEntryKind, - } - - let mut all_candidates = Vec::new(); - let repo_capabilities = self.repository.capabilities.get(&self.store); - - for (label, detail, command) in [ - ( - "Choose Repository".to_owned(), - "Open repository picker".to_owned(), - PaletteCommand::OpenRepoPicker, - ), - ( - "New Text Compare".to_owned(), - "Compare arbitrary pasted text".to_owned(), - PaletteCommand::NewTextCompare, - ), - ( - "GitHub Sign In".to_owned(), - "Start device flow".to_owned(), - PaletteCommand::OpenGitHubAuthModal, - ), - ( - "GitHub Account Menu".to_owned(), - "Open GitHub account actions".to_owned(), - PaletteCommand::OpenGitHubAccountMenu, - ), - ( - "GitHub Sign Out".to_owned(), - "Remove the saved GitHub session".to_owned(), - PaletteCommand::SignOutGitHub, - ), - ( - "Focus File List".to_owned(), - "Move keyboard focus to sidebar".to_owned(), - PaletteCommand::FocusFileList, - ), - ( - "Focus Diff Viewport".to_owned(), - "Move keyboard focus to editor".to_owned(), - PaletteCommand::FocusViewport, - ), - ( - "Show Working Tree".to_owned(), - "Return to the repository working tree view".to_owned(), - PaletteCommand::ShowWorkingTree, - ), - ( - "Refresh Repository".to_owned(), - "Refresh status or rerun the current compare".to_owned(), - PaletteCommand::RefreshRepository, - ), - ( - "Select Base Ref".to_owned(), - "Open the left-side ref picker".to_owned(), - PaletteCommand::OpenBaseRefPicker, - ), - ( - "Select Head Ref".to_owned(), - "Open the right-side ref picker".to_owned(), - PaletteCommand::OpenHeadRefPicker, - ), - ( - "Swap Compare Refs".to_owned(), - "Swap the current base and head refs".to_owned(), - PaletteCommand::SwapRefs, - ), - ( - "Run Compare".to_owned(), - "Compare the selected refs now".to_owned(), - PaletteCommand::StartCompare, - ), - ( - "Open Compare Menu".to_owned(), - "Change compare mode or preset".to_owned(), - PaletteCommand::OpenCompareMenu, - ), - ( - "Keymaps".to_owned(), - "Review and rebind keyboard shortcuts".to_owned(), - PaletteCommand::ShowKeyboardShortcuts, - ), - ( - "Toggle Sidebar".to_owned(), - "Show or hide the file sidebar".to_owned(), - PaletteCommand::ToggleSidebar, - ), - ( - "Toggle File Tree".to_owned(), - "Switch sidebar between tree and flat list".to_owned(), - PaletteCommand::ToggleFileTree, - ), - ( - "Expand All Folders".to_owned(), - "Expand every folder in the file tree".to_owned(), - PaletteCommand::ExpandAllFolders, - ), - ( - "Collapse All Folders".to_owned(), - "Collapse every folder in the file tree".to_owned(), - PaletteCommand::CollapseAllFolders, - ), - ( - "Toggle Wrap".to_owned(), - "Enable or disable line wrapping".to_owned(), - PaletteCommand::ToggleWrap, - ), - ( - "Toggle Continuous Scroll".to_owned(), - "Switch between continuous and single-file diff navigation".to_owned(), - PaletteCommand::ToggleContinuousScroll, - ), - ( - "Toggle Theme".to_owned(), - "Switch light and dark mode".to_owned(), - PaletteCommand::ToggleThemeMode, - ), - ( - "Change Theme".to_owned(), - "Browse and preview color themes".to_owned(), - PaletteCommand::ChangeTheme, - ), - ( - "Use Unified Layout".to_owned(), - "Set unified diff mode".to_owned(), - PaletteCommand::SetLayout(LayoutMode::Unified), - ), - ( - "Use Split Layout".to_owned(), - "Set side-by-side diff mode".to_owned(), - PaletteCommand::SetLayout(LayoutMode::Split), - ), - ( - "Use Built-in Renderer".to_owned(), - "Render diffs with Diffy's built-in engine".to_owned(), - PaletteCommand::SetRenderer(RendererKind::Builtin), - ), - ( - "Use Difftastic Renderer".to_owned(), - "Render diffs with Difftastic".to_owned(), - PaletteCommand::SetRenderer(RendererKind::Difftastic), - ), - ( - "Expand All Context".to_owned(), - "Show all hidden context in the active diff".to_owned(), - PaletteCommand::ExpandAllContext, - ), - ( - "Clear Line Selection".to_owned(), - "Clear the current partial-line staging selection".to_owned(), - PaletteCommand::ClearLineSelection, - ), - ( - "Generate Commit Message".to_owned(), - "Draft a commit message from the current changes".to_owned(), - PaletteCommand::GenerateCommitMessage, - ), - ( - "Fetch origin".to_owned(), - "Update remote references from origin".to_owned(), - PaletteCommand::FetchOrigin, - ), - ( - "Fetch all remotes".to_owned(), - "Update remote references from every configured remote".to_owned(), - PaletteCommand::FetchAllRemotes, - ), - ( - "Pull current branch".to_owned(), - "Fast-forward the current Git branch from its upstream".to_owned(), - PaletteCommand::PullCurrentBranch, - ), - ( - self.vcs_ui_profile().publish_command_label().to_owned(), - self.vcs_ui_profile().publish_command_detail().to_owned(), - PaletteCommand::PushCurrentBranch, - ), - ( - "Publish options".to_owned(), - "Choose a backend-provided publish action".to_owned(), - PaletteCommand::PublishOptions, - ), - ( - "Push current branch (force with lease)".to_owned(), - "Force-push the current Git branch; refuse if upstream moved".to_owned(), - PaletteCommand::PushCurrentBranchForceWithLease, - ), - ( - "Open Settings".to_owned(), - "Configure appearance, editor, and behavior".to_owned(), - PaletteCommand::OpenSettings, - ), - ] { - if !palette_command_available(&command, repo_capabilities) { - continue; - } - let search_text = format!("{label} {detail}"); - all_candidates.push(PaletteCandidate { - search_text, - label, - detail, - kind: PaletteEntryKind::Command(command), - }); - } - - for section in SettingsSection::ALL { - let label = format!("Settings: {}", section.label()); - let detail = "Switch settings section".to_owned(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::SetSettingsSection(section)), - }); - } - for (label, detail, mode) in [ - ( - "Use Dark Mode", - "Set settings appearance to dark", - ThemeMode::Dark, - ), - ( - "Use Light Mode", - "Set settings appearance to light", - ThemeMode::Light, - ), - ] { - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label: label.to_owned(), - detail: detail.to_owned(), - kind: PaletteEntryKind::Command(PaletteCommand::SetThemeMode(mode)), - }); - } - for pct in [80, 90, 100, 110, 125, 150, 180] { - let label = format!("Set UI Scale {pct}%"); - let detail = "Change interface density".to_owned(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::SetUiScalePct(pct)), - }); - } - for (column, label_suffix) in [(0, "Auto"), (80, "80"), (100, "100"), (120, "120")] { - let label = format!("Set Wrap Column {label_suffix}"); - let detail = "Set line wrapping column".to_owned(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::SetWrapColumn(column)), - }); - } - for lines in [1, 2, 3, 5, 7] { - let label = format!("Set Mouse Wheel Speed {lines}"); - let detail = "Set lines scrolled per wheel notch".to_owned(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::SetWheelScrollLines(lines)), - }); - } - all_candidates.push(PaletteCandidate { - search_text: "Toggle Automatic Updates auto update".to_owned(), - label: "Toggle Automatic Updates".to_owned(), - detail: "Enable or disable hourly update checks".to_owned(), - kind: PaletteEntryKind::Command(PaletteCommand::ToggleAutoUpdate), - }); - all_candidates.push(PaletteCandidate { - search_text: "Check For Updates update release".to_owned(), - label: "Check For Updates".to_owned(), - detail: "Check Diffy's release channel now".to_owned(), - kind: PaletteEntryKind::Command(PaletteCommand::CheckForUpdates), - }); - match self.update.get(&self.store) { - UpdateState::Available(update) => { - let label = format!("Install Update {}", update.version); - let detail = "Download and verify the available update".to_owned(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::InstallUpdate), - }); - } - UpdateState::ReadyToRestart(update) => { - let label = format!("Restart To Update {}", update.update.version); - let detail = "Restart Diffy and apply the staged update".to_owned(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::RestartToUpdate), - }); - } - _ => {} - } - - let repo_location = self.repository.location.get(&self.store); - for operation in JjOperation::ALL.map(VcsOperation::Jj) { - if !vcs_operation_available_for_location(&operation, repo_location.as_ref()) { - continue; - } - let label = format!("jj: {}", operation.label()); - let detail = operation.detail(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), - }); - } - if repo_location - .as_ref() - .is_some_and(|location| location.profile == VCS_PROFILE_JJ) - { - let mut destinations = self.repository.refs.with(&self.store, |refs| { - refs.iter() - .filter(|reference| { - !reference.active - && matches!(reference.kind, RefKind::Bookmark | RefKind::Branch) - }) - .map(|reference| reference.name.clone()) - .collect::>() - }); - destinations.sort(); - destinations.dedup(); - for destination in destinations.into_iter().take(12) { - let operation = VcsOperation::JjRebaseCurrentChangeOnto { - destination: destination.clone(), - }; - let label = format!("jj: {}", operation.label()); - let detail = operation.detail(); - all_candidates.push(PaletteCandidate { - search_text: format!("{label} {detail}"), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), - }); - } - let changes = self.repository.changes.get(&self.store); - for change in changes - .iter() - .filter(|change| { - !change.flags.current && !change.flags.working_copy && !change.flags.immutable - }) - .take(12) - { - let change_label = change - .short_change_id - .as_deref() - .unwrap_or(change.short_revision.as_str()) - .to_owned(); - let operation = VcsOperation::JjEditRevision { - revision: change.revision.id.clone(), - label: change_label.clone(), - }; - let label = format!("jj: {}", operation.label()); - let detail = crate::ui::vcs::change_summary_label(change); - all_candidates.push(PaletteCandidate { - search_text: format!( - "{label} {detail} {} {}", - change.short_revision, change.revision.id - ), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), - }); - } - let operation_log = self.repository.operation_log.get(&self.store); - for entry in operation_log.iter().skip(1).take(12) { - let operation_label = entry.short_operation_id.clone(); - let operation = VcsOperation::JjRestoreOperation { - operation_id: entry.operation_id.clone(), - label: operation_label.clone(), - }; - let label = format!("jj: {}", operation.label()); - let detail = operation_log_entry_detail(entry); - all_candidates.push(PaletteCandidate { - search_text: format!( - "{label} {detail} {} {}", - entry.operation_id, entry.short_operation_id - ), - label, - detail, - kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), - }); - } - } - - if self - .workspace - .pre_drill_compare - .with(&self.store, |pre_drill| pre_drill.is_some()) - { - all_candidates.push(PaletteCandidate { - search_text: "Restore compare return range comparison commit drilldown".to_owned(), - label: "Restore Compare".to_owned(), - detail: "Return from the selected commit to the previous compare".to_owned(), - kind: PaletteEntryKind::Command(PaletteCommand::RestoreCompare), - }); - } - - if self - .editor - .line_selection - .with(&self.store, |selection| !selection.is_empty()) - { - all_candidates.push(PaletteCandidate { - search_text: "Comment on selected lines review pull request".to_owned(), - label: "Comment on Selected Lines".to_owned(), - detail: "Open the pull request review comment composer".to_owned(), - kind: PaletteEntryKind::Command(PaletteCommand::OpenReviewComment), - }); - } - - if self.active_pull_request_web_url().is_some() { - all_candidates.push(PaletteCandidate { - search_text: "Open pull request in GitHub browser web PR".to_owned(), - label: "Open Pull Request in GitHub".to_owned(), - detail: "Open the active pull request on github.com".to_owned(), - kind: PaletteEntryKind::Command(PaletteCommand::OpenPullRequestInGitHub), - }); - } - - let file_count = self.workspace_file_count(); - for index in 0..file_count { - let Some(file) = self.workspace_file_entry_at(index) else { - continue; - }; - let meta = self.file_list_entry_meta(index); - let detail = format!( - "File \u{2022} {} \u{2022} +{} -{}", - meta.status.label(), - meta.additions, - meta.deletions - ); - let search_text = format!("{} {detail}", file.path); - all_candidates.push(PaletteCandidate { - search_text, - label: file.path.to_string(), - detail, - kind: PaletteEntryKind::File(index), - }); - } - - let range_commits = self.workspace.range_commits.get(&self.store); - for change in &range_commits { - let label = crate::ui::vcs::change_summary_label(change); - let detail = format!("Commit {}", change.short_revision); - let search_text = format!("{} {} {}", change.short_revision, change.revision.id, label); - all_candidates.push(PaletteCandidate { - search_text, - label, - detail, - kind: PaletteEntryKind::Commit(change.revision.id.clone()), - }); - } - - let palette_repos = crate::core::frecency::recent_repo_paths(self.frecency.as_ref(), 10); - for repo in &palette_repos { - let repo_name = repo - .file_name() - .and_then(|name| name.to_str()) - .filter(|n| *n != ".") - .map(str::to_owned) - .unwrap_or_else(|| repo.display().to_string()); - let detail = repo.display().to_string(); - let search_text = format!("{repo_name} {detail}"); - all_candidates.push(PaletteCandidate { - search_text, - label: repo_name, - detail, - kind: PaletteEntryKind::Repo(repo.clone()), - }); - } - - let repo_refs = self.repository.refs.get(&self.store); - for reference in repo_refs.iter().filter(|reference| { - matches!( - reference.kind, - RefKind::Branch - | RefKind::RemoteBranch - | RefKind::Bookmark - | RefKind::RemoteBookmark - | RefKind::Tag - ) - }) { - let (detail, _) = self - .vcs_ui_profile() - .ref_kind_label_and_icon(reference.kind); - let search_text = format!("{} {}", reference.name, detail); - all_candidates.push(PaletteCandidate { - search_text, - label: reference.name.clone(), - detail: detail.to_owned(), - kind: PaletteEntryKind::Ref(CompareField::Left, reference.name.clone()), - }); - } - - let mut entries: Vec; - if query.is_empty() { - entries = all_candidates - .into_iter() - .map(|c| PaletteEntry { - label: c.label, - detail: c.detail, - kind: c.kind, - highlights: Vec::new(), - rhs: None, - disabled: false, - }) - .collect(); - } else { - let haystack: Vec<&str> = all_candidates - .iter() - .map(|c| c.search_text.as_str()) - .collect(); - let config = neo_frizbee::Config { - max_typos: Some(2), - sort: false, - ..Default::default() - }; - let matches = neo_frizbee::match_list_indices(query, &haystack, &config); - let mut scored: Vec<_> = matches - .into_iter() - .map(|m| { - let c = &all_candidates[m.index as usize]; - ( - m.score, - PaletteEntry { - label: c.label.clone(), - detail: c.detail.clone(), - kind: c.kind.clone(), - highlights: highlight_ranges_for_visible_match( - query, &c.label, &m.indices, &config, - ), - rhs: None, - disabled: false, - }, - ) - }) - .collect(); - scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.label.cmp(&b.1.label))); - entries = scored.into_iter().map(|(_, e)| e).collect(); - } - if let Some(pr) = pr_entry { - entries.insert(0, pr); - } - entries.truncate(18); - let entry_count = entries.len(); - self.overlays - .command_palette - .entries - .set(&self.store, entries); - let current_selected = self - .overlays - .command_palette - .selected_index - .get(&self.store); - self.overlays.command_palette.selected_index.set( - &self.store, - current_selected.min(entry_count.saturating_sub(1)), - ); - self.overlays.command_palette.list.update(&self.store, |l| { - l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); - l.clamp_scroll(entry_count); - }); - out_effects - } - - fn shift_loaded_file(&mut self, delta: isize) -> Vec { - let file_count = self.workspace_file_count(); - if file_count == 0 { - return Vec::new(); - } - let current = self.reconcile_selected_file_index_from_path().unwrap_or(0); - let next = if delta.is_negative() { - current.saturating_sub(delta.unsigned_abs()) - } else { - current - .saturating_add(delta as usize) - .min(file_count.saturating_sub(1)) - }; - self.select_file(next, true) - } - - fn select_file(&mut self, index: usize, reveal: bool) -> Vec { - if self.settings.continuous_scroll - && !matches!( - self.workspace.source.get(&self.store), - WorkspaceSource::None - ) - { - let target = self - .file_start_offset_px(index) - .min(self.global_max_scroll_top_px()); - self.set_viewport_anchor_for_global(target, ViewportAnchorBias::PreserveTop); - self.workspace.global_scroll_top_px.set(&self.store, target); - } - self.select_file_inner(index, reveal) - } - - fn select_file_inner(&mut self, index: usize, reveal: bool) -> Vec { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare => self.select_compare_file(index, reveal), - WorkspaceSource::TextCompare => self.select_text_compare_file(index, reveal), - WorkspaceSource::Status => self.select_status_item(index, reveal), - WorkspaceSource::None => { - self.startup.preferred_file_index = Some(index); - Vec::new() - } - } - } - - fn active_file_matches_workspace_file(&self, index: usize) -> bool { - let Some(path) = self.workspace_file_path_at(index) else { - return false; - }; - let source = self.workspace.source.get(&self.store); - let selected_bucket = self.workspace.selected_change_bucket.get(&self.store); - self.workspace.active_file.with(&self.store, |active| { - active.as_ref().is_some_and(|active| { - if active.index != index || active.path != path { - return false; - } - match source { - WorkspaceSource::Status => selected_bucket.is_some_and(|bucket| { - let (left_ref, right_ref) = self.status_refs_for_bucket(bucket); - active.left_ref == left_ref && active.right_ref == right_ref - }), - WorkspaceSource::Compare | WorkspaceSource::TextCompare => true, - WorkspaceSource::None => false, - } - }) - }) - } - - fn select_text_compare_file(&mut self, index: usize, reveal: bool) -> Vec { - let Some(entry) = self.workspace_file_entry_at(index) else { - self.push_error("Selected file index is out of range."); - return Vec::new(); - }; - let mut effects = vec![ - SyntaxEffect::EnsureSyntaxPackForPath { - path: entry.path.to_string(), - } - .into(), - ]; - effects.extend(self.select_loaded_compare_file(index, reveal)); - effects - } - - fn select_compare_file(&mut self, index: usize, reveal: bool) -> Vec { - let Some(entry) = self.workspace_file_entry_at(index) else { - self.push_error("Selected file index is out of range."); - return Vec::new(); - }; - - if !self.compare_file_is_large(index) { - let mut effects = vec![ - SyntaxEffect::EnsureSyntaxPackForPath { - path: entry.path.to_string(), - } - .into(), - ]; - effects.extend(self.select_loaded_compare_file(index, reveal)); - return effects; - } - - let entry_path = entry.path.to_string(); - - if let Some(mut active_file) = self.cached_compare_file_at(index, &entry_path) { - active_file.last_used_tick = self.next_file_working_set_tick(); - self.workspace - .selected_file_index - .set(&self.store, Some(index)); - self.workspace - .selected_file_path - .set(&self.store, Some(entry_path.clone())); - self.workspace.selected_change_bucket.set(&self.store, None); - self.workspace.active_file_loading.set(&self.store, None); - self.workspace - .active_file - .set(&self.store, Some(active_file.clone())); - self.cache_active_file(active_file); - self.compare_progress.set(&self.store, None); - self.editor_clear_document(); - self.file_list.hovered_index.set(&self.store, Some(index)); - if reveal { - self.reveal_file_list_row(index); - } - let mut effects = self.sync_editor_scroll_from_global(); - effects.push(SyntaxEffect::EnsureSyntaxPackForPath { path: entry_path }.into()); - effects.extend(self.request_active_file_syntax_effect()); - return effects; - } - - let should_load = self.should_enqueue_file_load( - index, - &entry_path, - CompareWorkPriority::InteractiveSelectedFile, - ); - - // If we're mid-compare (first file selection post-CompareFinished), - // flip the phase so the progress panel reports "Preparing first - // file…". Subsequent selections don't touch compare_progress. - self.compare_progress.update(&self.store, |slot| { - if let Some(p) = slot.as_mut() { - p.phase = ComparePhase::RenderingFirstFile; - } - }); - - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - self.push_error("Open a repository before selecting a compare file."); - return Vec::new(); - }; - let deferred_file = self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .and_then(|output| compare_output_deferred_summary(output, index)) - }); - - self.workspace - .selected_file_index - .set(&self.store, Some(index)); - self.workspace - .selected_file_path - .set(&self.store, Some(entry_path.clone())); - self.workspace.selected_change_bucket.set(&self.store, None); - self.workspace.active_file.set(&self.store, None); - self.workspace.active_file_loading.set( - &self.store, - Some(ActiveFileLoading { - index, - path: entry_path.clone(), - priority: CompareWorkPriority::InteractiveSelectedFile, - }), - ); - self.mark_file_cache_loading( - index, - entry_path.clone(), - CompareWorkPriority::InteractiveSelectedFile, - ); - self.editor_clear_document(); - self.file_list.hovered_index.set(&self.store, Some(index)); - if reveal { - self.reveal_file_list_row(index); - } - - let mut effects = vec![ - SyntaxEffect::EnsureSyntaxPackForPath { - path: entry_path.clone(), - } - .into(), - ]; - if should_load { - effects.push( - CompareEffect::LoadFile(Task { - generation: self.workspace.compare_generation.get(&self.store), - request: CompareFileRequest { - repo_path, - request: vcs_compare_request( - self.compare.mode.get(&self.store), - self.compare.left_ref.get(&self.store), - self.compare.right_ref.get(&self.store), - self.compare.layout.get(&self.store), - self.compare.renderer.get(&self.store), - ), - path: entry_path, - index, - deferred_file, - priority: CompareWorkPriority::InteractiveSelectedFile, - }, - }) - .into(), - ); - } - effects - } - - #[profiling::function] - fn select_loaded_compare_file(&mut self, index: usize, reveal: bool) -> Vec { - let mut selected_path = None; - let mut prepared = None; - let mut oob = false; - self.workspace - .compare_output - .update(&self.store, |maybe_output| { - let Some(output) = maybe_output.as_mut() else { - return; - }; - let Some(carbon_file) = output.carbon.files.get(index) else { - oob = true; - return; - }; - selected_path = Some(carbon_file.path().to_owned()); - prepared = Some(prepare_active_file(index, carbon_file)); - }); - - let Some(prepared) = prepared else { - if oob { - self.push_error("Selected file index is out of range."); - return Vec::new(); - } - self.startup.preferred_file_index = Some(index); - return Vec::new(); - }; - - let Some(path) = selected_path else { - self.startup.preferred_file_index = Some(index); - return Vec::new(); - }; - - self.install_compare_active_file(index, path, prepared); - if reveal { - self.reveal_file_list_row(index); - } - let mut effects = self.sync_editor_scroll_from_global(); - effects.extend(self.request_active_file_syntax_effect()); - effects - } - - fn reveal_file_list_row(&mut self, index: usize) { - let row_top = self.sidebar_row_index_for_file(index) as f32 * self.file_list_row_stride(); - let row_bottom = row_top + self.file_list.row_height.get(&self.store); - let scroll = self.file_list.scroll_offset_px.get(&self.store); - let viewport = self.file_list.viewport_height.get(&self.store); - if row_top < scroll { - self.file_list.scroll_offset_px.set(&self.store, row_top); - } else if row_bottom > scroll + viewport { - self.file_list - .scroll_offset_px - .set(&self.store, row_bottom - viewport); - } - self.file_list_clamp_scroll(self.sidebar_row_count()); - } - - fn select_status_item(&mut self, index: usize, reveal: bool) -> Vec { - let Some(file_change) = self - .workspace - .status_file_changes - .with(&self.store, |changes| changes.get(index).cloned()) - else { - tracing::warn!( - index, - "select_status_item: index out of range, returning empty" - ); - return Vec::new(); - }; - tracing::debug!( - index, - path = %file_change.path, - bucket = ?file_change.bucket, - status_gen = self.workspace.status_generation.get(&self.store), - "select_status_item: dispatching LoadStatusDiff" - ); - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - tracing::warn!("select_status_item: no repo_path"); - return Vec::new(); - }; - - self.workspace - .source - .set(&self.store, WorkspaceSource::Status); - // Keep the current document visible while the new diff loads — no - // Loading state, no tear-down. handle_status_diff_finished swaps the - // ActiveFile atomically when the fresh diff arrives. - self.workspace - .selected_file_index - .set(&self.store, Some(index)); - self.workspace - .selected_file_path - .set(&self.store, Some(file_change.path.clone())); - self.workspace - .selected_change_bucket - .set(&self.store, Some(file_change.bucket)); - let (left_ref, right_ref) = self.status_refs_for_bucket(file_change.bucket); - let active_matches_selection = self.workspace.active_file.with(&self.store, |af| { - af.as_ref().is_some_and(|active| { - active.index == index - && active.path == file_change.path - && active.left_ref == left_ref - && active.right_ref == right_ref - }) - }); - if active_matches_selection { - self.workspace.active_file_loading.set(&self.store, None); - self.clear_file_cache_loading(index); - self.file_list.hovered_index.set(&self.store, Some(index)); - if reveal { - self.reveal_file_list_row(index); - } - let mut effects = self.sync_editor_scroll_from_global(); - effects.push(ensure_syntax_packs_for_file_change_effect(&file_change)); - effects.extend(self.request_active_file_syntax_effect()); - return effects; - } else if let Some(mut active_file) = self.cached_status_file_at(index, &file_change) { - active_file.last_used_tick = self.next_file_working_set_tick(); - self.workspace.active_file_loading.set(&self.store, None); - self.workspace - .active_file - .set(&self.store, Some(active_file.clone())); - self.cache_active_file(active_file); - self.editor_clear_document(); - self.file_list.hovered_index.set(&self.store, Some(index)); - if reveal { - self.reveal_file_list_row(index); - } - let mut effects = self.sync_editor_scroll_from_global(); - effects.push(ensure_syntax_packs_for_file_change_effect(&file_change)); - effects.extend(self.request_active_file_syntax_effect()); - return effects; - } else { - let should_load = self.should_enqueue_file_load( - index, - &file_change.path, - CompareWorkPriority::InteractiveSelectedFile, - ); - self.workspace.active_file_loading.set( - &self.store, - Some(ActiveFileLoading { - index, - path: file_change.path.clone(), - priority: CompareWorkPriority::InteractiveSelectedFile, - }), - ); - self.mark_file_cache_loading( - index, - file_change.path.clone(), - CompareWorkPriority::InteractiveSelectedFile, - ); - self.file_list.hovered_index.set(&self.store, Some(index)); - if reveal { - self.reveal_file_list_row(index); - } - - let mut effects = vec![ensure_syntax_packs_for_file_change_effect(&file_change)]; - if should_load { - let generation = self.workspace.status_generation.get(&self.store); - let renderer = self.compare.renderer.get(&self.store); - effects.push( - RepositoryEffect::LoadStatusDiff { - task: Task { - generation, - request: StatusDiffRequest { - repo_path, - file_change, - renderer, - }, - }, - index, - } - .into(), - ); - } - return effects; - } - } - - fn apply_selected_status_operation(&mut self, operation: FileOperation) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.staging_area) - }) - { - self.push_error("This repository backend does not support staging operations."); - return Vec::new(); - } - if self.workspace.source.get(&self.store) != WorkspaceSource::Status { - return Vec::new(); - } - if self.workspace.status_operation_pending.get(&self.store) { - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - let Some(index) = self.workspace.selected_file_index.get(&self.store) else { - return Vec::new(); - }; - let Some(file_change) = self - .workspace - .status_file_changes - .with(&self.store, |changes| changes.get(index).cloned()) - else { - return Vec::new(); - }; - - self.workspace - .status_operation_pending - .set(&self.store, true); - vec![ - RepositoryEffect::ApplyFileOperation(FileOperationRequest { - repo_path, - file_change, - operation, - }) - .into(), - ] - } - - fn apply_file_status_operation( - &mut self, - index: usize, - operation: FileOperation, - ) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.staging_area) - }) - { - self.push_error("This repository backend does not support staging operations."); - return Vec::new(); - } - if self.workspace.source.get(&self.store) != WorkspaceSource::Status { - return Vec::new(); - } - if self.workspace.status_operation_pending.get(&self.store) { - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - let Some(file_change) = self - .workspace - .status_file_changes - .with(&self.store, |changes| changes.get(index).cloned()) - else { - return Vec::new(); - }; - - self.workspace - .status_operation_pending - .set(&self.store, true); - vec![ - RepositoryEffect::ApplyFileOperation(FileOperationRequest { - repo_path, - file_change, - operation, - }) - .into(), - ] - } - - fn apply_batch_scope_operation( - &mut self, - buckets: &[ChangeBucket], - operation: FileOperation, - ) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.staging_area) - }) - { - self.push_error("This repository backend does not support staging operations."); - return Vec::new(); - } - if self.workspace.source.get(&self.store) != WorkspaceSource::Status { - return Vec::new(); - } - if self.workspace.status_operation_pending.get(&self.store) { - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - let file_changes: Vec = - self.workspace - .status_file_changes - .with(&self.store, |changes| { - changes - .iter() - .filter(|change| buckets.contains(&change.bucket)) - .cloned() - .collect() - }); - if file_changes.is_empty() { - return Vec::new(); - } - - self.workspace - .status_operation_pending - .set(&self.store, true); - vec![ - RepositoryEffect::ApplyBatchFileOperation(BatchFileOperationRequest { - repo_path, - file_changes, - operation, - }) - .into(), - ] - } - - fn current_hunk_index_from_hover(&self) -> Option { - self.editor - .hovered_hunk_index - .get(&self.store) - .or_else(|| self.editor_current_hunk_index().map(|(idx, _)| idx as i16)) - } - - fn current_render_line_index_from_hover(&self) -> Option { - self.editor - .hovered_render_line_index - .get(&self.store) - .or_else(|| self.editor.hovered_row.get(&self.store)) - } - - fn apply_hunk_operation( - &mut self, - operation: FileOperation, - explicit_hunk: Option, - ) -> Vec { - tracing::debug!( - ?operation, - ?explicit_hunk, - source = ?self.workspace.source.get(&self.store), - pending = self.workspace.status_operation_pending.get(&self.store), - hovered_row = ?self.editor.hovered_row.get(&self.store), - hovered_hunk_index = ?self.editor.hovered_hunk_index.get(&self.store), - "apply_hunk_operation: entered" - ); - if self.workspace.source.get(&self.store) != WorkspaceSource::Status { - tracing::debug!("apply_hunk_operation: bail: source != Status"); - return Vec::new(); - } - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.partial_hunk_mutation) - }) - { - self.push_error("This repository backend does not support hunk operations."); - return Vec::new(); - } - if self.workspace.status_operation_pending.get(&self.store) { - tracing::debug!("apply_hunk_operation: bail: status_operation_pending=true"); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - tracing::debug!("apply_hunk_operation: bail: no repo_path"); - return Vec::new(); - }; - let Some(bucket) = self.workspace.selected_change_bucket.get(&self.store) else { - tracing::debug!("apply_hunk_operation: bail: no selected_change_bucket"); - return Vec::new(); - }; - let resolved = explicit_hunk.or_else(|| self.current_hunk_index_from_hover()); - let hunk_index = match resolved { - Some(idx) if idx >= 0 => idx as usize, - _ => { - tracing::debug!(?resolved, "apply_hunk_operation: bail: no hunk_index"); - return Vec::new(); - } - }; - - let patch_text = self.workspace.active_file.with(&self.store, |af| { - let active = af.as_ref()?; - patch::format_carbon_hunk_patch( - &active.carbon_file, - hunk_index, - operation != FileOperation::Stage, - ) - }); - let Some(patch) = patch_text else { - tracing::debug!( - hunk_index, - "apply_hunk_operation: bail: format_hunk_patch returned None" - ); - return Vec::new(); - }; - - tracing::debug!( - ?operation, - hunk_index, - "apply_hunk_operation: dispatching ApplyPatchOperation" - ); - self.workspace - .status_operation_pending - .set(&self.store, true); - vec![ - RepositoryEffect::ApplyPatchOperation(PatchOperationRequest { - repo_path, - patch, - bucket, - operation, - }) - .into(), - ] - } - - fn toggle_line_selection(&mut self, row: usize, _extend: bool) { - let line_opt = self.workspace.active_file.with(&self.store, |af| { - af.as_ref() - .and_then(|active| active.render_doc.lines.get(row).copied()) - }); - let Some(line) = line_opt else { - return; - }; - let kind = line.row_kind(); - if !matches!( - kind, - crate::editor::diff::render_doc::RenderRowKind::Added - | crate::editor::diff::render_doc::RenderRowKind::Removed - | crate::editor::diff::render_doc::RenderRowKind::Modified - ) { - return; - } - if line.hunk_index < 0 { - return; - } - let hunk_id = line.hunk_index as u32; - self.editor.line_selection.update(&self.store, |ls| { - if line.old_line_index >= 0 { - ls.toggle(hunk_id, carbon::DiffSide::Old, line.old_line_index as u32); - } - if line.new_line_index >= 0 { - ls.toggle(hunk_id, carbon::DiffSide::New, line.new_line_index as u32); - } - ls.last_toggled_row = Some(row); - }); - } - - fn toggle_line_selection_range(&mut self, row: usize, anchor: usize) { - self.insert_line_selection_range(row, anchor, false); - } - - fn set_line_selection_range(&mut self, row: usize, anchor: usize) { - self.insert_line_selection_range(row, anchor, true); - } - - fn insert_line_selection_range(&mut self, row: usize, anchor: usize, clear_first: bool) { - let (start, end) = if row <= anchor { - (row, anchor) - } else { - (anchor, row) - }; - let lines = self.workspace.active_file.with(&self.store, |af| { - let Some(active) = af.as_ref() else { - return Vec::new(); - }; - (start..=end) - .filter_map(|r| active.render_doc.lines.get(r).copied()) - .collect::>() - }); - if lines.is_empty() { - return; - } - // Staging only selects changed lines; in PR review mode a comment can anchor - // to any line (incl. context), like GitHub. - let review = self.pull_request_review_enabled(); - self.editor.line_selection.update(&self.store, |ls| { - if clear_first { - ls.clear(); - } - for line in &lines { - use crate::editor::diff::render_doc::RenderRowKind; - let kind = line.row_kind(); - if !kind.is_body() || line.hunk_index < 0 { - continue; - } - if !review - && !matches!( - kind, - RenderRowKind::Added | RenderRowKind::Removed | RenderRowKind::Modified - ) - { - continue; - } - let hunk_id = line.hunk_index as u32; - if line.old_line_index >= 0 { - ls.entries - .insert(crate::editor::diff::state::LineSelectionKey { - file_path: None, - hunk_id, - side: carbon::DiffSide::Old, - source_index: line.old_line_index as u32, - }); - } - if line.new_line_index >= 0 { - ls.entries - .insert(crate::editor::diff::state::LineSelectionKey { - file_path: None, - hunk_id, - side: carbon::DiffSide::New, - source_index: line.new_line_index as u32, - }); - } - } - ls.last_toggled_row = Some(row); - }); - } - - fn toggle_current_line_selection(&mut self) { - let Some(row) = self.current_render_line_index_from_hover() else { - self.push_error("Move the row cursor to a changed line before selecting lines."); - return; - }; - self.toggle_line_selection(row, false); - } - - fn toggle_current_line_selection_range(&mut self) { - let Some(row) = self.current_render_line_index_from_hover() else { - self.push_error("Move the row cursor to a changed line before selecting lines."); - return; - }; - let anchor = self - .editor - .line_selection - .with(&self.store, |ls| ls.last_toggled_row); - if let Some(anchor) = anchor { - self.toggle_line_selection_range(row, anchor); - } else { - self.toggle_line_selection(row, false); - } - } - - fn apply_line_selection_operation(&mut self, operation: FileOperation) -> Vec { - if self.workspace.source.get(&self.store) != WorkspaceSource::Status { - return Vec::new(); - } - if self.workspace.status_operation_pending.get(&self.store) { - return Vec::new(); - } - if self - .editor - .line_selection - .with(&self.store, |ls| ls.is_empty()) - { - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.get(&self.store) else { - return Vec::new(); - }; - let Some(bucket) = self.workspace.selected_change_bucket.get(&self.store) else { - return Vec::new(); - }; - let reverse = operation != FileOperation::Stage; - - let (hunk_indices, selection_snapshot) = - self.editor.line_selection.with(&self.store, |ls| { - let indices: Vec = ls - .entries - .iter() - .map(|key| key.hunk_id) - .collect::>() - .into_iter() - .collect(); - (indices, ls.clone()) - }); - - let patches = self.workspace.active_file.with(&self.store, |af| { - let Some(active) = af.as_ref() else { - return Vec::new(); - }; - let mut patches = Vec::new(); - for hunk_idx in hunk_indices { - let selected = selection_snapshot - .selected_lines_for_hunk(hunk_idx) - .into_iter() - .map(|key| patch::CarbonLineSelection { - side: key.side, - source_index: key.source_index, - }) - .collect::>(); - let patch = patch::format_carbon_lines_patch( - &active.carbon_file, - carbon::u32_to_usize_saturating(hunk_idx), - &selected, - reverse, - ); - if let Some(p) = patch { - patches.push(p); - } - } - patches - }); - - self.editor - .line_selection - .update(&self.store, |ls| ls.clear()); - - if patches.is_empty() { - return Vec::new(); - } - - self.workspace - .status_operation_pending - .set(&self.store, true); - patches - .into_iter() - .map(|p| { - RepositoryEffect::ApplyPatchOperation(PatchOperationRequest { - repo_path: repo_path.clone(), - patch: p, - bucket, - operation, - }) - .into() - }) - .collect() - } - - fn scroll_viewport_lines(&mut self, delta_lines: i32) -> Vec { - let step_px = 20_i32; - let delta_px = delta_lines.saturating_mul(step_px); - self.scroll_viewport_px(delta_px) - } - - fn scroll_active_overlay_list_px(&mut self, delta_px: i32) { - match self.overlays_top() { - Some( - OverlaySurface::RepoPicker - | OverlaySurface::RefPicker - | OverlaySurface::ThemePicker - | OverlaySurface::FontPicker, - ) => { - let count = self.overlays.picker.entries.with(&self.store, |e| e.len()); - self.overlays - .picker - .list - .update(&self.store, |l| l.scroll_px(delta_px, count)); - } - Some(OverlaySurface::CommandPalette) => { - let count = self - .overlays - .command_palette - .entries - .with(&self.store, |e| e.len()); - self.overlays - .command_palette - .list - .update(&self.store, |l| l.scroll_px(delta_px, count)); - } - _ => {} - } - } - - fn scroll_viewport_px(&mut self, delta_px: i32) -> Vec { - if !self.settings.continuous_scroll { - let current = self.editor.scroll_top_px.get(&self.store); - let max = self.editor_max_scroll_top_px(); - let next = apply_scroll_delta_px(current, delta_px, max); - self.editor.scroll_top_px.set(&self.store, next); - return Vec::new(); - } - - if delta_px == 0 { - return Vec::new(); - } - - let current = self.workspace.global_scroll_top_px.get(&self.store); - let target = apply_scroll_delta_px(current, delta_px, self.global_max_scroll_top_px()); - self.scroll_viewport_to_global(target) - } - - fn clear_file_scroll_layout(&mut self) { - self.workspace - .file_content_heights - .set(&self.store, Vec::new()); - self.workspace - .file_scroll_total_height_px - .set(&self.store, 0); - self.workspace - .pending_file_content_heights - .set(&self.store, HashMap::new()); - self.workspace - .file_scroll_recompute_pending - .set(&self.store, false); - self.workspace - .viewport_scrollbar_drag - .set(&self.store, None); - self.virtual_diff_document.clear(); - self.virtual_scroll.clear(); - self.last_virtual_scroll_top_px = None; - } - - fn reset_file_scroll_layout(&mut self) { - self.workspace - .file_content_heights - .set(&self.store, Vec::new()); - self.workspace - .pending_file_content_heights - .set(&self.store, HashMap::new()); - self.workspace - .file_scroll_recompute_pending - .set(&self.store, false); - self.workspace - .viewport_scrollbar_drag - .set(&self.store, None); - self.virtual_scroll.clear(); - self.last_virtual_scroll_top_px = None; - self.recompute_file_scroll_total_height_px(); - } - - pub fn recompute_file_scroll_total_height_px(&mut self) { - let count = self.workspace_file_count(); - let source = self.workspace.source.get(&self.store); - let generation = self.workspace_render_generation(); - if self - .virtual_diff_document - .sync_identity(source, generation, count) - { - self.virtual_scroll.clear(); - self.last_virtual_scroll_top_px = None; - } - self.workspace - .file_content_heights - .update(&self.store, |heights| { - if heights.len() > count { - heights.truncate(count); - } - }); - - let heights = (0..count) - .map(|index| self.file_scroll_height_px(index).max(1)) - .collect::>(); - self.virtual_diff_document.rebuild_heights(heights); - let total = self.virtual_diff_document.total_u32(); - self.workspace - .file_scroll_total_height_px - .set(&self.store, total); - } - - fn update_file_scroll_heights(&mut self, old_heights: Vec<(usize, u32)>) { - let count = self.workspace_file_count(); - if self.virtual_diff_document.len() != count { - self.recompute_file_scroll_total_height_px(); - return; - } - - let mut total = self.workspace.file_scroll_total_height_px.get(&self.store); - for (index, old_height) in old_heights { - if index >= count { - continue; - } - let new_height = self.file_scroll_height_px(index).max(1); - total = total.saturating_sub(old_height).saturating_add(new_height); - self.virtual_diff_document.update_height(index, new_height); - } - self.workspace - .file_scroll_total_height_px - .set(&self.store, total); - } - - pub fn update_file_content_height_px(&mut self, index: usize, height: u32) -> bool { - let count = self.workspace_file_count(); - if index >= count || height == 0 { - return false; - } - if self.settings.continuous_scroll - && self - .workspace - .viewport_scrollbar_drag - .get(&self.store) - .is_some() - { - self.workspace - .pending_file_content_heights - .update(&self.store, |pending| { - pending.insert(index, height); - }); - return false; - } - if self.virtual_diff_document.len() != count { - self.recompute_file_scroll_total_height_px(); - } - - let old_slot_height = self.file_scroll_height_px(index); - let old_total = self.total_diff_height_px(); - let anchor = self - .settings - .continuous_scroll - .then(|| self.current_or_derived_viewport_anchor()) - .flatten(); - let row_count = self.workspace_file_row_count(index); - let mut recorded_changed = false; - self.workspace - .file_content_heights - .update(&self.store, |heights| { - if heights.len() < count { - heights.resize(count, None); - } - if heights[index] != Some(height) { - heights[index] = Some(height); - recorded_changed = true; - } - }); - - let mut calibration_initialized = false; - if let Some(rows) = row_count - && rows > 0 - { - let sample_q16 = (u64::from(height) << 16) / u64::from(rows); - let prev = self.workspace.measured_px_per_row_q16.get(&self.store); - let next = if prev == 0 { - calibration_initialized = true; - sample_q16 as u32 - } else { - (((u64::from(prev) * 7) + sample_q16) / 8) as u32 - }; - self.workspace - .measured_px_per_row_q16 - .set(&self.store, next); - } - - if calibration_initialized { - self.recompute_file_scroll_total_height_px(); - } - - if recorded_changed { - let new_slot_height = self.file_scroll_height_px(index); - let slot_height_changed = new_slot_height != old_slot_height; - if calibration_initialized { - self.workspace - .file_scroll_total_height_px - .set(&self.store, self.virtual_diff_document.total_u32()); - } else { - let next_total = old_total - .saturating_sub(old_slot_height) - .saturating_add(new_slot_height); - self.workspace - .file_scroll_total_height_px - .set(&self.store, next_total); - self.virtual_diff_document - .update_height(index, new_slot_height.max(1)); - } - - if self.settings.continuous_scroll - && slot_height_changed - && let Some(anchor) = anchor - { - self.rebase_viewport_anchor(anchor); - } - } - - recorded_changed && old_slot_height != self.file_scroll_height_px(index) - } - - pub fn update_virtual_diff_item_height_px( - &mut self, - item_id: VirtualDiffItemId, - height: u32, - ) -> bool { - if item_id.kind != VirtualDiffItemKind::File - || item_id.source != self.workspace.source.get(&self.store) - || item_id.generation != self.workspace_render_generation() - { - return false; - } - self.update_file_content_height_px(item_id.index, height) - } - - pub fn virtual_stream_item( - &self, - file_index: usize, - kind: VirtualDiffItemKind, - ordinal: u32, - stable_key: u64, - sort_key: u64, - measured_height_px: Option, - ) -> VirtualDiffStreamItem { - VirtualDiffStreamItem::new( - VirtualDiffItemId::new( - self.workspace.source.get(&self.store), - self.workspace_render_generation(), - kind, - file_index, - ordinal, - stable_key, - ), - sort_key, - measured_height_px.unwrap_or_else(|| estimated_virtual_item_height_px(kind)), - measured_height_px, - ) - } - - fn virtual_stream_items_for_viewport_doc( - &self, - source: WorkspaceSource, - generation: u64, - slots: &[ViewportSlotKey], - doc: &RenderDoc, - ) -> Vec { - let mut items = Vec::new(); - let mut slot_pos = None::; - let mut local_ordinal = 0_u32; - - for (line_index, line) in doc.lines.iter().enumerate() { - if line.row_kind() == RenderRowKind::FileHeader { - slot_pos = Some(slot_pos.map_or(0, |pos| pos.saturating_add(1))); - local_ordinal = 0; - } - - let Some(slot) = slot_pos.and_then(|pos| slots.get(pos)) else { - continue; - }; - let Some(kind) = virtual_stream_item_kind(slot, line) else { - continue; - }; - let ordinal = match kind { - VirtualDiffItemKind::FileHeader => 0, - VirtualDiffItemKind::Hunk if line.hunk_index >= 0 => line.hunk_index as u32, - _ => local_ordinal, - }; - - items.push(VirtualDiffStreamItem::new( - VirtualDiffItemId::new( - source, - generation, - kind, - slot.index, - ordinal, - virtual_row_stable_key(line, ordinal), - ), - virtual_row_sort_key(line_index), - estimated_virtual_item_height_px(kind), - None, - )); - local_ordinal = local_ordinal.saturating_add(1); - } - - items - } - - fn file_scroll_height_px(&self, index: usize) -> u32 { - self.workspace - .file_content_heights - .with(&self.store, |heights| heights.get(index).copied().flatten()) - .unwrap_or_else(|| self.estimated_file_height_px(index)) - } - - fn viewport_file_scroll_height_px(&self, index: usize) -> u32 { - if let Some(height) = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| { - drag.as_ref() - .and_then(|drag| drag.file_heights_px.get(index).copied()) - }) - { - return height; - } - self.file_scroll_height_px(index) - } - - pub fn workspace_file_count(&self) -> usize { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - let count = self.workspace.compare_output.with(&self.store, |output| { - output.as_ref().map(CompareOutput::file_count).unwrap_or(0) - }); - count.max(self.workspace.files.with(&self.store, |f| f.len())) - } - WorkspaceSource::Status => self - .workspace - .status_file_changes - .with(&self.store, |s| s.len()), - WorkspaceSource::None => self.workspace.files.with(&self.store, |f| f.len()), - } - } - - pub fn workspace_file_path_at(&self, index: usize) -> Option { - self.workspace_file_entry_at(index) - .map(|entry| entry.path.to_string()) - } - - pub fn selected_workspace_file_index(&self) -> Option { - let count = self.workspace_file_count(); - let selected_index = self - .workspace - .selected_file_index - .get(&self.store) - .filter(|index| *index < count); - - if let Some(path) = self.workspace.selected_file_path.get(&self.store) { - if let Some(index) = selected_index - && self - .workspace_file_entry_at(index) - .is_some_and(|entry| entry.path == path.as_str()) - { - return Some(index); - } - if let Some(index) = self.workspace_file_index_for_path(&path) { - return Some(index); - } - } - - selected_index - } - - fn reconcile_selected_file_index_from_path(&mut self) -> Option { - let resolved = self.selected_workspace_file_index(); - if let Some(index) = resolved - && self.workspace.selected_file_index.get(&self.store) != Some(index) - { - self.workspace - .selected_file_index - .set(&self.store, Some(index)); - } - resolved - } - - pub fn workspace_render_generation(&self) -> u64 { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare => self.workspace.compare_generation.get(&self.store), - WorkspaceSource::TextCompare => self.text_compare.generation, - WorkspaceSource::Status => self.workspace.status_generation.get(&self.store), - WorkspaceSource::None => 0, - } - } - - pub fn estimated_file_height_px(&self, index: usize) -> u32 { - const BASELINE_ROWS: u32 = 8; - let row_height_q16 = { - let cal = self.workspace.measured_px_per_row_q16.get(&self.store); - if cal == 0 { 24_u32 << 16 } else { cal } - }; - let row_height_px = - |rows: u32| ((u64::from(rows) * u64::from(row_height_q16)) >> 16) as u32; - - if matches!( - self.workspace.source.get(&self.store), - WorkspaceSource::Compare | WorkspaceSource::TextCompare - ) && let Some(rows) = self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .and_then(|output| output.carbon.files.get(index)) - .map(estimated_carbon_file_rows_with_overhead) - }) { - return row_height_px(rows); - } - - let line_count = match self.workspace.source.get(&self.store) { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - if index < self.workspace_file_count() { - let meta = self.file_list_entry_meta(index); - meta.additions.saturating_add(meta.deletions).max(1) as u32 + BASELINE_ROWS - } else { - BASELINE_ROWS - } - } - WorkspaceSource::Status => BASELINE_ROWS, - WorkspaceSource::None => BASELINE_ROWS, - }; - row_height_px(line_count) - } - - fn workspace_file_row_count(&self, index: usize) -> Option { - if !matches!( - self.workspace.source.get(&self.store), - WorkspaceSource::Compare | WorkspaceSource::TextCompare - ) { - return None; - } - self.workspace.compare_output.with(&self.store, |output| { - output - .as_ref() - .and_then(|output| output.carbon.files.get(index)) - .map(estimated_carbon_file_rows_with_overhead) - }) - } - - pub fn total_diff_height_px(&self) -> u32 { - if let Some(total) = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| { - drag.as_ref().map(|drag| drag.metrics.content_height_px) - }) - { - return total; - } - let cached = self.workspace.file_scroll_total_height_px.get(&self.store); - if cached > 0 || self.workspace_file_count() == 0 { - return cached; - } - - self.virtual_diff_document.total_u32() - } - - pub fn file_start_offset_px(&self, index: usize) -> u32 { - if self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| drag.is_none()) - && self.virtual_diff_document.len() == self.workspace_file_count() - { - return self.virtual_diff_document.prefix_u32(index); - } - let mut total: u32 = 0; - for slot in 0..index.min(self.workspace_file_count()) { - total = total.saturating_add(self.viewport_file_scroll_height_px(slot)); - } - total - } - - pub fn global_max_scroll_top_px(&self) -> u32 { - if let Some(max) = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| { - drag.as_ref().map(|drag| drag.metrics.max_scroll_top_px) - }) - { - return max; - } - let viewport = self.editor.viewport_height_px.get(&self.store); - self.total_diff_height_px().saturating_sub(viewport.max(1)) - } - - fn viewport_anchor_bias_for_global(&self, scroll_top_px: u32) -> ViewportAnchorBias { - let max = self.global_max_scroll_top_px(); - if max > 0 && scroll_top_px.saturating_add(CONTINUOUS_BOTTOM_ANCHOR_TOLERANCE_PX) >= max { - ViewportAnchorBias::FollowEnd - } else { - ViewportAnchorBias::PreserveTop - } - } - - fn viewport_anchor_for_file_offset( - &self, - index: usize, - local_offset_px: u32, - bias: ViewportAnchorBias, - ) -> Option { - let item_id = self.virtual_diff_document.item_id(index)?; - Some(ViewportAnchor { - item_id, - intra_item_offset_px: local_offset_px, - bias, - }) - } - - fn viewport_anchor_for_global( - &self, - scroll_top_px: u32, - bias: ViewportAnchorBias, - ) -> Option { - let target_px = match bias { - ViewportAnchorBias::PreserveBottom => { - scroll_top_px.saturating_add(self.editor.viewport_height_px.get(&self.store).max(1)) - } - ViewportAnchorBias::PreserveTop | ViewportAnchorBias::FollowEnd => scroll_top_px, - }; - let (index, local_offset_px) = self.locate_global_scroll_px(target_px)?; - self.viewport_anchor_for_file_offset(index, local_offset_px, bias) - } - - fn current_or_derived_viewport_anchor(&self) -> Option { - if let Some(anchor) = self.virtual_scroll.anchor - && self.virtual_diff_document.anchor_is_current(anchor) - { - return Some(anchor); - } - let scroll_top_px = self.workspace.global_scroll_top_px.get(&self.store); - let bias = self.viewport_anchor_bias_for_global(scroll_top_px); - self.viewport_anchor_for_global(scroll_top_px, bias) - } - - fn scroll_top_for_viewport_anchor(&self, anchor: ViewportAnchor) -> Option { - if !self.virtual_diff_document.anchor_is_current(anchor) { - return None; - } - if anchor.bias == ViewportAnchorBias::FollowEnd { - return Some(self.global_max_scroll_top_px()); - } - - let index = anchor.item_id.index; - let item_height = self - .viewport_file_scroll_height_px(index) - .max(self.virtual_diff_document.height_at(index)) - .max(1); - let local_offset = anchor - .intra_item_offset_px - .min(item_height.saturating_sub(1)); - let item_top = self.file_start_offset_px(index); - let target = match anchor.bias { - ViewportAnchorBias::PreserveTop => item_top.saturating_add(local_offset), - ViewportAnchorBias::PreserveBottom => item_top - .saturating_add(local_offset) - .saturating_sub(self.editor.viewport_height_px.get(&self.store).max(1)), - ViewportAnchorBias::FollowEnd => unreachable!(), - }; - Some(target.min(self.global_max_scroll_top_px())) - } - - fn set_viewport_anchor(&mut self, anchor: ViewportAnchor) { - if let Some(scroll_top_px) = self.scroll_top_for_viewport_anchor(anchor) { - self.workspace - .global_scroll_top_px - .set(&self.store, scroll_top_px); - self.virtual_scroll.set_anchor(anchor); - } else { - self.virtual_scroll.clear(); - self.clamp_global_scroll_top_px(); - } - } - - fn set_viewport_anchor_for_global(&mut self, scroll_top_px: u32, bias: ViewportAnchorBias) { - if let Some(anchor) = self.viewport_anchor_for_global(scroll_top_px, bias) { - self.set_viewport_anchor(anchor); - } else { - self.virtual_scroll.clear(); - self.workspace.global_scroll_top_px.set(&self.store, 0); - } - } - - fn rebase_viewport_anchor(&mut self, anchor: ViewportAnchor) { - self.set_viewport_anchor(anchor); - } - - fn clamp_global_scroll_top_px(&mut self) { - if let Some(anchor) = self.virtual_scroll.anchor - && let Some(scroll_top_px) = self.scroll_top_for_viewport_anchor(anchor) - { - self.workspace - .global_scroll_top_px - .set(&self.store, scroll_top_px); - return; - } - let max = self.global_max_scroll_top_px(); - let current = self.workspace.global_scroll_top_px.get(&self.store); - self.workspace - .global_scroll_top_px - .set(&self.store, current.min(max)); - } - - fn locate_global_scroll_px(&self, target_px: u32) -> Option<(usize, u32)> { - let count = self.workspace_file_count(); - if count == 0 { - return None; - } - if self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| drag.is_none()) - && self.virtual_diff_document.len() == count - { - return self.virtual_diff_document.locate(target_px); - } - let mut prior: u32 = 0; - for index in 0..count { - let height = self.viewport_file_scroll_height_px(index).max(1); - let next_prior = prior.saturating_add(height); - if target_px < next_prior || index + 1 == count { - return Some((index, target_px.saturating_sub(prior))); - } - prior = next_prior; - } - Some((count - 1, 0)) - } - - fn scroll_viewport_to_global(&mut self, target_px: u32) -> Vec { - if self.virtual_diff_document.len() != self.workspace_file_count() { - self.recompute_file_scroll_total_height_px(); - } - let target_px = target_px.min(self.global_max_scroll_top_px()); - let bias = self.viewport_anchor_bias_for_global(target_px); - self.set_viewport_anchor_for_global(target_px, bias); - let target_px = self.workspace.global_scroll_top_px.get(&self.store); - let Some((target_index, local_offset)) = self.locate_global_scroll_px(target_px) else { - self.workspace.global_scroll_top_px.set(&self.store, 0); - self.virtual_scroll.clear(); - return Vec::new(); - }; - self.workspace - .global_scroll_top_px - .set(&self.store, target_px); - self.workspace - .viewport_scrollbar_drag - .update(&self.store, |drag| { - if let Some(drag) = drag.as_mut() { - drag.metrics.scroll_top_px = target_px.min(drag.metrics.max_scroll_top_px); - } - }); - - let dragging_scrollbar = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| drag.is_some()); - let mut effects = if dragging_scrollbar { - Vec::new() - } else if self.active_file_matches_workspace_file(target_index) { - Vec::new() - } else { - self.select_file_inner(target_index, true) - }; - - let local_max = self.editor_max_scroll_top_px(); - self.editor - .scroll_top_px - .set(&self.store, local_offset.min(local_max)); - if !dragging_scrollbar { - effects.extend(self.request_active_file_syntax_effect()); - } - effects - } - - pub fn global_scroll_position_px(&self) -> u32 { - self.workspace.global_scroll_top_px.get(&self.store) - } - - pub fn continuous_viewport_scrollbar_metrics(&self) -> ViewportScrollbarMetrics { - if let Some(metrics) = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| drag.as_ref().map(|drag| drag.metrics)) - { - return metrics; - } - let viewport_height_px = self.editor.viewport_height_px.get(&self.store); - let content_height_px = self.total_diff_height_px(); - ViewportScrollbarMetrics { - content_height_px, - viewport_height_px, - scroll_top_px: self.global_scroll_position_px(), - max_scroll_top_px: content_height_px.saturating_sub(viewport_height_px.max(1)), - } - } - - pub fn begin_viewport_scrollbar_drag( - &mut self, - content_height_px: u32, - viewport_height_px: u32, - scroll_top_px: u32, - max_scroll_top_px: u32, - ) { - if !self.settings.continuous_scroll { - return; - } - let file_heights_px = (0..self.workspace_file_count()) - .map(|index| self.file_scroll_height_px(index).max(1)) - .collect(); - self.workspace.viewport_scrollbar_drag.set( - &self.store, - Some(ViewportScrollbarDragState { - metrics: ViewportScrollbarMetrics { - content_height_px, - viewport_height_px, - scroll_top_px: scroll_top_px.min(max_scroll_top_px), - max_scroll_top_px, - }, - file_heights_px, - }), - ); - } - - pub fn end_viewport_scrollbar_drag(&mut self) { - self.workspace - .viewport_scrollbar_drag - .set(&self.store, None); - self.apply_pending_file_scroll_updates(); - } - - fn apply_pending_file_scroll_updates(&mut self) { - let pending_heights = self - .workspace - .pending_file_content_heights - .with(&self.store, |pending| pending.clone()); - self.workspace - .pending_file_content_heights - .set(&self.store, HashMap::new()); - for (index, height) in pending_heights { - self.update_file_content_height_px(index, height); - } - if self - .workspace - .file_scroll_recompute_pending - .get(&self.store) - { - self.workspace - .file_scroll_recompute_pending - .set(&self.store, false); - self.recompute_file_scroll_total_height_px(); - self.clamp_global_scroll_top_px(); - } - } - - pub fn sync_editor_scroll_from_global(&mut self) -> Vec { - if !self.settings.continuous_scroll { - return Vec::new(); - } - self.clamp_global_scroll_top_px(); - let target = self.workspace.global_scroll_top_px.get(&self.store); - let Some((_, local_offset)) = self.locate_global_scroll_px(target) else { - self.workspace.global_scroll_top_px.set(&self.store, 0); - self.virtual_scroll.clear(); - return Vec::new(); - }; - let max = self.editor_max_scroll_top_px(); - self.editor - .scroll_top_px - .set(&self.store, local_offset.min(max)); - Vec::new() - } - - pub fn sync_global_scroll_from_editor(&mut self) { - let Some(selected_index) = self.reconcile_selected_file_index_from_path() else { - self.workspace.global_scroll_top_px.set(&self.store, 0); - self.virtual_scroll.clear(); - return; - }; - let start = self.file_start_offset_px(selected_index); - let local = self.editor.scroll_top_px.get(&self.store); - let target = start - .saturating_add(local) - .min(self.global_max_scroll_top_px()); - self.workspace.global_scroll_top_px.set(&self.store, target); - if self.settings.continuous_scroll { - if let Some(anchor) = self.viewport_anchor_for_file_offset( - selected_index, - local, - self.viewport_anchor_bias_for_global(target), - ) { - self.virtual_scroll.set_anchor(anchor); - } else { - self.virtual_scroll.clear(); - } - } - } - - fn prefetch_compare_working_set( - &mut self, - render_start_index: usize, - render_end_index: usize, - direction: ScrollDirection, - viewport_height_px: u32, - ) -> Vec { - if self.workspace.source.get(&self.store) != WorkspaceSource::Compare { - return Vec::new(); - } - let count = self.workspace_file_count(); - if count == 0 { - return Vec::new(); - } - - let forward_pages = if direction == ScrollDirection::Forward { - COMPARE_WORKING_SET_PREFETCH_PAGES - } else { - COMPARE_WORKING_SET_TRAILING_PAGES - }; - let backward_pages = if direction == ScrollDirection::Backward { - COMPARE_WORKING_SET_PREFETCH_PAGES - } else { - COMPARE_WORKING_SET_TRAILING_PAGES - }; - - let mut effects = Vec::new(); - effects.extend(self.prefetch_compare_files_forward( - render_end_index, - viewport_height_px.saturating_mul(forward_pages).max(1), - )); - effects.extend(self.prefetch_compare_files_backward( - render_start_index, - viewport_height_px.saturating_mul(backward_pages).max(1), - )); - effects - } - - fn prefetch_compare_files_forward( - &mut self, - start_index: usize, - target_height: u32, - ) -> Vec { - let count = self.workspace_file_count(); - let mut effects = Vec::new(); - let mut accumulated = 0_u32; - let mut index = start_index; - while index < count && accumulated < target_height { - if let Some(path) = self.workspace_file_path_at(index) { - effects.extend(self.ensure_compare_file_cached_for_viewport( - index, - &path, - CompareWorkPriority::Overscan, - )); - } - accumulated = - accumulated.saturating_add(self.viewport_file_scroll_height_px(index).max(1)); - index += 1; - } - effects - } - - fn prefetch_compare_files_backward( - &mut self, - start_index: usize, - target_height: u32, - ) -> Vec { - let mut effects = Vec::new(); - let mut accumulated = 0_u32; - let mut index = start_index; - while index > 0 && accumulated < target_height { - index -= 1; - if let Some(path) = self.workspace_file_path_at(index) { - effects.extend(self.ensure_compare_file_cached_for_viewport( - index, - &path, - CompareWorkPriority::Overscan, - )); - } - accumulated = - accumulated.saturating_add(self.viewport_file_scroll_height_px(index).max(1)); - } - effects - } - - pub fn build_continuous_viewport_document( - &mut self, - ) -> (Option, Vec) { - if !self.settings.continuous_scroll { - return (None, Vec::new()); - } - if self.virtual_diff_document.len() != self.workspace_file_count() { - self.recompute_file_scroll_total_height_px(); - } - self.clamp_global_scroll_top_px(); - let scroll_top_px = self.workspace.global_scroll_top_px.get(&self.store); - let scroll_direction = match self.last_virtual_scroll_top_px { - Some(previous) if scroll_top_px < previous => ScrollDirection::Backward, - _ => ScrollDirection::Forward, - }; - self.last_virtual_scroll_top_px = Some(scroll_top_px); - let Some((anchor_index, _)) = self.locate_global_scroll_px(scroll_top_px) else { - return (None, Vec::new()); - }; - - let source = self.workspace.source.get(&self.store); - if source == WorkspaceSource::None { - return (None, Vec::new()); - } - let dragging_scrollbar = self - .workspace - .viewport_scrollbar_drag - .with(&self.store, |drag| drag.is_some()); - - let count = self.workspace_file_count(); - let viewport = self.editor.viewport_height_px.get(&self.store).max(1); - let follow_end = self.virtual_scroll.anchor.is_some_and(|anchor| { - anchor.bias == ViewportAnchorBias::FollowEnd - && self.virtual_diff_document.anchor_is_current(anchor) - }) || self.viewport_anchor_bias_for_global(scroll_top_px) - == ViewportAnchorBias::FollowEnd; - let (start_index, start_offset, local_top, target_height) = if follow_end { - let mut start_index = count.saturating_sub(1); - let mut tail_height = self.viewport_file_scroll_height_px(start_index).max(1); - let target_tail_height = viewport.saturating_mul(2).max(viewport); - while start_index > 0 && tail_height < target_tail_height { - start_index -= 1; - tail_height = tail_height - .saturating_add(self.viewport_file_scroll_height_px(start_index).max(1)); - } - ( - start_index, - self.file_start_offset_px(start_index), - tail_height.saturating_sub(viewport), - tail_height.max(1), - ) - } else { - let mut start_index = anchor_index; - let mut before_viewport_px = 0_u32; - while start_index > 0 && before_viewport_px < viewport { - start_index -= 1; - before_viewport_px = before_viewport_px - .saturating_add(self.viewport_file_scroll_height_px(start_index).max(1)); - } - let start_offset = self.file_start_offset_px(start_index); - let local_top = self - .workspace - .global_scroll_top_px - .get(&self.store) - .saturating_sub(start_offset); - let target_height = local_top - .saturating_add(viewport) - .saturating_add(viewport / 2) - .max(1); - (start_index, start_offset, local_top, target_height) - }; - - let mut effects = Vec::new(); - let mut slot_keys = Vec::new(); - let mut slot_loading = Vec::new(); - let mut accumulated = 0_u32; - let mut index = start_index; - while index < count && (slot_keys.is_empty() || accumulated < target_height) { - let path = self - .workspace_file_path_at(index) - .unwrap_or_else(|| format!("File {}", index + 1)); - let slot_key = match source { - WorkspaceSource::Compare | WorkspaceSource::TextCompare => { - effects.extend(self.ensure_compare_file_cached_for_viewport( - index, - &path, - CompareWorkPriority::VisibleViewportDiff, - )); - self.compare_slot_key_at(index, &path) - } - WorkspaceSource::Status => { - effects.extend(self.ensure_status_file_cached_for_viewport(index)); - let file_change = self - .workspace - .status_file_changes - .with(&self.store, |changes| changes.get(index).cloned()); - file_change.as_ref().map_or_else( - || { - self.loading_slot_key( - WorkspaceSource::Status, - index, - &path, - String::new(), - String::new(), - ) - }, - |change| self.status_slot_key_at(index, change), - ) - } - WorkspaceSource::None => self.loading_slot_key( - WorkspaceSource::None, - index, - &path, - String::new(), - String::new(), - ), - }; - let slot_height = self.viewport_file_scroll_height_px(index).max(1); - if let Some(window) = self.viewport_slot_syntax_window( - &slot_key, - accumulated, - slot_height, - local_top, - viewport, - ) { - effects.extend(self.request_viewport_slot_syntax_window(&slot_key, window)); - } - let slot_is_loading = matches!(&slot_key.kind, ViewportSlotKind::Loading); - if !slot_is_loading { - self.touch_viewport_slot(&slot_key); - } - slot_loading.push(slot_is_loading); - slot_keys.push(slot_key); - accumulated = accumulated.saturating_add(slot_height); - index += 1; - } - let render_end_index = index; - self.protect_working_set_slots(&slot_keys); - self.trim_file_working_set(); - effects.extend(self.prefetch_compare_working_set( - start_index, - render_end_index, - scroll_direction, - viewport, - )); - - let key = ViewportDocumentKey { - source, - generation: self.workspace_render_generation(), - start_index, - slots: slot_keys, - }; - let doc = if let Some(cache) = self.viewport_document_cache.as_ref() - && cache.key == key - { - cache.doc.clone() - } else { - let mut doc = RenderDoc::default(); - let loading_message = if dragging_scrollbar { - "" - } else { - "Loading diff..." - }; - for slot in &key.slots { - self.append_viewport_slot_doc(&mut doc, slot, loading_message); - } - let doc = Arc::new(doc); - self.viewport_document_cache = Some(ViewportDocumentCache { - key: key.clone(), - doc: doc.clone(), - }); - doc - }; - let slot_indices = key.slots.iter().map(|slot| slot.index).collect(); - let slot_item_ids = key - .slots - .iter() - .map(|slot| { - self.virtual_diff_document - .item_id(slot.index) - .unwrap_or_else(|| { - VirtualDiffItemId::file( - source, - self.workspace_render_generation(), - slot.index, - ) - }) - }) - .collect(); - let stream_items = self.virtual_stream_items_for_viewport_doc( - source, - self.workspace_render_generation(), - &key.slots, - doc.as_ref(), - ); - - ( - Some(ViewportDocument { - doc, - mode: ViewportDocumentMode::Continuous, - generation: self.workspace_render_generation(), - start_index, - start_offset_px: start_offset, - scroll_top_px: local_top, - slot_indices, - slot_item_ids, - stream_items, - slot_loading, - path: String::new(), - }), - effects, - ) - } - - fn scroll_viewport_pages(&mut self, delta_pages: i32) -> Vec { - let viewport = self.editor.viewport_height_px.get(&self.store); - let page_px = ((viewport as f32) * 0.85).round().max(1.0) as i32; - let delta_px = delta_pages.saturating_mul(page_px); - if self.settings.continuous_scroll { - return self.scroll_viewport_px(delta_px); - } - let current = self.editor.scroll_top_px.get(&self.store); - let max = self.editor_max_scroll_top_px(); - let next = apply_scroll_delta_px(current, delta_px, max); - self.editor.scroll_top_px.set(&self.store, next); - Vec::new() - } - - fn scroll_viewport_half_page(&mut self, direction: i32) -> Vec { - let viewport = self.editor.viewport_height_px.get(&self.store); - let half_px = ((viewport as f32) * 0.5).round().max(1.0) as i32; - let delta_px = direction.saturating_mul(half_px); - if self.settings.continuous_scroll { - return self.scroll_viewport_px(delta_px); - } - let current = self.editor.scroll_top_px.get(&self.store); - let max = self.editor_max_scroll_top_px(); - let next = apply_scroll_delta_px(current, delta_px, max); - self.editor.scroll_top_px.set(&self.store, next); - Vec::new() - } - - fn request_active_file_syntax_effect(&mut self) -> Option { - if !self.syntax_request_budget_available() { - return None; - } - let repo_path = self.compare.repo_path.get(&self.store)?; - let window = self.desired_syntax_window()?; - let generation = self.active_syntax_generation(); - let syntax_epoch = self.syntax_requests.epoch(); - let mut request = None; - let request_id = self.syntax_requests.next_request_id(); - let mut active_to_cache = None; - - self.workspace.active_file.update(&self.store, |active| { - let Some(active) = active.as_mut() else { - return; - }; - if let Some(next_request) = request_syntax_for_active_file( - active, - repo_path, - generation, - syntax_epoch, - window, - request_id, - ) { - active_to_cache = Some(active.clone()); - request = Some(next_request); - } - }); - if let Some(active_file) = active_to_cache { - self.cache_active_file(active_file); - } - - request.map(|request| { - self.track_syntax_request(&request); - SyntaxEffect::LoadFileSyntax(Task { - generation, - request, - }) - .into() - }) - } - - fn active_syntax_generation(&self) -> u64 { - match self.workspace.source.get(&self.store) { - WorkspaceSource::Status => self.workspace.status_generation.get(&self.store), - _ => self.workspace.compare_generation.get(&self.store), - } - } - - fn desired_syntax_window(&self) -> Option { - let line_count = self.workspace.active_file.with(&self.store, |active| { - active.as_ref().map(|active| active.render_doc.lines.len()) - })?; - if line_count == 0 { - return None; - } - - if let (Some(start), Some(end)) = ( - self.editor.visible_row_start.get(&self.store), - self.editor.visible_row_end.get(&self.store), - ) && end > start - { - return Some(SyntaxRowWindow { - start: start.saturating_sub(SYNTAX_OVERSCAN_ROWS), - end: end.saturating_add(SYNTAX_OVERSCAN_ROWS).min(line_count), - }); - } - - let scroll = self.editor.scroll_top_px.get(&self.store) as usize; - let viewport = self.editor.viewport_height_px.get(&self.store) as usize; - let approx_row_height = 20usize; - let start = scroll / approx_row_height; - let visible = (viewport / approx_row_height).saturating_add(SYNTAX_INITIAL_ROWS); - Some(SyntaxRowWindow { - start: start.saturating_sub(SYNTAX_OVERSCAN_ROWS), - end: start.saturating_add(visible).min(line_count), - }) - } - - fn navigate_to_hunk(&mut self, forward: bool) { - let current = self.editor.scroll_top_px.get(&self.store); - let target = self.editor.hunk_positions.with(&self.store, |positions| { - if positions.is_empty() { - return None; - } - if forward { - positions - .iter() - .find(|&&y| y > current) - .or_else(|| positions.first()) - .copied() - } else { - positions - .iter() - .rev() - .find(|&&y| y < current) - .or_else(|| positions.last()) - .copied() - } - }); - if let Some(y) = target { - self.editor.scroll_top_px.set(&self.store, y); - self.editor_clamp_scroll(); - } - } - - fn navigate_to_file(&mut self, forward: bool) -> Vec { - let Some(current) = self.reconcile_selected_file_index_from_path() else { - return Vec::new(); - }; - let count = self.workspace_file_count(); - if count == 0 { - return Vec::new(); - } - let target = if forward { - current.saturating_add(1).min(count.saturating_sub(1)) - } else { - current.saturating_sub(1) - }; - if target == current { - return Vec::new(); - } - - if self.settings.continuous_scroll { - return self.select_file(target, true); - } - - self.select_file(target, true) - } - - fn push_error(&mut self, message: &str) -> u64 { - self.last_error.set(&self.store, Some(message.to_owned())); - self.push_toast(ToastKind::Error, message, None, None) - } - - fn push_info(&mut self, message: &str) -> u64 { - self.push_toast(ToastKind::Info, message, None, None) - } - - #[allow(dead_code)] - fn push_error_with_description(&mut self, message: &str, description: &str) -> u64 { - self.last_error.set(&self.store, Some(message.to_owned())); - self.push_toast( - ToastKind::Error, - message, - Some(description.to_owned()), - None, - ) - } - - #[allow(dead_code)] - fn push_info_with_description(&mut self, message: &str, description: &str) -> u64 { - self.push_toast(ToastKind::Info, message, Some(description.to_owned()), None) - } - - /// Create an info toast with an externally-driven progress bar (0.0-1.0). - /// The toast is pinned until `finish_progress_toast` or `fail_progress_toast` - /// is called — it does not auto-dismiss based on time. - fn push_progress_toast(&mut self, message: &str) -> u64 { - self.push_toast(ToastKind::Info, message, None, Some(0.0)) - } - - /// Convert a pinned progress toast into a normal info toast and let it - /// auto-dismiss. Also updates its message and description. - fn finish_progress_toast(&mut self, toast_id: u64, message: &str, description: Option) { - let now = self.clock_ms; - self.toasts.update(&self.store, |toasts| { - if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { - toast.kind = ToastKind::Info; - toast.message = message.to_owned(); - toast.description = description; - toast.created_at_ms = now; - toast.progress = None; - } - }); - } - - /// Convert a pinned progress toast into an error toast. - fn fail_progress_toast(&mut self, toast_id: u64, message: &str, description: Option) { - let now = self.clock_ms; - self.last_error.set(&self.store, Some(message.to_owned())); - self.toasts.update(&self.store, |toasts| { - if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { - toast.kind = ToastKind::Error; - toast.message = message.to_owned(); - toast.description = description; - toast.created_at_ms = now; - toast.progress = None; - } - }); - } - - fn update_toast_progress(&mut self, toast_id: u64, fraction: f32) { - let clamped = fraction.clamp(0.0, 1.0); - self.toasts.update(&self.store, |toasts| { - if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { - toast.progress = Some(clamped); - } - }); - } - - fn update_toast_message(&mut self, toast_id: u64, message: &str) { - self.toasts.update(&self.store, |toasts| { - if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { - toast.message = message.to_owned(); - } - }); - } - - fn start_fetch_remote(&mut self, remote: String) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }) - { - self.push_error("This repository backend does not support remotes."); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before fetching."); - return Vec::new(); - }; - let toast_id = self.push_progress_toast(&format!("Fetching {remote}\u{2026}")); - vec![ - RepositoryEffect::FetchRemote(FetchRemoteRequest { - repo_path, - remote, - toast_id, - }) - .into(), - ] - } - - fn start_fetch_all_remotes(&mut self) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }) - { - self.push_error("This repository backend does not support remotes."); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before fetching."); - return Vec::new(); - }; - let remotes = self.repository.refs.with(&self.store, |refs| { - remote_names_from_refs(refs).into_iter().collect::>() - }); - if remotes.is_empty() { - self.push_error("No remotes are configured for this repository."); - return Vec::new(); - } - remotes - .into_iter() - .flat_map(|remote| { - let toast_id = self.push_progress_toast(&format!("Fetching {remote}\u{2026}")); - std::iter::once( - RepositoryEffect::FetchRemote(FetchRemoteRequest { - repo_path: repo_path.clone(), - remote, - toast_id, - }) - .into(), - ) - }) - .collect() - } - - fn start_publish_default(&mut self) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }) - { - self.push_error("This repository backend does not support publishing."); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before publishing."); - return Vec::new(); - }; - let toast_id = self.push_progress_toast(&format!( - "{}\u{2026}", - self.vcs_ui_profile().publish_command_label() - )); - vec![ - RepositoryEffect::PublishDefault(PublishRequest { - repo_path, - action: None, - toast_id, - }) - .into(), - ] - } - - fn start_open_publish_menu(&mut self) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }) - { - self.push_error("This repository backend does not support publishing."); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before publishing."); - return Vec::new(); - }; - self.push_overlay(OverlaySurface::PublishMenu, None); - vec![ - RepositoryEffect::LoadPublishPlan(PublishPlanRequest { - repo_path, - toast_id: None, - }) - .into(), - ] - } - - fn start_publish_action(&mut self, action: PublishAction) -> Vec { - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before publishing."); - return Vec::new(); - }; - if self.overlays_top() == Some(OverlaySurface::PublishMenu) { - self.pop_overlay(); - } - let toast_id = self.push_progress_toast(&format!("{}\u{2026}", action.label)); - vec![ - RepositoryEffect::PublishDefault(PublishRequest { - repo_path, - action: Some(action), - toast_id, - }) - .into(), - ] - } - - fn start_push_current_branch(&mut self, force_with_lease: bool) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }) - { - self.push_error("This repository backend does not support push."); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before pushing."); - return Vec::new(); - }; - let Some(branch_ref) = self - .repository - .refs - .with(&self.store, |refs| active_publish_ref(refs)) - else { - self.push_error("No active branch or bookmark to push."); - return Vec::new(); - }; - let branch = branch_ref.name; - let (remote, refspec) = match branch_ref.upstream.as_deref().and_then(upstream_pair) { - Some((remote, upstream_branch)) => ( - remote, - format!("refs/heads/{branch}:refs/heads/{upstream_branch}"), - ), - None => { - // No upstream configured yet — default to `origin/`. - let remotes = self.repository.refs.with(&self.store, |refs| { - remote_names_from_refs(refs).into_iter().collect::>() - }); - let remote = if remotes.iter().any(|n| n == "origin") { - "origin".to_owned() - } else if let Some(first) = remotes.first() { - first.clone() - } else { - self.push_error("No remotes are configured for this repository."); - return Vec::new(); - }; - (remote, format!("refs/heads/{branch}:refs/heads/{branch}")) - } - }; - let label = if force_with_lease { - format!("Force-pushing {branch} to {remote}\u{2026}") - } else { - format!("Pushing {branch} to {remote}\u{2026}") - }; - let toast_id = self.push_progress_toast(&label); - vec![ - RepositoryEffect::Push(PushRequest { - repo_path, - remote, - refspec, - force_with_lease, - toast_id, - }) - .into(), - ] - } - - fn start_pull_current_branch(&mut self) -> Vec { - if !self - .repository - .capabilities - .with(&self.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.pull_fast_forward) - }) - { - self.push_error("This repository backend does not support fast-forward pull."); - return Vec::new(); - } - let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { - self.push_error("Open a repository before pulling."); - return Vec::new(); - }; - let Some(branch_ref) = self - .repository - .refs - .with(&self.store, |refs| active_publish_ref(refs)) - else { - self.push_error("No active branch or bookmark to pull into."); - return Vec::new(); - }; - let branch = branch_ref.name; - let (remote, upstream_branch) = match branch_ref.upstream.as_deref().and_then(upstream_pair) - { - Some(pair) => pair, - None => { - self.push_error(&format!( - "No upstream configured for {branch}. Push once to set one." - )); - return Vec::new(); - } - }; - let toast_id = self.push_progress_toast(&format!("Pulling {branch} from {remote}\u{2026}")); - vec![ - RepositoryEffect::PullFf(PullFfRequest { - repo_path, - remote, - branch: upstream_branch, - toast_id, - }) - .into(), - ] - } - - fn push_toast( - &mut self, - kind: ToastKind, - message: &str, - description: Option, - progress: Option, - ) -> u64 { - use crate::ui::animation::AnimationKey; - let id = self.next_toast_id; - self.next_toast_id = self.next_toast_id.saturating_add(1); - self.animation.set_target( - AnimationKey::ToastEntrance(id), - 1.0, - TOAST_ANIM_MS, - self.clock_ms, - ); - let now = self.clock_ms; - self.toasts.update(&self.store, |toasts| { - toasts.push(Toast { - id, - kind, - message: message.to_owned(), - description, - created_at_ms: now, - hovered: false, - progress, - }); - if toasts.len() > MAX_VISIBLE_TOASTS { - toasts.remove(0); - } - }); - id - } - - fn open_search(&mut self) { - self.editor.search.open.set(&self.store, true); - let len = self.editor.search.query.with(&self.store, |q| q.len()); - self.text_edit.cursor.set(&self.store, len); - self.text_edit.anchor.set(&self.store, 0); - self.text_edit - .cursor_moved_at_ms - .set(&self.store, self.clock_ms); - self.focus.set(&self.store, Some(FocusTarget::SearchInput)); - self.editor.focused.set(&self.store, false); - self.recompute_search_matches(); - } - - fn close_search(&mut self) { - self.editor.search.open.set(&self.store, false); - self.editor - .search - .matches - .update(&self.store, |matches| matches.clear()); - self.editor.search.active_index.set(&self.store, None); - self.set_focus(Some(FocusTarget::Editor)); - } - - fn recompute_search_matches(&mut self) { - use crate::editor::diff::state::MatchSide; - - self.editor - .search - .matches - .update(&self.store, |matches| matches.clear()); - self.editor.search.active_index.set(&self.store, None); - - let query = self - .editor - .search - .query - .with(&self.store, |q| q.to_ascii_lowercase()); - if query.is_empty() { - return; - } - - let new_matches: Vec = self.workspace.active_file.with(&self.store, |af| { - let Some(active_file) = af.as_ref() else { - return Vec::new(); - }; - let doc = &active_file.render_doc; - let mut new_matches: Vec = Vec::new(); - for (line_idx, line) in doc.lines.iter().enumerate() { - let line_idx = line_idx as u32; - if line.left_text.is_valid() { - let text = doc.line_text(line.left_text); - let lower = text.to_ascii_lowercase(); - let mut start = 0; - while let Some(pos) = lower[start..].find(&query) { - let byte_start = (start + pos) as u32; - new_matches.push(SearchMatch { - line_index: line_idx, - byte_start, - byte_len: query.len() as u32, - side: MatchSide::Left, - }); - start += pos + query.len(); - } - } - if line.right_text.is_valid() { - let text = doc.line_text(line.right_text); - let lower = text.to_ascii_lowercase(); - let mut start = 0; - while let Some(pos) = lower[start..].find(&query) { - let byte_start = (start + pos) as u32; - new_matches.push(SearchMatch { - line_index: line_idx, - byte_start, - byte_len: query.len() as u32, - side: MatchSide::Right, - }); - start += pos + query.len(); - } - } - } - new_matches - }); - - let has_matches = !new_matches.is_empty(); - self.editor.search.matches.set(&self.store, new_matches); - if has_matches { - self.editor.search.active_index.set(&self.store, Some(0)); - } - } - - fn search_navigate(&mut self, direction: i32) { - let count = self.editor.search.matches.with(&self.store, |m| m.len()); - if count == 0 { - return; - } - - let current = self - .editor - .search - .active_index - .get(&self.store) - .unwrap_or(0); - let next = if direction > 0 { - if current + 1 >= count { 0 } else { current + 1 } - } else { - if current == 0 { count - 1 } else { current - 1 } - }; - self.editor.search.active_index.set(&self.store, Some(next)); - self.scroll_to_search_match(next); - } - - fn scroll_to_search_match(&mut self, match_index: usize) { - let y_pos = self - .editor - .search_match_y_positions - .with(&self.store, |v| v.get(match_index).copied()); - let target_y = if let Some(y) = y_pos { - y - } else { - let m = self - .editor - .search - .matches - .with(&self.store, |m| m.get(match_index).copied()); - let Some(m) = m else { - return; - }; - self.estimate_line_y(m.line_index) - }; - - let viewport_h = self.editor.viewport_height_px.get(&self.store); - let centered = target_y.saturating_sub(viewport_h / 3); - let max = self.editor_max_scroll_top_px(); - self.editor - .scroll_top_px - .set(&self.store, centered.min(max)); - } - - fn estimate_line_y(&self, line_index: u32) -> u32 { - let content_height = self.editor.content_height_px.get(&self.store); - if content_height == 0 { - return 0; - } - let total_lines = self.workspace.active_file.with(&self.store, |af| { - af.as_ref() - .map(|active_file| active_file.render_doc.lines.len() as u32) - .unwrap_or(0) - }); - if total_lines == 0 { - return 0; - } - let avg_height = content_height / total_lines; - line_index.saturating_mul(avg_height) - } - - // -------- EditorState helpers on AppState -------- - - /// Clear document-specific editor state (scroll, content, hunks, etc.) - pub fn editor_clear_document(&mut self) { - self.editor.scroll_top_px.set(&self.store, 0); - self.editor.content_height_px.set(&self.store, 0); - self.editor.hovered_row.set(&self.store, None); - self.editor.hovered_render_line_index.set(&self.store, None); - self.editor.hovered_hunk_index.set(&self.store, None); - self.editor.visible_row_start.set(&self.store, None); - self.editor.visible_row_end.set(&self.store, None); - self.editor - .hunk_positions - .update(&self.store, |v| v.clear()); - self.editor - .file_positions - .update(&self.store, |v| v.clear()); - self.editor - .search_match_y_positions - .update(&self.store, |v| v.clear()); - self.editor - .line_selection - .update(&self.store, |ls| ls.clear()); - self.editor.text_selection.set(&self.store, None); - self.context_menu.close(); - } - - pub fn editor_max_scroll_top_px(&self) -> u32 { - let content = self.editor.content_height_px.get(&self.store); - let viewport = self.editor.viewport_height_px.get(&self.store); - content.saturating_sub(viewport.max(1)) - } - - pub fn editor_clamp_scroll(&mut self) { - let max = self.editor_max_scroll_top_px(); - let cur = self.editor.scroll_top_px.get(&self.store); - self.editor.scroll_top_px.set(&self.store, cur.min(max)); - } - - pub fn editor_current_hunk_index(&self) -> Option<(usize, usize)> { - let scroll = self.editor.scroll_top_px.get(&self.store); - self.editor.hunk_positions.with(&self.store, |positions| { - if positions.is_empty() { - return None; - } - let idx = positions - .partition_point(|&y| y <= scroll) - .saturating_sub(1); - Some((idx, positions.len())) - }) - } - - fn move_editor_row_cursor(&mut self, delta: i32) { - let Some(start) = self.editor.visible_row_start.get(&self.store) else { - return; - }; - let Some(end) = self.editor.visible_row_end.get(&self.store) else { - return; - }; - if start >= end { - return; - } - let max = end.saturating_sub(1); - let Some(current) = self - .editor - .hovered_row - .get(&self.store) - .filter(|row| *row >= start && *row <= max) - else { - self.editor - .hovered_row - .set(&self.store, Some(if delta < 0 { max } else { start })); - return; - }; - let next = if delta < 0 { - current - .saturating_sub(delta.unsigned_abs() as usize) - .max(start) - } else { - current.saturating_add(delta as usize).min(max) - }; - self.editor.hovered_row.set(&self.store, Some(next)); - } -} - -fn matching_persisted_compare<'a>( - startup: &'a StartupOptions, - settings: &'a Settings, -) -> Option<&'a PersistedCompare> { - settings.last_compare.as_ref().filter(|compare| { - startup.args.repo.is_some() && compare.repo_path.as_ref() == startup.args.repo.as_ref() - }) -} - -fn compare_refs_are_valid(mode: CompareMode, left_ref: &str, right_ref: &str) -> bool { - match mode { - CompareMode::SingleCommit => !left_ref.is_empty() || !right_ref.is_empty(), - CompareMode::TwoDot | CompareMode::ThreeDot => { - !left_ref.is_empty() && !right_ref.is_empty() - } - } -} - -fn apply_scroll_delta_px(current: u32, delta: i32, max: u32) -> u32 { - let next = if delta.is_negative() { - current.saturating_sub(delta.unsigned_abs()) - } else { - current.saturating_add(delta as u32) - }; - next.min(max) -} - -fn estimated_carbon_file_rows_with_overhead(file: &carbon::FileDiff) -> u32 { - if file.is_binary { - return 4; - } - estimated_carbon_file_rows(file).saturating_add(1).max(1) -} - -fn estimated_carbon_file_rows(file: &carbon::FileDiff) -> u32 { - if file.hunks.is_empty() { - return file.additions.saturating_add(file.deletions).max(1); - } - - let mut rows = 0_u32; - for (hunk_index, hunk) in file.hunks.iter().enumerate() { - if !file.is_partial { - let gap_len = if hunk_index == 0 { - hunk.old_start_index().min(hunk.new_start_index()) - } else { - let prev = &file.hunks[hunk_index - 1]; - hunk.old_start_index() - .saturating_sub(prev.old_end_index()) - .min(hunk.new_start_index().saturating_sub(prev.new_end_index())) - }; - rows = rows.saturating_add((gap_len > 0) as u32); - } - - rows = rows.saturating_add(1); - for block in file.hunk_blocks(hunk) { - rows = rows.saturating_add(match block.kind { - carbon::BlockKind::Context => block.old.len.min(block.new.len), - carbon::BlockKind::Change => block.old.len.saturating_add(block.new.len), - }); - } - - if !file.is_partial && hunk_index + 1 == file.hunks.len() { - let old_end = file - .old_text - .as_ref() - .map(|text| text.line_count()) - .unwrap_or_else(|| hunk.old_end_index()); - let new_end = file - .new_text - .as_ref() - .map(|text| text.line_count()) - .unwrap_or_else(|| hunk.new_end_index()); - let gap_len = old_end - .saturating_sub(hunk.old_end_index()) - .min(new_end.saturating_sub(hunk.new_end_index())); - rows = rows.saturating_add((gap_len > 0) as u32); - } - } - rows -} - -fn compare_summary_file_entry(summary: &CompareFileSummary) -> FileListEntry { - FileListEntry { - path: summary.paths.display_path_ref(), - } -} - -fn compare_output_file_entry_meta( - output: &CompareOutput, - index: usize, -) -> Option { - if let Some(summary) = output.file_summaries.get(index) { - let (additions, deletions) = summary.fallback_stats(); - return Some(FileListEntryMeta { - status: carbon_list_status(summary.status), - additions, - deletions, - is_binary: summary.is_binary, - }); - } - output.carbon.files.get(index).map(carbon_file_entry_meta) -} - -fn carbon_file_entry_meta(file: &carbon::FileDiff) -> FileListEntryMeta { - let (additions, deletions) = carbon_file_stats(file); - FileListEntryMeta { - status: carbon_list_status(file.status), - additions, - deletions, - is_binary: file.is_binary, - } -} - -fn compare_output_summary_is_deferred(output: &CompareOutput, index: usize) -> bool { - if let Some(summary) = output.file_summaries.get(index) { - return summary.is_partial; - } - output - .carbon - .files - .get(index) - .is_some_and(|file| file.is_partial && file.hunks.is_empty()) -} - -fn compare_output_deferred_summary( - output: &CompareOutput, - index: usize, -) -> Option { - if let Some(summary) = output.file_summaries.get(index) { - return summary.is_partial.then(|| summary.clone()); - } - output - .carbon - .files - .get(index) - .filter(|file| file.is_partial && file.hunks.is_empty()) - .map(CompareFileSummary::from_file) -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -struct CompareStatsSnapshot { - hydrated_total: (i32, i32), - deferred_count: usize, -} - -fn compare_output_stats_snapshot(output: &CompareOutput) -> CompareStatsSnapshot { - let mut snapshot = CompareStatsSnapshot::default(); - output.for_each_summary(|_, summary| { - if summary.stats_deferred { - snapshot.deferred_count = snapshot.deferred_count.saturating_add(1); - } else { - let stats = summary.fallback_stats(); - snapshot.hydrated_total = ( - snapshot.hydrated_total.0.saturating_add(stats.0), - snapshot.hydrated_total.1.saturating_add(stats.1), - ); - } - }); - snapshot -} - -fn compare_output_has_deferred_stats(output: &CompareOutput) -> bool { - if output.file_summaries.is_empty() { - output.carbon.files.iter().any(|file| file.stats_deferred) - } else { - output - .file_summaries - .iter() - .any(|summary| summary.stats_deferred) - } -} - -fn carbon_file_stats(file: &carbon::FileDiff) -> (i32, i32) { - if file.additions > 0 || file.deletions > 0 || file.stats_deferred { - return ( - u32_to_i32_saturating(file.additions), - u32_to_i32_saturating(file.deletions), - ); - } - let mut additions = 0_i32; - let mut deletions = 0_i32; - for block in &file.blocks { - if block.kind == carbon::BlockKind::Change { - additions = additions.saturating_add(block.new.len.min(i32::MAX as u32) as i32); - deletions = deletions.saturating_add(block.old.len.min(i32::MAX as u32) as i32); - } - } - (additions, deletions) -} - -fn u32_to_i32_saturating(value: u32) -> i32 { - i32::try_from(value).unwrap_or(i32::MAX) -} - -fn carbon_list_status(status: carbon::FileStatus) -> FileListStatus { - match status { - carbon::FileStatus::Added => FileListStatus::Added, - carbon::FileStatus::Deleted => FileListStatus::Deleted, - carbon::FileStatus::Renamed | carbon::FileStatus::RenamedModified => { - FileListStatus::Renamed - } - carbon::FileStatus::Binary => FileListStatus::Binary, - carbon::FileStatus::ModeChanged | carbon::FileStatus::Modified => FileListStatus::Modified, - } -} - -fn build_status_file_entries(changes: &[FileChange]) -> Vec { - changes.iter().map(FileListEntry::from).collect() -} - -fn active_publish_ref(refs: &[VcsRef]) -> Option { - refs.iter() - .find(|reference| { - reference.active && matches!(reference.kind, RefKind::Branch | RefKind::Bookmark) - }) - .cloned() -} - -fn upstream_pair(upstream: &str) -> Option<(String, String)> { - upstream - .split_once('/') - .map(|(remote, branch)| (remote.to_owned(), branch.to_owned())) -} - -fn remote_names_from_refs(refs: &[VcsRef]) -> std::collections::BTreeSet { - let mut remotes = std::collections::BTreeSet::new(); - for reference in refs { - if let Some((remote, _)) = reference - .upstream - .as_deref() - .and_then(|upstream| upstream.split_once('/')) - { - remotes.insert(remote.to_owned()); - } - if matches!( - reference.kind, - RefKind::RemoteBranch | RefKind::RemoteBookmark - ) && let Some((remote, _)) = reference.name.split_once('/') - { - remotes.insert(remote.to_owned()); - } - } - remotes -} - -fn status_section_count(changes: &[FileChange]) -> usize { - let mut last_bucket = None; - let mut count = 0; - for change in changes { - if Some(change.bucket) != last_bucket { - count += 1; - last_bucket = Some(change.bucket); - } - } - count -} - -fn status_section_count_before(changes: &[FileChange], len: usize) -> usize { - status_section_count(&changes[..len.min(changes.len())]) -} - -fn overlay_name(surface: OverlaySurface) -> &'static str { - match surface { - OverlaySurface::RepoPicker => "repo-picker", - OverlaySurface::RefPicker => "ref-picker", - OverlaySurface::CommandPalette => "command-palette", - OverlaySurface::Confirmation => "confirmation", - OverlaySurface::GitHubAuthModal => "github-auth-modal", - OverlaySurface::AccountMenu => "account-menu", - OverlaySurface::KeyboardShortcuts => "keyboard-shortcuts", - OverlaySurface::ThemePicker => "theme-picker", - OverlaySurface::FontPicker => "font-picker", - OverlaySurface::CompareMenu => "compare-menu", - OverlaySurface::PublishMenu => "publish-menu", - } -} - -fn font_picker_entry( - entry: &FontFamilyEntry, - selected_family: &str, - highlights: Vec<(usize, usize)>, -) -> PickerEntry { - let source = entry.source.label(); - let detail = if entry.family == selected_family { - format!("Selected - {source}") - } else { - source.to_owned() - }; - PickerEntry { - label: entry.label.clone(), - detail, - value: entry.family.clone(), - highlights, - label_style: PickerLabelStyle::Default, - icon: Some(if entry.monospaced { - lucide::TERMINAL - } else { - lucide::FILE - }), - section_header: false, - } -} - -pub fn workspace_mode_name(mode: WorkspaceMode) -> &'static str { - match mode { - WorkspaceMode::Empty => "empty", - WorkspaceMode::Loading => "loading", - WorkspaceMode::Ready => "ready", - } -} - -impl From<&FileChange> for FileListEntry { - fn from(value: &FileChange) -> Self { - Self { - path: ComparePath::from(value.path.as_str()), - } - } -} - -fn status_file_entry_meta(change: &FileChange) -> FileListEntryMeta { - FileListEntryMeta { - status: file_change_list_status(change.status, change.bucket), - additions: 0, - deletions: 0, - is_binary: matches!(change.status, FileChangeStatus::Binary), - } -} - -// --------------------------------------------------------------------------- -// Grapheme / word boundary helpers -// --------------------------------------------------------------------------- - -// Grapheme/word boundary helpers are in text_edit.rs - -fn highlight_ranges_from_match_indices(text: &str, indices_rev: &[usize]) -> Vec<(usize, usize)> { - let len = text.len(); - let mut indices: Vec = indices_rev - .iter() - .copied() - .filter(|&idx| idx < len && text.is_char_boundary(idx)) - .collect(); - indices.sort_unstable(); - - let mut ranges = Vec::new(); - for index in indices { - let mut end = index + 1; - while end < len && !text.is_char_boundary(end) { - end += 1; - } - if let Some((_, last_end)) = ranges.last_mut() { - if index <= *last_end { - *last_end = (*last_end).max(end); - continue; - } - } - ranges.push((index, end)); - } - ranges -} - -fn highlight_ranges_for_prefix_match(text: &str, indices_rev: &[usize]) -> Vec<(usize, usize)> { - let prefix_indices: Vec = indices_rev - .iter() - .copied() - .filter(|&idx| idx < text.len()) - .collect(); - highlight_ranges_from_match_indices(text, &prefix_indices) -} - -fn highlight_ranges_for_visible_match( - query: &str, - visible_text: &str, - search_indices_rev: &[usize], - config: &neo_frizbee::Config, -) -> Vec<(usize, usize)> { - if query.is_empty() { - return Vec::new(); - } - - let visible_only = [visible_text]; - if let Some(m) = neo_frizbee::match_list_indices(query, &visible_only, config) - .into_iter() - .next() - { - return highlight_ranges_from_match_indices(visible_text, &m.indices); - } - - highlight_ranges_for_prefix_match(visible_text, search_indices_rev) -} - -fn query_looks_like_path(query: &str) -> bool { - query.starts_with('/') - || query.starts_with("~/") - || query.starts_with("./") - || (query.len() >= 2 && query.as_bytes()[1] == b':') -} - -fn path_looks_like_repository(path: &Path) -> bool { - path.join(".git").exists() || path.join(".jj").exists() -} - -fn normalize_repository_open_path(path: PathBuf) -> PathBuf { - crate::core::vcs::discovery::discover_repository(&path) - .ok() - .flatten() - .map(|location| location.workspace_root) - .unwrap_or(path) -} - -fn expand_tilde(path: &str) -> String { - if path.starts_with("~/") || path == "~" { - if let Some(home) = dirs::home_dir() { - return format!("{}{}", home.display(), &path[1..]); - } - } - path.to_owned() -} - -fn split_browse_query(expanded: &str) -> (String, &str) { - if let Some(pos) = expanded.rfind('/') { - let dir = if pos == 0 { - "/".to_owned() - } else { - expanded[..pos].to_owned() - }; - let filter = &expanded[pos + 1..]; - (dir, filter) - } else if expanded.len() >= 2 && expanded.as_bytes()[1] == b':' { - if let Some(pos) = expanded.rfind('\\') { - let dir = expanded[..pos].to_owned(); - let filter = &expanded[pos + 1..]; - (dir, filter) - } else { - (expanded.to_owned(), "") - } - } else { - (expanded.to_owned(), "") - } -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - use std::sync::Arc; - - use clap::Parser; - - use super::{ - ActiveFile, ActiveFileLoading, AppState, AsyncStatus, CarbonStyleOverlays, - CardTextSelection, CompareField, FILE_HEIGHT_SPARSE_MIN_COUNT, FileHeightIndex, - FileListEntry, FocusTarget, OverlayEntry, OverlaySurface, PickerItem, PickerLabelStyle, - PreparedActiveFile, SidebarMode, SidebarTab, TextCompareLanguage, TextCompareView, - ViewportAnchorBias, VirtualDiffItemKind, WorkspaceMode, WorkspaceSource, - prepare_active_file, vcs_compare_request, - }; - use crate::core::compare::{ - CompareFileSummary, CompareMode, CompareOutput, LayoutMode, RendererKind, - }; - use crate::core::text::TokenBuffer; - use crate::core::vcs::model::{ - ChangeBucket, ChangeFlags, FileChange, FileChangeStatus, JjOperation, RefKind, - RepoCapabilities, RepoLocation, RevisionId, VcsChange, VcsKind, VcsOperation, - VcsOperationLogEntry, VcsRef, - }; - use crate::editor::EditorMode; - use crate::editor::diff::render_doc::{RenderDoc, build_render_doc_from_carbon}; - use crate::effects::{ - AiEffect, CompareEffect, CompareWorkPriority, Effect, GitHubEffect, RepositoryEffect, - SettingsEffect, SyntaxEffect, - }; - use crate::events::{ - AppEvent, CompareEvent, CompareFileFinished, CompareFileStat, CompareFileStatsReady, - CompareStatsReady, GitHubEvent, RepositoryEvent, TextCompareFinished, - }; - use crate::platform::persistence::Settings; - use crate::platform::startup::{Args, StartupOptions}; - - fn carbon_summary_for_path(index: usize, path: &str) -> carbon::FileDiff { - carbon::FileDiff { - id: carbon::FileId(index as u32), - old_path: Some(path.to_owned()), - new_path: Some(path.to_owned()), - is_partial: true, - ..carbon::FileDiff::default() - } - } - - fn carbon_context_file(index: usize, path: &str, text: &str) -> carbon::FileDiff { - carbon::parse_unified_patch(&format!( - "diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -1 +1 @@\n {text}\n" - )) - .unwrap() - .files - .into_iter() - .next() - .map(|mut file| { - file.id = carbon::FileId(index as u32); - file - }) - .unwrap() - } - - #[test] - fn new_text_compare_enters_text_workspace_with_left_focus() { - let mut state = AppState::default(); - - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - - assert_eq!( - state.workspace.source.get(&state.store), - WorkspaceSource::TextCompare - ); - assert_eq!(state.text_compare.view, TextCompareView::Edit); - assert_eq!(state.text_compare.left_editor.mode(), EditorMode::CodeInput); - assert_eq!( - state.text_compare.right_editor.mode(), - EditorMode::CodeInput - ); - assert_eq!(state.text_compare.language, TextCompareLanguage::Auto); - assert_eq!(state.text_compare.path_hint, "text.txt"); - assert_eq!( - state.focus.get(&state.store), - Some(FocusTarget::TextCompareLeft) - ); - } - - #[test] - fn text_compare_paste_routes_to_focused_side() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - - state.apply_action(crate::actions::TextEditAction::Paste("left".to_owned())); - state.apply_action(crate::actions::AppAction::SetFocus(Some( - FocusTarget::TextCompareRight, - ))); - state.apply_action(crate::actions::TextEditAction::Paste("right".to_owned())); - - assert_eq!(state.text_compare.left_editor.text(), "left"); - assert_eq!(state.text_compare.right_editor.text(), "right"); - assert_eq!(state.text_compare.left_editor.line_count(), 1); - assert_eq!(state.text_compare.right_editor.line_count(), 1); - } - - #[test] - fn text_compare_auto_language_detects_pasted_rust() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - - state.apply_action(crate::actions::TextEditAction::Paste( - "pub fn main() {\n println!(\"hi\");\n}\n".to_owned(), - )); - - assert_eq!( - state.text_compare.detected_language, - Some(TextCompareLanguage::Rust) - ); - assert_eq!(state.text_compare.path_hint, "scratch.rs"); - } - - #[test] - fn text_compare_auto_language_detects_pasted_typescript() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - - state.apply_action(crate::actions::TextEditAction::Paste( - "const answer: number = 42;\nexport { answer };\n".to_owned(), - )); - - assert_eq!( - state.text_compare.detected_language, - Some(TextCompareLanguage::TypeScript) - ); - assert_eq!(state.text_compare.path_hint, "scratch.ts"); - } - - #[test] - fn text_compare_language_override_sets_compare_path() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - - state.apply_action(crate::actions::TextCompareAction::SetLanguage( - TextCompareLanguage::TypeScript, - )); - state.apply_action(crate::actions::TextEditAction::Paste( - "pub fn main() {}\n".to_owned(), - )); - let effects = state.apply_action(crate::actions::TextCompareAction::CompareNow); - let request_path = effects - .iter() - .find_map(|effect| match effect { - Effect::Compare(CompareEffect::RunText(task)) => { - Some(task.request.display_path.as_str()) - } - _ => None, - }) - .unwrap(); - - assert_eq!(state.text_compare.language, TextCompareLanguage::TypeScript); - assert_eq!(state.text_compare.path_hint, "scratch.ts"); - assert_eq!(request_path, "scratch.ts"); - } - - #[test] - fn text_compare_swap_sides_preserves_text_and_marks_stale() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - state.text_compare.left_editor.set_text("old"); - state.text_compare.right_editor.set_text("new"); - let generation = state.text_compare.generation; - - state.apply_action(crate::actions::TextCompareAction::SwapSides); - - assert_eq!(state.text_compare.left_editor.text(), "new"); - assert_eq!(state.text_compare.right_editor.text(), "old"); - assert!(state.text_compare.generation > generation); - assert!(state.text_compare_is_stale()); - } - - #[test] - fn stale_text_compare_finished_event_is_ignored() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - let effects = state.apply_action(crate::actions::TextCompareAction::CompareNow); - let generation = effects - .iter() - .find_map(|effect| match effect { - Effect::Compare(CompareEffect::RunText(task)) => Some(task.generation), - _ => None, - }) - .unwrap(); - state.apply_action(crate::actions::TextEditAction::Paste("newer".to_owned())); - - state.apply_event(AppEvent::from(CompareEvent::TextCompareFinished( - TextCompareFinished { - generation, - display_path: "text.txt".to_owned(), - renderer: RendererKind::Builtin, - layout: LayoutMode::Unified, - output: CompareOutput::default(), - }, - ))); - - assert!(state.workspace.compare_output.get(&state.store).is_none()); - assert_eq!(state.text_compare.view, TextCompareView::Edit); - } - - #[test] - fn text_compare_finished_installs_diff_view() { - let mut state = AppState::default(); - state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); - let generation = state.text_compare.generation.saturating_add(1); - state.text_compare.generation = generation; - let output = crate::core::compare::compare_text( - "old\n", - "new\n", - "text.txt", - RendererKind::Builtin, - LayoutMode::Unified, - ) - .unwrap(); - - state.apply_event(AppEvent::from(CompareEvent::TextCompareFinished( - TextCompareFinished { - generation, - display_path: "text.txt".to_owned(), - renderer: RendererKind::Builtin, - layout: LayoutMode::Unified, - output, - }, - ))); - - assert_eq!(state.text_compare.view, TextCompareView::Diff); - assert_eq!( - state.workspace.source.get(&state.store), - WorkspaceSource::TextCompare - ); - assert!(state.workspace.active_file.get(&state.store).is_some()); - } - - fn status_state_with_two_hunks() -> AppState { - let state = AppState::default(); - let repo_path = PathBuf::from("/repo"); - let path = "src/lib.rs".to_owned(); - let token_buffer = TokenBuffer::default(); - let carbon_file = carbon::parse_unified_patch( - "\ -diff --git a/src/lib.rs b/src/lib.rs ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -1,3 +1,2 @@ - fn one() { -- old_first(); - } -@@ -8,3 +7,2 @@ - fn two() { -- old_second(); - } -", - ) - .unwrap() - .files - .into_iter() - .next() - .unwrap(); - let carbon_expansion = carbon::ExpansionState::default(); - let render_doc = build_render_doc_from_carbon( - &carbon_file, - 0, - &carbon_expansion, - &CarbonStyleOverlays::default(), - &token_buffer, - ); - let (left_ref, right_ref) = - crate::ui::vcs::profile(None).status_compare_refs(ChangeBucket::Unstaged); - - state.compare.repo_path.set(&state.store, Some(repo_path)); - state - .repository - .capabilities - .set(&state.store, Some(RepoCapabilities::git())); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Status); - state.workspace.status.set(&state.store, AsyncStatus::Ready); - state - .workspace - .status_operation_pending - .set(&state.store, false); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - state.workspace.files.set( - &state.store, - vec![FileListEntry { - path: path.as_str().into(), - }], - ); - state.workspace.status_file_changes.set( - &state.store, - vec![FileChange { - path: path.clone(), - old_path: None, - status: FileChangeStatus::Modified, - bucket: ChangeBucket::Unstaged, - }], - ); - state - .workspace - .selected_file_index - .set(&state.store, Some(0)); - state - .workspace - .selected_file_path - .set(&state.store, Some(path.clone())); - state - .workspace - .selected_change_bucket - .set(&state.store, Some(ChangeBucket::Unstaged)); - state.workspace.active_file.set( - &state.store, - Some(ActiveFile { - index: 0, - path, - carbon_file: Arc::new(carbon_file.clone()), - carbon_expansion, - carbon_overlays: CarbonStyleOverlays::default(), - render_doc: Arc::new(render_doc), - token_buffer, - left_ref, - right_ref, - file_line_count: None, - old_file_lines: None, - file_lines: None, - syntax_pending: Vec::new(), - syntax_covered: Vec::new(), - last_used_tick: 0, - }), - ); - - state - } - - fn loaded_state_with_files(paths: &[&str]) -> AppState { - let state = AppState::default(); - let carbon_files: Vec = paths - .iter() - .enumerate() - .map(|(index, path)| carbon_context_file(index, path, "loaded")) - .collect(); - let entries: Vec = carbon_files - .iter() - .map(|file| FileListEntry { - path: file.path().into(), - }) - .collect(); - - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - carbon: carbon::DiffDocument { - files: carbon_files, - }, - ..CompareOutput::default() - }), - ); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.workspace.files.set(&state.store, entries); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - state.file_list.row_height.set(&state.store, 36.0); - state.file_list.gap.set(&state.store, 4.0); - state.file_list.viewport_height.set(&state.store, 80.0); - state - } - - #[test] - fn bootstrap_with_no_repo_starts_empty_workspace() { - let startup = StartupOptions::from_parts( - Args::parse_from(["diffy"]), - None, - "client".to_owned(), - false, - ); - - let (state, effects) = AppState::bootstrap(startup, Settings::default()); - assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Empty); - assert_eq!( - state.focus.get(&state.store), - Some(FocusTarget::WorkspacePrimaryButton) - ); - assert!(effects.iter().all(|e| matches!( - e, - Effect::GitHub(GitHubEffect::LoadGitHubToken) - | Effect::Ai(AiEffect::LoadAiKeys) - | Effect::Syntax(SyntaxEffect::InstallCommonSyntaxPacks) - ))); - } - - #[test] - fn bootstrap_with_repo_starts_repo_sync() { - let startup = StartupOptions::from_parts( - Args { - repo: Some("C:\\repo".into()), - left: Some("main".to_owned()), - right: None, - compare_mode: Some(CompareMode::TwoDot), - layout: Some(LayoutMode::Unified), - renderer: Some(RendererKind::Builtin), - file_index: None, - file_path: None, - open_pr: None, - }, - None, - "client".to_owned(), - false, - ); - - let (state, effects) = AppState::bootstrap(startup, Settings::default()); - assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Empty); - assert_eq!(state.active_overlay_name(), None); - assert_eq!( - effects - .iter() - .filter(|e| matches!( - e, - Effect::Repository(RepositoryEffect::SyncRepository { .. }) - | Effect::Repository(RepositoryEffect::WatchRepository { .. }) - )) - .count(), - 2 - ); - } - - #[test] - fn overlay_close_restores_prior_focus() { - let startup = StartupOptions::from_parts( - Args::parse_from(["diffy"]), - None, - "client".to_owned(), - false, - ); - let (mut state, _) = AppState::bootstrap(startup, Settings::default()); - state.apply_action(crate::actions::AppAction::SetFocus(Some( - FocusTarget::TitleBar, - ))); - state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); - assert_eq!(state.overlays_top(), Some(OverlaySurface::CommandPalette)); - state.apply_action(crate::actions::OverlayAction::CloseOverlay); - assert_eq!(state.focus.get(&state.store), Some(FocusTarget::TitleBar)); - } - - #[test] - fn pixel_scroll_actions_clamp_file_list_and_viewport() { - let mut state = AppState::default(); - - state.workspace.files.set( - &state.store, - vec![ - FileListEntry { - path: "a.rs".into(), - }, - FileListEntry { - path: "b.rs".into(), - }, - FileListEntry { - path: "c.rs".into(), - }, - FileListEntry { - path: "d.rs".into(), - }, - FileListEntry { - path: "e.rs".into(), - }, - ], - ); - state.file_list.row_height.set(&state.store, 36.0); - state.file_list.gap.set(&state.store, 4.0); - state.file_list.viewport_height.set(&state.store, 80.0); - - state.apply_action(crate::actions::FileListAction::ScrollFileListPx(50)); - assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 50.0); - - state.apply_action(crate::actions::FileListAction::ScrollFileListPx(500)); - assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 116.0); - - state.apply_action(crate::actions::FileListAction::ScrollFileListPx(-500)); - assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 0.0); - - state.editor.content_height_px.set(&state.store, 600); - state.editor.viewport_height_px.set(&state.store, 200); - - state.apply_action(crate::actions::EditorAction::ScrollViewportPx(75)); - assert_eq!(state.editor.scroll_top_px.get(&state.store), 75); - - state.apply_action(crate::actions::EditorAction::ScrollViewportPx(500)); - assert_eq!(state.editor.scroll_top_px.get(&state.store), 400); - - state.apply_action(crate::actions::EditorAction::ScrollViewportPx(-500)); - assert_eq!(state.editor.scroll_top_px.get(&state.store), 0); - } - - #[test] - fn file_height_index_keeps_uniform_large_lists_sparse() { - let mut index = FileHeightIndex::default(); - index.rebuild(vec![192; FILE_HEIGHT_SPARSE_MIN_COUNT + 1]); - - assert_eq!(index.len(), FILE_HEIGHT_SPARSE_MIN_COUNT + 1); - assert_eq!( - index.total_u32(), - ((FILE_HEIGHT_SPARSE_MIN_COUNT + 1) as u32) * 192 - ); - assert!(matches!(index, FileHeightIndex::Sparse { .. })); - assert_eq!(index.locate(192 * 7 + 12), Some((7, 12))); - } - - #[test] - fn sparse_file_height_index_updates_prefix_and_locate() { - let mut index = FileHeightIndex::default(); - index.rebuild(vec![100; FILE_HEIGHT_SPARSE_MIN_COUNT + 2]); - index.update(3, 250); - index.update(7, 40); - - assert_eq!(index.prefix_u32(4), 550); - assert_eq!(index.prefix_u32(8), 890); - assert_eq!(index.locate(549), Some((3, 249))); - assert_eq!(index.locate(550), Some((4, 0))); - assert_eq!(index.locate(849), Some((6, 99))); - assert_eq!(index.locate(850), Some((7, 0))); - assert_eq!(index.locate(889), Some((7, 39))); - assert_eq!(index.locate(890), Some((8, 0))); - } - - #[test] - fn clicking_a_visible_file_does_not_force_sidebar_reveal() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.file_list.scroll_offset_px.set(&state.store, 10.0); - - state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - assert_eq!( - state.workspace.selected_file_index.get(&state.store), - Some(0) - ); - assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 10.0); - } - - #[test] - fn keyboard_file_navigation_still_reveals_selection() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs", "d.rs"]); - state - .workspace - .selected_file_index - .set(&state.store, Some(0)); - state - .workspace - .selected_file_path - .set(&state.store, Some("a.rs".into())); - state.file_list.scroll_offset_px.set(&state.store, 50.0); - - state.apply_action(crate::actions::FileListAction::SelectNextFile); - - assert_eq!( - state.workspace.selected_file_index.get(&state.store), - Some(1) - ); - assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 40.0); - } - - #[test] - fn next_file_action_selects_adjacent_file_in_single_file_mode() { - let mut state = - loaded_state_with_files(&["src/ui/state/mod.rs", "src/ui/state/text_edit.rs"]); - state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - state.apply_action(crate::actions::EditorAction::GoToNextFile); - state.sync_editor_scroll_from_global(); - - assert_eq!( - state.workspace.selected_file_index.get(&state.store), - Some(1) - ); - assert_eq!( - state - .workspace - .selected_file_path - .get(&state.store) - .as_deref(), - Some("src/ui/state/text_edit.rs") - ); - assert_eq!( - state - .workspace - .active_file - .get(&state.store) - .as_ref() - .map(|file| file.path.as_str()), - Some("src/ui/state/text_edit.rs") - ); - assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 0); - } - - #[test] - fn next_file_action_selects_next_file_when_tail_is_short() { - let mut state = - loaded_state_with_files(&["src/ui/state/mod.rs", "src/ui/state/text_edit.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 10_000); - state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - state.apply_action(crate::actions::EditorAction::GoToNextFile); - - assert_eq!( - state.workspace.selected_file_index.get(&state.store), - Some(1) - ); - assert_eq!( - state - .workspace - .selected_file_path - .get(&state.store) - .as_deref(), - Some("src/ui/state/text_edit.rs") - ); - assert_eq!( - state - .workspace - .active_file - .get(&state.store) - .as_ref() - .map(|file| file.path.as_str()), - Some("src/ui/state/text_edit.rs") - ); - } - - #[test] - fn continuous_scroll_keeps_short_tail_at_natural_bottom() { - let state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.editor.viewport_height_px.set(&state.store, 10_000); - - assert_eq!(state.global_max_scroll_top_px(), 0); - } - - #[test] - fn continuous_scroll_first_height_measurement_keeps_total_cache_in_sync_with_index() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.recompute_file_scroll_total_height_px(); - - assert_eq!(state.workspace.measured_px_per_row_q16.get(&state.store), 0); - - assert!(state.update_file_content_height_px(0, 1_200)); - - assert_eq!( - state - .workspace - .file_scroll_total_height_px - .get(&state.store), - state.virtual_diff_document.total_u32() - ); - } - - #[test] - fn continuous_scroll_keeps_bottom_anchor_when_visible_file_height_grows() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 100); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(200), Some(200), Some(200)]); - state.recompute_file_scroll_total_height_px(); - - let old_max = state.global_max_scroll_top_px(); - assert_eq!(old_max, 500); - state - .workspace - .global_scroll_top_px - .set(&state.store, old_max); - - assert!(state.update_file_content_height_px(2, 350)); - - assert_eq!(state.global_max_scroll_top_px(), 650); - assert_eq!( - state.workspace.global_scroll_top_px.get(&state.store), - state.global_max_scroll_top_px() - ); - } - - #[test] - fn continuous_scroll_follow_end_anchor_is_explicit_after_scrolling_to_bottom() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 100); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(200), Some(200), Some(200)]); - state.recompute_file_scroll_total_height_px(); - - let old_max = state.global_max_scroll_top_px(); - state.scroll_viewport_to_global(old_max); - - let anchor = state.virtual_scroll.anchor.expect("bottom anchor"); - assert_eq!(anchor.bias, ViewportAnchorBias::FollowEnd); - - assert!(state.update_file_content_height_px(2, 350)); - - assert_eq!( - state.workspace.global_scroll_top_px.get(&state.store), - state.global_max_scroll_top_px() - ); - } - - #[test] - fn continuous_scroll_preserves_top_anchor_when_prior_file_height_changes() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 100); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(200), Some(200), Some(200)]); - state.recompute_file_scroll_total_height_px(); - - state.scroll_viewport_to_global(250); - let anchor = state.virtual_scroll.anchor.expect("top anchor"); - assert_eq!(anchor.item_id.index, 1); - assert_eq!(anchor.intra_item_offset_px, 50); - assert_eq!(anchor.bias, ViewportAnchorBias::PreserveTop); - - assert!(state.update_file_content_height_px(0, 300)); - - assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 350); - let anchor = state.virtual_scroll.anchor.expect("rebased anchor"); - assert_eq!(anchor.item_id.index, 1); - assert_eq!(anchor.intra_item_offset_px, 50); - } - - #[test] - fn continuous_scroll_preserves_bottom_anchor_when_prior_file_height_changes() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 100); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(200), Some(200), Some(200)]); - state.recompute_file_scroll_total_height_px(); - - state.set_viewport_anchor_for_global(350, ViewportAnchorBias::PreserveBottom); - let anchor = state.virtual_scroll.anchor.expect("bottom-edge anchor"); - assert_eq!(anchor.item_id.index, 2); - assert_eq!(anchor.intra_item_offset_px, 50); - - assert!(state.update_file_content_height_px(0, 300)); - - assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 450); - let anchor = state.virtual_scroll.anchor.expect("rebased anchor"); - assert_eq!(anchor.bias, ViewportAnchorBias::PreserveBottom); - assert_eq!(anchor.item_id.index, 2); - assert_eq!(anchor.intra_item_offset_px, 50); - } - - #[test] - fn continuous_scroll_keeps_bottom_anchor_after_pending_scrollbar_drag_height_update() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 100); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(200), Some(200), Some(200)]); - state.recompute_file_scroll_total_height_px(); - - let old_max = state.global_max_scroll_top_px(); - assert_eq!(old_max, 500); - state - .workspace - .global_scroll_top_px - .set(&state.store, old_max); - state.begin_viewport_scrollbar_drag(600, 100, old_max, old_max); - - assert!(!state.update_file_content_height_px(2, 350)); - state.end_viewport_scrollbar_drag(); - - assert_eq!(state.global_max_scroll_top_px(), 650); - assert_eq!( - state.workspace.global_scroll_top_px.get(&state.store), - state.global_max_scroll_top_px() - ); - } - - #[test] - fn continuous_scroll_does_not_treat_zero_max_as_bottom_anchor() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 1_000); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(200), Some(200), Some(200)]); - state.recompute_file_scroll_total_height_px(); - - assert_eq!(state.global_max_scroll_top_px(), 0); - assert!(state.update_file_content_height_px(2, 700)); - - assert_eq!(state.global_max_scroll_top_px(), 100); - assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 0); - } - - #[test] - fn virtual_diff_document_keeps_large_compare_ranges_sparse_and_anchorable() { - let count = FILE_HEIGHT_SPARSE_MIN_COUNT + 32; - let summaries = (0..count) - .map(|index| { - let path = format!("kernel/file_{index}.c"); - CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ) - }) - .collect::>(); - let mut state = AppState::default(); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 900); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - state.recompute_file_scroll_total_height_px(); - - assert!(matches!( - state.virtual_diff_document.height_index, - FileHeightIndex::Sparse { .. } - )); - - let target = state.global_max_scroll_top_px() / 2; - state.scroll_viewport_to_global(target); - let anchor = state.virtual_scroll.anchor.expect("compare anchor"); - - assert_eq!(anchor.item_id.source, WorkspaceSource::Compare); - assert_eq!( - anchor.item_id.generation, - state.workspace_render_generation() - ); - assert!(anchor.item_id.index < count); - } - - #[test] - fn virtual_diff_document_rejects_stale_measurement_item_ids() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs"]); - state.settings.continuous_scroll = true; - state.recompute_file_scroll_total_height_px(); - let item_id = state.virtual_diff_document.item_id(1).expect("item id"); - - assert!(state.update_virtual_diff_item_height_px(item_id, 300)); - state.workspace.compare_generation.set(&state.store, 1); - - assert!(!state.update_virtual_diff_item_height_px(item_id, 500)); - assert_eq!( - state - .workspace - .file_content_heights - .with(&state.store, |heights| heights.get(1).copied().flatten()), - Some(300) - ); - } - - #[test] - fn continuous_compare_count_keeps_sidebar_files_when_output_is_partially_hydrated() { - let mut state = AppState::default(); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 10_000); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - carbon: carbon::DiffDocument { - files: vec![carbon_context_file(0, "a.rs", "loaded")], - }, - ..CompareOutput::default() - }), - ); - state.workspace.files.set( - &state.store, - vec![ - FileListEntry { - path: "a.rs".into(), - }, - FileListEntry { - path: "b.rs".into(), - }, - FileListEntry { - path: "c.rs".into(), - }, - ], - ); - - assert_eq!(state.workspace_file_count(), 3); - - let (doc, _effects) = state.build_continuous_viewport_document(); - let doc = doc.expect("viewport doc"); - - assert_eq!(doc.slot_indices, vec![0, 1, 2]); - assert_eq!(doc.slot_loading, vec![false, true, true]); - } - - #[test] - fn continuous_viewport_document_exposes_virtual_stream_rows() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 900); - state - .cache_compare_file_from_output(0, "a.rs") - .expect("cached first file"); - state - .cache_compare_file_from_output(1, "b.rs") - .expect("cached second file"); - - let (doc, _effects) = state.build_continuous_viewport_document(); - let doc = doc.expect("viewport doc"); - - assert!( - doc.stream_items - .iter() - .any(|item| item.id.kind == VirtualDiffItemKind::FileHeader) - ); - assert!( - doc.stream_items - .iter() - .any(|item| item.id.kind == VirtualDiffItemKind::Hunk) - ); - assert!( - doc.stream_items - .iter() - .any(|item| item.id.kind == VirtualDiffItemKind::DiffRow) - ); - assert!( - doc.stream_items - .windows(2) - .all(|items| items[0].sort_key <= items[1].sort_key) - ); - assert!( - doc.stream_items - .iter() - .all(|item| item.estimated_height_px > 0) - ); - } - - #[test] - fn continuous_viewport_document_backfills_before_tail_file() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 500); - state - .workspace - .file_content_heights - .set(&state.store, vec![Some(800), Some(800), Some(800)]); - state.recompute_file_scroll_total_height_px(); - state - .workspace - .global_scroll_top_px - .set(&state.store, 1_700); - - let (doc, _effects) = state.build_continuous_viewport_document(); - let doc = doc.expect("viewport doc"); - - assert_eq!(doc.slot_indices, vec![1, 2]); - assert_eq!(doc.start_index, 1); - assert_eq!(doc.start_offset_px, 800); - assert_eq!(doc.scroll_top_px, 900); - } - - #[test] - fn continuous_viewport_document_follow_end_builds_from_tail() { - let mut state = - loaded_state_with_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs", "tail.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 500); - state.workspace.file_content_heights.set( - &state.store, - vec![ - Some(1_000), - Some(1_000), - Some(1_000), - Some(1_000), - Some(1_000), - Some(1_000), - Some(100), - ], - ); - state.recompute_file_scroll_total_height_px(); - - state.scroll_viewport_to_global(state.global_max_scroll_top_px()); - - let (doc, _effects) = state.build_continuous_viewport_document(); - let doc = doc.expect("viewport doc"); - - assert_eq!(doc.slot_indices, vec![5, 6]); - assert_eq!(doc.start_index, 5); - assert_eq!(doc.start_offset_px, 5_000); - assert_eq!(doc.scroll_top_px, 600); - } - - #[test] - fn next_file_action_resolves_current_file_from_selected_path() { - let mut state = loaded_state_with_files(&[ - "src/core/compare/backends/git_diff.rs", - "src/core/compare/mod.rs", - "src/core/compare/service.rs", - "src/core/compare/stats.rs", - "src/core/frecency.rs", - "src/ui/state/mod.rs", - "src/ui/state/text_edit.rs", - "src/ui/toolbar.rs", - ]); - state.settings.continuous_scroll = true; - state - .workspace - .selected_file_index - .set(&state.store, Some(0)); - state - .workspace - .selected_file_path - .set(&state.store, Some("src/ui/state/mod.rs".to_owned())); - - state.apply_action(crate::actions::EditorAction::GoToNextFile); - - assert_eq!( - state.workspace.selected_file_index.get(&state.store), - Some(6) - ); - assert_eq!( - state - .workspace - .selected_file_path - .get(&state.store) - .as_deref(), - Some("src/ui/state/text_edit.rs") - ); - } - - #[test] - fn selecting_a_file_requests_async_syntax_without_mutating_compare_output() { - let mut state = AppState::default(); - let mut output = CompareOutput::default(); - output.carbon.files = vec![carbon_context_file( - 0, - "src/lib.rs", - "fn answer() -> i32 { 42 }", - )]; - state - .workspace - .compare_output - .set(&state.store, Some(output)); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.workspace.files.set( - &state.store, - vec![FileListEntry { - path: "src/lib.rs".into(), - }], - ); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/tmp/repo"))); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - - let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - assert!(effects.iter().any(|effect| { - matches!( - effect, - Effect::Syntax(SyntaxEffect::LoadFileSyntax(task)) - if task.request.path == "src/lib.rs" - && task.request.window.start == 0 - && task.request.window.end > 0 - ) - })); - state.workspace.compare_output.with(&state.store, |co| { - let output = co.as_ref().expect("compare output"); - assert_eq!(output.carbon.files[0].path(), "src/lib.rs"); - assert_eq!(output.carbon.files[0].hunks.len(), 1); - }); - } - - #[test] - fn prepare_active_file_builds_from_carbon_text() { - let carbon_file = carbon::parse_unified_patch( - "\ -diff --git a/src/lib.rs b/src/lib.rs ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -1 +1 @@ - fn answer() -> i32 { 42 } -", - ) - .unwrap() - .files - .into_iter() - .next() - .unwrap(); - - let prepared = prepare_active_file(0, &carbon_file); - - assert_eq!(prepared.carbon_file.path(), "src/lib.rs"); - assert!(prepared.render_doc.lines.iter().any(|render_line| { - prepared.render_doc.line_text(render_line.left_text) == "fn answer() -> i32 { 42 }" - || prepared.render_doc.line_text(render_line.right_text) - == "fn answer() -> i32 { 42 }" - })); - } - - #[test] - fn small_compare_file_selection_stays_synchronous() { - let mut state = AppState::default(); - let mut output = CompareOutput::default(); - let mut carbon_file = carbon_context_file(0, "src/lib.rs", "fn answer() -> i32 { 42 }"); - carbon_file.additions = 10; - carbon_file.deletions = 5; - output.carbon.files = vec![carbon_file]; - - state - .workspace - .compare_output - .set(&state.store, Some(output)); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.workspace.files.set( - &state.store, - vec![FileListEntry { - path: "src/lib.rs".into(), - }], - ); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - - let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - assert!(effects.iter().any(|effect| { - matches!(effect, Effect::Syntax(SyntaxEffect::EnsureSyntaxPackForPath { path }) if path == "src/lib.rs") - })); - assert!( - !effects - .iter() - .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadFile(_)))) - ); - assert!( - state - .workspace - .active_file_loading - .get(&state.store) - .is_none() - ); - assert_eq!( - state - .workspace - .active_file - .get(&state.store) - .as_ref() - .map(|file| file.path.as_str()), - Some("src/lib.rs") - ); - } - - #[test] - fn selecting_large_compare_file_dispatches_async_load() { - let mut state = loaded_state_with_files(&["src/big.rs"]); - state - .workspace - .compare_output - .update(&state.store, |output| { - let file = &mut output.as_mut().expect("compare output").carbon.files[0]; - file.additions = 1_500; - }); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - state.compare.left_ref.set(&state.store, "v5.5".to_owned()); - state.compare.right_ref.set(&state.store, "v5.6".to_owned()); - state - .compare - .renderer - .set(&state.store, RendererKind::Builtin); - state.compare.layout.set(&state.store, LayoutMode::Unified); - state.compare.mode.set(&state.store, CompareMode::TwoDot); - - let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - assert!(matches!( - effects.as_slice(), - [ - Effect::Syntax(SyntaxEffect::EnsureSyntaxPackForPath { path }), - Effect::Compare(CompareEffect::LoadFile(task)) - ] - if path == "src/big.rs" - && task.request.index == 0 - && task.request.path == "src/big.rs" - )); - assert_eq!( - state.workspace.active_file_loading.get(&state.store), - Some(ActiveFileLoading { - index: 0, - path: "src/big.rs".to_owned(), - priority: CompareWorkPriority::InteractiveSelectedFile, - }) - ); - assert!(state.workspace.active_file.get(&state.store).is_none()); - } - - #[test] - fn selecting_deferred_compare_file_dispatches_async_load() { - let mut state = loaded_state_with_files(&["src/kernel.c"]); - state - .workspace - .compare_output - .update(&state.store, |output| { - let file = &mut output.as_mut().expect("compare output").carbon.files[0]; - file.is_partial = true; - file.hunks.clear(); - file.blocks.clear(); - }); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - - let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); - - assert!(effects.iter().any(|effect| { - matches!( - effect, - Effect::Compare(CompareEffect::LoadFile(task)) - if task.request.index == 0 - && task.request.path == "src/kernel.c" - && task.request.deferred_file.as_ref().is_some_and(|file| file.is_partial) - ) - })); - assert_eq!( - state.workspace.active_file_loading.get(&state.store), - Some(ActiveFileLoading { - index: 0, - path: "src/kernel.c".to_owned(), - priority: CompareWorkPriority::InteractiveSelectedFile, - }) - ); - assert!(state.workspace.active_file.get(&state.store).is_none()); - } - - #[test] - fn scrollbar_drag_loads_visible_compare_files_without_selecting_them() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state.settings.continuous_scroll = true; - state.editor.viewport_height_px.set(&state.store, 240); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - state - .workspace - .compare_output - .update(&state.store, |output| { - for file in &mut output.as_mut().expect("compare output").carbon.files { - file.is_partial = true; - file.hunks.clear(); - file.blocks.clear(); - } - }); - state.begin_viewport_scrollbar_drag(900, 240, 300, 660); - - let (_doc, effects) = state.build_continuous_viewport_document(); - - assert!( - effects - .iter() - .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadFile(_)))) - ); - assert!( - state - .workspace - .active_file_loading - .get(&state.store) - .is_none() - ); - } - - #[test] - fn overscan_prefetch_does_not_enqueue_syntax_work() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - - let effects = state.prefetch_compare_files_forward(0, 1_000); - - assert!( - !effects - .iter() - .any(|effect| matches!(effect, Effect::Syntax(SyntaxEffect::LoadFileSyntax(_)))), - "overscan should warm file diffs without adding syntax windows" - ); - state.workspace.file_cache.with(&state.store, |files| { - assert!(files.values().all(|file| file.syntax_pending.is_empty())); - }); - } - - #[test] - fn offscreen_viewport_slots_do_not_enqueue_syntax_work() { - let mut state = loaded_state_with_files(&["a.rs"]); - state - .cache_compare_file_from_output(0, "a.rs") - .expect("cached file"); - let key = state.compare_slot_key_at(0, "a.rs"); - - let window = state.viewport_slot_syntax_window(&key, 1_000, 120, 0, 240); - - assert_eq!(window, None); - } - - #[test] - fn syntax_budget_counts_inflight_requests_after_cache_eviction() { - let mut state = loaded_state_with_files(&["a.rs"]); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - state - .cache_compare_file_from_output(0, "a.rs") - .expect("cached file"); - let key = state.compare_slot_key_at(0, "a.rs"); - - let effect = state.request_viewport_slot_syntax_window( - &key, - crate::core::syntax::annotator::SyntaxRowWindow { start: 0, end: 32 }, - ); - - assert!(matches!( - effect, - Some(Effect::Syntax(SyntaxEffect::LoadFileSyntax(_))) - )); - state.workspace.file_cache.update(&state.store, |files| { - files.clear(); - }); - assert_eq!(state.syntax_pending_window_count(), 0); - assert_eq!(state.syntax_requests.inflight_len(), 1); - } - - #[test] - fn syntax_epoch_invalidation_clears_attached_pending_windows() { - let mut state = loaded_state_with_files(&["a.rs", "b.rs"]); - state - .cache_compare_file_from_output(0, "a.rs") - .expect("cached file"); - state - .cache_compare_file_from_output(1, "b.rs") - .expect("cached file"); - let active = state - .workspace - .file_cache - .with(&state.store, |files| files.get(&0).cloned()) - .expect("cached active"); - state.workspace.active_file.set(&state.store, Some(active)); - let pending = super::SyntaxPendingWindow { - request_id: 1, - window: crate::core::syntax::annotator::SyntaxRowWindow { start: 0, end: 32 }, - }; - state.workspace.active_file.update(&state.store, |active| { - active - .as_mut() - .expect("active file") - .syntax_pending - .push(pending); - }); - state.workspace.file_cache.update(&state.store, |files| { - for file in files.values_mut() { - file.syntax_pending.push(pending); - } - }); - state.syntax_requests.insert_inflight(0, 1); - - let effect = state.invalidate_syntax_epoch_effect(); - - assert!(matches!( - effect, - Effect::Syntax(SyntaxEffect::SetFileSyntaxEpoch { .. }) - )); - assert_eq!(state.syntax_pending_window_count(), 0); - assert_eq!(state.syntax_requests.inflight_len(), 0); - } - - #[test] - fn context_expansion_invalidates_existing_syntax_windows() { - let mut state = status_state_with_two_hunks(); - let stale_window = crate::core::syntax::annotator::SyntaxRowWindow { start: 0, end: 8 }; - - state.workspace.active_file.update(&state.store, |active| { - let active = active.as_mut().expect("active file"); - active.syntax_pending.push(super::SyntaxPendingWindow { - request_id: 7, - window: stale_window, - }); - active.syntax_covered.push(stale_window); - let range = active - .token_buffer - .append(&[crate::core::text::DiffTokenSpan { - offset: 0, - length: 2, - kind: Default::default(), - intensity: Default::default(), - }]); - active - .carbon_overlays - .insert_syntax(0, carbon::DiffSide::Old, 0, range); - }); - - state.apply_context_expansion( - crate::events::ContextDirection::All, - 0, - 0, - Arc::new((0..12).map(|index| format!("old {index}")).collect()), - Arc::new((0..12).map(|index| format!("new {index}")).collect()), - ); - - state.workspace.active_file.with(&state.store, |active| { - let active = active.as_ref().expect("active file"); - assert!(active.syntax_pending.is_empty()); - assert!(active.syntax_covered.is_empty()); - assert_eq!(active.token_buffer.len(), 0); - }); - } - - #[test] - fn context_expansion_retires_old_syntax_epoch_before_requeue() { - let mut state = status_state_with_two_hunks(); - state.workspace.active_file.update(&state.store, |active| { - let active = active.as_mut().expect("active file"); - active.old_file_lines = Some(Arc::new( - (0..12).map(|index| format!("old {index}")).collect(), - )); - active.file_lines = Some(Arc::new( - (0..12).map(|index| format!("new {index}")).collect(), - )); - }); - state.workspace.compare_generation.set(&state.store, 1); - for request_id in 0..super::MAX_PENDING_SYNTAX_WINDOWS as u64 { - state.syntax_requests.insert_inflight(0, request_id); - } - - let effects = state.dispatch_context_expansion(0, crate::events::ContextDirection::All, 0); - - assert!(matches!( - effects.first(), - Some(Effect::Syntax(SyntaxEffect::SetFileSyntaxEpoch { .. })) - )); - assert!(effects.iter().any(|effect| { - matches!( - effect, - Effect::Syntax(SyntaxEffect::LoadFileSyntax(task)) - if task.request.syntax_epoch == state.syntax_requests.epoch() - ) - })); - assert_eq!(state.syntax_requests.inflight_len(), 1); - } - - #[test] - fn syntax_pack_install_retires_old_epoch_before_refresh() { - let mut state = status_state_with_two_hunks(); - for request_id in 0..super::MAX_PENDING_SYNTAX_WINDOWS as u64 { - state.syntax_requests.insert_inflight(0, request_id); - } - - let effects = state.handle_syntax_packs_installed(&["rust".to_owned()]); - - assert!(matches!( - effects.first(), - Some(Effect::Syntax(SyntaxEffect::SetFileSyntaxEpoch { .. })) - )); - assert!(effects.iter().any(|effect| { - matches!( - effect, - Effect::Syntax(SyntaxEffect::LoadFileSyntax(task)) - if task.request.syntax_epoch == state.syntax_requests.epoch() - ) - })); - assert_eq!(state.syntax_requests.inflight_len(), 1); - } - - #[test] - fn compare_file_finished_ignores_stale_path() { - let mut state = loaded_state_with_files(&["src/lib.rs"]); - state.workspace.compare_generation.set(&state.store, 7); - state - .workspace - .selected_file_index - .set(&state.store, Some(0)); - state - .workspace - .selected_file_path - .set(&state.store, Some("src/lib.rs".to_owned())); - state.workspace.active_file_loading.set( - &state.store, - Some(ActiveFileLoading { - index: 0, - path: "src/lib.rs".to_owned(), - priority: CompareWorkPriority::InteractiveSelectedFile, - }), - ); - - state.apply_event(AppEvent::from(CompareEvent::CompareFileFinished( - CompareFileFinished { - generation: 7, - index: 0, - path: "src/other.rs".to_owned(), - prepared: PreparedActiveFile { - carbon_file: carbon::FileDiff::default(), - carbon_expansion: carbon::ExpansionState::default(), - carbon_overlays: CarbonStyleOverlays::default(), - render_doc: Arc::new(RenderDoc::default()), - token_buffer: TokenBuffer::default(), - }, - }, - ))); - - assert!(state.workspace.active_file.get(&state.store).is_none()); - assert_eq!( - state.workspace.active_file_loading.get(&state.store), - Some(ActiveFileLoading { - index: 0, - path: "src/lib.rs".to_owned(), - priority: CompareWorkPriority::InteractiveSelectedFile, - }) - ); - } - - #[test] - fn overlay_list_pixel_scroll_action_clamps_active_overlay() { - let mut state = AppState::default(); - state.overlays.stack.update(&state.store, |stack| { - stack.push(super::OverlayEntry { - surface: OverlaySurface::RepoPicker, - focus_return: None, - }); - }); - let picker_entries: Vec = (0..12) - .map(|index| super::PickerEntry { - label: format!("repo-{index}"), - detail: format!("C:\\repo-{index}"), - value: format!("C:\\repo-{index}"), - highlights: Vec::new(), - label_style: PickerLabelStyle::Default, - icon: None, - section_header: false, - }) - .collect(); - state - .overlays - .picker - .entries - .set(&state.store, picker_entries); - state - .overlays - .picker - .list - .update(&state.store, |l| l.viewport_height_px = 120); - - state.apply_action(crate::actions::OverlayAction::ScrollActiveOverlayListPx(50)); - assert_eq!( - state - .overlays - .picker - .list - .with(&state.store, |l| l.scroll_top_px), - 50 - ); - - state.apply_action(crate::actions::OverlayAction::ScrollActiveOverlayListPx( - 1_000, - )); - assert_eq!( - state - .overlays - .picker - .list - .with(&state.store, |l| l.scroll_top_px), - 312 - ); - - state.apply_action(crate::actions::OverlayAction::ScrollActiveOverlayListPx( - -1_000, - )); - assert_eq!( - state - .overlays - .picker - .list - .with(&state.store, |l| l.scroll_top_px), - 0 - ); - } - - #[test] - fn stage_hunk_at_stages_the_given_index() { - let mut state = status_state_with_two_hunks(); - - let effects = state.apply_action(crate::actions::RepositoryAction::StageHunkAt(1)); - - let [Effect::Repository(RepositoryEffect::ApplyPatchOperation(request))] = - effects.as_slice() - else { - panic!("expected one patch effect, got {:?}", effects); - }; - assert!(request.patch.contains("old_second();")); - assert!(!request.patch.contains("old_first();")); - } - - #[test] - fn stage_hunk_reads_the_hovered_hunk_index() { - let mut state = status_state_with_two_hunks(); - state.editor.hovered_hunk_index.set(&state.store, Some(1)); - - let effects = state.apply_action(crate::actions::RepositoryAction::StageHunk); - - let [Effect::Repository(RepositoryEffect::ApplyPatchOperation(request))] = - effects.as_slice() - else { - panic!("expected one patch effect"); - }; - assert!(request.patch.contains("old_second();")); - } - - #[test] - fn stage_hunk_without_partial_hunk_capability_is_ignored() { - let mut state = status_state_with_two_hunks(); - let mut capabilities = RepoCapabilities::git(); - capabilities.staging_area = false; - capabilities.partial_hunk_mutation = false; - state - .repository - .capabilities - .set(&state.store, Some(capabilities)); - - let effects = state.apply_action(crate::actions::RepositoryAction::StageHunkAt(0)); - - assert!(effects.is_empty()); - } - - #[test] - fn status_operation_failure_clears_the_pending_flag() { - let mut state = status_state_with_two_hunks(); - let _ = state.apply_action(crate::actions::RepositoryAction::StageHunkAt(0)); - assert!(state.workspace.status_operation_pending.get(&state.store)); - - let _ = state.apply_event(AppEvent::from(RepositoryEvent::FileOperationFailed { - path: PathBuf::from("/repo"), - message: "patch failed".to_owned(), - })); - - assert!(!state.workspace.status_operation_pending.get(&state.store)); - } - - #[test] - fn ref_picker_rebuilds_matches_while_typing_and_keeps_raw_git_revisions_selectable() { - let mut state = AppState::default(); - state.repository.refs.set( - &state.store, - vec![VcsRef { - name: "main".to_owned(), - kind: RefKind::Branch, - target: RevisionId::git("0000000000000000000000000000000000000000"), - active: true, - upstream: None, - ahead_behind: None, - }], - ); - - state.open_ref_picker(CompareField::Left); - state.apply_action(crate::actions::TextEditAction::InsertText("mai".to_owned())); - - let branch_highlights = state.overlays.picker.entries.with(&state.store, |entries| { - entries - .iter() - .find(|entry| entry.value == "main") - .expect("main branch entry") - .highlights - .clone() - }); - assert_eq!(branch_highlights, vec![(0, 3)]); - - let mut state = AppState::default(); - state.open_ref_picker(CompareField::Left); - state.apply_action(crate::actions::TextEditAction::InsertText( - "HEAD~2".to_owned(), - )); - - let (typed_value, typed_highlights) = - state.overlays.picker.entries.with(&state.store, |entries| { - let typed_entry = entries.first().expect("typed ref entry"); - (typed_entry.value.clone(), typed_entry.highlights.clone()) - }); - assert_eq!(typed_value, "HEAD~2"); - assert_eq!(typed_highlights, vec![(0, "HEAD~2".len())]); - - state.apply_action(crate::actions::OverlayAction::ConfirmOverlaySelection); - assert_eq!(state.compare.left_ref.get(&state.store), "HEAD~2"); - } - - #[test] - fn ref_picker_uses_jj_refs_and_change_ids_without_git_workdir() { - let mut state = AppState::default(); - let working_commit = "3e2d7a6e55221e519e3efb86e4f8fbb324980427".to_owned(); - let change_id = "xxyzvpwmsuxytmqltlzwzqpylvlqqyso".to_owned(); - - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: PathBuf::from("/repo"), - store_root: Some(PathBuf::from("/repo/.jj")), - }), - ); - state.repository.refs.set( - &state.store, - vec![ - VcsRef { - name: "@".to_owned(), - kind: RefKind::WorkingCopy, - target: RevisionId { - backend: VcsKind::JJ, - id: working_commit.clone(), - }, - active: true, - upstream: None, - ahead_behind: None, - }, - VcsRef { - name: "main".to_owned(), - kind: RefKind::Bookmark, - target: RevisionId { - backend: VcsKind::JJ, - id: "a4c9f6e8b1d24036a78610a332e12ca25e97c315".to_owned(), - }, - active: false, - upstream: None, - ahead_behind: None, - }, - ], - ); - state.repository.changes.set( - &state.store, - vec![VcsChange { - revision: RevisionId { - backend: VcsKind::JJ, - id: working_commit, - }, - change_id: Some(change_id.clone()), - short_change_id: Some("xsvsonvs".to_owned()), - short_change_id_prefix_len: Some(2), - short_revision: "3e2d7a6e5522".to_owned(), - summary: "Working copy".to_owned(), - author_name: "ro".to_owned(), - timestamp: 0, - flags: ChangeFlags { - current: true, - working_copy: true, - ..ChangeFlags::default() - }, - }], - ); - - state.open_ref_picker(CompareField::Left); - - state.overlays.picker.entries.with(&state.store, |entries| { - assert!(!entries.iter().any(|entry| entry.value == "@workdir")); - - let working_copy = entries - .iter() - .find(|entry| entry.value == "@") - .expect("working copy ref"); - assert_eq!( - working_copy.detail, - "Working copy change \u{2022} current / xsvsonvs 3e2d7a6e5522" - ); - - let bookmark = entries - .iter() - .find(|entry| entry.value == "main") - .expect("bookmark ref"); - assert_eq!(bookmark.detail, "Bookmark"); - - let change = entries - .iter() - .find(|entry| entry.value == change_id) - .expect("change id entry"); - assert_eq!(change.label, "xsvsonvs"); - assert!(change.highlights.is_empty()); - assert_eq!( - change.label_style(), - PickerLabelStyle::JjChangeId { - prefix_len: 2, - working_copy: true, - } - ); - }); - } - - #[test] - fn command_palette_uses_actual_match_indices_for_highlighting() { - let mut state = AppState::default(); - state - .overlays - .command_palette - .query - .set(&state.store, "them".to_owned()); - - state.rebuild_command_palette(); - - let highlights = state - .overlays - .command_palette - .entries - .with(&state.store, |entries| { - entries - .iter() - .find(|entry| entry.label == "Change Theme") - .expect("Change Theme entry") - .highlights - .clone() - }); - assert_eq!(highlights, vec![(7, 11)]); - } - - #[test] - fn command_palette_surfaces_jj_operations_for_jj_repositories() { - let mut state = AppState::default(); - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: PathBuf::from("/repo"), - store_root: Some(PathBuf::from("/repo/.jj")), - }), - ); - state - .overlays - .command_palette - .query - .set(&state.store, "jj".to_owned()); - - state.rebuild_command_palette(); - - let entries = state - .overlays - .command_palette - .entries - .with(&state.store, |entries| entries.clone()); - for operation in JjOperation::ALL { - let label = format!("jj: {}", operation.label()); - let entry = entries - .iter() - .find(|entry| entry.label == label) - .unwrap_or_else(|| panic!("missing {label} command")); - assert!(matches!( - entry.kind, - super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( - VcsOperation::Jj(found) - )) if found == operation - )); - } - - let mut state = AppState::default(); - state - .overlays - .command_palette - .query - .set(&state.store, "jj".to_owned()); - state.rebuild_command_palette(); - let has_jj_operation = - state - .overlays - .command_palette - .entries - .with(&state.store, |entries| { - entries.iter().any(|entry| { - JjOperation::ALL - .into_iter() - .any(|operation| entry.label == format!("jj: {}", operation.label())) - }) - }); - assert!(!has_jj_operation); - } - - #[test] - fn jj_operation_action_emits_repository_effect() { - let mut state = AppState::default(); - let repo_path = PathBuf::from("/repo"); - let operation = VcsOperation::Jj(JjOperation::NewChange); - state - .compare - .repo_path - .set(&state.store, Some(repo_path.clone())); - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: repo_path.clone(), - store_root: Some(repo_path.join(".jj")), - }), - ); - - let effects = state.apply_action(crate::actions::RepositoryAction::RunOperation( - operation.clone(), - )); - - let [Effect::Repository(RepositoryEffect::RunOperation(request))] = effects.as_slice() - else { - panic!("expected RunOperation effect, got {effects:?}"); - }; - assert_eq!(request.repo_path, repo_path); - assert_eq!(request.operation, operation); - } - - #[test] - fn destructive_jj_palette_operation_requires_confirmation() { - let mut state = AppState::default(); - let repo_path = PathBuf::from("/repo"); - let operation = VcsOperation::Jj(JjOperation::AbandonChange); - state - .compare - .repo_path - .set(&state.store, Some(repo_path.clone())); - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: repo_path.clone(), - store_root: Some(repo_path.join(".jj")), - }), - ); - state - .overlays - .command_palette - .query - .set(&state.store, "abandon".to_owned()); - state.overlays.stack.update(&state.store, |stack| { - stack.push(OverlayEntry { - surface: OverlaySurface::CommandPalette, - focus_return: None, - }); - }); - state.rebuild_command_palette(); - - let effects = state.apply_action(crate::actions::OverlayAction::ConfirmOverlaySelection); - - assert!(effects.is_empty()); - assert_eq!(state.overlays_top(), Some(OverlaySurface::Confirmation)); - assert_eq!( - state.overlays.confirmation.action.get(&state.store), - Some(crate::actions::RepositoryAction::RunOperation(operation.clone()).into()) - ); - - let effects = state.apply_action(crate::actions::OverlayAction::ConfirmOverlaySelection); - - let [Effect::Repository(RepositoryEffect::RunOperation(request))] = effects.as_slice() - else { - panic!("expected RunOperation effect, got {effects:?}"); - }; - assert_eq!(request.repo_path, repo_path); - assert_eq!(request.operation, operation); - assert_eq!(state.overlays_top(), None); - } - - #[test] - fn command_palette_surfaces_jj_rebase_destinations() { - let mut state = AppState::default(); - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: PathBuf::from("/repo"), - store_root: Some(PathBuf::from("/repo/.jj")), - }), - ); - state.repository.refs.set( - &state.store, - vec![ - VcsRef { - name: "@".to_owned(), - kind: RefKind::WorkingCopy, - target: RevisionId { - backend: VcsKind::JJ, - id: "current".to_owned(), - }, - active: true, - upstream: None, - ahead_behind: None, - }, - VcsRef { - name: "main".to_owned(), - kind: RefKind::Bookmark, - target: RevisionId { - backend: VcsKind::JJ, - id: "main-revision".to_owned(), - }, - active: false, - upstream: None, - ahead_behind: None, - }, - ], - ); - state - .overlays - .command_palette - .query - .set(&state.store, "rebase main".to_owned()); - - state.rebuild_command_palette(); - - let entry = state - .overlays - .command_palette - .entries - .with(&state.store, |entries| entries.first().cloned()) - .expect("rebase entry"); - assert_eq!(entry.label, "jj: Rebase @ Onto main"); - assert!(matches!( - entry.kind, - super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( - VcsOperation::JjRebaseCurrentChangeOnto { ref destination } - )) if destination == "main" - )); - } - - #[test] - fn command_palette_surfaces_jj_editable_changes() { - let mut state = AppState::default(); - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: PathBuf::from("/repo"), - store_root: Some(PathBuf::from("/repo/.jj")), - }), - ); - state.repository.changes.set( - &state.store, - vec![ - VcsChange { - revision: RevisionId { - backend: VcsKind::JJ, - id: "current-revision".to_owned(), - }, - change_id: Some("current-change".to_owned()), - short_change_id: Some("cur".to_owned()), - short_change_id_prefix_len: Some(3), - short_revision: "currev".to_owned(), - summary: "current".to_owned(), - author_name: "ro".to_owned(), - timestamp: 0, - flags: ChangeFlags { - current: true, - working_copy: true, - ..ChangeFlags::default() - }, - }, - VcsChange { - revision: RevisionId { - backend: VcsKind::JJ, - id: "target-revision".to_owned(), - }, - change_id: Some("target-change".to_owned()), - short_change_id: Some("tgt".to_owned()), - short_change_id_prefix_len: Some(3), - short_revision: "tgt123".to_owned(), - summary: "target change".to_owned(), - author_name: "ro".to_owned(), - timestamp: 0, - flags: ChangeFlags::default(), - }, - ], - ); - state - .overlays - .command_palette - .query - .set(&state.store, "edit tgt".to_owned()); - - state.rebuild_command_palette(); - - let entry = state - .overlays - .command_palette - .entries - .with(&state.store, |entries| entries.first().cloned()) - .expect("edit entry"); - assert_eq!(entry.label, "jj: Edit tgt"); - assert!(matches!( - entry.kind, - super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( - VcsOperation::JjEditRevision { - ref revision, - ref label - } - )) if revision == "target-revision" && label == "tgt" - )); - } - - #[test] - fn command_palette_surfaces_jj_operation_log_restore_targets() { - let mut state = AppState::default(); - state.repository.location.set( - &state.store, - Some(RepoLocation { - kind: VcsKind::JJ, - profile: crate::core::vcs::model::VCS_PROFILE_JJ, - workspace_root: PathBuf::from("/repo"), - store_root: Some(PathBuf::from("/repo/.jj")), - }), - ); - state.repository.operation_log.set( - &state.store, - vec![ - VcsOperationLogEntry { - operation_id: "current-operation".to_owned(), - short_operation_id: "current".to_owned(), - user: "ro".to_owned(), - time: "later".to_owned(), - description: "snapshot working copy".to_owned(), - }, - VcsOperationLogEntry { - operation_id: "target-operation".to_owned(), - short_operation_id: "target".to_owned(), - user: "ro".to_owned(), - time: "earlier".to_owned(), - description: "describe change".to_owned(), - }, - ], - ); - state - .overlays - .command_palette - .query - .set(&state.store, "restore target".to_owned()); - - state.rebuild_command_palette(); - - let entries = state - .overlays - .command_palette - .entries - .with(&state.store, |entries| entries.clone()); - assert!( - !entries - .iter() - .any(|entry| entry.label == "jj: Restore Operation current") - ); - let entry = entries - .iter() - .find(|entry| entry.label == "jj: Restore Operation target") - .expect("restore entry"); - assert_eq!(entry.detail, "describe change - ro - earlier"); - assert!(matches!( - entry.kind, - super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( - VcsOperation::JjRestoreOperation { - ref operation_id, - ref label - } - )) if operation_id == "target-operation" && label == "target" - )); - } - - #[test] - fn sidebar_width_action_clamps_and_stores_manual_preference() { - let mut state = AppState::default(); - - state.apply_action(crate::actions::SettingsAction::SetSidebarWidthPx(40)); - assert_eq!(state.settings.sidebar_width_px, Some(179)); - - state.apply_action(crate::actions::SettingsAction::SetSidebarWidthPx(420)); - assert_eq!(state.settings.sidebar_width_px, Some(420)); - } - - #[test] - fn ui_scale_actions_step_and_persist_within_bounds() { - let mut state = AppState::default(); - - let effects = state.apply_action(crate::actions::SettingsAction::IncreaseUiScale); - assert_eq!(state.settings.ui_scale_pct, 110); - assert_eq!(effects.len(), 1); - - for _ in 0..20 { - state.apply_action(crate::actions::SettingsAction::IncreaseUiScale); - } - assert_eq!(state.settings.ui_scale_pct, 180); - - for _ in 0..20 { - state.apply_action(crate::actions::SettingsAction::DecreaseUiScale); - } - assert_eq!(state.settings.ui_scale_pct, 70); - } - - #[test] - fn avatar_url_sized_appends_or_replaces_s_param() { - use super::avatar_url_sized; - assert_eq!( - avatar_url_sized("https://avatars.githubusercontent.com/u/1?v=4", 128), - Some("https://avatars.githubusercontent.com/u/1?v=4&s=128".to_owned()) - ); - assert_eq!( - avatar_url_sized("https://avatars.githubusercontent.com/u/1", 64), - Some("https://avatars.githubusercontent.com/u/1?s=64".to_owned()) - ); - assert_eq!( - avatar_url_sized("https://avatars.githubusercontent.com/u/1?s=40&v=4", 128), - Some("https://avatars.githubusercontent.com/u/1?v=4&s=128".to_owned()) - ); - assert_eq!(avatar_url_sized("", 128), None); - } - - #[test] - fn card_text_selection_slices_normalized_range() { - let body = "the quick brown fox".to_owned(); - // Forward selection. - let mut sel = CardTextSelection::new(7, body.clone(), 4); - sel.focus = 9; - assert_eq!(sel.normalized(), (4, 9)); - assert_eq!(sel.selected_text().as_deref(), Some("quick")); - assert!(!sel.is_collapsed()); - - // Reversed drag yields the same substring. - let mut rev = CardTextSelection::new(7, body.clone(), 9); - rev.focus = 4; - assert_eq!(rev.normalized(), (4, 9)); - assert_eq!(rev.selected_text().as_deref(), Some("quick")); - - // Collapsed selection copies nothing. - let collapsed = CardTextSelection::new(7, body.clone(), 4); - assert!(collapsed.is_collapsed()); - assert_eq!(collapsed.selected_text(), None); - - // Out-of-range anchor is clamped at construction (no panic / no copy). - let clamped = CardTextSelection::new(7, body, 999); - assert!(clamped.is_collapsed()); - } - - #[test] - fn command_palette_detects_pr_url_and_emits_peek_effect() { - let mut state = AppState::default(); - state.overlays.command_palette.query.set( - &state.store, - "https://github.com/foo/bar/pull/42".to_owned(), - ); - - let effects = state.rebuild_command_palette(); - - // A peek effect was fired for the parsed key. - assert!(effects.iter().any(|e| matches!( - e, - Effect::GitHub(GitHubEffect::PeekPullRequest { - owner, repo, number, .. - }) if owner == "foo" && repo == "bar" && *number == 42 - ))); - - // Palette has the synthesized PR entry as the top row with key intact. - let top = state - .overlays - .command_palette - .entries - .with(&state.store, |e| e.first().cloned()) - .expect("palette has at least one entry"); - assert!(matches!( - top.kind, - super::PaletteEntryKind::PullRequest((ref o, ref r, n)) - if o == "foo" && r == "bar" && n == 42 - )); - - // Cache entry is initialized to Loading. - let cached = state.github.pull_request.cache.with(&state.store, |c| { - c.get(&("foo".to_owned(), "bar".to_owned(), 42)).cloned() - }); - let cached = cached.expect("cache entry"); - assert!(matches!(cached.meta, super::PrPeekMeta::Loading)); - } - - #[test] - fn pr_peeked_event_transitions_cache_meta_to_ready() { - use crate::core::forge::github::PullRequestInfo; - use crate::events::AppEvent; - - let mut state = AppState::default(); - state - .overlays - .command_palette - .query - .set(&state.store, "https://github.com/foo/bar/pull/7".to_owned()); - let _ = state.rebuild_command_palette(); - - let info = PullRequestInfo { - title: "Fix thing".to_owned(), - state: "open".to_owned(), - author_login: "alice".to_owned(), - number: 7, - additions: 12, - deletions: 3, - changed_files: 1, - base_branch: "main".to_owned(), - head_branch: "fix".to_owned(), - base_sha: "a".to_owned(), - head_sha: "b".to_owned(), - base_repo_url: String::new(), - head_repo_url: String::new(), - }; - state.apply_event(AppEvent::from(GitHubEvent::PullRequestPeeked { - owner: "foo".to_owned(), - repo: "bar".to_owned(), - number: 7, - info: info.clone(), - })); - - let meta = state.github.pull_request.cache.with(&state.store, |c| { - c.get(&("foo".to_owned(), "bar".to_owned(), 7)) - .map(|e| e.meta.clone()) - }); - assert!(matches!(meta, Some(super::PrPeekMeta::Ready(_)))); - } - - // ----------------------------------------------------------------- - // Compare progress — end-to-end through the event lifecycle - // ----------------------------------------------------------------- - - use super::{ComparePhase, CompareProgress, LoadingSubject}; - use crate::events::{CompareFinished, RepositorySyncReason}; - - fn compare_ready_state() -> AppState { - let state = AppState::default(); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - state.compare.left_ref.set(&state.store, "v5.0".to_owned()); - state.compare.right_ref.set(&state.store, "v5.1".to_owned()); - state.compare.mode.set(&state.store, CompareMode::TwoDot); - state - } - - #[test] - fn kickoff_compare_seeds_progress_with_labels_and_started_at() { - let mut state = compare_ready_state(); - state.clock_ms = 1_000; - let _ = state.kickoff_compare(); - - let progress = state - .compare_progress - .with(&state.store, |p| p.clone()) - .expect("progress should be populated"); - match &progress.subject { - LoadingSubject::Compare { - left_label, - right_label, - } => { - assert_eq!(left_label, "v5.0"); - assert_eq!(right_label, "v5.1"); - } - other => panic!("expected Compare subject, got {other:?}"), - } - assert_eq!(progress.started_at_ms, 1_000); - assert_eq!(progress.phase, ComparePhase::OpeningRepo); - assert_eq!(progress.file_count_total, None); - assert_eq!( - state.workspace_mode.get(&state.store), - WorkspaceMode::Loading, - "viewport should flip to loading so the panel actually renders" - ); - } - - #[test] - fn compare_progress_update_applies_only_when_generation_matches() { - let mut state = compare_ready_state(); - let _ = state.kickoff_compare(); - let generation = state.workspace.compare_generation.get(&state.store); - - // Stale reporter — must be ignored. - state.apply_event(AppEvent::from(CompareEvent::CompareProgressUpdate { - generation: generation.wrapping_sub(1), - phase: ComparePhase::EnumeratingChanges, - })); - assert_eq!( - state - .compare_progress - .with(&state.store, |p| p.as_ref().unwrap().phase), - ComparePhase::OpeningRepo, - "stale generation must not advance the phase" - ); - - // Fresh reporter — applies. - state.apply_event(AppEvent::from(CompareEvent::CompareProgressUpdate { - generation, - phase: ComparePhase::EnumeratingChanges, - })); - assert_eq!( - state - .compare_progress - .with(&state.store, |p| p.as_ref().unwrap().phase), - ComparePhase::EnumeratingChanges, - ); - } - - #[test] - fn loading_files_phase_updates_counts_on_struct() { - let mut state = compare_ready_state(); - let _ = state.kickoff_compare(); - let generation = state.workspace.compare_generation.get(&state.store); - - state.apply_event(AppEvent::from(CompareEvent::CompareProgressUpdate { - generation, - phase: ComparePhase::LoadingFiles { - files_seen: 142, - files_total: 3_891, - }, - })); - - let progress = state - .compare_progress - .with(&state.store, |p| p.clone()) - .expect("progress exists"); - assert_eq!(progress.files_loaded, 142); - assert_eq!(progress.file_count_total, Some(3_891)); - assert!(matches!(progress.phase, ComparePhase::LoadingFiles { .. })); - } - - #[test] - fn kickoff_with_prior_state_reveals_loading_immediately() { - let mut state = compare_ready_state(); - // Simulate a previously loaded compare (files present). - state.workspace.files.set( - &state.store, - vec![FileListEntry { - path: "old.rs".into(), - }], - ); - state.clock_ms = 10_000; - - let _ = state.kickoff_compare(); - let progress = state - .compare_progress - .with(&state.store, |p| p.clone()) - .expect("progress populated"); - assert_eq!(progress.started_at_ms, 10_000); - assert_eq!( - progress.reveal_at_ms, 10_000, - "compare loading should be visible immediately" - ); - assert_ne!( - state.workspace_mode.get(&state.store), - WorkspaceMode::Loading - ); - // Prior files are preserved so fast compares don't cause a flash. - assert_eq!(state.workspace.files.with(&state.store, |f| f.len()), 1); - } - - #[test] - fn open_repository_seeds_repo_subject_progress() { - let mut state = AppState::default(); - state.clock_ms = 500; - - let effects = state.open_repository(PathBuf::from("/tmp/linux")); - - let progress = state - .compare_progress - .with(&state.store, |p| p.clone()) - .expect("progress seeded for repo open"); - match progress.subject { - LoadingSubject::RepoOpen { ref name } => { - assert_eq!(name, "linux"); - } - other => panic!("expected RepoOpen subject, got {other:?}"), - } - assert_eq!(progress.phase, ComparePhase::OpeningRepo); - assert_eq!( - progress.reveal_at_ms, - 500 + super::COMPARE_REVEAL_DELAY_MS, - "every repo open delays reveal so sub-threshold opens don't flash" - ); - // Reporter generation is threaded through the SyncRepository effect - // so the worker's phase events stamp the matching generation. - let sync_gen = effects.iter().find_map(|eff| match eff { - Effect::Repository(RepositoryEffect::SyncRepository { - reporter_generation, - .. - }) => *reporter_generation, - _ => None, - }); - assert_eq!(sync_gen, Some(progress.generation)); - } - - #[test] - fn open_repository_with_prior_diff_delays_reveal() { - let mut state = AppState::default(); - state.workspace.files.set( - &state.store, - vec![FileListEntry { - path: "old.rs".into(), - }], - ); - state.clock_ms = 10_000; - - let _ = state.open_repository(PathBuf::from("/tmp/other")); - - let progress = state - .compare_progress - .with(&state.store, |p| p.clone()) - .expect("progress seeded"); - assert_eq!( - progress.reveal_at_ms, 10_500, - "re-open with prior diff delays reveal by COMPARE_REVEAL_DELAY_MS" - ); - } - - #[test] - fn open_repository_resets_stale_compare_refs_before_snapshot() { - let mut state = AppState::default(); - state.compare.left_ref.set(&state.store, "@-".to_owned()); - state.compare.right_ref.set(&state.store, "@".to_owned()); - state.compare.mode.set(&state.store, CompareMode::TwoDot); - - let path = PathBuf::from("/tmp/git-repo"); - let effects = state.open_repository(path.clone()); - - assert_eq!(state.compare.left_ref.get(&state.store), ""); - assert_eq!(state.compare.right_ref.get(&state.store), ""); - assert_eq!(state.compare.mode.get(&state.store), CompareMode::default()); - let saved = effects.iter().find_map(|effect| match effect { - Effect::Settings(SettingsEffect::SaveSettings(settings)) => { - settings.last_compare.as_ref() - } - _ => None, - }); - let saved = saved.expect("open_repository should persist settings"); - assert_eq!(saved.repo_path.as_ref(), Some(&path)); - assert_eq!(saved.left_ref, ""); - assert_eq!(saved.right_ref, ""); - } - - #[test] - fn git_snapshot_after_jj_refs_uses_git_defaults() { - let mut state = AppState::default(); - state.compare.left_ref.set(&state.store, "@-".to_owned()); - state.compare.right_ref.set(&state.store, "@".to_owned()); - state.compare.mode.set(&state.store, CompareMode::TwoDot); - - let path = PathBuf::from("/tmp/git-repo"); - let _ = state.open_repository(path.clone()); - state.apply_event(AppEvent::from(RepositoryEvent::RepositorySnapshotReady( - crate::events::RepositorySnapshot::from_vcs_snapshot( - crate::core::vcs::model::VcsSnapshot { - location: RepoLocation { - kind: VcsKind::GIT, - profile: crate::core::vcs::model::VCS_PROFILE_GIT, - workspace_root: path, - store_root: None, - }, - reason: RepositorySyncReason::Open, - change_kind: None, - capabilities: RepoCapabilities::git(), - refs: Vec::new(), - changes: Vec::new(), - operation_log: Vec::new(), - file_changes: Vec::new(), - }, - ), - ))); - - let (left, right, mode) = - crate::ui::vcs::profile(state.repository.location.get(&state.store).as_ref()) - .default_compare(); - assert_eq!(state.compare.left_ref.get(&state.store), left); - assert_eq!(state.compare.right_ref.get(&state.store), right); - assert_eq!(state.compare.mode.get(&state.store), mode); - } - - #[test] - fn large_compare_stats_stream_offscreen_background_rows_after_visible_rows() { - let state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - state.file_list.row_height.set(&state.store, 36.0); - state.file_list.gap.set(&state.store, 4.0); - state.file_list.viewport_height.set(&state.store, 80.0); - - let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) - .map(|index| { - let path = format!("src/file-{index}.rs"); - let mut summary = CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ); - if index < 128 { - summary.stats_deferred = false; - } - summary - }) - .collect(); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - - let effect = state - .next_compare_stats_hydration_effect() - .expect("huge compares should keep streaming offscreen stats"); - - match effect { - Effect::Compare(CompareEffect::LoadFileStats(task)) => { - assert_eq!(task.request.priority, CompareWorkPriority::Warmup); - assert_eq!(task.request.files.first().map(|item| item.index), Some(128)); - } - other => panic!("expected LoadFileStats effect, got {other:?}"), - } - } - - #[test] - fn large_compare_still_loads_exact_total_stats() { - let mut state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - - let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) - .map(|index| { - let path = format!("src/file-{index}.rs"); - CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ) - }) - .collect(); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - - let effect = state - .start_compare_total_stats_if_needed() - .expect("large deferred compares should request one bounded total-stats job"); - - match effect { - Effect::Compare(CompareEffect::LoadStats(task)) => { - assert_eq!(task.request.priority, CompareWorkPriority::TotalStats); - assert!( - state - .workspace - .compare_total_stats_loading - .get(&state.store) - ); - } - other => panic!("expected LoadStats effect, got {other:?}"), - } - } - - #[test] - fn filtered_compare_stats_hydrates_filtered_visible_raw_indices() { - let state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.file_list.row_height.set(&state.store, 36.0); - state.file_list.gap.set(&state.store, 4.0); - state.file_list.viewport_height.set(&state.store, 80.0); - state - .file_list - .filter - .set(&state.store, "target-only".to_owned()); - - let summaries = (0..50) - .map(|index| { - let path = if index == 40 { - "src/target-only.rs".to_owned() - } else { - format!("src/file-{index}.rs") - }; - CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ) - }) - .collect(); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - - let items = state.visible_compare_stats_hydration_items(); - - assert_eq!(items.len(), 1); - assert_eq!(items[0].index, 40); - } - - #[test] - fn tree_compare_stats_hydrates_visible_tree_file_indices() { - let state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state - .file_list - .mode - .set(&state.store, SidebarMode::TreeView); - state - .file_list - .expanded_folders - .set(&state.store, ["a".to_owned()].into_iter().collect()); - state.file_list.row_height.set(&state.store, 36.0); - state.file_list.gap.set(&state.store, 4.0); - state.file_list.viewport_height.set(&state.store, 80.0); - - let summaries = (0..50) - .map(|index| { - let path = if index == 40 { - "a/target-visible.rs".to_owned() - } else { - format!("z/file-{index}.rs") - }; - CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ) - }) - .collect(); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - - let items = state.visible_compare_stats_hydration_items(); - - assert_eq!(items.len(), 1); - assert_eq!(items[0].index, 40); - } - - #[test] - fn loaded_compare_stats_update_sidebar_meta() { - let mut state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state - .file_list - .mode - .set(&state.store, SidebarMode::TreeView); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: vec![CompareFileSummary::from_paths_status( - None, - Some("arch/arm64/boot/dts/mediatek/mt8183-kukui-jacuzzi-kenzo.dts"), - carbon::FileStatus::Added, - true, - )], - ..CompareOutput::default() - }), - ); - - let effects = state.handle_compare_file_stats_ready(CompareFileStatsReady { - generation: state.workspace.compare_generation.get(&state.store), - stats: vec![CompareFileStat { - index: 0, - path: "arch/arm64/boot/dts/mediatek/mt8183-kukui-jacuzzi-kenzo.dts".to_owned(), - additions: 13, - deletions: 0, - }], - request_complete: false, - }); - - assert!(effects.is_empty()); - let meta = state.file_list_entry_meta(0); - assert_eq!(meta.additions, 13); - assert_eq!(meta.deletions, 0); - assert!( - !state.workspace.compare_output.with(&state.store, |output| { - output - .as_ref() - .and_then(|output| output.file_summaries.first()) - .is_none_or(|summary| summary.stats_deferred) - }), - "loaded stats must clear the deferred marker used by sidebar rows", - ); - } - - #[test] - fn expanding_tree_folder_starts_visible_stats_hydration() { - let mut state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state - .compare - .repo_path - .set(&state.store, Some(PathBuf::from("/repo"))); - state - .file_list - .mode - .set(&state.store, SidebarMode::TreeView); - state.file_list.row_height.set(&state.store, 36.0); - state.file_list.gap.set(&state.store, 4.0); - state.file_list.viewport_height.set(&state.store, 80.0); - - let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) - .map(|index| { - let path = if index == 40 { - "a/target-visible.rs".to_owned() - } else { - format!("z/file-{index}.rs") - }; - CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ) - }) - .collect(); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - state.set_compare_stats_hydration(super::CompareStatsHydrationState::Running); - - let effects = - state.apply_action(crate::actions::FileListAction::ToggleFolder("a".to_owned())); - - assert!(effects.iter().any(|effect| { - matches!( - effect, - Effect::Compare(CompareEffect::LoadFileStats(task)) - if task.request.priority == CompareWorkPriority::VisibleSidebarStats - && task.request.files.iter().any(|item| item.index == 40) - ) - })); - } - - #[test] - fn compare_stats_ready_drains_history_when_hydration_has_no_visible_work() { - let mut state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.file_list.tab.set(&state.store, SidebarTab::Commits); - state.workspace.compare_history_pending.set( - &state.store, - Some(crate::effects::CompareHistoryRequest { - repo_path: PathBuf::from("/repo"), - left_ref: "v5.0".to_owned(), - right_ref: "v5.1".to_owned(), - }), - ); - let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) - .map(|index| { - let path = format!("src/file-{index}.rs"); - CompareFileSummary::from_paths_status( - Some(&path), - Some(&path), - carbon::FileStatus::Modified, - true, - ) - }) - .collect(); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: summaries, - ..CompareOutput::default() - }), - ); - - let effects = state.handle_compare_stats_ready(CompareStatsReady { - generation: state.workspace.compare_generation.get(&state.store), - additions: 0, - deletions: 0, - }); - - assert!( - effects - .iter() - .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadHistory(_)))) - ); - assert!( - state - .workspace - .compare_history_pending - .get(&state.store) - .is_none() - ); - } - - #[test] - fn compare_file_stats_failure_does_not_retry_same_chunk() { - let mut state = compare_ready_state(); - state - .workspace - .source - .set(&state.store, WorkspaceSource::Compare); - state.set_compare_stats_hydration(super::CompareStatsHydrationState::Running); - state.workspace.compare_history_pending.set( - &state.store, - Some(crate::effects::CompareHistoryRequest { - repo_path: PathBuf::from("/repo"), - left_ref: "v5.0".to_owned(), - right_ref: "v5.1".to_owned(), - }), - ); - state.workspace.compare_output.set( - &state.store, - Some(CompareOutput { - file_summaries: vec![CompareFileSummary::from_paths_status( - Some("src/file.rs"), - Some("src/file.rs"), - carbon::FileStatus::Modified, - true, - )], - ..CompareOutput::default() - }), - ); - - let effects = state.apply_event(AppEvent::from(CompareEvent::CompareFileStatsFailed { - generation: state.workspace.compare_generation.get(&state.store), - message: "backend failed".to_owned(), - })); - - assert!( - !effects - .iter() - .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadFileStats(_)))), - "failed stats hydration should not immediately retry the same deferred chunk" - ); - assert!( - effects - .iter() - .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadHistory(_)))) - ); - assert!( - state - .workspace - .compare_history_pending - .get(&state.store) - .is_none() - ); - assert!(state.compare_stats_hydration_failed()); - } - - #[test] - fn repository_snapshot_ready_clears_repo_open_progress() { - let mut state = AppState::default(); - let path = PathBuf::from("/tmp/linux"); - let _ = state.open_repository(path.clone()); - assert!(state.compare_progress.with(&state.store, |p| p.is_some())); - - state.apply_event(AppEvent::from(RepositoryEvent::RepositorySnapshotReady( - crate::events::RepositorySnapshot::from_vcs_snapshot( - crate::core::vcs::model::VcsSnapshot { - location: RepoLocation { - kind: VcsKind::GIT, - profile: crate::core::vcs::model::VCS_PROFILE_GIT, - workspace_root: path, - store_root: None, - }, - reason: RepositorySyncReason::Open, - change_kind: None, - capabilities: RepoCapabilities::git(), - refs: Vec::new(), - changes: Vec::new(), - operation_log: Vec::new(), - file_changes: Vec::new(), - }, - ), - ))); - - assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), - "snapshot-ready must tear down the repo-open progress panel" - ); - } - - #[test] - fn kickoff_without_prior_state_reveals_loading_immediately() { - let mut state = compare_ready_state(); - state.clock_ms = 5_000; - - let _ = state.kickoff_compare(); - let progress = state - .compare_progress - .with(&state.store, |p| p.clone()) - .expect("progress populated"); - assert_eq!(progress.started_at_ms, 5_000); - assert_eq!( - progress.reveal_at_ms, 5_000, - "compare loading should be visible immediately" - ); - // With no prior state to preserve, workspace_mode flips to Loading - // up front so the editor/ready-hint stops rendering in the background. - assert_eq!( - state.workspace_mode.get(&state.store), - WorkspaceMode::Loading - ); - } - - #[test] - fn cancel_compare_bumps_generation_and_drops_stale_result() { - let mut state = compare_ready_state(); - let _ = state.kickoff_compare(); - let generation = state.workspace.compare_generation.get(&state.store); - - let _ = state.cancel_compare(); - - assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), - "progress should be cleared after cancel" - ); - let new_gen = state.workspace.compare_generation.get(&state.store); - assert!(new_gen > generation, "generation should be bumped"); - assert_eq!( - state.workspace_mode.get(&state.store), - WorkspaceMode::Empty, - "fresh-state cancel should revert the Loading flip" - ); - - // A stale CompareFinished arriving after cancel must be silently dropped. - state.apply_event(AppEvent::from(CompareEvent::CompareFinished( - CompareFinished { - generation, - request: vcs_compare_request( - CompareMode::TwoDot, - "v5.0".to_owned(), - "v5.1".to_owned(), - LayoutMode::Unified, - RendererKind::Builtin, - ), - resolved_left: "deadbeef".to_owned(), - resolved_right: "cafefeed".to_owned(), - output: CompareOutput::default(), - range_commits: Vec::new(), + let store = Rc::new(SignalStore::default()); + let ui = UiStateStore::new( + &store, + UiState { + focus: Some(if repo_path.is_some() { + FocusTarget::TitleBar + } else { + FocusTarget::WorkspacePrimaryButton + }), + ..UiState::default() }, - ))); - assert_eq!( - state.workspace_mode.get(&state.store), - WorkspaceMode::Empty, - "stale finished result must not promote workspace to Ready", ); - assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), - "stale finished result must not re-seed progress", - ); - } - - #[test] - fn cancel_compare_preserves_previous_diff_on_recompare() { - let mut state = compare_ready_state(); - // Prior state: an existing file in the workspace. - state.workspace.files.set( - &state.store, - vec![FileListEntry { - path: "old.rs".into(), - }], + let focus = ui.focus; + let text_focused = + store.create_memo(move |s| s.read(focus).is_some_and(|t| t.is_text_field())); + let debug = DebugStateStore::new(&store, DebugState::default()); + let file_list = FileListStateStore::new_default(&store); + let editor = EditorStateStore::new( + &store, + EditorState { + layout, + wrap_enabled: settings.viewport.wrap_enabled, + wrap_column: settings.viewport.wrap_column, + ..EditorState::default() + }, ); - state.workspace_mode.set(&state.store, WorkspaceMode::Ready); - - let _ = state.kickoff_compare(); - let _ = state.cancel_compare(); - - assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), - "progress cleared on cancel" + let overlays = OverlayStackStateStore::new_default(&store); + let compare = CompareStateStore::new( + &store, + CompareState { + repo_path: repo_path.clone(), + left_ref, + right_ref, + mode, + layout, + renderer, + resolved_left: None, + resolved_right: None, + }, ); - assert_eq!( - state.workspace_mode.get(&state.store), - WorkspaceMode::Ready, - "previous workspace state is preserved on cancel — no blanking" + let repository = RepositoryStateStore::new_default(&store); + let workspace = WorkspaceStateStore::new( + &store, + WorkspaceState { + mode: if repo_path.is_some() && auto_compare_pending { + WorkspaceMode::Loading + } else { + WorkspaceMode::Empty + }, + ..WorkspaceState::default() + }, ); - assert_eq!( - state.workspace.files.with(&state.store, |f| f.len()), - 1, - "prior file list must not be wiped by cancel" + let text_edit = TextEditStateStore::new_default(&store); + let initial_token_present = settings.github_user.is_some(); + let github = GitHubStateStore::new( + &store, + GitHubState { + client_id: startup.github_client_id.clone(), + auth: GitHubAuthState { + token_present: initial_token_present, + user: settings.github_user.clone(), + ..GitHubAuthState::default() + }, + pull_request: PullRequestState::default(), + }, ); - } - - #[test] - fn compare_finished_advances_phase_and_records_file_count() { - let mut state = compare_ready_state(); - let _ = state.kickoff_compare(); - let generation = state.workspace.compare_generation.get(&state.store); - - // Simulate a successful compare with 3 files. - let files = ["a.rs", "b.rs", "c.rs"]; - let output = CompareOutput { - carbon: carbon::DiffDocument { - files: files - .iter() - .enumerate() - .map(|(index, path)| carbon_summary_for_path(index, path)) - .collect(), + let mut state = Self { + ui, + compare, + repository, + workspace, + file_list, + overlays, + text_edit, + editor, + github, + settings, + startup: StartupState { + keyring_enabled: startup.keyring_enabled, + github_token_store: startup.github_token_store, + auto_compare_pending: auto_compare_pending && !bootstrap_compare_started, + bootstrap_compare_started, + pending_pr_url: startup.args.open_pr.clone(), + preferred_file_index: startup.args.file_index, + preferred_file_path: startup.args.file_path.clone(), }, - ..CompareOutput::default() + context_menu: ContextMenuState::default(), + text_focused, + animation: crate::ui::animation::AnimationState::default(), + commit_editor: Editor::default(), + review_comment_editor: Editor::default(), + steering_prompt_editor: Editor::default(), + text_compare: TextCompareState::default(), + ai_openai_key: String::new(), + ai_anthropic_key: String::new(), + ai_openai_editing: false, + ai_anthropic_editing: false, + ai_generation_id: 0, + ai_generation_active: false, + ai_generation_error: None, + debug, + store, + clock_ms: 0, + next_toast_id: 1, + frecency: crate::core::frecency::open_default_store(), + theme_names: Vec::new(), + theme_variants: Vec::new(), + github_access_token: None, + viewport_document_cache: None, + virtual_diff_document: VirtualDiffDocument::default(), + virtual_scroll: VirtualScrollModel::default(), + file_working_set: FileWorkingSet::default(), + syntax_requests: SyntaxRequestTracker::default(), + last_virtual_scroll_top_px: None, + }; + let seed_prompt = if state.settings.ai_steering_prompt.trim().is_empty() { + crate::ai::DEFAULT_STEERING_PROMPT + } else { + state.settings.ai_steering_prompt.as_str() }; + state.steering_prompt_editor.set_text(seed_prompt); + state.sync_settings_snapshot(); - state.apply_event(AppEvent::from(CompareEvent::CompareFinished( - CompareFinished { - generation, - request: vcs_compare_request( - CompareMode::TwoDot, - "v5.0".to_owned(), - "v5.1".to_owned(), - LayoutMode::Unified, - RendererKind::Builtin, - ), - resolved_left: "deadbeef".to_owned(), - resolved_right: "cafefeed".to_owned(), - output, - range_commits: Vec::new(), - }, - ))); + let mut effects = Vec::new(); + if let Some(path) = repo_path { + state + .repository + .status + .set(&state.store, AsyncStatus::Loading); - // Small files load synchronously, so progress is already cleared by the - // time handle_compare_finished returns. We at least know the workspace - // is Ready and the compare file view is populated from CompareOutput. - assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Ready,); - assert_eq!(state.workspace_file_count(), 3); - } + // Bootstrap: seed the loading panel so a slow cold-boot open + // shows staged progress. Reveal is gated by the same 500ms + // threshold as user-initiated opens — if the whole bootstrap + // open completes within the threshold the panel never appears + // and the user lands straight in the ready UI. + let boot_gen = state + .workspace + .compare_generation + .get(&state.store) + .saturating_add(1); + state + .workspace + .compare_generation + .set(&state.store, boot_gen); + effects.push(state.invalidate_syntax_epoch_effect()); + let repo_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("repository") + .to_owned(); + state.workspace.compare_progress.set( + &state.store, + Some(Arc::new(CompareProgress { + generation: boot_gen, + phase: ComparePhase::OpeningRepo, + subject: if bootstrap_compare_started { + LoadingSubject::Compare { + left_label: state.vcs_ui_profile().compare_ref_display_label( + &state.compare.left_ref.get(&state.store), + ), + right_label: state.vcs_ui_profile().compare_ref_display_label( + &state.compare.right_ref.get(&state.store), + ), + } + } else { + LoadingSubject::RepoOpen { name: repo_name } + }, + started_at_ms: 0, + reveal_at_ms: COMPARE_REVEAL_DELAY_MS, + file_count_total: None, + files_loaded: 0, + })), + ); - #[test] - fn compare_failed_clears_progress_and_marks_workspace_empty() { - let mut state = compare_ready_state(); - let _ = state.kickoff_compare(); - let generation = state.workspace.compare_generation.get(&state.store); + effects.push( + RepositoryEffect::SyncRepository { + path: path.clone(), + reason: RepositorySyncReason::Open, + reporter_generation: (!bootstrap_compare_started).then_some(boot_gen), + } + .into(), + ); + effects.push(RepositoryEffect::WatchRepository { path: Some(path) }.into()); + if bootstrap_compare_started { + effects.push( + CompareEffect::Run(Task { + generation: boot_gen, + request: CompareRequest { + repo_path: state.compare.repo_path.get(&state.store).unwrap(), + request: vcs_compare_request( + state.compare.mode.get(&state.store), + state.compare.left_ref.get(&state.store), + state.compare.right_ref.get(&state.store), + state.compare.layout.get(&state.store), + state.compare.renderer.get(&state.store), + ), + github_token: startup.github_token.clone(), + }, + }) + .into(), + ); + } + } + if let Some(token) = startup.github_token.clone() { + state.github_access_token = Some(token.clone()); + state.github.auth.token_present.set(&state.store, true); + if startup.github_token_store.is_enabled() { + effects.push(GitHubEffect::SaveGitHubToken(token).into()); + } + } else if startup.github_token_store.is_enabled() { + effects.push(GitHubEffect::LoadGitHubToken.into()); + } - state.apply_event(AppEvent::from(CompareEvent::CompareFailed { - generation, - message: "boom".to_owned(), - })); + // Show the cached user + avatar optimistically while the token loads. + if let Some(user) = state.settings.github_user.as_ref() + && let Some(url) = avatar_url_sized(&user.avatar_url, 128) + { + state.github.auth.avatar_fetching.set(&state.store, true); + effects.push(GitHubEffect::FetchAvatar { url }.into()); + } - assert_eq!(state.workspace_mode.get(&state.store), WorkspaceMode::Empty,); - assert!( - state.compare_progress.with(&state.store, |p| p.is_none()), - "progress panel must tear down on compare failure", - ); + effects.push(SyntaxEffect::InstallCommonSyntaxPacks.into()); + if startup.keyring_enabled { + effects.push(AiEffect::LoadAiKeys.into()); + } + if state.update_polling_enabled() { + effects.push(UpdateEffect::CheckForUpdates { silent: true }.into()); + } + (state, effects) } - #[test] - fn compare_progress_label_does_not_panic_for_all_phases() { - // Non-empty labels matter for the title-bar fallback. Cheap to - // check exhaustively. - let phases = [ - ComparePhase::OpeningRepo, - ComparePhase::ResolvingRefs, - ComparePhase::EnumeratingChanges, - ComparePhase::LoadingFiles { - files_seen: 142, - files_total: 3_891, - }, - ComparePhase::FetchingHistory, - ComparePhase::PopulatingList, - ComparePhase::RenderingFirstFile, - ]; - for phase in phases { - let label = phase.label(); - assert!(!label.is_empty()); + pub fn apply_action>(&mut self, action: A) -> Vec { + let action = action.into(); + match action { + Action::App(action) => app::reduce_action(self, action), + Action::Workspace(action) => workspace::reduce_action(self, action), + Action::TextCompare(action) => text_compare::reduce_action(self, action), + Action::Compare(action) => compare::reduce_action(self, action), + Action::Repository(action) => repository::reduce_action(self, action), + Action::FileList(action) => file_list::reduce_action(self, action), + Action::Overlay(action) => overlay::reduce_action(self, action), + Action::Editor(action) => editor::reduce_action(self, action), + Action::TextEdit(action) => text_edit::reduce_action(self, action), + Action::Settings(action) => settings::reduce_action(self, action), + Action::GitHub(action) => github::reduce_action(self, action), + Action::Update(action) => update::reduce_action(self, action), + Action::Window(_) => Vec::new(), + Action::Syntax(action) => syntax::reduce_action(self, action), + Action::Ai(action) => ai::reduce_action(self, action), + Action::Noop => Vec::new(), } - // LoadingFiles label should interpolate counts. - assert!( - ComparePhase::LoadingFiles { - files_seen: 142, - files_total: 3_891, - } - .label() - .contains("142"), - "file counts must appear in the label" - ); + } - let _ = CompareProgress { - generation: 0, - phase: ComparePhase::default(), - subject: LoadingSubject::Compare { - left_label: String::new(), - right_label: String::new(), - }, - started_at_ms: 0, - reveal_at_ms: 0, - file_count_total: None, - files_loaded: 0, - }; + pub fn apply_event(&mut self, event: AppEvent) -> Vec { + match event { + AppEvent::Ui(event) => app::reduce_event(self, event), + AppEvent::Repository(event) => repository::reduce_event(self, event), + AppEvent::Compare(event) => compare::reduce_event(self, event), + AppEvent::GitHub(event) => github::reduce_event(self, event), + AppEvent::Settings(event) => settings::reduce_event(self, event), + AppEvent::Update(event) => update::reduce_event(self, event), + AppEvent::Syntax(event) => syntax::reduce_event(self, event), + AppEvent::Ai(event) => ai::reduce_event(self, event), + } } } + +#[cfg(test)] +mod tests; diff --git a/src/ui/state/overlay.rs b/src/ui/state/overlay.rs index ae74972f..8b7e97fa 100644 --- a/src/ui/state/overlay.rs +++ b/src/ui/state/overlay.rs @@ -62,11 +62,2999 @@ impl AppState { } ShowKeyboardShortcuts => { self.clear_overlays(); - self.app_view.set(&self.store, AppView::Settings); - self.settings_section + self.ui.app_view.set(&self.store, AppView::Settings); + self.ui + .settings_section .set(&self.store, SettingsSection::Keymaps); Vec::new() } } } } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OverlayListState { + pub scroll_top_px: u32, + pub viewport_height_px: u32, + pub row_height_px: u32, + pub gap_px: u32, +} + +impl Default for OverlayListState { + fn default() -> Self { + Self { + scroll_top_px: 0, + viewport_height_px: 0, + row_height_px: 36, + gap_px: 0, + } + } +} + +impl OverlayListState { + pub fn stride_px(&self) -> u32 { + self.row_height_px + self.gap_px + } + + pub fn total_content_height_px(&self, entry_count: usize) -> u32 { + if entry_count == 0 { + return 0; + } + self.stride_px() + .saturating_mul(entry_count as u32) + .saturating_sub(self.gap_px) + } + + pub fn viewport_for_max_rows(&self, max_rows: usize, entry_count: usize) -> u32 { + let visible = entry_count.min(max_rows); + if visible == 0 { + return 0; + } + self.stride_px() + .saturating_mul(visible as u32) + .saturating_sub(self.gap_px) + } + + pub fn max_scroll_top_px(&self, entry_count: usize) -> u32 { + self.total_content_height_px(entry_count) + .saturating_sub(self.viewport_height_px) + } + + pub fn clamp_scroll(&mut self, entry_count: usize) { + self.scroll_top_px = self.scroll_top_px.min(self.max_scroll_top_px(entry_count)); + } + + pub fn scroll_px(&mut self, delta_px: i32, entry_count: usize) { + self.scroll_top_px = apply_scroll_delta_px( + self.scroll_top_px, + delta_px, + self.max_scroll_top_px(entry_count), + ); + } + + pub fn reveal_index(&mut self, index: usize, entry_count: usize) { + let stride = self.stride_px().max(1); + let item_top = stride.saturating_mul(index as u32); + let item_bottom = item_top.saturating_add(self.row_height_px); + let viewport_bottom = self.scroll_top_px.saturating_add(self.viewport_height_px); + + if item_top < self.scroll_top_px { + self.scroll_top_px = item_top; + } else if item_bottom > viewport_bottom { + self.scroll_top_px = item_bottom.saturating_sub(self.viewport_height_px); + } + + self.clamp_scroll(entry_count); + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PickerKind { + #[default] + Repository, + LeftRef, + RightRef, + Theme, + UiFont, + MonoFont, +} + +pub trait PickerItem { + fn label(&self) -> &str; + fn detail(&self) -> Option<&str>; + fn label_style(&self) -> PickerLabelStyle { + PickerLabelStyle::Default + } + fn highlight_ranges(&self) -> &[(usize, usize)] { + &[] + } + fn icon_svg(&self) -> Option<&'static str> { + None + } + fn is_section_header(&self) -> bool { + false + } + fn rhs(&self) -> Option<&str> { + None + } + fn is_disabled(&self) -> bool { + false + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PickerLabelStyle { + #[default] + Default, + JjChangeId { + prefix_len: usize, + working_copy: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickerEntry { + pub label: String, + pub detail: String, + pub value: String, + pub highlights: Vec<(usize, usize)>, + pub label_style: PickerLabelStyle, + pub icon: Option<&'static str>, + pub section_header: bool, +} + +impl PickerItem for PickerEntry { + fn label(&self) -> &str { + &self.label + } + fn detail(&self) -> Option<&str> { + Some(&self.detail) + } + fn label_style(&self) -> PickerLabelStyle { + self.label_style + } + fn highlight_ranges(&self) -> &[(usize, usize)] { + &self.highlights + } + fn icon_svg(&self) -> Option<&'static str> { + self.icon + } + fn is_section_header(&self) -> bool { + self.section_header + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct PickerState { + pub kind: PickerKind, + pub query: String, + pub entries: Vec, + pub selected_index: usize, + pub hovered_index: Option, + pub list: OverlayListState, + pub browse_path: Option, + pub ref_resolve_generation: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaletteCommand { + OpenRepoPicker, + NewTextCompare, + OpenGitHubAuthModal, + OpenGitHubAccountMenu, + SignOutGitHub, + FocusFileList, + FocusViewport, + ShowWorkingTree, + RefreshRepository, + OpenBaseRefPicker, + OpenHeadRefPicker, + SwapRefs, + StartCompare, + OpenCompareMenu, + ShowKeyboardShortcuts, + RestoreCompare, + ToggleSidebar, + ToggleFileTree, + ExpandAllFolders, + CollapseAllFolders, + ToggleWrap, + ToggleContinuousScroll, + SetSettingsSection(SettingsSection), + SetThemeMode(ThemeMode), + SetUiScalePct(u16), + SetWrapColumn(u32), + SetWheelScrollLines(u8), + ToggleAutoUpdate, + ToggleThemeMode, + ChangeTheme, + SetLayout(LayoutMode), + SetRenderer(RendererKind), + SetTheme(String), + ExpandAllContext, + ClearLineSelection, + GenerateCommitMessage, + OpenReviewComment, + OpenPullRequestInGitHub, + CheckForUpdates, + InstallUpdate, + RestartToUpdate, + RunOperation(VcsOperation), + FetchOrigin, + FetchAllRemotes, + PushCurrentBranch, + PublishOptions, + PushCurrentBranchForceWithLease, + PullCurrentBranch, + OpenSettings, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaletteEntryKind { + Command(PaletteCommand), + File(usize), + Commit(String), + Repo(PathBuf), + Ref(CompareField, String), + PullRequest(PrKey), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaletteEntry { + pub label: String, + pub detail: String, + pub kind: PaletteEntryKind, + pub highlights: Vec<(usize, usize)>, + /// Extra right-aligned summary (e.g. "+12 −3 · open"). + pub rhs: Option, + /// Disables the entry when set; `detail` usually explains why. + pub disabled: bool, +} + +pub(super) fn palette_command_available( + command: &PaletteCommand, + capabilities: Option, +) -> bool { + match command { + PaletteCommand::FetchOrigin + | PaletteCommand::FetchAllRemotes + | PaletteCommand::PushCurrentBranch + | PaletteCommand::PublishOptions => { + capabilities.is_some_and(|capabilities| capabilities.remotes) + } + PaletteCommand::PushCurrentBranchForceWithLease => { + capabilities.is_some_and(|capabilities| capabilities.remotes && capabilities.branches) + } + PaletteCommand::PullCurrentBranch => { + capabilities.is_some_and(|capabilities| capabilities.pull_fast_forward) + } + _ => true, + } +} + +pub(super) fn vcs_operation_available_for_location( + operation: &VcsOperation, + location: Option<&RepoLocation>, +) -> bool { + match operation { + VcsOperation::Jj(_) => location.is_some_and(|location| location.profile == VCS_PROFILE_JJ), + VcsOperation::JjRebaseCurrentChangeOnto { .. } => { + location.is_some_and(|location| location.profile == VCS_PROFILE_JJ) + } + VcsOperation::JjEditRevision { .. } => { + location.is_some_and(|location| location.profile == VCS_PROFILE_JJ) + } + VcsOperation::JjRestoreOperation { .. } => { + location.is_some_and(|location| location.profile == VCS_PROFILE_JJ) + } + } +} + +pub(super) fn operation_log_entry_detail(entry: &VcsOperationLogEntry) -> String { + match ( + entry.description.is_empty(), + entry.user.is_empty(), + entry.time.is_empty(), + ) { + (false, false, false) => format!("{} - {} - {}", entry.description, entry.user, entry.time), + (false, false, true) => format!("{} - {}", entry.description, entry.user), + (false, true, false) => format!("{} - {}", entry.description, entry.time), + (false, true, true) => entry.description.clone(), + (true, false, false) => format!("{} - {}", entry.user, entry.time), + (true, false, true) => entry.user.clone(), + (true, true, false) => entry.time.clone(), + (true, true, true) => "jj operation log entry".to_owned(), + } +} + +impl PickerItem for PaletteEntry { + fn label(&self) -> &str { + &self.label + } + fn detail(&self) -> Option<&str> { + Some(&self.detail) + } + fn highlight_ranges(&self) -> &[(usize, usize)] { + &self.highlights + } + fn rhs(&self) -> Option<&str> { + self.rhs.as_deref() + } + fn is_disabled(&self) -> bool { + self.disabled + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct CommandPaletteState { + pub query: String, + pub entries: Vec, + pub selected_index: usize, + pub list: OverlayListState, +} + +/// Ephemeral ref-picker overlay state. `active_field` tracks which chip the +/// search input currently drives; `original_*` snapshots the refs at the moment +/// the picker opened so we can revert cleanly on cancel/backdrop. +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct RefPickerState { + pub active_field: CompareField, + pub original_left: String, + pub original_right: String, +} +/// Overlays live as normal elements in the main tree with a z-index above the +/// viewport. Occluding the viewport is the overlay's own responsibility: modal +/// surfaces (pickers, auth, shortcuts) render a full-screen `overlay_scrim` +/// backdrop; anchored dropdowns (AccountMenu, CompareMenu) render a transparent +/// backdrop and let the viewport show through. Do NOT gate viewport rendering +/// on overlay presence — let z-index handle layering. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverlaySurface { + RepoPicker, + RefPicker, + CommandPalette, + Confirmation, + GitHubAuthModal, + KeyboardShortcuts, + ThemePicker, + FontPicker, + CompareMenu, + AccountMenu, + PublishMenu, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OverlayEntry { + pub surface: OverlaySurface, + pub focus_return: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct ConfirmationState { + pub title: String, + pub message: String, + pub confirm_label: String, + pub action: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct OverlayStackState { + pub stack: Vec, + #[store(flatten)] + pub picker: PickerState, + #[store(flatten)] + pub command_palette: CommandPaletteState, + #[store(flatten)] + pub ref_picker: RefPickerState, + #[store(flatten)] + pub confirmation: ConfirmationState, +} + +impl AppState { + pub fn overlays_top(&self) -> Option { + self.overlays + .stack + .with(&self.store, |stack| stack.last().map(|e| e.surface)) + } + + pub fn overlays_active_name(&self) -> Option<&'static str> { + self.overlays_top().map(overlay_name) + } + + /// `(pending, failed)` draft counts for the active pull request's review + /// session, for the submit bar. Zeroes when no PR/session is active. + pub fn active_review_draft_metrics(&self) -> (usize, usize) { + let Some(key) = self.active_pull_request_key() else { + return (0, 0); + }; + self.github + .pull_request + .review_sessions + .with(&self.store, |sessions| { + sessions + .get(&key) + .map(|session| { + let metrics = session.metrics(); + (metrics.pending_drafts, metrics.failed_drafts) + }) + .unwrap_or((0, 0)) + }) + } + + pub fn reset_picker(&mut self) { + let d = PickerState::default(); + self.overlays.picker.kind.set(&self.store, d.kind); + self.overlays.picker.query.set(&self.store, d.query); + self.overlays.picker.entries.set(&self.store, d.entries); + self.overlays + .picker + .selected_index + .set(&self.store, d.selected_index); + self.overlays + .picker + .hovered_index + .set(&self.store, d.hovered_index); + self.overlays.picker.list.set(&self.store, d.list); + self.overlays + .picker + .browse_path + .set(&self.store, d.browse_path); + self.overlays + .picker + .ref_resolve_generation + .set(&self.store, d.ref_resolve_generation); + } + + pub fn reset_command_palette(&mut self) { + let d = CommandPaletteState::default(); + self.overlays + .command_palette + .query + .set(&self.store, d.query); + self.overlays + .command_palette + .entries + .set(&self.store, d.entries); + self.overlays + .command_palette + .selected_index + .set(&self.store, d.selected_index); + self.overlays.command_palette.list.set(&self.store, d.list); + } + + pub fn reset_confirmation(&mut self) { + let d = ConfirmationState::default(); + self.overlays.confirmation.title.set(&self.store, d.title); + self.overlays + .confirmation + .message + .set(&self.store, d.message); + self.overlays + .confirmation + .confirm_label + .set(&self.store, d.confirm_label); + self.overlays.confirmation.action.set(&self.store, d.action); + } + + pub fn clear_overlays(&mut self) { + // The bottom-most entry recorded the focus from before any overlay + // opened; restore it so focus never dangles on a dismissed surface. + let mut focus_return: Option> = None; + self.overlays.stack.update(&self.store, |stack| { + focus_return = stack.first().map(|entry| entry.focus_return); + stack.clear(); + }); + self.reset_picker(); + self.reset_command_palette(); + self.reset_confirmation(); + if let Some(target) = focus_return { + self.set_focus(target); + } + } +} +pub(super) fn overlay_name(surface: OverlaySurface) -> &'static str { + match surface { + OverlaySurface::RepoPicker => "repo-picker", + OverlaySurface::RefPicker => "ref-picker", + OverlaySurface::CommandPalette => "command-palette", + OverlaySurface::Confirmation => "confirmation", + OverlaySurface::GitHubAuthModal => "github-auth-modal", + OverlaySurface::AccountMenu => "account-menu", + OverlaySurface::KeyboardShortcuts => "keyboard-shortcuts", + OverlaySurface::ThemePicker => "theme-picker", + OverlaySurface::FontPicker => "font-picker", + OverlaySurface::CompareMenu => "compare-menu", + OverlaySurface::PublishMenu => "publish-menu", + } +} + +pub(super) fn font_picker_entry( + entry: &FontFamilyEntry, + selected_family: &str, + highlights: Vec<(usize, usize)>, +) -> PickerEntry { + let source = entry.source.label(); + let detail = if entry.family == selected_family { + format!("Selected - {source}") + } else { + source.to_owned() + }; + PickerEntry { + label: entry.label.clone(), + detail, + value: entry.family.clone(), + highlights, + label_style: PickerLabelStyle::Default, + icon: Some(if entry.monospaced { + lucide::TERMINAL + } else { + lucide::FILE + }), + section_header: false, + } +} + +pub(super) fn highlight_ranges_from_match_indices( + text: &str, + indices_rev: &[usize], +) -> Vec<(usize, usize)> { + let len = text.len(); + let mut indices: Vec = indices_rev + .iter() + .copied() + .filter(|&idx| idx < len && text.is_char_boundary(idx)) + .collect(); + indices.sort_unstable(); + + let mut ranges = Vec::new(); + for index in indices { + let mut end = index + 1; + while end < len && !text.is_char_boundary(end) { + end += 1; + } + if let Some((_, last_end)) = ranges.last_mut() { + if index <= *last_end { + *last_end = (*last_end).max(end); + continue; + } + } + ranges.push((index, end)); + } + ranges +} + +pub(super) fn highlight_ranges_for_prefix_match( + text: &str, + indices_rev: &[usize], +) -> Vec<(usize, usize)> { + let prefix_indices: Vec = indices_rev + .iter() + .copied() + .filter(|&idx| idx < text.len()) + .collect(); + highlight_ranges_from_match_indices(text, &prefix_indices) +} + +pub(super) fn highlight_ranges_for_visible_match( + query: &str, + visible_text: &str, + search_indices_rev: &[usize], + config: &neo_frizbee::Config, +) -> Vec<(usize, usize)> { + if query.is_empty() { + return Vec::new(); + } + + let visible_only = [visible_text]; + if let Some(m) = neo_frizbee::match_list_indices(query, &visible_only, config) + .into_iter() + .next() + { + return highlight_ranges_from_match_indices(visible_text, &m.indices); + } + + highlight_ranges_for_prefix_match(visible_text, search_indices_rev) +} + +pub(super) fn query_looks_like_path(query: &str) -> bool { + query.starts_with('/') + || query.starts_with("~/") + || query.starts_with("./") + || (query.len() >= 2 && query.as_bytes()[1] == b':') +} + +pub(super) fn path_looks_like_repository(path: &Path) -> bool { + path.join(".git").exists() || path.join(".jj").exists() +} + +pub(super) fn normalize_repository_open_path(path: PathBuf) -> PathBuf { + crate::core::vcs::discovery::discover_repository(&path) + .ok() + .flatten() + .map(|location| location.workspace_root) + .unwrap_or(path) +} + +pub(super) fn expand_tilde(path: &str) -> String { + if path.starts_with("~/") || path == "~" { + if let Some(home) = dirs::home_dir() { + return format!("{}{}", home.display(), &path[1..]); + } + } + path.to_owned() +} + +pub(super) fn split_browse_query(expanded: &str) -> (String, &str) { + if let Some(pos) = expanded.rfind('/') { + let dir = if pos == 0 { + "/".to_owned() + } else { + expanded[..pos].to_owned() + }; + let filter = &expanded[pos + 1..]; + (dir, filter) + } else if expanded.len() >= 2 && expanded.as_bytes()[1] == b':' { + if let Some(pos) = expanded.rfind('\\') { + let dir = expanded[..pos].to_owned(); + let filter = &expanded[pos + 1..]; + (dir, filter) + } else { + (expanded.to_owned(), "") + } + } else { + (expanded.to_owned(), "") + } +} + +impl AppState { + pub fn active_overlay_name(&self) -> Option<&'static str> { + self.overlays_active_name() + } + + pub(super) fn open_repo_picker(&mut self) { + let scale = self.ui_scale_factor(); + self.overlays + .picker + .kind + .set(&self.store, PickerKind::Repository); + self.overlays.picker.list.update(&self.store, |l| { + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + l.scroll_top_px = 0; + }); + self.overlays.picker.browse_path.set(&self.store, None); + self.overlays.picker.selected_index.set(&self.store, 0); + + let has_recents = crate::core::frecency::recent_repo_paths(self.frecency.as_ref(), 1) + .first() + .is_some(); + + if has_recents { + self.overlays.picker.query.set(&self.store, String::new()); + } else { + let home = dirs::home_dir() + .map(|p| format!("{}/", p.display())) + .unwrap_or_else(|| "/".to_owned()); + let home_len = home.len(); + self.overlays.picker.query.set(&self.store, home); + self.reset_text_edit(home_len); + } + + self.rebuild_repo_picker(); + self.push_overlay(OverlaySurface::RepoPicker, Some(FocusTarget::PickerInput)); + } + + pub(super) fn open_theme_picker(&mut self) { + let scale = self.ui_scale_factor(); + self.ui + .theme_preview_original + .set(&self.store, Some(self.settings.theme_name.clone())); + self.overlays + .picker + .kind + .set(&self.store, PickerKind::Theme); + self.overlays.picker.query.set(&self.store, String::new()); + self.overlays.picker.list.update(&self.store, |l| { + l.scroll_top_px = 0; + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + }); + let entries = self.build_theme_entries_grouped(); + let selected = entries.iter().position(|e| !e.section_header).unwrap_or(0); + self.overlays.picker.entries.set(&self.store, entries); + self.overlays + .picker + .selected_index + .set(&self.store, selected); + self.push_overlay(OverlaySurface::ThemePicker, Some(FocusTarget::PickerInput)); + } + + pub(super) fn build_theme_entries_grouped(&self) -> Vec { + use crate::core::themes::ThemeVariant; + + let original = self + .ui + .theme_preview_original + .get(&self.store) + .unwrap_or_else(|| self.settings.theme_name.clone()); + let make_entry = |name: &String| PickerEntry { + label: name.clone(), + detail: if *name == original { + "\u{2713}".to_owned() + } else { + String::new() + }, + value: name.clone(), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + icon: None, + section_header: false, + }; + let make_header = |label: &str| PickerEntry { + label: label.to_owned(), + detail: String::new(), + value: String::new(), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + icon: None, + section_header: true, + }; + + let variant_of = |index: usize| { + self.theme_variants + .get(index) + .copied() + .unwrap_or(ThemeVariant::Dark) + }; + let mut ordered: Vec = Vec::with_capacity(self.theme_names.len()); + for group in [ThemeVariant::Dual, ThemeVariant::Dark, ThemeVariant::Light] { + ordered.extend((0..self.theme_names.len()).filter(|&index| variant_of(index) == group)); + } + + build_sectioned_rows( + &ordered, + |index| Some(variant_of(index)), + |variant| { + make_header(match variant { + ThemeVariant::Dual => "Dark & Light", + ThemeVariant::Dark => "Dark", + ThemeVariant::Light => "Light", + }) + }, + |index| self.theme_names.get(index).map(make_entry), + ) + } + + pub(super) fn rebuild_theme_picker(&mut self) { + let query = self + .overlays + .picker + .query + .with(&self.store, |q| q.trim().to_owned()); + let original = self + .ui + .theme_preview_original + .get(&self.store) + .unwrap_or_else(|| self.settings.theme_name.clone()); + let (entries, selected) = if query.is_empty() { + let entries = self.build_theme_entries_grouped(); + let selected = entries.iter().position(|e| !e.section_header).unwrap_or(0); + (entries, selected) + } else { + let haystack: Vec<&str> = self.theme_names.iter().map(|s| s.as_str()).collect(); + let config = neo_frizbee::Config { + max_typos: Some(2), + sort: false, + ..Default::default() + }; + let mut matches = neo_frizbee::match_list_indices(&query, &haystack, &config); + matches.sort_by(|a, b| b.score.cmp(&a.score)); + let entries: Vec = matches + .iter() + .map(|m| { + let name = &self.theme_names[m.index as usize]; + PickerEntry { + label: name.clone(), + detail: if *name == *original { + "\u{2713}".to_owned() + } else { + String::new() + }, + value: name.clone(), + highlights: highlight_ranges_from_match_indices(name, &m.indices), + label_style: PickerLabelStyle::Default, + icon: None, + section_header: false, + } + }) + .collect(); + (entries, 0) + }; + if let Some(entry) = entries.get(selected) { + if !entry.section_header { + self.settings.theme_name = entry.value.clone(); + } + } + let entry_count = entries.len(); + self.overlays.picker.entries.set(&self.store, entries); + self.overlays + .picker + .selected_index + .set(&self.store, selected); + self.overlays.picker.list.update(&self.store, |l| { + l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); + l.scroll_top_px = 0; + }); + } + + pub(super) fn open_font_picker(&mut self, role: FontRole) { + let scale = self.ui_scale_factor(); + self.overlays.picker.kind.set( + &self.store, + match role { + FontRole::Ui => PickerKind::UiFont, + FontRole::Mono => PickerKind::MonoFont, + }, + ); + self.overlays.picker.query.set(&self.store, String::new()); + self.overlays.picker.list.update(&self.store, |l| { + l.scroll_top_px = 0; + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + }); + self.rebuild_font_picker(); + self.reset_text_edit(0); + self.push_overlay(OverlaySurface::FontPicker, Some(FocusTarget::PickerInput)); + } + + pub(super) fn rebuild_font_picker(&mut self) { + let Some(role) = self.font_picker_role() else { + return; + }; + let query = self + .overlays + .picker + .query + .with(&self.store, |q| q.trim().to_owned()); + let selected_family = self.selected_font_family(role); + let font_entries = crate::fonts::font_family_entries(role); + let entries: Vec = if query.is_empty() { + font_entries + .iter() + .map(|entry| font_picker_entry(entry, &selected_family, Vec::new())) + .collect() + } else { + let search_texts: Vec = font_entries + .iter() + .map(|entry| { + if entry.label == entry.family { + entry.label.clone() + } else { + format!("{} {}", entry.label, entry.family) + } + }) + .collect(); + let haystack: Vec<&str> = search_texts.iter().map(|s| s.as_str()).collect(); + let config = neo_frizbee::Config { + max_typos: Some(2), + sort: false, + ..Default::default() + }; + let mut matches = neo_frizbee::match_list_indices(&query, &haystack, &config); + matches.sort_by(|a, b| b.score.cmp(&a.score).then(a.index.cmp(&b.index))); + matches + .into_iter() + .map(|m| { + let entry = &font_entries[m.index as usize]; + let highlights = highlight_ranges_for_visible_match( + &query, + &entry.label, + &m.indices, + &config, + ); + font_picker_entry(entry, &selected_family, highlights) + }) + .collect() + }; + + let selected = entries + .iter() + .position(|entry| entry.value == selected_family) + .unwrap_or(0); + let entry_count = entries.len(); + self.overlays.picker.entries.set(&self.store, entries); + self.overlays + .picker + .selected_index + .set(&self.store, selected); + self.overlays.picker.list.update(&self.store, |l| { + l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); + l.scroll_top_px = 0; + }); + } + + pub(super) fn font_picker_role(&self) -> Option { + match self.overlays.picker.kind.get(&self.store) { + PickerKind::UiFont => Some(FontRole::Ui), + PickerKind::MonoFont => Some(FontRole::Mono), + _ => None, + } + } + + pub(super) fn selected_font_family(&self, role: FontRole) -> String { + match role { + FontRole::Ui => { + crate::fonts::normalize_font_selection(role, &self.settings.fonts.ui_family) + } + FontRole::Mono => { + crate::fonts::normalize_font_selection(role, &self.settings.fonts.mono_family) + } + } + } + + pub(super) fn open_ref_picker(&mut self, field: CompareField) -> Vec { + let scale = self.ui_scale_factor(); + let already_open = self.overlays_top() == Some(OverlaySurface::RefPicker); + // Snapshot originals only on first open; switching chips shouldn't + // refresh the revert baseline. + if !already_open { + let left = self.compare.left_ref.get(&self.store); + let right = self.compare.right_ref.get(&self.store); + self.overlays + .ref_picker + .original_left + .set(&self.store, left); + self.overlays + .ref_picker + .original_right + .set(&self.store, right); + } + self.overlays + .ref_picker + .active_field + .set(&self.store, field); + self.overlays.picker.kind.set( + &self.store, + match field { + CompareField::Left => PickerKind::LeftRef, + CompareField::Right => PickerKind::RightRef, + }, + ); + self.overlays.picker.selected_index.set(&self.store, 0); + self.overlays.picker.list.update(&self.store, |l| { + l.scroll_top_px = 0; + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + }); + let effects = self.rebuild_ref_picker(field); + self.push_overlay(OverlaySurface::RefPicker, Some(FocusTarget::PickerInput)); + // Move cursor to end of the active field's current value so typing + // continues from where the label ends. + let len = match field { + CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), + CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), + }; + self.reset_text_edit(len); + effects + } + + pub(super) fn open_command_palette(&mut self) -> Vec { + let scale = self.ui_scale_factor(); + self.overlays.command_palette.list.update(&self.store, |l| { + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + l.scroll_top_px = 0; + }); + let effects = self.rebuild_command_palette(); + self.push_overlay( + OverlaySurface::CommandPalette, + Some(FocusTarget::CommandPaletteInput), + ); + effects + } + + pub(super) fn push_overlay( + &mut self, + surface: OverlaySurface, + focus_target: Option, + ) { + if self.overlays_top() == Some(surface) { + self.set_focus(focus_target); + return; + } + let focus_return = self.ui.focus.get(&self.store); + self.overlays.stack.update(&self.store, |stack| { + stack.push(OverlayEntry { + surface, + focus_return, + }); + }); + self.set_focus(focus_target); + } + + pub(super) fn pop_overlay(&mut self) { + let mut popped: Option = None; + self.overlays.stack.update(&self.store, |stack| { + popped = stack.pop(); + }); + let Some(entry) = popped else { + return; + }; + match entry.surface { + OverlaySurface::ThemePicker => { + let original = self.ui.theme_preview_original.get(&self.store); + self.ui.theme_preview_original.set(&self.store, None); + if let Some(original) = original { + self.settings.theme_name = original; + } + self.reset_picker(); + } + OverlaySurface::RepoPicker | OverlaySurface::RefPicker | OverlaySurface::FontPicker => { + self.reset_picker(); + } + OverlaySurface::CommandPalette => { + self.reset_command_palette(); + } + OverlaySurface::Confirmation => { + self.reset_confirmation(); + } + _ => {} + } + self.set_focus(entry.focus_return); + } + + pub(super) fn open_confirmation( + &mut self, + title: impl Into, + message: impl Into, + confirm_label: impl Into, + action: Action, + ) { + self.overlays + .confirmation + .title + .set(&self.store, title.into()); + self.overlays + .confirmation + .message + .set(&self.store, message.into()); + self.overlays + .confirmation + .confirm_label + .set(&self.store, confirm_label.into()); + self.overlays + .confirmation + .action + .set(&self.store, Some(action)); + // Let push_overlay snapshot the current focus as the restore target + // before it moves focus off the field; closing the confirmation then + // returns focus (and IME state) to wherever the user was. + self.push_overlay(OverlaySurface::Confirmation, None); + } + + pub(super) fn move_overlay_selection(&mut self, delta: i32) { + match self.overlays_top() { + Some(OverlaySurface::ThemePicker) => { + let current = self.overlays.picker.selected_index.get(&self.store); + let (idx, len, value) = self.overlays.picker.entries.with(&self.store, |entries| { + let len = entries.len(); + let idx = step_selection(current, delta, len, |i| entries[i].section_header); + let value = idx.and_then(|idx| { + entries + .get(idx) + .filter(|e| !e.section_header) + .map(|e| e.value.clone()) + }); + (idx, len, value) + }); + let Some(idx) = idx else { + return; + }; + self.overlays.picker.selected_index.set(&self.store, idx); + self.overlays + .picker + .list + .update(&self.store, |l| l.reveal_index(idx, len)); + if let Some(value) = value { + tracing::debug!(theme = %value, "theme preview"); + self.settings.theme_name = value; + } + } + Some( + OverlaySurface::RepoPicker | OverlaySurface::RefPicker | OverlaySurface::FontPicker, + ) => { + let current = self.overlays.picker.selected_index.get(&self.store); + let (idx, len) = self.overlays.picker.entries.with(&self.store, |entries| { + let len = entries.len(); + let idx = step_selection(current, delta, len, |i| entries[i].section_header); + (idx, len) + }); + let Some(idx) = idx else { + return; + }; + self.overlays.picker.selected_index.set(&self.store, idx); + self.overlays + .picker + .list + .update(&self.store, |l| l.reveal_index(idx, len)); + } + Some(OverlaySurface::CommandPalette) => { + let entry_count = self + .overlays + .command_palette + .entries + .with(&self.store, |e| e.len()); + let current = self + .overlays + .command_palette + .selected_index + .get(&self.store); + // Palette entries have no section headers; an empty palette + // still pins the selection to row zero. + let idx = step_selection(current, delta, entry_count, |_| false).unwrap_or(0); + self.overlays + .command_palette + .selected_index + .set(&self.store, idx); + self.overlays + .command_palette + .list + .update(&self.store, |l| l.reveal_index(idx, entry_count)); + } + _ => {} + } + } + + pub(super) fn select_overlay_entry(&mut self, index: usize) { + match self.overlays_top() { + Some(OverlaySurface::ThemePicker) => { + let (clamped, len, value) = + self.overlays.picker.entries.with(&self.store, |entries| { + let len = entries.len(); + let clamped = index.min(len.saturating_sub(1)); + let value = entries.get(clamped).map(|e| e.value.clone()); + (clamped, len, value) + }); + self.overlays + .picker + .selected_index + .set(&self.store, clamped); + if let Some(value) = value { + self.settings.theme_name = value; + } + self.overlays + .picker + .list + .update(&self.store, |l| l.reveal_index(clamped, len)); + } + Some( + OverlaySurface::RepoPicker | OverlaySurface::RefPicker | OverlaySurface::FontPicker, + ) => { + let (clamped, len, is_header) = + self.overlays.picker.entries.with(&self.store, |entries| { + let len = entries.len(); + let clamped = index.min(len.saturating_sub(1)); + let is_header = entries.get(clamped).map_or(false, |e| e.section_header); + (clamped, len, is_header) + }); + if is_header { + return; + } + self.overlays + .picker + .selected_index + .set(&self.store, clamped); + self.overlays + .picker + .list + .update(&self.store, |l| l.reveal_index(clamped, len)); + } + Some(OverlaySurface::CommandPalette) => { + let len = self + .overlays + .command_palette + .entries + .with(&self.store, |e| e.len()); + let clamped = index.min(len.saturating_sub(1)); + self.overlays + .command_palette + .selected_index + .set(&self.store, clamped); + self.overlays + .command_palette + .list + .update(&self.store, |l| l.reveal_index(clamped, len)); + } + _ => {} + } + } + + pub(super) fn confirm_overlay_selection(&mut self) -> Vec { + match self.overlays_top() { + Some(OverlaySurface::ThemePicker) => { + let selected = self.overlays.picker.selected_index.get(&self.store); + let value = self.overlays.picker.entries.with(&self.store, |entries| { + entries.get(selected).map(|e| e.value.clone()) + }); + if let Some(value) = value { + tracing::info!(theme = %value, "theme confirmed"); + self.settings.theme_name = value; + } + self.ui.theme_preview_original.set(&self.store, None); + self.pop_overlay(); + self.persist_settings_effect() + } + Some(OverlaySurface::FontPicker) => self.confirm_font_picker(), + Some(OverlaySurface::RepoPicker) => self.confirm_repo_picker(), + Some(OverlaySurface::RefPicker) => { + let field = self.overlays.ref_picker.active_field.get(&self.store); + self.confirm_ref_picker(field) + } + Some(OverlaySurface::CommandPalette) => self.confirm_command_palette(), + Some(OverlaySurface::Confirmation) => { + let action = self.overlays.confirmation.action.get(&self.store); + self.pop_overlay(); + if let Some(action) = action { + self.apply_action(action) + } else { + Vec::new() + } + } + Some(OverlaySurface::GitHubAuthModal) => { + if self + .github + .auth + .device_flow + .with(&self.store, |opt| opt.is_some()) + { + self.apply_action(crate::actions::GitHubAction::OpenDeviceFlowBrowser) + } else { + self.apply_action(crate::actions::GitHubAction::StartGitHubDeviceFlow) + } + } + Some( + OverlaySurface::KeyboardShortcuts + | OverlaySurface::CompareMenu + | OverlaySurface::AccountMenu + | OverlaySurface::PublishMenu, + ) => Vec::new(), + None => Vec::new(), + } + } + + pub(super) fn confirm_font_picker(&mut self) -> Vec { + let Some(role) = self.font_picker_role() else { + return Vec::new(); + }; + let selected = self.overlays.picker.selected_index.get(&self.store); + let family = self.overlays.picker.entries.with(&self.store, |entries| { + entries.get(selected).map(|entry| entry.value.clone()) + }); + let Some(family) = family else { + return Vec::new(); + }; + let family = crate::fonts::normalize_font_selection(role, &family); + let changed = match role { + FontRole::Ui => { + if self.settings.fonts.ui_family == family { + false + } else { + self.settings.fonts.ui_family = family; + true + } + } + FontRole::Mono => { + if self.settings.fonts.mono_family == family { + false + } else { + self.settings.fonts.mono_family = family; + true + } + } + }; + self.pop_overlay(); + if changed { + self.persist_settings_effect() + } else { + Vec::new() + } + } + + pub(super) fn confirm_repo_picker(&mut self) -> Vec { + let selected = self.overlays.picker.selected_index.get(&self.store); + let entry = self + .overlays + .picker + .entries + .with(&self.store, |entries| entries.get(selected).cloned()); + + let Some(entry) = entry else { + let query = self + .overlays + .picker + .query + .with(&self.store, |q| q.trim().to_owned()); + if !query.is_empty() { + let expanded = expand_tilde(&query); + let path = PathBuf::from(&expanded); + if path.is_dir() && path_looks_like_repository(&path) { + self.pop_overlay(); + return self.open_repository(path); + } + if path.is_dir() { + self.navigate_picker_to_dir(&path); + return Vec::new(); + } + } + return Vec::new(); + }; + + if entry.section_header { + return Vec::new(); + } + + if entry.value.starts_with("open:") { + let path = PathBuf::from(&entry.value[5..]); + self.pop_overlay(); + return self.open_repository(path); + } + + let path = PathBuf::from(&entry.value); + + let browsing = self + .overlays + .picker + .browse_path + .with(&self.store, |p| p.is_some()); + if browsing { + if entry.label == ".." { + self.navigate_picker_to_dir(&path); + return Vec::new(); + } + if path.is_dir() && path_looks_like_repository(&path) { + self.pop_overlay(); + return self.open_repository(path); + } + if path.is_dir() { + self.navigate_picker_to_dir(&path); + return Vec::new(); + } + return Vec::new(); + } + + self.pop_overlay(); + self.open_repository(path) + } + + pub(super) fn tab_complete_picker_dir(&mut self) { + if self.overlays.picker.kind.get(&self.store) != PickerKind::Repository { + return; + } + let selected = self.overlays.picker.selected_index.get(&self.store); + let entry = self + .overlays + .picker + .entries + .with(&self.store, |entries| entries.get(selected).cloned()); + let Some(entry) = entry else { return }; + if entry.section_header || entry.value.is_empty() { + return; + } + let path = PathBuf::from(&entry.value); + if path.is_dir() { + self.navigate_picker_to_dir(&path); + } + } + + pub(super) fn navigate_picker_to_dir(&mut self, path: &Path) { + let display = path.display().to_string(); + let new_query = if display.ends_with('/') || display.ends_with('\\') { + display + } else { + format!("{}/", display) + }; + let new_len = new_query.len(); + self.overlays.picker.query.set(&self.store, new_query); + self.reset_text_edit(new_len); + self.rebuild_repo_picker(); + } + + pub(super) fn confirm_ref_picker(&mut self, field: CompareField) -> Vec { + let selected = self.overlays.picker.selected_index.get(&self.store); + let entry = self + .overlays + .picker + .entries + .with(&self.store, |entries| entries.get(selected).cloned()) + .or_else(|| { + let query = match field { + CompareField::Left => self + .compare + .left_ref + .with(&self.store, |s| s.trim().to_owned()), + CompareField::Right => self + .compare + .right_ref + .with(&self.store, |s| s.trim().to_owned()), + }; + (!query.is_empty()).then(|| PickerEntry { + label: query.clone(), + detail: "Use typed ref".to_owned(), + value: query.clone(), + highlights: vec![(0, query.len())], + label_style: PickerLabelStyle::Default, + icon: None, + section_header: false, + }) + }); + let Some(entry) = entry else { + return Vec::new(); + }; + // Presets apply both refs at once; treat them as an explicit commit. + if let Some(rest) = entry.value.strip_prefix("@preset:") { + return self.apply_compare_preset(rest); + } + if let Some(ref store) = self.frecency { + store.record_access(&format!("ref:{}", entry.value)); + } + let _ = self.update_compare_field(field, entry.value); + // Auto-advance to the other chip if it's still at its snapshot — the + // user is likely changing both refs. Only commit when both chips have + // diverged from their snapshots (or neither, which is a no-op). + let other = match field { + CompareField::Left => CompareField::Right, + CompareField::Right => CompareField::Left, + }; + let other_current = match other { + CompareField::Left => self.compare.left_ref.get(&self.store), + CompareField::Right => self.compare.right_ref.get(&self.store), + }; + let other_original = match other { + CompareField::Left => self.overlays.ref_picker.original_left.get(&self.store), + CompareField::Right => self.overlays.ref_picker.original_right.get(&self.store), + }; + if other_current == other_original { + let scale = self.ui_scale_factor(); + self.overlays + .ref_picker + .active_field + .set(&self.store, other); + self.overlays.picker.kind.set( + &self.store, + match other { + CompareField::Left => PickerKind::LeftRef, + CompareField::Right => PickerKind::RightRef, + }, + ); + self.overlays.picker.selected_index.set(&self.store, 0); + self.overlays.picker.list.update(&self.store, |l| { + l.scroll_top_px = 0; + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + }); + let effects = self.rebuild_ref_picker(other); + let len = match other { + CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), + CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), + }; + self.reset_text_edit(len); + return effects; + } + // Both chips changed — commit. + self.commit_ref_picker() + } + + pub(super) fn commit_ref_picker(&mut self) -> Vec { + let original_left = self.overlays.ref_picker.original_left.get(&self.store); + let original_right = self.overlays.ref_picker.original_right.get(&self.store); + let current_left = self.compare.left_ref.get(&self.store); + let current_right = self.compare.right_ref.get(&self.store); + let changed = current_left != original_left || current_right != original_right; + self.pop_overlay(); + let mut effects = self.persist_settings_effect(); + if !changed { + return effects; + } + let has_repo = self.compare.repo_path.with(&self.store, |p| p.is_some()); + let not_loading = self.workspace.status.get(&self.store) != AsyncStatus::Loading; + let refs_valid = compare_refs_are_valid( + self.compare.mode.get(&self.store), + ¤t_left, + ¤t_right, + ); + if has_repo && not_loading && refs_valid { + effects.extend(self.kickoff_compare()); + } + effects + } + + pub(super) fn cancel_ref_picker(&mut self) -> Vec { + let left = self.overlays.ref_picker.original_left.get(&self.store); + let right = self.overlays.ref_picker.original_right.get(&self.store); + self.compare.left_ref.set(&self.store, left); + self.compare.right_ref.set(&self.store, right); + self.compare.resolved_left.set(&self.store, None); + self.compare.resolved_right.set(&self.store, None); + self.pop_overlay(); + Vec::new() + } + + pub(super) fn set_active_ref_field(&mut self, field: CompareField) -> Vec { + if self.overlays_top() != Some(OverlaySurface::RefPicker) { + return Vec::new(); + } + let scale = self.ui_scale_factor(); + self.overlays + .ref_picker + .active_field + .set(&self.store, field); + self.overlays.picker.kind.set( + &self.store, + match field { + CompareField::Left => PickerKind::LeftRef, + CompareField::Right => PickerKind::RightRef, + }, + ); + self.overlays.picker.selected_index.set(&self.store, 0); + self.overlays.picker.list.update(&self.store, |l| { + l.scroll_top_px = 0; + l.row_height_px = (Sz::ROW * scale).round() as u32; + l.gap_px = (Sp::XS * scale).round() as u32; + }); + let effects = self.rebuild_ref_picker(field); + let len = match field { + CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), + CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), + }; + self.reset_text_edit(len); + effects + } + + pub(super) fn swap_draft_refs(&mut self) -> Vec { + if self.overlays_top() != Some(OverlaySurface::RefPicker) { + return Vec::new(); + } + let left = self.compare.left_ref.get(&self.store); + let right = self.compare.right_ref.get(&self.store); + self.compare.left_ref.set(&self.store, right); + self.compare.right_ref.set(&self.store, left); + self.compare.resolved_left.set(&self.store, None); + self.compare.resolved_right.set(&self.store, None); + // Re-sync the search input to the active chip's new value. + let field = self.overlays.ref_picker.active_field.get(&self.store); + let len = match field { + CompareField::Left => self.compare.left_ref.with(&self.store, |s| s.len()), + CompareField::Right => self.compare.right_ref.with(&self.store, |s| s.len()), + }; + self.reset_text_edit(len); + self.rebuild_ref_picker(field) + } + + pub(super) fn apply_compare_preset(&mut self, preset: &str) -> Vec { + let parts: Vec<&str> = preset.splitn(3, ':').collect(); + if parts.len() != 3 { + return Vec::new(); + } + let (left, right, mode_str) = (parts[0], parts[1], parts[2]); + let mode = match mode_str { + "commit" => CompareMode::SingleCommit, + "diff" => CompareMode::TwoDot, + _ => CompareMode::ThreeDot, + }; + let profile = self.vcs_ui_profile(); + let mode = if profile.accepts_compare_mode(mode) { + mode + } else { + profile.compare_modes()[0].mode + }; + self.workspace.pre_drill_compare.set(&self.store, None); + self.compare.left_ref.set(&self.store, left.to_owned()); + self.compare.right_ref.set(&self.store, right.to_owned()); + self.compare.resolved_left.set(&self.store, None); + self.compare.resolved_right.set(&self.store, None); + self.compare.mode.set(&self.store, mode); + self.pop_overlay(); + let mut effects = self.persist_settings_effect(); + if self.compare.repo_path.with(&self.store, |p| p.is_some()) { + effects.extend(self.kickoff_compare()); + } + effects + } + + pub(super) fn confirm_command_palette(&mut self) -> Vec { + let selected = self + .overlays + .command_palette + .selected_index + .get(&self.store); + let Some(entry) = self + .overlays + .command_palette + .entries + .with(&self.store, |entries| entries.get(selected).cloned()) + else { + return Vec::new(); + }; + if entry.disabled { + return Vec::new(); + } + self.clear_overlays(); + match entry.kind { + PaletteEntryKind::Command(command) => { + match command { + PaletteCommand::OpenRepoPicker => { + self.open_repo_picker(); + Vec::new() + } + PaletteCommand::NewTextCompare => { + self.apply_action(crate::actions::WorkspaceAction::NewTextCompare) + } + PaletteCommand::OpenGitHubAuthModal => { + self.push_overlay( + OverlaySurface::GitHubAuthModal, + Some(FocusTarget::AuthPrimaryAction), + ); + Vec::new() + } + PaletteCommand::OpenGitHubAccountMenu => { + self.apply_action(crate::actions::GitHubAction::OpenAccountMenu) + } + PaletteCommand::SignOutGitHub => { + self.apply_action(crate::actions::GitHubAction::SignOutGitHub) + } + PaletteCommand::FocusFileList => { + self.set_focus(Some(FocusTarget::FileList)); + Vec::new() + } + PaletteCommand::FocusViewport => { + self.set_focus(Some(FocusTarget::Editor)); + Vec::new() + } + PaletteCommand::ShowWorkingTree => { + self.apply_action(crate::actions::WorkspaceAction::ShowWorkingTree) + } + PaletteCommand::RefreshRepository => { + self.apply_action(crate::actions::WorkspaceAction::RefreshRepository) + } + PaletteCommand::OpenBaseRefPicker => self.apply_action( + crate::actions::OverlayAction::OpenRefPicker(CompareField::Left), + ), + PaletteCommand::OpenHeadRefPicker => self.apply_action( + crate::actions::OverlayAction::OpenRefPicker(CompareField::Right), + ), + PaletteCommand::SwapRefs => { + self.apply_action(crate::actions::CompareAction::SwapRefs) + } + PaletteCommand::StartCompare => { + self.apply_action(crate::actions::CompareAction::StartCompare) + } + PaletteCommand::OpenCompareMenu => { + self.apply_action(crate::actions::CompareAction::OpenCompareMenu) + } + PaletteCommand::ShowKeyboardShortcuts => { + self.apply_action(crate::actions::SettingsAction::OpenKeymaps) + } + PaletteCommand::RestoreCompare => { + self.apply_action(crate::actions::CompareAction::ClearSidebarCommit) + } + PaletteCommand::ToggleSidebar => { + self.apply_action(crate::actions::FileListAction::ToggleSidebar) + } + PaletteCommand::ToggleFileTree => { + self.apply_action(crate::actions::FileListAction::ToggleSidebarMode) + } + PaletteCommand::ExpandAllFolders => { + self.apply_action(crate::actions::FileListAction::ExpandAllFolders) + } + PaletteCommand::CollapseAllFolders => { + self.apply_action(crate::actions::FileListAction::CollapseAllFolders) + } + PaletteCommand::ToggleWrap => { + self.apply_action(crate::actions::SettingsAction::ToggleWrap) + } + PaletteCommand::ToggleContinuousScroll => { + self.apply_action(crate::actions::SettingsAction::ToggleContinuousScroll) + } + PaletteCommand::SetSettingsSection(section) => self + .apply_action(crate::actions::SettingsAction::SetSettingsSection(section)), + PaletteCommand::SetThemeMode(mode) => { + self.apply_action(crate::actions::SettingsAction::SetThemeMode(mode)) + } + PaletteCommand::SetUiScalePct(pct) => { + self.apply_action(crate::actions::SettingsAction::SetUiScalePct(pct)) + } + PaletteCommand::SetWrapColumn(column) => { + self.apply_action(crate::actions::SettingsAction::SetWrapColumn(column)) + } + PaletteCommand::SetWheelScrollLines(lines) => self + .apply_action(crate::actions::SettingsAction::SetWheelScrollLines(lines)), + PaletteCommand::ToggleAutoUpdate => { + self.apply_action(crate::actions::SettingsAction::ToggleAutoUpdate) + } + PaletteCommand::ToggleThemeMode => { + self.apply_action(crate::actions::SettingsAction::ToggleThemeMode) + } + PaletteCommand::SetLayout(layout) => { + self.apply_action(crate::actions::CompareAction::SetLayoutMode(layout)) + } + PaletteCommand::SetRenderer(renderer) => { + self.apply_action(crate::actions::CompareAction::SetRenderer(renderer)) + } + PaletteCommand::ChangeTheme => { + self.apply_action(crate::actions::SettingsAction::OpenThemePicker) + } + PaletteCommand::SetTheme(name) => { + self.apply_action(crate::actions::SettingsAction::SetThemeName(name)) + } + PaletteCommand::ExpandAllContext => { + self.apply_action(crate::actions::EditorAction::ExpandAllContext) + } + PaletteCommand::ClearLineSelection => { + self.apply_action(crate::actions::RepositoryAction::ClearLineSelection) + } + PaletteCommand::GenerateCommitMessage => { + self.apply_action(crate::actions::AiAction::GenerateCommitMessage) + } + PaletteCommand::OpenReviewComment => { + self.apply_action(crate::actions::GitHubAction::OpenReviewCommentComposer) + } + PaletteCommand::OpenPullRequestInGitHub => { + self.apply_action(crate::actions::GitHubAction::OpenPullRequestInBrowser) + } + PaletteCommand::CheckForUpdates => { + self.apply_action(crate::actions::UpdateAction::CheckForUpdates) + } + PaletteCommand::InstallUpdate => { + self.apply_action(crate::actions::UpdateAction::InstallUpdate) + } + PaletteCommand::RestartToUpdate => { + self.apply_action(crate::actions::UpdateAction::RestartToUpdate) + } + PaletteCommand::RunOperation(operation) => { + self.confirm_or_run_vcs_operation(operation) + } + PaletteCommand::FetchOrigin => self.apply_action( + crate::actions::RepositoryAction::FetchRemote("origin".to_owned()), + ), + PaletteCommand::FetchAllRemotes => { + self.apply_action(crate::actions::RepositoryAction::FetchAllRemotes) + } + PaletteCommand::PushCurrentBranch => { + self.apply_action(crate::actions::RepositoryAction::PushCurrentBranch { + force_with_lease: false, + }) + } + PaletteCommand::PublishOptions => { + self.apply_action(crate::actions::RepositoryAction::OpenPublishMenu) + } + PaletteCommand::PushCurrentBranchForceWithLease => { + self.apply_action(crate::actions::RepositoryAction::PushCurrentBranch { + force_with_lease: true, + }) + } + PaletteCommand::PullCurrentBranch => { + self.apply_action(crate::actions::RepositoryAction::PullCurrentBranch) + } + PaletteCommand::OpenSettings => { + self.apply_action(crate::actions::SettingsAction::OpenSettings) + } + } + } + PaletteEntryKind::File(index) => self.select_file(index, true), + PaletteEntryKind::Commit(oid) => { + self.apply_action(crate::actions::CompareAction::SelectSidebarCommit(oid)) + } + PaletteEntryKind::Repo(path) => self.open_repository(path), + PaletteEntryKind::Ref(field, value) => { + let _ = self.update_compare_field(field, value); + self.persist_settings_effect() + } + PaletteEntryKind::PullRequest(key) => self.confirm_pr_entry(key), + } + } + + pub(super) fn confirm_pr_entry(&mut self, key: PrKey) -> Vec { + if self.compare.repo_path.with(&self.store, |p| p.is_none()) { + self.push_error("Open a repository before loading a pull request."); + return Vec::new(); + } + let diff_state = self + .github + .pull_request + .cache + .with(&self.store, |c| c.get(&key).map(|e| e.diff.clone())); + match diff_state { + Some(PrPeekDiff::Ready { + left_ref, + right_ref, + .. + }) => { + self.github + .pull_request + .pending_confirm + .set(&self.store, None); + self.github.pull_request.active.set(&self.store, Some(key)); + self.apply_pr_compare(left_ref, right_ref) + } + Some(PrPeekDiff::Loading) | Some(PrPeekDiff::Idle) => { + self.github + .pull_request + .pending_confirm + .set(&self.store, Some(key.clone())); + self.push_info(&format!("Preparing PR #{}\u{2026}", key.2)); + Vec::new() + } + Some(PrPeekDiff::Failed(message)) => { + self.push_error(&message); + Vec::new() + } + None => { + self.push_error("Pull request not available."); + Vec::new() + } + } + } + + pub(super) fn confirm_or_run_vcs_operation(&mut self, operation: VcsOperation) -> Vec { + let action = crate::actions::RepositoryAction::RunOperation(operation.clone()); + if let Some(message) = operation.confirmation_message() { + self.open_confirmation( + format!("Confirm {}", operation.label()), + message, + operation.label(), + action.into(), + ); + Vec::new() + } else { + self.apply_action(action) + } + } + + pub(super) fn rebuild_repo_picker(&mut self) { + let query = self.overlays.picker.query.with(&self.store, |q| q.clone()); + let trimmed = query.trim(); + + if query_looks_like_path(trimmed) { + self.rebuild_repo_picker_browse(trimmed); + } else { + self.overlays.picker.browse_path.set(&self.store, None); + self.rebuild_repo_picker_recent(trimmed); + } + + let current_selected = self.overlays.picker.selected_index.get(&self.store); + let (entry_count, new_selected) = + self.overlays.picker.entries.with(&self.store, |entries| { + let entry_count = entries.len(); + let new_selected = if entries.is_empty() { + 0 + } else { + let first_selectable = + entries.iter().position(|e| !e.section_header).unwrap_or(0); + current_selected + .max(first_selectable) + .min(entries.len().saturating_sub(1)) + }; + (entry_count, new_selected) + }); + self.overlays + .picker + .selected_index + .set(&self.store, new_selected); + self.overlays.picker.list.update(&self.store, |l| { + l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); + l.clamp_scroll(entry_count); + }); + } + + pub(super) fn rebuild_repo_picker_recent(&mut self, query: &str) { + let mut entries = Vec::new(); + + let all_repos = crate::core::frecency::recent_repo_paths(self.frecency.as_ref(), 20); + + let mut seen = HashSet::new(); + let mut unique_repos = Vec::new(); + for repo in &all_repos { + if seen.insert(repo.clone()) { + unique_repos.push(repo.clone()); + } + } + + if !unique_repos.is_empty() { + entries.push(PickerEntry { + label: "Recent".to_owned(), + detail: String::new(), + value: String::new(), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + icon: None, + section_header: true, + }); + } + + if query.is_empty() { + for repo in &unique_repos { + let display = repo.display().to_string(); + let is_repo = path_looks_like_repository(repo); + entries.push(PickerEntry { + label: repo + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&display) + .to_owned(), + detail: display.clone(), + value: repo.display().to_string(), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + icon: Some(if is_repo { + lucide::FOLDER_GIT + } else { + lucide::FOLDER + }), + section_header: false, + }); + } + } else { + let haystack: Vec = unique_repos + .iter() + .map(|r| r.display().to_string()) + .collect(); + let haystack_refs: Vec<&str> = haystack.iter().map(|s| s.as_str()).collect(); + let config = neo_frizbee::Config { + max_typos: Some(2), + sort: false, + ..Default::default() + }; + let mut matches = neo_frizbee::match_list_indices(query, &haystack_refs, &config); + matches.sort_by(|a, b| b.score.cmp(&a.score)); + if matches.is_empty() { + entries.clear(); + } + for m in matches { + let repo = &unique_repos[m.index as usize]; + let display = &haystack[m.index as usize]; + let label = repo + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(display) + .to_owned(); + let highlights = + highlight_ranges_for_visible_match(query, &label, &m.indices, &config); + let is_repo = path_looks_like_repository(repo); + entries.push(PickerEntry { + label, + detail: display.clone(), + value: repo.display().to_string(), + highlights, + label_style: PickerLabelStyle::Default, + icon: Some(if is_repo { + lucide::FOLDER_GIT + } else { + lucide::FOLDER + }), + section_header: false, + }); + } + } + self.overlays.picker.entries.set(&self.store, entries); + } + + pub(super) fn rebuild_repo_picker_browse(&mut self, query: &str) { + let expanded = expand_tilde(query); + let (dir_path, filter) = split_browse_query(&expanded); + + let dir = PathBuf::from(&dir_path); + if !dir.is_dir() { + self.overlays.picker.browse_path.set(&self.store, None); + self.overlays.picker.entries.set(&self.store, Vec::new()); + return; + } + + self.overlays + .picker + .browse_path + .set(&self.store, Some(dir.clone())); + + let mut entries = Vec::new(); + + if path_looks_like_repository(&dir) { + entries.push(PickerEntry { + label: "open this directory".to_owned(), + detail: String::new(), + value: format!("open:{}", dir.display()), + icon: Some(lucide::CORNER_UP_LEFT), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + section_header: false, + }); + } + + if dir.parent().is_some() { + entries.push(PickerEntry { + label: "..".to_owned(), + detail: String::new(), + value: dir + .parent() + .map(|p| p.display().to_string()) + .unwrap_or_default(), + icon: Some(lucide::CORNER_UP_LEFT), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + section_header: false, + }); + } + + let mut dirs: Vec<(String, PathBuf, bool)> = Vec::new(); + if let Ok(read) = std::fs::read_dir(&dir) { + for entry in read.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name().to_str().unwrap_or_default().to_owned(); + if name.starts_with('.') { + continue; + } + let is_repo = path_looks_like_repository(&path); + dirs.push((name, path, is_repo)); + } + } + + dirs.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + + if filter.is_empty() { + for (name, path, is_repo) in &dirs { + entries.push(PickerEntry { + label: name.clone(), + detail: String::new(), + value: path.display().to_string(), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + icon: Some(if *is_repo { + lucide::FOLDER_GIT + } else { + lucide::FOLDER + }), + section_header: false, + }); + } + } else { + let haystack: Vec<&str> = dirs.iter().map(|(n, _, _)| n.as_str()).collect(); + let config = neo_frizbee::Config { + max_typos: Some(1), + sort: false, + ..Default::default() + }; + let mut matches = neo_frizbee::match_list_indices(filter, &haystack, &config); + matches.sort_by(|a, b| b.score.cmp(&a.score)); + for m in matches { + let (name, path, is_repo) = &dirs[m.index as usize]; + entries.push(PickerEntry { + label: name.clone(), + detail: String::new(), + value: path.display().to_string(), + highlights: highlight_ranges_from_match_indices(name, &m.indices), + label_style: PickerLabelStyle::Default, + icon: Some(if *is_repo { + lucide::FOLDER_GIT + } else { + lucide::FOLDER + }), + section_header: false, + }); + } + } + + self.overlays.picker.entries.set(&self.store, entries); + } + + pub(super) fn rebuild_ref_picker(&mut self, field: CompareField) -> Vec { + let query_owned = match field { + CompareField::Left => self + .compare + .left_ref + .with(&self.store, |s| s.trim().to_owned()), + CompareField::Right => self + .compare + .right_ref + .with(&self.store, |s| s.trim().to_owned()), + }; + let query = query_owned.as_str(); + let mut seen = HashSet::new(); + + struct RefCandidate { + search_text: String, + label: String, + detail: String, + value: String, + icon: Option<&'static str>, + default_highlights: Vec<(usize, usize)>, + label_style: PickerLabelStyle, + ordinal: usize, + } + + let mut all_candidates = Vec::new(); + let mut ordinal = 0_usize; + + let mut push = |search_text: String, + label: String, + detail: String, + value: String, + icon: Option<&'static str>, + default_highlights: Vec<(usize, usize)>, + label_style: PickerLabelStyle| { + if !seen.insert(value.clone()) { + return; + } + all_candidates.push(RefCandidate { + search_text, + label, + detail, + value, + icon, + default_highlights, + label_style, + ordinal, + }); + ordinal += 1; + }; + + let profile = self.vcs_ui_profile(); + let refs = self.repository.refs.get(&self.store); + let changes = self.repository.changes.get(&self.store); + + for reference in &refs { + let value = reference.name.clone(); + let (kind_label, icon) = profile.ref_kind_label_and_icon(reference.kind); + let mut detail = kind_label.to_owned(); + if reference.active { + detail.push_str(" \u{2022} current"); + } + let mut search_text = format!("{} {detail}", reference.name); + if reference.target.id != reference.name { + search_text.push(' '); + search_text.push_str(&reference.target.id); + } + if reference.kind == RefKind::WorkingCopy + && let Some((detail_suffix, search_suffix)) = + profile.working_copy_ref_suffix(&changes) + { + detail.push_str(&detail_suffix); + search_text.push_str(&search_suffix); + } + push( + search_text, + reference.name.clone(), + detail, + value, + icon, + Vec::new(), + PickerLabelStyle::Default, + ); + } + + for change in &changes { + let entry = profile.change_ref_entry(change); + let label_style = entry + .prefix_len + .map(|prefix_len| PickerLabelStyle::JjChangeId { + prefix_len, + working_copy: entry.working_copy, + }) + .unwrap_or_default(); + push( + entry.search_text, + entry.label, + entry.detail, + entry.value, + Some(lucide::HASH), + entry.default_highlights, + label_style, + ); + } + + let mut needs_resolve = false; + + if query.is_empty() { + let entries = all_candidates + .into_iter() + .take(10) + .map(|c| PickerEntry { + label: c.label, + detail: c.detail, + value: c.value, + highlights: c.default_highlights, + label_style: c.label_style, + icon: c.icon, + section_header: false, + }) + .collect(); + self.overlays.picker.entries.set(&self.store, entries); + } else { + let haystack: Vec<&str> = all_candidates + .iter() + .map(|c| c.search_text.as_str()) + .collect(); + let config = neo_frizbee::Config { + max_typos: Some(2), + sort: false, + ..Default::default() + }; + let matches = neo_frizbee::match_list_indices(query, &haystack, &config); + let mut scored: Vec<_> = matches + .into_iter() + .map(|m| { + let c = &all_candidates[m.index as usize]; + ( + m.score, + c.ordinal, + PickerEntry { + label: c.label.clone(), + detail: c.detail.clone(), + value: c.value.clone(), + highlights: highlight_ranges_for_visible_match( + query, &c.label, &m.indices, &config, + ), + label_style: c.label_style, + icon: c.icon, + section_header: false, + }, + ) + }) + .collect(); + scored.sort_by(|a, b| { + b.0.cmp(&a.0) + .then(a.1.cmp(&b.1)) + .then(a.2.label.cmp(&b.2.label)) + }); + let mut entries = Vec::new(); + entries.extend(scored.into_iter().map(|(_, _, entry)| entry).take(10)); + if !entries.iter().any(|entry| entry.value == query) { + entries.insert( + 0, + PickerEntry { + label: query.to_owned(), + detail: "Resolving\u{2026}".to_owned(), + value: query.to_owned(), + highlights: vec![(0, query.len())], + label_style: PickerLabelStyle::Default, + icon: None, + section_header: false, + }, + ); + needs_resolve = true; + } + self.overlays.picker.entries.set(&self.store, entries); + } + + self.overlays.picker.entries.update(&self.store, |e| { + e.truncate(10); + }); + let entry_count = self.overlays.picker.entries.with(&self.store, |e| e.len()); + let current_selected = self.overlays.picker.selected_index.get(&self.store); + self.overlays.picker.selected_index.set( + &self.store, + current_selected.min(entry_count.saturating_sub(1)), + ); + self.overlays.picker.list.update(&self.store, |l| { + l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); + l.clamp_scroll(entry_count); + }); + + if needs_resolve { + if let Some(repo_path) = self.compare.repo_path.get(&self.store) { + let new_gen = self.overlays.picker.ref_resolve_generation.get(&self.store) + 1; + self.overlays + .picker + .ref_resolve_generation + .set(&self.store, new_gen); + return vec![ + CompareEffect::ResolveRef { + repo_path, + query: query.to_owned(), + generation: new_gen, + } + .into(), + ]; + } + } + Vec::new() + } + + pub(super) fn rebuild_command_palette_if_open(&mut self) -> Vec { + if self.overlays_top() == Some(OverlaySurface::CommandPalette) { + self.rebuild_command_palette() + } else { + Vec::new() + } + } + + pub(super) fn rebuild_command_palette(&mut self) -> Vec { + let query_owned = self + .overlays + .command_palette + .query + .with(&self.store, |q| q.trim().to_owned()); + let query = query_owned.as_str(); + + let mut out_effects = Vec::new(); + let mut pr_entry: Option = None; + + if let Some(parsed) = crate::core::forge::github::parse_pr_url(query) { + let key: PrKey = (parsed.owner.clone(), parsed.repo.clone(), parsed.number); + let token = self.github_access_token.clone(); + let repo_path = self.compare.repo_path.get(&self.store); + let supports_github_prs = repo_path.is_some() + && self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.github_pull_requests) + }); + + let already_cached = self + .github + .pull_request + .cache + .with(&self.store, |c| c.contains_key(&key)); + if !already_cached { + self.github.pull_request.cache.update(&self.store, |c| { + c.insert( + key.clone(), + PrCacheEntry { + meta: PrPeekMeta::Loading, + diff: PrPeekDiff::Idle, + last_peek_ms: self.clock_ms, + }, + ); + }); + out_effects.push( + GitHubEffect::PeekPullRequest { + owner: parsed.owner.clone(), + repo: parsed.repo.clone(), + number: parsed.number, + github_token: token.clone(), + } + .into(), + ); + } + + // Speculative diff load — kick off as soon as we know the key, provided + // a repo is open. Dedupe via the cache's diff state. + if supports_github_prs && let Some(repo_path) = repo_path.clone() { + let diff_idle = self.github.pull_request.cache.with(&self.store, |c| { + matches!(c.get(&key).map(|e| &e.diff), Some(PrPeekDiff::Idle) | None) + }); + if diff_idle { + self.github.pull_request.cache.update(&self.store, |c| { + if let Some(e) = c.get_mut(&key) { + e.diff = PrPeekDiff::Loading; + } + }); + let url = format!( + "https://github.com/{}/{}/pull/{}", + parsed.owner, parsed.repo, parsed.number + ); + out_effects.push( + GitHubEffect::LoadPullRequest { + url, + repo_path, + github_token: token, + } + .into(), + ); + } + } + + pr_entry = Some(build_pr_palette_entry( + &self.github.pull_request.cache.get(&self.store), + &key, + supports_github_prs, + )); + } + + struct PaletteCandidate { + search_text: String, + label: String, + detail: String, + kind: PaletteEntryKind, + } + + let mut all_candidates = Vec::new(); + let repo_capabilities = self.repository.capabilities.get(&self.store); + + for (label, detail, command) in [ + ( + "Choose Repository".to_owned(), + "Open repository picker".to_owned(), + PaletteCommand::OpenRepoPicker, + ), + ( + "New Text Compare".to_owned(), + "Compare arbitrary pasted text".to_owned(), + PaletteCommand::NewTextCompare, + ), + ( + "GitHub Sign In".to_owned(), + "Start device flow".to_owned(), + PaletteCommand::OpenGitHubAuthModal, + ), + ( + "GitHub Account Menu".to_owned(), + "Open GitHub account actions".to_owned(), + PaletteCommand::OpenGitHubAccountMenu, + ), + ( + "GitHub Sign Out".to_owned(), + "Remove the saved GitHub session".to_owned(), + PaletteCommand::SignOutGitHub, + ), + ( + "Focus File List".to_owned(), + "Move keyboard focus to sidebar".to_owned(), + PaletteCommand::FocusFileList, + ), + ( + "Focus Diff Viewport".to_owned(), + "Move keyboard focus to editor".to_owned(), + PaletteCommand::FocusViewport, + ), + ( + "Show Working Tree".to_owned(), + "Return to the repository working tree view".to_owned(), + PaletteCommand::ShowWorkingTree, + ), + ( + "Refresh Repository".to_owned(), + "Refresh status or rerun the current compare".to_owned(), + PaletteCommand::RefreshRepository, + ), + ( + "Select Base Ref".to_owned(), + "Open the left-side ref picker".to_owned(), + PaletteCommand::OpenBaseRefPicker, + ), + ( + "Select Head Ref".to_owned(), + "Open the right-side ref picker".to_owned(), + PaletteCommand::OpenHeadRefPicker, + ), + ( + "Swap Compare Refs".to_owned(), + "Swap the current base and head refs".to_owned(), + PaletteCommand::SwapRefs, + ), + ( + "Run Compare".to_owned(), + "Compare the selected refs now".to_owned(), + PaletteCommand::StartCompare, + ), + ( + "Open Compare Menu".to_owned(), + "Change compare mode or preset".to_owned(), + PaletteCommand::OpenCompareMenu, + ), + ( + "Keymaps".to_owned(), + "Review and rebind keyboard shortcuts".to_owned(), + PaletteCommand::ShowKeyboardShortcuts, + ), + ( + "Toggle Sidebar".to_owned(), + "Show or hide the file sidebar".to_owned(), + PaletteCommand::ToggleSidebar, + ), + ( + "Toggle File Tree".to_owned(), + "Switch sidebar between tree and flat list".to_owned(), + PaletteCommand::ToggleFileTree, + ), + ( + "Expand All Folders".to_owned(), + "Expand every folder in the file tree".to_owned(), + PaletteCommand::ExpandAllFolders, + ), + ( + "Collapse All Folders".to_owned(), + "Collapse every folder in the file tree".to_owned(), + PaletteCommand::CollapseAllFolders, + ), + ( + "Toggle Wrap".to_owned(), + "Enable or disable line wrapping".to_owned(), + PaletteCommand::ToggleWrap, + ), + ( + "Toggle Continuous Scroll".to_owned(), + "Switch between continuous and single-file diff navigation".to_owned(), + PaletteCommand::ToggleContinuousScroll, + ), + ( + "Toggle Theme".to_owned(), + "Switch light and dark mode".to_owned(), + PaletteCommand::ToggleThemeMode, + ), + ( + "Change Theme".to_owned(), + "Browse and preview color themes".to_owned(), + PaletteCommand::ChangeTheme, + ), + ( + "Use Unified Layout".to_owned(), + "Set unified diff mode".to_owned(), + PaletteCommand::SetLayout(LayoutMode::Unified), + ), + ( + "Use Split Layout".to_owned(), + "Set side-by-side diff mode".to_owned(), + PaletteCommand::SetLayout(LayoutMode::Split), + ), + ( + "Use Built-in Renderer".to_owned(), + "Render diffs with Diffy's built-in engine".to_owned(), + PaletteCommand::SetRenderer(RendererKind::Builtin), + ), + ( + "Use Difftastic Renderer".to_owned(), + "Render diffs with Difftastic".to_owned(), + PaletteCommand::SetRenderer(RendererKind::Difftastic), + ), + ( + "Expand All Context".to_owned(), + "Show all hidden context in the active diff".to_owned(), + PaletteCommand::ExpandAllContext, + ), + ( + "Clear Line Selection".to_owned(), + "Clear the current partial-line staging selection".to_owned(), + PaletteCommand::ClearLineSelection, + ), + ( + "Generate Commit Message".to_owned(), + "Draft a commit message from the current changes".to_owned(), + PaletteCommand::GenerateCommitMessage, + ), + ( + "Fetch origin".to_owned(), + "Update remote references from origin".to_owned(), + PaletteCommand::FetchOrigin, + ), + ( + "Fetch all remotes".to_owned(), + "Update remote references from every configured remote".to_owned(), + PaletteCommand::FetchAllRemotes, + ), + ( + "Pull current branch".to_owned(), + "Fast-forward the current Git branch from its upstream".to_owned(), + PaletteCommand::PullCurrentBranch, + ), + ( + self.vcs_ui_profile().publish_command_label().to_owned(), + self.vcs_ui_profile().publish_command_detail().to_owned(), + PaletteCommand::PushCurrentBranch, + ), + ( + "Publish options".to_owned(), + "Choose a backend-provided publish action".to_owned(), + PaletteCommand::PublishOptions, + ), + ( + "Push current branch (force with lease)".to_owned(), + "Force-push the current Git branch; refuse if upstream moved".to_owned(), + PaletteCommand::PushCurrentBranchForceWithLease, + ), + ( + "Open Settings".to_owned(), + "Configure appearance, editor, and behavior".to_owned(), + PaletteCommand::OpenSettings, + ), + ] { + if !palette_command_available(&command, repo_capabilities) { + continue; + } + let search_text = format!("{label} {detail}"); + all_candidates.push(PaletteCandidate { + search_text, + label, + detail, + kind: PaletteEntryKind::Command(command), + }); + } + + for section in SettingsSection::ALL { + let label = format!("Settings: {}", section.label()); + let detail = "Switch settings section".to_owned(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::SetSettingsSection(section)), + }); + } + for (label, detail, mode) in [ + ( + "Use Dark Mode", + "Set settings appearance to dark", + ThemeMode::Dark, + ), + ( + "Use Light Mode", + "Set settings appearance to light", + ThemeMode::Light, + ), + ] { + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label: label.to_owned(), + detail: detail.to_owned(), + kind: PaletteEntryKind::Command(PaletteCommand::SetThemeMode(mode)), + }); + } + for pct in [80, 90, 100, 110, 125, 150, 180] { + let label = format!("Set UI Scale {pct}%"); + let detail = "Change interface density".to_owned(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::SetUiScalePct(pct)), + }); + } + for (column, label_suffix) in [(0, "Auto"), (80, "80"), (100, "100"), (120, "120")] { + let label = format!("Set Wrap Column {label_suffix}"); + let detail = "Set line wrapping column".to_owned(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::SetWrapColumn(column)), + }); + } + for lines in [1, 2, 3, 5, 7] { + let label = format!("Set Mouse Wheel Speed {lines}"); + let detail = "Set lines scrolled per wheel notch".to_owned(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::SetWheelScrollLines(lines)), + }); + } + all_candidates.push(PaletteCandidate { + search_text: "Toggle Automatic Updates auto update".to_owned(), + label: "Toggle Automatic Updates".to_owned(), + detail: "Enable or disable hourly update checks".to_owned(), + kind: PaletteEntryKind::Command(PaletteCommand::ToggleAutoUpdate), + }); + all_candidates.push(PaletteCandidate { + search_text: "Check For Updates update release".to_owned(), + label: "Check For Updates".to_owned(), + detail: "Check Diffy's release channel now".to_owned(), + kind: PaletteEntryKind::Command(PaletteCommand::CheckForUpdates), + }); + match self.ui.update.get(&self.store) { + UpdateState::Available(update) => { + let label = format!("Install Update {}", update.version); + let detail = "Download and verify the available update".to_owned(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::InstallUpdate), + }); + } + UpdateState::ReadyToRestart(update) => { + let label = format!("Restart To Update {}", update.update.version); + let detail = "Restart Diffy and apply the staged update".to_owned(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::RestartToUpdate), + }); + } + _ => {} + } + + let repo_location = self.repository.location.get(&self.store); + for operation in JjOperation::ALL.map(VcsOperation::Jj) { + if !vcs_operation_available_for_location(&operation, repo_location.as_ref()) { + continue; + } + let label = format!("jj: {}", operation.label()); + let detail = operation.detail(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), + }); + } + if repo_location + .as_ref() + .is_some_and(|location| location.profile == VCS_PROFILE_JJ) + { + let mut destinations = self.repository.refs.with(&self.store, |refs| { + refs.iter() + .filter(|reference| { + !reference.active + && matches!(reference.kind, RefKind::Bookmark | RefKind::Branch) + }) + .map(|reference| reference.name.clone()) + .collect::>() + }); + destinations.sort(); + destinations.dedup(); + for destination in destinations.into_iter().take(12) { + let operation = VcsOperation::JjRebaseCurrentChangeOnto { + destination: destination.clone(), + }; + let label = format!("jj: {}", operation.label()); + let detail = operation.detail(); + all_candidates.push(PaletteCandidate { + search_text: format!("{label} {detail}"), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), + }); + } + let changes = self.repository.changes.get(&self.store); + for change in changes + .iter() + .filter(|change| { + !change.flags.current && !change.flags.working_copy && !change.flags.immutable + }) + .take(12) + { + let change_label = change + .short_change_id + .as_deref() + .unwrap_or(change.short_revision.as_str()) + .to_owned(); + let operation = VcsOperation::JjEditRevision { + revision: change.revision.id.clone(), + label: change_label.clone(), + }; + let label = format!("jj: {}", operation.label()); + let detail = crate::ui::vcs::change_summary_label(change); + all_candidates.push(PaletteCandidate { + search_text: format!( + "{label} {detail} {} {}", + change.short_revision, change.revision.id + ), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), + }); + } + let operation_log = self.repository.operation_log.get(&self.store); + for entry in operation_log.iter().skip(1).take(12) { + let operation_label = entry.short_operation_id.clone(); + let operation = VcsOperation::JjRestoreOperation { + operation_id: entry.operation_id.clone(), + label: operation_label.clone(), + }; + let label = format!("jj: {}", operation.label()); + let detail = operation_log_entry_detail(entry); + all_candidates.push(PaletteCandidate { + search_text: format!( + "{label} {detail} {} {}", + entry.operation_id, entry.short_operation_id + ), + label, + detail, + kind: PaletteEntryKind::Command(PaletteCommand::RunOperation(operation)), + }); + } + } + + if self + .workspace + .pre_drill_compare + .with(&self.store, |pre_drill| pre_drill.is_some()) + { + all_candidates.push(PaletteCandidate { + search_text: "Restore compare return range comparison commit drilldown".to_owned(), + label: "Restore Compare".to_owned(), + detail: "Return from the selected commit to the previous compare".to_owned(), + kind: PaletteEntryKind::Command(PaletteCommand::RestoreCompare), + }); + } + + if self + .editor + .line_selection + .with(&self.store, |selection| !selection.is_empty()) + { + all_candidates.push(PaletteCandidate { + search_text: "Comment on selected lines review pull request".to_owned(), + label: "Comment on Selected Lines".to_owned(), + detail: "Open the pull request review comment composer".to_owned(), + kind: PaletteEntryKind::Command(PaletteCommand::OpenReviewComment), + }); + } + + if self.active_pull_request_web_url().is_some() { + all_candidates.push(PaletteCandidate { + search_text: "Open pull request in GitHub browser web PR".to_owned(), + label: "Open Pull Request in GitHub".to_owned(), + detail: "Open the active pull request on github.com".to_owned(), + kind: PaletteEntryKind::Command(PaletteCommand::OpenPullRequestInGitHub), + }); + } + + let file_count = self.workspace_file_count(); + for index in 0..file_count { + let Some(file) = self.workspace_file_entry_at(index) else { + continue; + }; + let meta = self.file_list_entry_meta(index); + let detail = format!( + "File \u{2022} {} \u{2022} +{} -{}", + meta.status.label(), + meta.additions, + meta.deletions + ); + let search_text = format!("{} {detail}", file.path); + all_candidates.push(PaletteCandidate { + search_text, + label: file.path.to_string(), + detail, + kind: PaletteEntryKind::File(index), + }); + } + + let range_commits = self.workspace.range_commits.get(&self.store); + for change in &range_commits { + let label = crate::ui::vcs::change_summary_label(change); + let detail = format!("Commit {}", change.short_revision); + let search_text = format!("{} {} {}", change.short_revision, change.revision.id, label); + all_candidates.push(PaletteCandidate { + search_text, + label, + detail, + kind: PaletteEntryKind::Commit(change.revision.id.clone()), + }); + } + + let palette_repos = crate::core::frecency::recent_repo_paths(self.frecency.as_ref(), 10); + for repo in &palette_repos { + let repo_name = repo + .file_name() + .and_then(|name| name.to_str()) + .filter(|n| *n != ".") + .map(str::to_owned) + .unwrap_or_else(|| repo.display().to_string()); + let detail = repo.display().to_string(); + let search_text = format!("{repo_name} {detail}"); + all_candidates.push(PaletteCandidate { + search_text, + label: repo_name, + detail, + kind: PaletteEntryKind::Repo(repo.clone()), + }); + } + + let repo_refs = self.repository.refs.get(&self.store); + for reference in repo_refs.iter().filter(|reference| { + matches!( + reference.kind, + RefKind::Branch + | RefKind::RemoteBranch + | RefKind::Bookmark + | RefKind::RemoteBookmark + | RefKind::Tag + ) + }) { + let (detail, _) = self + .vcs_ui_profile() + .ref_kind_label_and_icon(reference.kind); + let search_text = format!("{} {}", reference.name, detail); + all_candidates.push(PaletteCandidate { + search_text, + label: reference.name.clone(), + detail: detail.to_owned(), + kind: PaletteEntryKind::Ref(CompareField::Left, reference.name.clone()), + }); + } + + let mut entries: Vec; + if query.is_empty() { + entries = all_candidates + .into_iter() + .map(|c| PaletteEntry { + label: c.label, + detail: c.detail, + kind: c.kind, + highlights: Vec::new(), + rhs: None, + disabled: false, + }) + .collect(); + } else { + let haystack: Vec<&str> = all_candidates + .iter() + .map(|c| c.search_text.as_str()) + .collect(); + let config = neo_frizbee::Config { + max_typos: Some(2), + sort: false, + ..Default::default() + }; + let matches = neo_frizbee::match_list_indices(query, &haystack, &config); + let mut scored: Vec<_> = matches + .into_iter() + .map(|m| { + let c = &all_candidates[m.index as usize]; + ( + m.score, + PaletteEntry { + label: c.label.clone(), + detail: c.detail.clone(), + kind: c.kind.clone(), + highlights: highlight_ranges_for_visible_match( + query, &c.label, &m.indices, &config, + ), + rhs: None, + disabled: false, + }, + ) + }) + .collect(); + scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.label.cmp(&b.1.label))); + entries = scored.into_iter().map(|(_, e)| e).collect(); + } + if let Some(pr) = pr_entry { + entries.insert(0, pr); + } + entries.truncate(18); + let entry_count = entries.len(); + self.overlays + .command_palette + .entries + .set(&self.store, entries); + let current_selected = self + .overlays + .command_palette + .selected_index + .get(&self.store); + self.overlays.command_palette.selected_index.set( + &self.store, + current_selected.min(entry_count.saturating_sub(1)), + ); + self.overlays.command_palette.list.update(&self.store, |l| { + l.viewport_height_px = l.viewport_for_max_rows(Sz::PICKER_MAX_ROWS, entry_count); + l.clamp_scroll(entry_count); + }); + out_effects + } + + pub(super) fn scroll_active_overlay_list_px(&mut self, delta_px: i32) { + match self.overlays_top() { + Some( + OverlaySurface::RepoPicker + | OverlaySurface::RefPicker + | OverlaySurface::ThemePicker + | OverlaySurface::FontPicker, + ) => { + let count = self.overlays.picker.entries.with(&self.store, |e| e.len()); + self.overlays + .picker + .list + .update(&self.store, |l| l.scroll_px(delta_px, count)); + } + Some(OverlaySurface::CommandPalette) => { + let count = self + .overlays + .command_palette + .entries + .with(&self.store, |e| e.len()); + self.overlays + .command_palette + .list + .update(&self.store, |l| l.scroll_px(delta_px, count)); + } + _ => {} + } + } +} diff --git a/src/ui/state/presentation.rs b/src/ui/state/presentation.rs new file mode 100644 index 00000000..c11c41a5 --- /dev/null +++ b/src/ui/state/presentation.rs @@ -0,0 +1,2154 @@ +//! Presentation-layer caches for the diff viewport: the continuous-scroll +//! virtual document model, per-slot render-doc cache, file height index, and +//! scroll anchoring. Pure code motion from `mod.rs`. + +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewportDocumentMode { + Single, + Continuous, +} + +#[derive(Debug, Clone)] +pub struct ViewportDocument { + pub doc: Arc, + pub mode: ViewportDocumentMode, + pub generation: u64, + pub start_index: usize, + pub start_offset_px: u32, + pub scroll_top_px: u32, + pub slot_indices: Vec, + pub slot_item_ids: Vec, + pub stream_items: Vec, + pub slot_loading: Vec, + pub path: String, +} + +impl ViewportDocument { + pub fn single(doc: Arc, generation: u64, file_index: usize, path: String) -> Self { + Self { + doc, + mode: ViewportDocumentMode::Single, + generation, + start_index: file_index, + start_offset_px: 0, + scroll_top_px: 0, + slot_indices: vec![file_index], + slot_item_ids: vec![VirtualDiffItemId::file( + WorkspaceSource::None, + generation, + file_index, + )], + stream_items: Vec::new(), + slot_loading: vec![false], + path, + } + } + + pub fn is_continuous(&self) -> bool { + self.mode == ViewportDocumentMode::Continuous + } + + pub fn insert_stream_item(&mut self, item: VirtualDiffStreamItem) { + let index = self + .stream_items + .partition_point(|existing| existing.sort_key <= item.sort_key); + self.stream_items.insert(index, item); + } +} + +pub(super) fn virtual_stream_item_kind( + slot: &ViewportSlotKey, + line: &RenderLine, +) -> Option { + match line.row_kind() { + RenderRowKind::FileHeader => Some(VirtualDiffItemKind::FileHeader), + RenderRowKind::HunkSeparator + if matches!(slot.kind, ViewportSlotKind::Loading) || line.hunk_index < 0 => + { + Some(VirtualDiffItemKind::LoadingPlaceholder) + } + RenderRowKind::HunkSeparator => Some(VirtualDiffItemKind::Hunk), + RenderRowKind::Context + | RenderRowKind::Added + | RenderRowKind::Removed + | RenderRowKind::Modified => Some(VirtualDiffItemKind::DiffRow), + RenderRowKind::Block => None, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VirtualDiffItemKind { + File, + FileHeader, + Hunk, + DiffRow, + ReviewThread, + ReviewComment, + Composer, + LoadingPlaceholder, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VirtualDiffItemId { + pub source: WorkspaceSource, + pub generation: u64, + pub kind: VirtualDiffItemKind, + pub index: usize, + pub ordinal: u32, + pub stable_key: u64, +} + +impl VirtualDiffItemId { + pub(super) fn file(source: WorkspaceSource, generation: u64, index: usize) -> Self { + Self { + source, + generation, + kind: VirtualDiffItemKind::File, + index, + ordinal: 0, + stable_key: 0, + } + } + + pub fn new( + source: WorkspaceSource, + generation: u64, + kind: VirtualDiffItemKind, + index: usize, + ordinal: u32, + stable_key: u64, + ) -> Self { + Self { + source, + generation, + kind, + index, + ordinal, + stable_key, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VirtualDiffStreamItem { + pub id: VirtualDiffItemId, + pub sort_key: u64, + pub estimated_height_px: u32, + pub measured_height_px: Option, +} + +impl VirtualDiffStreamItem { + pub fn new( + id: VirtualDiffItemId, + sort_key: u64, + estimated_height_px: u32, + measured_height_px: Option, + ) -> Self { + Self { + id, + sort_key, + estimated_height_px, + measured_height_px, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewportAnchorBias { + PreserveTop, + PreserveBottom, + FollowEnd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ViewportAnchor { + pub item_id: VirtualDiffItemId, + pub intra_item_offset_px: u32, + pub bias: ViewportAnchorBias, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ViewportSlotKey { + pub(super) source: WorkspaceSource, + pub(super) index: usize, + pub(super) path: String, + pub(super) left_ref: String, + pub(super) right_ref: String, + pub(super) kind: ViewportSlotKind, +} + +impl ViewportSlotKey { + pub(super) fn working_set_key(&self) -> Option { + if self.source == WorkspaceSource::None { + return None; + } + Some(WorkingSetFileKey::new( + self.index, + self.path.clone(), + self.left_ref.clone(), + self.right_ref.clone(), + )) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ViewportSlotKind { + Text { + line_count: usize, + text_len: usize, + style_run_count: usize, + syntax_covered_count: usize, + }, + Binary, + Loading, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ViewportDocumentKey { + pub(super) source: WorkspaceSource, + pub(super) generation: u64, + pub(super) start_index: usize, + pub(super) slots: Vec, +} + +#[derive(Debug, Clone)] +pub(super) struct ViewportDocumentCache { + pub(super) key: ViewportDocumentKey, + pub(super) doc: Arc, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ScrollDirection { + Backward, + Forward, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SyntaxPendingWindow { + pub(super) request_id: u64, + pub(super) window: SyntaxRowWindow, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SidebarWidthCache { + pub compare_generation: u64, + pub ui_scale_pct: u16, + pub intrinsic_width_px: f32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ViewportScrollbarMetrics { + pub content_height_px: u32, + pub viewport_height_px: u32, + pub scroll_top_px: u32, + pub max_scroll_top_px: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ViewportScrollbarDragState { + pub metrics: ViewportScrollbarMetrics, + pub file_heights_px: Vec, +} +pub(super) const FILE_HEIGHT_SPARSE_MIN_COUNT: usize = 4096; + +#[derive(Debug)] +pub(super) enum FileHeightIndex { + Empty, + Dense { + heights: Vec, + tree: Vec, + }, + Sparse { + count: usize, + default_height: u32, + total: u64, + overrides: BTreeMap, + tree: Vec, + }, +} + +impl Default for FileHeightIndex { + fn default() -> Self { + Self::Empty + } +} + +impl FileHeightIndex { + pub(super) fn rebuild(&mut self, heights: Vec) { + if heights.is_empty() { + self.clear(); + return; + } + + if let Some((default_height, overrides, total)) = sparse_height_index_parts(&heights) { + let mut tree = vec![0; heights.len() + 1]; + for (index, height) in heights.iter().copied().enumerate() { + height_tree_add(&mut tree, index, u64::from(height)); + } + *self = Self::Sparse { + count: heights.len(), + default_height, + total, + overrides, + tree, + }; + return; + } + + let mut tree = vec![0; heights.len() + 1]; + for (index, height) in heights.iter().copied().enumerate() { + dense_tree_add(&mut tree, index, height); + } + *self = Self::Dense { heights, tree }; + } + + pub(super) fn clear(&mut self) { + *self = Self::Empty; + } + + pub(super) fn len(&self) -> usize { + match self { + Self::Empty => 0, + Self::Dense { heights, .. } => heights.len(), + Self::Sparse { count, .. } => *count, + } + } + + pub(super) fn total_u64(&self) -> u64 { + match self { + Self::Empty => 0, + Self::Dense { heights, .. } => self.prefix_u64(heights.len()), + Self::Sparse { total, .. } => *total, + } + } + + pub(super) fn total_u32(&self) -> u32 { + self.total_u64().min(u64::from(u32::MAX)) as u32 + } + + pub(super) fn prefix_u32(&self, index: usize) -> u32 { + self.prefix_u64(index).min(u64::from(u32::MAX)) as u32 + } + + pub(super) fn update(&mut self, index: usize, height: u32) { + match self { + Self::Empty => {} + Self::Dense { heights, tree } => { + if index >= heights.len() { + return; + } + let old = heights[index]; + if old == height { + return; + } + heights[index] = height; + if height >= old { + dense_tree_add(tree, index, height - old); + } else { + dense_tree_sub(tree, index, old - height); + } + } + Self::Sparse { + count, + default_height, + total, + overrides, + tree, + } => { + if index >= *count { + return; + } + let old = overrides.get(&index).copied().unwrap_or(*default_height); + if old == height { + return; + } + if height == *default_height { + overrides.remove(&index); + } else { + overrides.insert(index, height); + } + *total = total + .saturating_sub(u64::from(old)) + .saturating_add(u64::from(height)); + if height >= old { + height_tree_add(tree, index, u64::from(height - old)); + } else { + height_tree_sub(tree, index, u64::from(old - height)); + } + if overrides.len() > *count / 4 { + self.promote_sparse_to_dense(); + } + } + } + } + + pub(super) fn locate(&self, target_px: u32) -> Option<(usize, u32)> { + match self { + Self::Empty => None, + Self::Dense { heights, tree } => locate_dense_height(heights, tree, target_px), + Self::Sparse { + count, total, tree, .. + } => locate_sparse_height(self, *count, *total, tree, target_px), + } + } + + pub(super) fn prefix_u64(&self, index: usize) -> u64 { + match self { + Self::Empty => 0, + Self::Dense { heights, tree } => dense_prefix_u64(heights, tree, index), + Self::Sparse { count, tree, .. } => height_tree_prefix_u64(tree, index.min(*count)), + } + } + + pub(super) fn height_at(&self, index: usize) -> u32 { + match self { + Self::Empty => 0, + Self::Dense { heights, .. } => heights.get(index).copied().unwrap_or(0), + Self::Sparse { + count, + default_height, + overrides, + .. + } => { + if index >= *count { + 0 + } else { + overrides.get(&index).copied().unwrap_or(*default_height) + } + } + } + } + + pub(super) fn promote_sparse_to_dense(&mut self) { + let Self::Sparse { + count, + default_height, + overrides, + .. + } = self + else { + return; + }; + let mut heights = vec![*default_height; *count]; + for (index, height) in overrides.iter() { + if let Some(slot) = heights.get_mut(*index) { + *slot = *height; + } + } + self.rebuild(heights); + } +} + +#[derive(Debug, Default)] +pub(super) struct VirtualDiffDocument { + pub(super) source: WorkspaceSource, + pub(super) generation: u64, + pub(super) file_count: usize, + pub(super) height_index: FileHeightIndex, +} + +impl VirtualDiffDocument { + pub(super) fn sync_identity( + &mut self, + source: WorkspaceSource, + generation: u64, + file_count: usize, + ) -> bool { + let changed = + self.source != source || self.generation != generation || self.file_count != file_count; + if changed { + self.source = source; + self.generation = generation; + self.file_count = file_count; + self.height_index.clear(); + } + changed + } + + pub(super) fn clear(&mut self) { + self.source = WorkspaceSource::None; + self.generation = 0; + self.file_count = 0; + self.height_index.clear(); + } + + pub(super) fn rebuild_heights(&mut self, heights: Vec) { + self.file_count = heights.len(); + self.height_index.rebuild(heights); + } + + pub(super) fn item_id(&self, index: usize) -> Option { + (index < self.file_count) + .then(|| VirtualDiffItemId::file(self.source, self.generation, index)) + } + + pub(super) fn anchor_is_current(&self, anchor: ViewportAnchor) -> bool { + anchor.item_id.source == self.source + && anchor.item_id.generation == self.generation + && anchor.item_id.kind == VirtualDiffItemKind::File + && anchor.item_id.index < self.file_count + } + + pub(super) fn len(&self) -> usize { + self.height_index.len() + } + + pub(super) fn total_u32(&self) -> u32 { + self.height_index.total_u32() + } + + pub(super) fn prefix_u32(&self, index: usize) -> u32 { + self.height_index.prefix_u32(index) + } + + pub(super) fn locate(&self, target_px: u32) -> Option<(usize, u32)> { + self.height_index.locate(target_px) + } + + pub(super) fn height_at(&self, index: usize) -> u32 { + self.height_index.height_at(index) + } + + pub(super) fn update_height(&mut self, index: usize, height: u32) { + self.height_index.update(index, height); + } +} + +#[derive(Debug, Default)] +pub(super) struct VirtualScrollModel { + pub(super) anchor: Option, +} + +impl VirtualScrollModel { + pub(super) fn clear(&mut self) { + self.anchor = None; + } + + pub(super) fn set_anchor(&mut self, anchor: ViewportAnchor) { + self.anchor = Some(anchor); + } +} + +const VIRTUAL_STREAM_SORT_STRIDE: u64 = 1024; +const VIRTUAL_STREAM_ROW_OFFSET: u64 = 512; +const VIRTUAL_STREAM_BLOCK_BELOW_OFFSET: u64 = 768; + +pub(super) fn virtual_row_sort_key(line_index: usize) -> u64 { + (line_index as u64) + .saturating_mul(VIRTUAL_STREAM_SORT_STRIDE) + .saturating_add(VIRTUAL_STREAM_ROW_OFFSET) +} + +pub fn virtual_block_below_sort_key(anchor_line_index: u32, block_order: usize) -> u64 { + u64::from(anchor_line_index) + .saturating_mul(VIRTUAL_STREAM_SORT_STRIDE) + .saturating_add(VIRTUAL_STREAM_BLOCK_BELOW_OFFSET) + .saturating_add(block_order.min(255) as u64) +} + +pub fn stable_virtual_key(text: &str) -> u64 { + let mut key = 0xcbf2_9ce4_8422_2325_u64; + for byte in text.as_bytes() { + key ^= u64::from(*byte); + key = key.wrapping_mul(0x100_0000_01b3); + } + key +} + +pub(super) fn estimated_virtual_item_height_px(kind: VirtualDiffItemKind) -> u32 { + match kind { + VirtualDiffItemKind::File => 192, + VirtualDiffItemKind::FileHeader => 40, + VirtualDiffItemKind::Hunk => 28, + VirtualDiffItemKind::DiffRow => 24, + VirtualDiffItemKind::ReviewThread => 160, + VirtualDiffItemKind::ReviewComment => 96, + VirtualDiffItemKind::Composer => 248, + VirtualDiffItemKind::LoadingPlaceholder => 48, + } +} + +pub(super) fn virtual_row_stable_key(line: &RenderLine, local_ordinal: u32) -> u64 { + let mut key = u64::from(line.kind); + key = key + .wrapping_mul(1_099_511_628_211) + .wrapping_add(line.hunk_index as i64 as u64); + key = key + .wrapping_mul(1_099_511_628_211) + .wrapping_add(u64::from(line.old_line_no)); + key = key + .wrapping_mul(1_099_511_628_211) + .wrapping_add(u64::from(line.new_line_no)); + key = key + .wrapping_mul(1_099_511_628_211) + .wrapping_add(line.line_index as i64 as u64); + key.wrapping_mul(1_099_511_628_211) + .wrapping_add(u64::from(local_ordinal)) +} + +fn sparse_height_index_parts(heights: &[u32]) -> Option<(u32, BTreeMap, u64)> { + if heights.len() < FILE_HEIGHT_SPARSE_MIN_COUNT { + return None; + } + let default_height = most_common_height(heights); + let mut overrides = BTreeMap::new(); + let mut total = 0_u64; + for (index, height) in heights.iter().copied().enumerate() { + total = total.saturating_add(u64::from(height)); + if height != default_height { + overrides.insert(index, height); + } + } + + if overrides.len() <= heights.len() / 4 { + Some((default_height, overrides, total)) + } else { + None + } +} + +fn most_common_height(heights: &[u32]) -> u32 { + let mut counts: HashMap = HashMap::new(); + let mut best_height = heights[0]; + let mut best_count = 0; + for height in heights { + let count = counts + .entry(*height) + .and_modify(|count| *count += 1) + .or_insert(1); + if *count > best_count { + best_height = *height; + best_count = *count; + } + } + best_height +} + +fn dense_tree_add(tree: &mut [u32], index: usize, delta: u32) { + let mut idx = index + 1; + while idx < tree.len() { + tree[idx] = tree[idx].saturating_add(delta); + idx += idx & idx.wrapping_neg(); + } +} + +fn dense_tree_sub(tree: &mut [u32], index: usize, delta: u32) { + let mut idx = index + 1; + while idx < tree.len() { + tree[idx] = tree[idx].saturating_sub(delta); + idx += idx & idx.wrapping_neg(); + } +} + +fn height_tree_add(tree: &mut [u64], index: usize, delta: u64) { + let mut idx = index + 1; + while idx < tree.len() { + tree[idx] = tree[idx].saturating_add(delta); + idx += idx & idx.wrapping_neg(); + } +} + +fn height_tree_sub(tree: &mut [u64], index: usize, delta: u64) { + let mut idx = index + 1; + while idx < tree.len() { + tree[idx] = tree[idx].saturating_sub(delta); + idx += idx & idx.wrapping_neg(); + } +} + +fn dense_prefix_u64(heights: &[u32], tree: &[u32], index: usize) -> u64 { + let mut idx = index.min(heights.len()); + let mut sum = 0_u64; + while idx > 0 { + sum = sum.saturating_add(u64::from(tree[idx])); + idx &= idx - 1; + } + sum +} + +fn height_tree_prefix_u64(tree: &[u64], index: usize) -> u64 { + let mut idx = index.min(tree.len().saturating_sub(1)); + let mut sum = 0_u64; + while idx > 0 { + sum = sum.saturating_add(tree[idx]); + idx &= idx - 1; + } + sum +} + +fn locate_dense_height(heights: &[u32], tree: &[u32], target_px: u32) -> Option<(usize, u32)> { + if heights.is_empty() { + return None; + } + let target = u64::from(target_px); + let total = dense_prefix_u64(heights, tree, heights.len()); + if target >= total { + let index = heights.len() - 1; + return Some((index, heights[index].saturating_sub(1))); + } + + let mut idx = 0_usize; + let mut bit = 1_usize; + while bit < tree.len() { + bit <<= 1; + } + let mut sum = 0_u64; + while bit > 0 { + let next = idx + bit; + if next < tree.len() { + let next_sum = sum.saturating_add(u64::from(tree[next])); + if next_sum <= target { + idx = next; + sum = next_sum; + } + } + bit >>= 1; + } + let index = idx.min(heights.len().saturating_sub(1)); + Some(( + index, + target.saturating_sub(sum).min(u64::from(u32::MAX)) as u32, + )) +} + +fn locate_sparse_height( + index: &FileHeightIndex, + count: usize, + total: u64, + tree: &[u64], + target_px: u32, +) -> Option<(usize, u32)> { + if count == 0 { + return None; + } + let target = u64::from(target_px); + if target >= total { + let slot = count - 1; + return Some((slot, index.height_at(slot).saturating_sub(1))); + } + + let mut slot = 0_usize; + let mut bit = 1_usize; + while bit < tree.len() { + bit <<= 1; + } + let mut sum = 0_u64; + while bit > 0 { + let next = slot + bit; + if next < tree.len() { + let next_sum = sum.saturating_add(tree[next]); + if next_sum <= target { + slot = next; + sum = next_sum; + } + } + bit >>= 1; + } + let slot = slot.min(count.saturating_sub(1)); + Some(( + slot, + target.saturating_sub(sum).min(u64::from(u32::MAX)) as u32, + )) +} + +pub(super) const CONTINUOUS_BOTTOM_ANCHOR_TOLERANCE_PX: u32 = 2; + +pub(super) fn apply_scroll_delta_px(current: u32, delta: i32, max: u32) -> u32 { + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta as u32) + }; + next.min(max) +} + +impl AppState { + pub(super) fn active_file_slot_key( + &self, + source: WorkspaceSource, + active: &ActiveFile, + ) -> ViewportSlotKey { + let kind = if active.carbon_file.is_binary { + ViewportSlotKind::Binary + } else { + ViewportSlotKind::Text { + line_count: active.render_doc.lines.len(), + text_len: active.render_doc.text_bytes.len(), + style_run_count: active.render_doc.style_runs.len(), + syntax_covered_count: active.syntax_covered.len(), + } + }; + ViewportSlotKey { + source, + index: active.index, + path: active.path.clone(), + left_ref: active.left_ref.clone(), + right_ref: active.right_ref.clone(), + kind, + } + } + + pub(super) fn loading_slot_key( + &self, + source: WorkspaceSource, + index: usize, + path: &str, + left_ref: String, + right_ref: String, + ) -> ViewportSlotKey { + ViewportSlotKey { + source, + index, + path: path.to_owned(), + left_ref, + right_ref, + kind: ViewportSlotKind::Loading, + } + } + + pub(super) fn compare_slot_key_at(&self, index: usize, path: &str) -> ViewportSlotKey { + let source = match self.workspace.source.get(&self.store) { + WorkspaceSource::TextCompare => WorkspaceSource::TextCompare, + _ => WorkspaceSource::Compare, + }; + let (left_ref, right_ref) = self.compare_refs(); + if let Some(key) = self.workspace.active_file.with(&self.store, |file| { + file.as_ref() + .filter(|file| { + file.index == index + && file.path == path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + .map(|file| self.active_file_slot_key(source, file)) + }) { + return key; + } + if let Some(key) = self.workspace.file_cache.with(&self.store, |files| { + files + .get(&index) + .filter(|file| { + file.index == index + && file.path == path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + .map(|file| self.active_file_slot_key(source, file)) + }) { + return key; + } + self.loading_slot_key(source, index, path, left_ref, right_ref) + } + + pub(super) fn status_slot_key_at(&self, index: usize, change: &FileChange) -> ViewportSlotKey { + let (left_ref, right_ref) = self.status_refs_for_bucket(change.bucket); + if let Some(key) = self.workspace.active_file.with(&self.store, |file| { + file.as_ref() + .filter(|file| { + file.index == index + && file.path == change.path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + .map(|file| self.active_file_slot_key(WorkspaceSource::Status, file)) + }) { + return key; + } + if let Some(key) = self.workspace.file_cache.with(&self.store, |files| { + files + .get(&index) + .filter(|file| { + file.index == index + && file.path == change.path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + .map(|file| self.active_file_slot_key(WorkspaceSource::Status, file)) + }) { + return key; + } + self.loading_slot_key( + WorkspaceSource::Status, + index, + &change.path, + left_ref, + right_ref, + ) + } + + pub(super) fn append_viewport_slot_doc( + &self, + out: &mut RenderDoc, + key: &ViewportSlotKey, + loading_message: &str, + ) { + if let ViewportSlotKind::Loading = key.kind { + out.append_doc(&build_placeholder_render_doc(&key.path, loading_message)); + return; + } + + let mut appended = false; + self.workspace.active_file.with(&self.store, |file| { + let Some(active) = file.as_ref() else { + return; + }; + if active.index == key.index + && active.path == key.path + && active.left_ref == key.left_ref + && active.right_ref == key.right_ref + { + append_active_file_doc(out, active); + appended = true; + } + }); + if appended { + return; + } + + self.workspace.file_cache.with(&self.store, |files| { + let Some(active) = files.get(&key.index).filter(|active| { + active.index == key.index + && active.path == key.path + && active.left_ref == key.left_ref + && active.right_ref == key.right_ref + }) else { + return; + }; + append_active_file_doc(out, active); + appended = true; + }); + + if !appended { + out.append_doc(&build_placeholder_render_doc(&key.path, loading_message)); + } + } + + pub(super) fn viewport_slot_syntax_window( + &self, + key: &ViewportSlotKey, + slot_top_px: u32, + slot_height_px: u32, + viewport_top_px: u32, + viewport_height_px: u32, + ) -> Option { + let ViewportSlotKind::Text { line_count, .. } = key.kind else { + return None; + }; + if line_count == 0 { + return None; + } + + let slot_bottom_px = slot_top_px.saturating_add(slot_height_px.max(1)); + let viewport_bottom_px = viewport_top_px.saturating_add(viewport_height_px.max(1)); + let visible_top_px = slot_top_px.max(viewport_top_px); + let visible_bottom_px = slot_bottom_px.min(viewport_bottom_px); + if visible_bottom_px <= visible_top_px { + return None; + } + + let row_height_q16 = self.workspace.measured_px_per_row_q16.get(&self.store); + let row_height_q16 = if row_height_q16 == 0 { + 24_u32 << 16 + } else { + row_height_q16 + }; + let row_height_q16 = u64::from(row_height_q16.max(1)); + let start_px = visible_top_px.saturating_sub(slot_top_px); + let end_px = visible_bottom_px.saturating_sub(slot_top_px); + let row_floor = |px: u32| ((u64::from(px) << 16) / row_height_q16) as usize; + let row_ceil = |px: u32| { + (((u64::from(px) << 16).saturating_add(row_height_q16 - 1)) / row_height_q16) as usize + }; + + let start = row_floor(start_px) + .saturating_sub(SYNTAX_OVERSCAN_ROWS) + .min(line_count); + let mut end = row_ceil(end_px) + .saturating_add(SYNTAX_OVERSCAN_ROWS) + .min(line_count); + if end <= start { + end = start.saturating_add(SYNTAX_INITIAL_ROWS).min(line_count); + } + Some(SyntaxRowWindow { start, end }) + } + + pub(super) fn request_viewport_slot_syntax_window( + &mut self, + key: &ViewportSlotKey, + window: SyntaxRowWindow, + ) -> Option { + if window.end <= window.start { + return None; + } + if !self.syntax_request_budget_available() { + return None; + } + let repo_path = self.compare.repo_path.get(&self.store)?; + let generation = self.active_syntax_generation(); + let syntax_epoch = self.syntax_requests.epoch(); + let mut request = None; + let request_id = self.syntax_requests.next_request_id(); + let mut matched_active = false; + let mut active_to_cache = None; + + self.workspace.active_file.update(&self.store, |slot| { + let Some(active) = slot.as_mut() else { + return; + }; + if active.index != key.index + || active.path != key.path + || active.left_ref != key.left_ref + || active.right_ref != key.right_ref + { + return; + } + matched_active = true; + if let Some(next_request) = request_syntax_for_active_file( + active, + repo_path.clone(), + generation, + syntax_epoch, + window, + request_id, + ) { + active_to_cache = Some(active.clone()); + request = Some(next_request); + } + }); + if let Some(active_file) = active_to_cache { + self.cache_active_file(active_file); + } + if matched_active { + if let Some(request) = request { + self.track_syntax_request(&request); + return Some( + SyntaxEffect::LoadFileSyntax(Task { + generation, + request, + }) + .into(), + ); + } + return None; + } + + let request_id = self.syntax_requests.next_request_id(); + self.workspace.file_cache.update(&self.store, |files| { + let Some(active) = files.get_mut(&key.index).filter(|active| { + active.index == key.index + && active.path == key.path + && active.left_ref == key.left_ref + && active.right_ref == key.right_ref + }) else { + return; + }; + request = request_syntax_for_active_file( + active, + repo_path, + generation, + syntax_epoch, + window, + request_id, + ); + }); + + request.map(|request| { + self.track_syntax_request(&request); + SyntaxEffect::LoadFileSyntax(Task { + generation, + request, + }) + .into() + }) + } + + pub(super) fn ensure_compare_file_cached_for_viewport( + &mut self, + index: usize, + path: &str, + priority: CompareWorkPriority, + ) -> Vec { + if self.cached_compare_file_at(index, path).is_some() { + return Vec::new(); + } + if self.workspace.source.get(&self.store) == WorkspaceSource::TextCompare { + if self.cache_compare_file_from_output(index, path).is_some() { + return vec![ + SyntaxEffect::EnsureSyntaxPackForPath { + path: path.to_owned(), + } + .into(), + ]; + } + return Vec::new(); + } + if !self.compare_file_is_large(index) + && self.cache_compare_file_from_output(index, path).is_some() + { + return vec![ + SyntaxEffect::EnsureSyntaxPackForPath { + path: path.to_owned(), + } + .into(), + ]; + } + if !self.should_enqueue_file_load(index, path, priority) { + return Vec::new(); + } + + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + let deferred_file = self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .and_then(|output| compare_output_deferred_summary(output, index)) + .filter(|summary| summary.path() == path) + }); + self.mark_file_cache_loading(index, path.to_owned(), priority); + vec![ + SyntaxEffect::EnsureSyntaxPackForPath { + path: path.to_owned(), + } + .into(), + CompareEffect::LoadFile(Task { + generation: self.workspace.compare_generation.get(&self.store), + request: CompareFileRequest { + repo_path, + request: vcs_compare_request( + self.compare.mode.get(&self.store), + self.compare.left_ref.get(&self.store), + self.compare.right_ref.get(&self.store), + self.compare.layout.get(&self.store), + self.compare.renderer.get(&self.store), + ), + path: path.to_owned(), + index, + deferred_file, + priority, + }, + }) + .into(), + ] + } + + pub(super) fn ensure_status_file_cached_for_viewport(&mut self, index: usize) -> Vec { + let Some(file_change) = self + .workspace + .status_file_changes + .with(&self.store, |changes| changes.get(index).cloned()) + else { + return Vec::new(); + }; + if self.cached_status_file_at(index, &file_change).is_some() { + return Vec::new(); + } + if !self.should_enqueue_file_load( + index, + &file_change.path, + CompareWorkPriority::VisibleViewportDiff, + ) { + return Vec::new(); + } + + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + self.mark_file_cache_loading( + index, + file_change.path.clone(), + CompareWorkPriority::VisibleViewportDiff, + ); + let generation = self.workspace.status_generation.get(&self.store); + let renderer = self.compare.renderer.get(&self.store); + vec![ + ensure_syntax_packs_for_file_change_effect(&file_change), + RepositoryEffect::LoadStatusDiff { + task: Task { + generation, + request: StatusDiffRequest { + repo_path, + file_change, + renderer, + }, + }, + index, + } + .into(), + ] + } +} + +impl AppState { + pub(super) fn scroll_viewport_lines(&mut self, delta_lines: i32) -> Vec { + let step_px = 20_i32; + let delta_px = delta_lines.saturating_mul(step_px); + self.scroll_viewport_px(delta_px) + } + + pub(super) fn scroll_viewport_px(&mut self, delta_px: i32) -> Vec { + if !self.settings.continuous_scroll { + let current = self.editor.scroll_top_px.get(&self.store); + let max = self.editor_max_scroll_top_px(); + let next = apply_scroll_delta_px(current, delta_px, max); + self.editor.scroll_top_px.set(&self.store, next); + return Vec::new(); + } + + if delta_px == 0 { + return Vec::new(); + } + + let current = self.workspace.global_scroll_top_px.get(&self.store); + let target = apply_scroll_delta_px(current, delta_px, self.global_max_scroll_top_px()); + self.scroll_viewport_to_global(target) + } + + pub(super) fn clear_file_scroll_layout(&mut self) { + self.workspace + .file_content_heights + .set(&self.store, Vec::new()); + self.workspace + .file_scroll_total_height_px + .set(&self.store, 0); + self.workspace + .pending_file_content_heights + .set(&self.store, HashMap::new()); + self.workspace + .file_scroll_recompute_pending + .set(&self.store, false); + self.workspace + .viewport_scrollbar_drag + .set(&self.store, None); + self.virtual_diff_document.clear(); + self.virtual_scroll.clear(); + self.last_virtual_scroll_top_px = None; + } + + pub(super) fn reset_file_scroll_layout(&mut self) { + self.workspace + .file_content_heights + .set(&self.store, Vec::new()); + self.workspace + .pending_file_content_heights + .set(&self.store, HashMap::new()); + self.workspace + .file_scroll_recompute_pending + .set(&self.store, false); + self.workspace + .viewport_scrollbar_drag + .set(&self.store, None); + self.virtual_scroll.clear(); + self.last_virtual_scroll_top_px = None; + self.recompute_file_scroll_total_height_px(); + } + + pub fn recompute_file_scroll_total_height_px(&mut self) { + let count = self.workspace_file_count(); + let source = self.workspace.source.get(&self.store); + let generation = self.workspace_render_generation(); + if self + .virtual_diff_document + .sync_identity(source, generation, count) + { + self.virtual_scroll.clear(); + self.last_virtual_scroll_top_px = None; + } + self.workspace + .file_content_heights + .update(&self.store, |heights| { + if heights.len() > count { + heights.truncate(count); + } + }); + + let heights = (0..count) + .map(|index| self.file_scroll_height_px(index).max(1)) + .collect::>(); + self.virtual_diff_document.rebuild_heights(heights); + let total = self.virtual_diff_document.total_u32(); + self.workspace + .file_scroll_total_height_px + .set(&self.store, total); + } + + pub(super) fn update_file_scroll_heights(&mut self, old_heights: Vec<(usize, u32)>) { + let count = self.workspace_file_count(); + if self.virtual_diff_document.len() != count { + self.recompute_file_scroll_total_height_px(); + return; + } + + let mut total = self.workspace.file_scroll_total_height_px.get(&self.store); + for (index, old_height) in old_heights { + if index >= count { + continue; + } + let new_height = self.file_scroll_height_px(index).max(1); + total = total.saturating_sub(old_height).saturating_add(new_height); + self.virtual_diff_document.update_height(index, new_height); + } + self.workspace + .file_scroll_total_height_px + .set(&self.store, total); + } + + pub fn update_file_content_height_px(&mut self, index: usize, height: u32) -> bool { + let count = self.workspace_file_count(); + if index >= count || height == 0 { + return false; + } + if self.settings.continuous_scroll + && self + .workspace + .viewport_scrollbar_drag + .get(&self.store) + .is_some() + { + self.workspace + .pending_file_content_heights + .update(&self.store, |pending| { + pending.insert(index, height); + }); + return false; + } + if self.virtual_diff_document.len() != count { + self.recompute_file_scroll_total_height_px(); + } + + let old_slot_height = self.file_scroll_height_px(index); + let old_total = self.total_diff_height_px(); + let anchor = self + .settings + .continuous_scroll + .then(|| self.current_or_derived_viewport_anchor()) + .flatten(); + let row_count = self.workspace_file_row_count(index); + let mut recorded_changed = false; + self.workspace + .file_content_heights + .update(&self.store, |heights| { + if heights.len() < count { + heights.resize(count, None); + } + if heights[index] != Some(height) { + heights[index] = Some(height); + recorded_changed = true; + } + }); + + let mut calibration_initialized = false; + if let Some(rows) = row_count + && rows > 0 + { + let sample_q16 = (u64::from(height) << 16) / u64::from(rows); + let prev = self.workspace.measured_px_per_row_q16.get(&self.store); + let next = if prev == 0 { + calibration_initialized = true; + sample_q16 as u32 + } else { + (((u64::from(prev) * 7) + sample_q16) / 8) as u32 + }; + self.workspace + .measured_px_per_row_q16 + .set(&self.store, next); + } + + if calibration_initialized { + self.recompute_file_scroll_total_height_px(); + } + + if recorded_changed { + let new_slot_height = self.file_scroll_height_px(index); + let slot_height_changed = new_slot_height != old_slot_height; + if calibration_initialized { + self.workspace + .file_scroll_total_height_px + .set(&self.store, self.virtual_diff_document.total_u32()); + } else { + let next_total = old_total + .saturating_sub(old_slot_height) + .saturating_add(new_slot_height); + self.workspace + .file_scroll_total_height_px + .set(&self.store, next_total); + self.virtual_diff_document + .update_height(index, new_slot_height.max(1)); + } + + if self.settings.continuous_scroll + && slot_height_changed + && let Some(anchor) = anchor + { + self.rebase_viewport_anchor(anchor); + } + } + + recorded_changed && old_slot_height != self.file_scroll_height_px(index) + } + + pub fn update_virtual_diff_item_height_px( + &mut self, + item_id: VirtualDiffItemId, + height: u32, + ) -> bool { + if item_id.kind != VirtualDiffItemKind::File + || item_id.source != self.workspace.source.get(&self.store) + || item_id.generation != self.workspace_render_generation() + { + return false; + } + self.update_file_content_height_px(item_id.index, height) + } + + pub fn virtual_stream_item( + &self, + file_index: usize, + kind: VirtualDiffItemKind, + ordinal: u32, + stable_key: u64, + sort_key: u64, + measured_height_px: Option, + ) -> VirtualDiffStreamItem { + VirtualDiffStreamItem::new( + VirtualDiffItemId::new( + self.workspace.source.get(&self.store), + self.workspace_render_generation(), + kind, + file_index, + ordinal, + stable_key, + ), + sort_key, + measured_height_px.unwrap_or_else(|| estimated_virtual_item_height_px(kind)), + measured_height_px, + ) + } + + pub(super) fn virtual_stream_items_for_viewport_doc( + &self, + source: WorkspaceSource, + generation: u64, + slots: &[ViewportSlotKey], + doc: &RenderDoc, + ) -> Vec { + let mut items = Vec::new(); + let mut slot_pos = None::; + let mut local_ordinal = 0_u32; + + for (line_index, line) in doc.lines.iter().enumerate() { + if line.row_kind() == RenderRowKind::FileHeader { + slot_pos = Some(slot_pos.map_or(0, |pos| pos.saturating_add(1))); + local_ordinal = 0; + } + + let Some(slot) = slot_pos.and_then(|pos| slots.get(pos)) else { + continue; + }; + let Some(kind) = virtual_stream_item_kind(slot, line) else { + continue; + }; + let ordinal = match kind { + VirtualDiffItemKind::FileHeader => 0, + VirtualDiffItemKind::Hunk if line.hunk_index >= 0 => line.hunk_index as u32, + _ => local_ordinal, + }; + + items.push(VirtualDiffStreamItem::new( + VirtualDiffItemId::new( + source, + generation, + kind, + slot.index, + ordinal, + virtual_row_stable_key(line, ordinal), + ), + virtual_row_sort_key(line_index), + estimated_virtual_item_height_px(kind), + None, + )); + local_ordinal = local_ordinal.saturating_add(1); + } + + items + } + + pub(super) fn file_scroll_height_px(&self, index: usize) -> u32 { + self.workspace + .file_content_heights + .with(&self.store, |heights| heights.get(index).copied().flatten()) + .unwrap_or_else(|| self.estimated_file_height_px(index)) + } + + pub(super) fn viewport_file_scroll_height_px(&self, index: usize) -> u32 { + if let Some(height) = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| { + drag.as_ref() + .and_then(|drag| drag.file_heights_px.get(index).copied()) + }) + { + return height; + } + self.file_scroll_height_px(index) + } + + pub fn estimated_file_height_px(&self, index: usize) -> u32 { + const BASELINE_ROWS: u32 = 8; + let row_height_q16 = { + let cal = self.workspace.measured_px_per_row_q16.get(&self.store); + if cal == 0 { 24_u32 << 16 } else { cal } + }; + let row_height_px = + |rows: u32| ((u64::from(rows) * u64::from(row_height_q16)) >> 16) as u32; + + if matches!( + self.workspace.source.get(&self.store), + WorkspaceSource::Compare | WorkspaceSource::TextCompare + ) && let Some(rows) = self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .and_then(|output| output.carbon.files.get(index)) + .map(estimated_carbon_file_rows_with_overhead) + }) { + return row_height_px(rows); + } + + let line_count = match self.workspace.source.get(&self.store) { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + if index < self.workspace_file_count() { + let meta = self.file_list_entry_meta(index); + meta.additions.saturating_add(meta.deletions).max(1) as u32 + BASELINE_ROWS + } else { + BASELINE_ROWS + } + } + WorkspaceSource::Status => BASELINE_ROWS, + WorkspaceSource::None => BASELINE_ROWS, + }; + row_height_px(line_count) + } + + pub(super) fn workspace_file_row_count(&self, index: usize) -> Option { + if !matches!( + self.workspace.source.get(&self.store), + WorkspaceSource::Compare | WorkspaceSource::TextCompare + ) { + return None; + } + self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .and_then(|output| output.carbon.files.get(index)) + .map(estimated_carbon_file_rows_with_overhead) + }) + } + + pub fn total_diff_height_px(&self) -> u32 { + if let Some(total) = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| { + drag.as_ref().map(|drag| drag.metrics.content_height_px) + }) + { + return total; + } + let cached = self.workspace.file_scroll_total_height_px.get(&self.store); + if cached > 0 || self.workspace_file_count() == 0 { + return cached; + } + + self.virtual_diff_document.total_u32() + } + + pub fn file_start_offset_px(&self, index: usize) -> u32 { + if self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| drag.is_none()) + && self.virtual_diff_document.len() == self.workspace_file_count() + { + return self.virtual_diff_document.prefix_u32(index); + } + let mut total: u32 = 0; + for slot in 0..index.min(self.workspace_file_count()) { + total = total.saturating_add(self.viewport_file_scroll_height_px(slot)); + } + total + } + + pub fn global_max_scroll_top_px(&self) -> u32 { + if let Some(max) = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| { + drag.as_ref().map(|drag| drag.metrics.max_scroll_top_px) + }) + { + return max; + } + let viewport = self.editor.viewport_height_px.get(&self.store); + self.total_diff_height_px().saturating_sub(viewport.max(1)) + } + + pub(super) fn viewport_anchor_bias_for_global(&self, scroll_top_px: u32) -> ViewportAnchorBias { + let max = self.global_max_scroll_top_px(); + if max > 0 && scroll_top_px.saturating_add(CONTINUOUS_BOTTOM_ANCHOR_TOLERANCE_PX) >= max { + ViewportAnchorBias::FollowEnd + } else { + ViewportAnchorBias::PreserveTop + } + } + + pub(super) fn viewport_anchor_for_file_offset( + &self, + index: usize, + local_offset_px: u32, + bias: ViewportAnchorBias, + ) -> Option { + let item_id = self.virtual_diff_document.item_id(index)?; + Some(ViewportAnchor { + item_id, + intra_item_offset_px: local_offset_px, + bias, + }) + } + + pub(super) fn viewport_anchor_for_global( + &self, + scroll_top_px: u32, + bias: ViewportAnchorBias, + ) -> Option { + let target_px = match bias { + ViewportAnchorBias::PreserveBottom => { + scroll_top_px.saturating_add(self.editor.viewport_height_px.get(&self.store).max(1)) + } + ViewportAnchorBias::PreserveTop | ViewportAnchorBias::FollowEnd => scroll_top_px, + }; + let (index, local_offset_px) = self.locate_global_scroll_px(target_px)?; + self.viewport_anchor_for_file_offset(index, local_offset_px, bias) + } + + pub(super) fn current_or_derived_viewport_anchor(&self) -> Option { + if let Some(anchor) = self.virtual_scroll.anchor + && self.virtual_diff_document.anchor_is_current(anchor) + { + return Some(anchor); + } + let scroll_top_px = self.workspace.global_scroll_top_px.get(&self.store); + let bias = self.viewport_anchor_bias_for_global(scroll_top_px); + self.viewport_anchor_for_global(scroll_top_px, bias) + } + + pub(super) fn scroll_top_for_viewport_anchor(&self, anchor: ViewportAnchor) -> Option { + if !self.virtual_diff_document.anchor_is_current(anchor) { + return None; + } + if anchor.bias == ViewportAnchorBias::FollowEnd { + return Some(self.global_max_scroll_top_px()); + } + + let index = anchor.item_id.index; + let item_height = self + .viewport_file_scroll_height_px(index) + .max(self.virtual_diff_document.height_at(index)) + .max(1); + let local_offset = anchor + .intra_item_offset_px + .min(item_height.saturating_sub(1)); + let item_top = self.file_start_offset_px(index); + let target = match anchor.bias { + ViewportAnchorBias::PreserveTop => item_top.saturating_add(local_offset), + ViewportAnchorBias::PreserveBottom => item_top + .saturating_add(local_offset) + .saturating_sub(self.editor.viewport_height_px.get(&self.store).max(1)), + ViewportAnchorBias::FollowEnd => unreachable!(), + }; + Some(target.min(self.global_max_scroll_top_px())) + } + + pub(super) fn set_viewport_anchor(&mut self, anchor: ViewportAnchor) { + if let Some(scroll_top_px) = self.scroll_top_for_viewport_anchor(anchor) { + self.workspace + .global_scroll_top_px + .set(&self.store, scroll_top_px); + self.virtual_scroll.set_anchor(anchor); + } else { + self.virtual_scroll.clear(); + self.clamp_global_scroll_top_px(); + } + } + + pub(super) fn set_viewport_anchor_for_global( + &mut self, + scroll_top_px: u32, + bias: ViewportAnchorBias, + ) { + if let Some(anchor) = self.viewport_anchor_for_global(scroll_top_px, bias) { + self.set_viewport_anchor(anchor); + } else { + self.virtual_scroll.clear(); + self.workspace.global_scroll_top_px.set(&self.store, 0); + } + } + + pub(super) fn rebase_viewport_anchor(&mut self, anchor: ViewportAnchor) { + self.set_viewport_anchor(anchor); + } + + pub(super) fn clamp_global_scroll_top_px(&mut self) { + if let Some(anchor) = self.virtual_scroll.anchor + && let Some(scroll_top_px) = self.scroll_top_for_viewport_anchor(anchor) + { + self.workspace + .global_scroll_top_px + .set(&self.store, scroll_top_px); + return; + } + let max = self.global_max_scroll_top_px(); + let current = self.workspace.global_scroll_top_px.get(&self.store); + self.workspace + .global_scroll_top_px + .set(&self.store, current.min(max)); + } + + pub(super) fn locate_global_scroll_px(&self, target_px: u32) -> Option<(usize, u32)> { + let count = self.workspace_file_count(); + if count == 0 { + return None; + } + if self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| drag.is_none()) + && self.virtual_diff_document.len() == count + { + return self.virtual_diff_document.locate(target_px); + } + let mut prior: u32 = 0; + for index in 0..count { + let height = self.viewport_file_scroll_height_px(index).max(1); + let next_prior = prior.saturating_add(height); + if target_px < next_prior || index + 1 == count { + return Some((index, target_px.saturating_sub(prior))); + } + prior = next_prior; + } + Some((count - 1, 0)) + } + + pub(super) fn scroll_viewport_to_global(&mut self, target_px: u32) -> Vec { + if self.virtual_diff_document.len() != self.workspace_file_count() { + self.recompute_file_scroll_total_height_px(); + } + let target_px = target_px.min(self.global_max_scroll_top_px()); + let bias = self.viewport_anchor_bias_for_global(target_px); + self.set_viewport_anchor_for_global(target_px, bias); + let target_px = self.workspace.global_scroll_top_px.get(&self.store); + let Some((target_index, local_offset)) = self.locate_global_scroll_px(target_px) else { + self.workspace.global_scroll_top_px.set(&self.store, 0); + self.virtual_scroll.clear(); + return Vec::new(); + }; + self.workspace + .global_scroll_top_px + .set(&self.store, target_px); + self.workspace + .viewport_scrollbar_drag + .update(&self.store, |drag| { + if let Some(drag) = drag.as_mut() { + drag.metrics.scroll_top_px = target_px.min(drag.metrics.max_scroll_top_px); + } + }); + + let dragging_scrollbar = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| drag.is_some()); + let mut effects = if dragging_scrollbar { + Vec::new() + } else if self.active_file_matches_workspace_file(target_index) { + Vec::new() + } else { + self.select_file_inner(target_index, true) + }; + + let local_max = self.editor_max_scroll_top_px(); + self.editor + .scroll_top_px + .set(&self.store, local_offset.min(local_max)); + if !dragging_scrollbar { + effects.extend(self.request_active_file_syntax_effect()); + } + effects + } + + pub fn global_scroll_position_px(&self) -> u32 { + self.workspace.global_scroll_top_px.get(&self.store) + } + + pub fn continuous_viewport_scrollbar_metrics(&self) -> ViewportScrollbarMetrics { + if let Some(metrics) = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| drag.as_ref().map(|drag| drag.metrics)) + { + return metrics; + } + let viewport_height_px = self.editor.viewport_height_px.get(&self.store); + let content_height_px = self.total_diff_height_px(); + ViewportScrollbarMetrics { + content_height_px, + viewport_height_px, + scroll_top_px: self.global_scroll_position_px(), + max_scroll_top_px: content_height_px.saturating_sub(viewport_height_px.max(1)), + } + } + + pub fn begin_viewport_scrollbar_drag( + &mut self, + content_height_px: u32, + viewport_height_px: u32, + scroll_top_px: u32, + max_scroll_top_px: u32, + ) { + if !self.settings.continuous_scroll { + return; + } + let file_heights_px = (0..self.workspace_file_count()) + .map(|index| self.file_scroll_height_px(index).max(1)) + .collect(); + self.workspace.viewport_scrollbar_drag.set( + &self.store, + Some(ViewportScrollbarDragState { + metrics: ViewportScrollbarMetrics { + content_height_px, + viewport_height_px, + scroll_top_px: scroll_top_px.min(max_scroll_top_px), + max_scroll_top_px, + }, + file_heights_px, + }), + ); + } + + pub fn end_viewport_scrollbar_drag(&mut self) { + self.workspace + .viewport_scrollbar_drag + .set(&self.store, None); + self.apply_pending_file_scroll_updates(); + } + + pub(super) fn apply_pending_file_scroll_updates(&mut self) { + let pending_heights = self + .workspace + .pending_file_content_heights + .with(&self.store, |pending| pending.clone()); + self.workspace + .pending_file_content_heights + .set(&self.store, HashMap::new()); + for (index, height) in pending_heights { + self.update_file_content_height_px(index, height); + } + if self + .workspace + .file_scroll_recompute_pending + .get(&self.store) + { + self.workspace + .file_scroll_recompute_pending + .set(&self.store, false); + self.recompute_file_scroll_total_height_px(); + self.clamp_global_scroll_top_px(); + } + } + + pub fn sync_editor_scroll_from_global(&mut self) -> Vec { + if !self.settings.continuous_scroll { + return Vec::new(); + } + self.clamp_global_scroll_top_px(); + let target = self.workspace.global_scroll_top_px.get(&self.store); + let Some((_, local_offset)) = self.locate_global_scroll_px(target) else { + self.workspace.global_scroll_top_px.set(&self.store, 0); + self.virtual_scroll.clear(); + return Vec::new(); + }; + let max = self.editor_max_scroll_top_px(); + self.editor + .scroll_top_px + .set(&self.store, local_offset.min(max)); + Vec::new() + } + + pub fn sync_global_scroll_from_editor(&mut self) { + let Some(selected_index) = self.reconcile_selected_file_index_from_path() else { + self.workspace.global_scroll_top_px.set(&self.store, 0); + self.virtual_scroll.clear(); + return; + }; + let start = self.file_start_offset_px(selected_index); + let local = self.editor.scroll_top_px.get(&self.store); + let target = start + .saturating_add(local) + .min(self.global_max_scroll_top_px()); + self.workspace.global_scroll_top_px.set(&self.store, target); + if self.settings.continuous_scroll { + if let Some(anchor) = self.viewport_anchor_for_file_offset( + selected_index, + local, + self.viewport_anchor_bias_for_global(target), + ) { + self.virtual_scroll.set_anchor(anchor); + } else { + self.virtual_scroll.clear(); + } + } + } + + pub fn build_continuous_viewport_document( + &mut self, + ) -> (Option, Vec) { + if !self.settings.continuous_scroll { + return (None, Vec::new()); + } + if self.virtual_diff_document.len() != self.workspace_file_count() { + self.recompute_file_scroll_total_height_px(); + } + self.clamp_global_scroll_top_px(); + let scroll_top_px = self.workspace.global_scroll_top_px.get(&self.store); + let scroll_direction = match self.last_virtual_scroll_top_px { + Some(previous) if scroll_top_px < previous => ScrollDirection::Backward, + _ => ScrollDirection::Forward, + }; + self.last_virtual_scroll_top_px = Some(scroll_top_px); + let Some((anchor_index, _)) = self.locate_global_scroll_px(scroll_top_px) else { + return (None, Vec::new()); + }; + + let source = self.workspace.source.get(&self.store); + if source == WorkspaceSource::None { + return (None, Vec::new()); + } + let dragging_scrollbar = self + .workspace + .viewport_scrollbar_drag + .with(&self.store, |drag| drag.is_some()); + + let count = self.workspace_file_count(); + let viewport = self.editor.viewport_height_px.get(&self.store).max(1); + let follow_end = self.virtual_scroll.anchor.is_some_and(|anchor| { + anchor.bias == ViewportAnchorBias::FollowEnd + && self.virtual_diff_document.anchor_is_current(anchor) + }) || self.viewport_anchor_bias_for_global(scroll_top_px) + == ViewportAnchorBias::FollowEnd; + let (start_index, start_offset, local_top, target_height) = if follow_end { + let mut start_index = count.saturating_sub(1); + let mut tail_height = self.viewport_file_scroll_height_px(start_index).max(1); + let target_tail_height = viewport.saturating_mul(2).max(viewport); + while start_index > 0 && tail_height < target_tail_height { + start_index -= 1; + tail_height = tail_height + .saturating_add(self.viewport_file_scroll_height_px(start_index).max(1)); + } + ( + start_index, + self.file_start_offset_px(start_index), + tail_height.saturating_sub(viewport), + tail_height.max(1), + ) + } else { + let mut start_index = anchor_index; + let mut before_viewport_px = 0_u32; + while start_index > 0 && before_viewport_px < viewport { + start_index -= 1; + before_viewport_px = before_viewport_px + .saturating_add(self.viewport_file_scroll_height_px(start_index).max(1)); + } + let start_offset = self.file_start_offset_px(start_index); + let local_top = self + .workspace + .global_scroll_top_px + .get(&self.store) + .saturating_sub(start_offset); + let target_height = local_top + .saturating_add(viewport) + .saturating_add(viewport / 2) + .max(1); + (start_index, start_offset, local_top, target_height) + }; + + let mut effects = Vec::new(); + let mut slot_keys = Vec::new(); + let mut slot_loading = Vec::new(); + let mut accumulated = 0_u32; + let mut index = start_index; + while index < count && (slot_keys.is_empty() || accumulated < target_height) { + let path = self + .workspace_file_path_at(index) + .unwrap_or_else(|| format!("File {}", index + 1)); + let slot_key = match source { + WorkspaceSource::Compare | WorkspaceSource::TextCompare => { + effects.extend(self.ensure_compare_file_cached_for_viewport( + index, + &path, + CompareWorkPriority::VisibleViewportDiff, + )); + self.compare_slot_key_at(index, &path) + } + WorkspaceSource::Status => { + effects.extend(self.ensure_status_file_cached_for_viewport(index)); + let file_change = self + .workspace + .status_file_changes + .with(&self.store, |changes| changes.get(index).cloned()); + file_change.as_ref().map_or_else( + || { + self.loading_slot_key( + WorkspaceSource::Status, + index, + &path, + String::new(), + String::new(), + ) + }, + |change| self.status_slot_key_at(index, change), + ) + } + WorkspaceSource::None => self.loading_slot_key( + WorkspaceSource::None, + index, + &path, + String::new(), + String::new(), + ), + }; + let slot_height = self.viewport_file_scroll_height_px(index).max(1); + if let Some(window) = self.viewport_slot_syntax_window( + &slot_key, + accumulated, + slot_height, + local_top, + viewport, + ) { + effects.extend(self.request_viewport_slot_syntax_window(&slot_key, window)); + } + let slot_is_loading = matches!(&slot_key.kind, ViewportSlotKind::Loading); + if !slot_is_loading { + self.touch_viewport_slot(&slot_key); + } + slot_loading.push(slot_is_loading); + slot_keys.push(slot_key); + accumulated = accumulated.saturating_add(slot_height); + index += 1; + } + let render_end_index = index; + self.protect_working_set_slots(&slot_keys); + self.trim_file_working_set(); + effects.extend(self.prefetch_compare_working_set( + start_index, + render_end_index, + scroll_direction, + viewport, + )); + + let key = ViewportDocumentKey { + source, + generation: self.workspace_render_generation(), + start_index, + slots: slot_keys, + }; + let doc = if let Some(cache) = self.viewport_document_cache.as_ref() + && cache.key == key + { + cache.doc.clone() + } else { + let mut doc = RenderDoc::default(); + let loading_message = if dragging_scrollbar { + "" + } else { + "Loading diff..." + }; + for slot in &key.slots { + self.append_viewport_slot_doc(&mut doc, slot, loading_message); + } + let doc = Arc::new(doc); + self.viewport_document_cache = Some(ViewportDocumentCache { + key: key.clone(), + doc: doc.clone(), + }); + doc + }; + let slot_indices = key.slots.iter().map(|slot| slot.index).collect(); + let slot_item_ids = key + .slots + .iter() + .map(|slot| { + self.virtual_diff_document + .item_id(slot.index) + .unwrap_or_else(|| { + VirtualDiffItemId::file( + source, + self.workspace_render_generation(), + slot.index, + ) + }) + }) + .collect(); + let stream_items = self.virtual_stream_items_for_viewport_doc( + source, + self.workspace_render_generation(), + &key.slots, + doc.as_ref(), + ); + + ( + Some(ViewportDocument { + doc, + mode: ViewportDocumentMode::Continuous, + generation: self.workspace_render_generation(), + start_index, + start_offset_px: start_offset, + scroll_top_px: local_top, + slot_indices, + slot_item_ids, + stream_items, + slot_loading, + path: String::new(), + }), + effects, + ) + } + + pub(super) fn scroll_viewport_pages(&mut self, delta_pages: i32) -> Vec { + let viewport = self.editor.viewport_height_px.get(&self.store); + let page_px = ((viewport as f32) * 0.85).round().max(1.0) as i32; + let delta_px = delta_pages.saturating_mul(page_px); + if self.settings.continuous_scroll { + return self.scroll_viewport_px(delta_px); + } + let current = self.editor.scroll_top_px.get(&self.store); + let max = self.editor_max_scroll_top_px(); + let next = apply_scroll_delta_px(current, delta_px, max); + self.editor.scroll_top_px.set(&self.store, next); + Vec::new() + } + + pub(super) fn scroll_viewport_half_page(&mut self, direction: i32) -> Vec { + let viewport = self.editor.viewport_height_px.get(&self.store); + let half_px = ((viewport as f32) * 0.5).round().max(1.0) as i32; + let delta_px = direction.saturating_mul(half_px); + if self.settings.continuous_scroll { + return self.scroll_viewport_px(delta_px); + } + let current = self.editor.scroll_top_px.get(&self.store); + let max = self.editor_max_scroll_top_px(); + let next = apply_scroll_delta_px(current, delta_px, max); + self.editor.scroll_top_px.set(&self.store, next); + Vec::new() + } +} diff --git a/src/ui/state/repository.rs b/src/ui/state/repository.rs index fb2154ed..dbbebc44 100644 --- a/src/ui/state/repository.rs +++ b/src/ui/state/repository.rs @@ -28,21 +28,32 @@ pub(super) fn reduce_event(state: &mut AppState, event: RepositoryEvent) -> Vec< .repository .status .set(&state.store, AsyncStatus::Failed); - state.workspace_mode.set(&state.store, WorkspaceMode::Empty); - state.compare_progress.update(&state.store, |slot| { - if let Some(p) = slot.as_ref() - && matches!(p.subject, LoadingSubject::RepoOpen { .. }) - { - *slot = None; - } - }); + state.workspace.mode.set(&state.store, WorkspaceMode::Empty); + state + .workspace + .compare_progress + .update(&state.store, |slot| { + if let Some(p) = slot.as_ref() + && matches!(p.subject, LoadingSubject::RepoOpen { .. }) + { + *slot = None; + } + }); state.push_error(&message); } else { - state.last_error.set(&state.store, Some(message)); + state.ui.last_error.set(&state.store, Some(message)); } } Vec::new() } + RepositoryEvent::WorkerStopped => { + state + .workspace + .status_operation_pending + .set(&state.store, false); + state.push_error("Version control worker stopped. Restart Diffy."); + Vec::new() + } RepositoryEvent::FileOperationFailed { path, message } => { if state .compare @@ -402,3 +413,1088 @@ impl AppState { ] } } + +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct RepositoryState { + pub status: AsyncStatus, + pub location: Option, + pub capabilities: Option, + pub refs: Vec, + pub changes: Vec, + pub operation_log: Vec, + pub file_changes: Vec, + pub publish_plan: Option, +} + +pub(super) fn active_publish_ref(refs: &[VcsRef]) -> Option { + refs.iter() + .find(|reference| { + reference.active && matches!(reference.kind, RefKind::Branch | RefKind::Bookmark) + }) + .cloned() +} + +pub(super) fn upstream_pair(upstream: &str) -> Option<(String, String)> { + upstream + .split_once('/') + .map(|(remote, branch)| (remote.to_owned(), branch.to_owned())) +} + +pub(super) fn remote_names_from_refs(refs: &[VcsRef]) -> std::collections::BTreeSet { + let mut remotes = std::collections::BTreeSet::new(); + for reference in refs { + if let Some((remote, _)) = reference + .upstream + .as_deref() + .and_then(|upstream| upstream.split_once('/')) + { + remotes.insert(remote.to_owned()); + } + if matches!( + reference.kind, + RefKind::RemoteBranch | RefKind::RemoteBookmark + ) && let Some((remote, _)) = reference.name.split_once('/') + { + remotes.insert(remote.to_owned()); + } + } + remotes +} + +impl AppState { + pub(super) fn status_refs_for_bucket(&self, bucket: ChangeBucket) -> (String, String) { + self.vcs_ui_profile().status_compare_refs(bucket) + } + + pub(super) fn vcs_ui_profile(&self) -> crate::ui::vcs::VcsUiProfile { + self.repository.location.with(&self.store, |location| { + crate::ui::vcs::profile(location.as_ref()) + }) + } +} + +impl AppState { + pub(super) fn open_repository(&mut self, path: PathBuf) -> Vec { + let path = normalize_repository_open_path(path); + self.workspace.mode.set(&self.store, WorkspaceMode::Loading); + self.compare.repo_path.set(&self.store, Some(path.clone())); + self.compare.left_ref.set(&self.store, String::new()); + self.compare.right_ref.set(&self.store, String::new()); + self.compare.mode.set(&self.store, CompareMode::default()); + self.compare.resolved_left.set(&self.store, None); + self.compare.resolved_right.set(&self.store, None); + self.repository + .status + .set(&self.store, AsyncStatus::Loading); + self.repository.location.set(&self.store, None); + self.repository.capabilities.set(&self.store, None); + self.repository.refs.set(&self.store, Vec::new()); + self.repository.changes.set(&self.store, Vec::new()); + self.repository.operation_log.set(&self.store, Vec::new()); + self.repository.file_changes.set(&self.store, Vec::new()); + self.repository.publish_plan.set(&self.store, None); + self.workspace_clear_compare(); + self.reset_file_list(); + self.editor_clear_document(); + self.editor.focused.set(&self.store, false); + self.ui.last_error.set(&self.store, None); + self.github.pull_request.cache.update(&self.store, |c| { + c.clear(); + }); + self.github + .pull_request + .pending_confirm + .set(&self.store, None); + self.clear_overlays(); + self.ui.focus.set(&self.store, Some(FocusTarget::TitleBar)); + self.sync_settings_snapshot(); + + // Seed the progress panel with a repo-open subject. We piggy-back + // on `compare_generation` as the loading generation — any in-flight + // compare is invalidated when the user opens a new repo anyway, + // and `handle_compare_progress_update` just matches on whatever + // generation the panel records. + let next_gen = self + .workspace + .compare_generation + .get(&self.store) + .saturating_add(1); + self.workspace.compare_generation.set(&self.store, next_gen); + let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); + let repo_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("repository") + .to_owned(); + // Always delay the panel reveal — a tiny repo that opens in under + // the threshold should finish without ever flashing a loading UI. + // Unlike re-compare (which can preserve the old diff during the + // grace window), repo-open has nothing to fall back to visually; + // the empty background / previous workspace is what the user sees + // for 500ms, which is a cheap price for zero flash on fast ops. + let started_at_ms = self.clock_ms; + let reveal_at_ms = started_at_ms.saturating_add(COMPARE_REVEAL_DELAY_MS); + self.workspace.compare_progress.set( + &self.store, + Some(Arc::new(CompareProgress { + generation: next_gen, + phase: ComparePhase::OpeningRepo, + subject: LoadingSubject::RepoOpen { name: repo_name }, + started_at_ms, + reveal_at_ms, + file_count_total: None, + files_loaded: 0, + })), + ); + + vec![ + syntax_epoch_effect, + SettingsEffect::SaveSettings(self.settings.clone()).into(), + RepositoryEffect::SyncRepository { + path: path.clone(), + reason: RepositorySyncReason::Open, + reporter_generation: Some(next_gen), + } + .into(), + RepositoryEffect::WatchRepository { path: Some(path) }.into(), + ] + } + + pub(super) fn handle_repository_snapshot( + &mut self, + payload: RepositorySnapshot, + ) -> Vec { + tracing::debug!( + path = %payload.path.display(), + reason = ?payload.reason, + change_kind = ?payload.change_kind, + pending = self.workspace.status_operation_pending.get(&self.store), + status_gen = self.workspace.status_generation.get(&self.store), + "handle_repository_snapshot: entered" + ); + if self + .compare + .repo_path + .with(&self.store, |p| p.as_ref() != Some(&payload.path)) + { + tracing::warn!("handle_repository_snapshot: path mismatch, ignored"); + return Vec::new(); + } + + self.repository.status.set(&self.store, AsyncStatus::Ready); + self.repository + .location + .set(&self.store, Some(payload.location.clone())); + self.repository + .capabilities + .set(&self.store, Some(payload.capabilities)); + let file_changes = payload.file_changes; + self.repository.refs.set(&self.store, payload.refs); + self.repository.changes.set(&self.store, payload.changes); + self.repository + .operation_log + .set(&self.store, payload.operation_log); + self.repository + .file_changes + .set(&self.store, file_changes.clone()); + self.repository + .publish_plan + .set(&self.store, payload.publish_plan); + self.workspace + .status_file_changes + .set(&self.store, file_changes); + + // Tear down a repo-open progress panel. Compare-subject progress + // survives — a kickoff_compare may be queued below and will + // replace it atomically via its own seeding path. + self.workspace.compare_progress.update(&self.store, |slot| { + if let Some(p) = slot.as_ref() + && matches!(p.subject, LoadingSubject::RepoOpen { .. }) + { + *slot = None; + } + }); + + match payload.reason { + RepositorySyncReason::Open => { + if let Some(ref store) = self.frecency { + store.record_access(&format!("repo:{}", payload.path.display())); + } + let mut effects = self.persist_settings_effect(); + if let Some(url) = self.startup.pending_pr_url.clone() { + self.startup.pending_pr_url = None; + self.startup.auto_compare_pending = false; + self.github + .pull_request + .status + .set(&self.store, AsyncStatus::Loading); + if let Some(parsed) = crate::core::forge::github::parse_pr_url(&url) { + let key: PrKey = (parsed.owner, parsed.repo, parsed.number); + self.github.pull_request.cache.update(&self.store, |c| { + c.entry(key.clone()).or_insert_with(|| PrCacheEntry { + meta: PrPeekMeta::Loading, + diff: PrPeekDiff::Loading, + last_peek_ms: 0, + }); + }); + self.github + .pull_request + .pending_confirm + .set(&self.store, Some(key)); + } + effects.push( + GitHubEffect::LoadPullRequest { + url, + repo_path: payload.path, + github_token: self.github_access_token.clone(), + } + .into(), + ); + } else if self.startup.auto_compare_pending { + self.startup.auto_compare_pending = false; + effects.extend(self.kickoff_compare()); + } else if self.startup.bootstrap_compare_started { + self.startup.bootstrap_compare_started = false; + } else if let Some(persisted) = self.settings.last_compare.as_ref().filter(|c| { + c.repo_path.as_ref() == Some(&payload.path) + && compare_refs_are_valid(c.mode, &c.left_ref, &c.right_ref) + }) { + self.compare + .left_ref + .set(&self.store, persisted.left_ref.clone()); + self.compare + .right_ref + .set(&self.store, persisted.right_ref.clone()); + self.compare.mode.set(&self.store, persisted.mode); + effects.extend(self.kickoff_compare()); + } else { + let profile = crate::ui::vcs::profile(Some(&payload.location)); + let (left, right, mode) = profile.default_compare(); + self.compare.left_ref.set(&self.store, left.to_owned()); + self.compare.right_ref.set(&self.store, right.to_owned()); + self.compare.mode.set(&self.store, mode); + effects.extend(self.activate_status_view(true)); + } + effects + } + RepositorySyncReason::Dirty | RepositorySyncReason::Rescan => { + if self.workspace.source.get(&self.store) == WorkspaceSource::Status { + return self.activate_status_view(false); + } + + let (mode, left_ref, right_ref) = ( + self.compare.mode.get(&self.store), + self.compare.left_ref.get(&self.store), + self.compare.right_ref.get(&self.store), + ); + if !compare_refs_are_valid(mode, &left_ref, &right_ref) { + return Vec::new(); + } + + match payload.change_kind { + Some(RepositoryChangeKind::Metadata | RepositoryChangeKind::Both) => { + self.kickoff_compare() + } + Some(RepositoryChangeKind::Worktree) + if self.vcs_ui_profile().is_working_copy_ref(&right_ref) => + { + self.kickoff_compare() + } + _ => Vec::new(), + } + } + } + } + + pub(super) fn handle_status_diff_finished( + &mut self, + payload: StatusDiffFinished, + ) -> Vec { + let current_gen = self.workspace.status_generation.get(&self.store); + tracing::debug!( + payload_gen = payload.generation, + current_gen, + payload_index = payload.index, + payload_path = %payload.file_change.path, + payload_bucket = ?payload.file_change.bucket, + "handle_status_diff_finished: entered" + ); + if payload.generation != current_gen { + tracing::debug!( + "handle_status_diff_finished: generation mismatch, discarding (pending NOT cleared)" + ); + return Vec::new(); + } + let matches = self.repository.file_changes.with(&self.store, |changes| { + match changes.get(payload.index) { + Some(current) => current == &payload.file_change, + None => false, + } + }); + if !matches { + let current_change_at_idx = self.repository.file_changes.with(&self.store, |changes| { + changes + .get(payload.index) + .map(|change| format!("{}:{:?}", change.path, change.bucket)) + .unwrap_or_else(|| "".to_owned()) + }); + tracing::debug!( + current_change_at_idx, + "handle_status_diff_finished: file change mismatch, discarding (pending NOT cleared)" + ); + return Vec::new(); + } + let matches_selection = self.workspace.selected_file_index.get(&self.store) + == Some(payload.index) + && self + .workspace + .selected_file_path + .get(&self.store) + .as_deref() + == Some(payload.file_change.path.as_str()) + && self.workspace.selected_change_bucket.get(&self.store) + == Some(payload.file_change.bucket); + let output = payload.output; + + let Some(carbon_file) = output.carbon.files.first() else { + self.clear_file_cache_loading(payload.index); + if matches_selection { + self.workspace.active_file.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + self.editor_clear_document(); + } + return Vec::new(); + }; + let prepared = prepare_active_file(payload.index, carbon_file); + let bucket = payload.file_change.bucket; + let (left_ref, right_ref) = self.status_refs_for_bucket(bucket); + let active_file = self.build_active_file( + payload.index, + payload.file_change.path.clone(), + prepared, + left_ref, + right_ref, + ); + let active_file = self.cache_active_file(active_file); + if !matches_selection { + return Vec::new(); + } + + tracing::debug!("handle_status_diff_finished: clearing status_operation_pending"); + self.workspace + .source + .set(&self.store, WorkspaceSource::Status); + self.workspace + .status_operation_pending + .set(&self.store, false); + self.workspace.status.set(&self.store, AsyncStatus::Ready); + self.workspace.mode.set(&self.store, WorkspaceMode::Ready); + self.workspace + .used_fallback + .set(&self.store, output.used_fallback); + self.workspace + .fallback_message + .set(&self.store, output.fallback_message.clone()); + self.workspace + .raw_diff_len + .set(&self.store, output.raw_diff_len); + self.workspace.compare_output.set(&self.store, None); + self.workspace.compare_total_stats.set(&self.store, None); + self.workspace.compare_hydrated_stats.set(&self.store, None); + self.workspace + .compare_deferred_stats_remaining + .set(&self.store, None); + self.workspace + .compare_deferred_stats_cursor + .set(&self.store, 0); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); + self.workspace.active_file_loading.set(&self.store, None); + + self.workspace + .selected_file_index + .set(&self.store, Some(payload.index)); + self.workspace + .selected_file_path + .set(&self.store, Some(payload.file_change.path.clone())); + self.workspace + .selected_change_bucket + .set(&self.store, Some(bucket)); + // Preserve scroll/hover/positional editor state when refreshing the + // same file (e.g. after staging a hunk). Only reset when the path + // changed (navigating to a different file). + let same_file = self.workspace.active_file.with(&self.store, |af| { + af.as_ref().is_some_and(|a| { + a.path == payload.file_change.path + && a.left_ref == active_file.left_ref + && a.right_ref == active_file.right_ref + }) + }); + self.workspace + .active_file + .set(&self.store, Some(active_file)); + if !same_file { + self.editor_clear_document(); + self.editor + .line_selection + .update(&self.store, |ls| ls.clear()); + } + if self.editor.search.open.get(&self.store) { + self.recompute_search_matches(); + } + let mut effects = self.sync_editor_scroll_from_global(); + effects.extend(self.request_active_file_syntax_effect()); + effects + } + + pub(super) fn activate_status_view(&mut self, reset_scroll: bool) -> Vec { + tracing::debug!( + reset_scroll, + pending = self.workspace.status_operation_pending.get(&self.store), + status_gen = self.workspace.status_generation.get(&self.store), + status_file_changes_count = self + .workspace + .status_file_changes + .with(&self.store, |i| i.len()), + "activate_status_view: entered" + ); + self.workspace + .source + .set(&self.store, WorkspaceSource::Status); + self.workspace.status.set(&self.store, AsyncStatus::Ready); + self.workspace.mode.set(&self.store, WorkspaceMode::Ready); + self.workspace.compare_output.set(&self.store, None); + self.workspace.compare_total_stats.set(&self.store, None); + self.workspace.compare_hydrated_stats.set(&self.store, None); + self.workspace + .compare_deferred_stats_remaining + .set(&self.store, None); + self.workspace + .compare_deferred_stats_cursor + .set(&self.store, 0); + self.workspace + .compare_total_stats_loading + .set(&self.store, false); + self.set_compare_stats_hydration(CompareStatsHydrationState::Idle); + self.workspace.active_file_loading.set(&self.store, None); + let new_files = self + .workspace + .status_file_changes + .with(&self.store, |changes| build_status_file_entries(changes)); + self.workspace.files.set(&self.store, new_files); + let next_status_gen = self + .workspace + .status_generation + .get(&self.store) + .saturating_add(1); + self.workspace + .status_generation + .set(&self.store, next_status_gen); + let syntax_epoch_effect = self.invalidate_syntax_epoch_effect(); + self.clear_file_cache(); + self.workspace.sidebar_auto_width.set(&self.store, None); + self.workspace.used_fallback.set(&self.store, false); + self.workspace + .fallback_message + .set(&self.store, String::new()); + self.workspace.raw_diff_len.set(&self.store, 0); + self.reset_file_scroll_layout(); + if reset_scroll { + self.file_list.scroll_offset_px.set(&self.store, 0.0); + self.workspace.global_scroll_top_px.set(&self.store, 0); + } else if self.settings.continuous_scroll { + self.clamp_global_scroll_top_px(); + } + + let current_path = self.workspace.selected_file_path.get(&self.store); + let current_bucket = self.workspace.selected_change_bucket.get(&self.store); + let (status_syntax_paths, selected_index, selected_syntax_paths) = self + .workspace + .status_file_changes + .with(&self.store, |changes| { + let paths = changes + .iter() + .flat_map(file_change_syntax_paths) + .collect::>(); + let selected_index = + if let Some((path, bucket)) = current_path.clone().zip(current_bucket) { + if let Some(idx) = changes + .iter() + .position(|change| change.path == path && change.bucket == bucket) + { + Some(idx) + } else { + None + } + } else if let Some(path) = current_path.as_deref() { + if let Some(idx) = changes.iter().position(|change| change.path == path) { + Some(idx) + } else { + None + } + } else { + None + } + .or_else(|| (!changes.is_empty()).then_some(0)); + let selected_paths = selected_index + .and_then(|index| changes.get(index)) + .map(file_change_syntax_paths) + .unwrap_or_default(); + (paths, selected_index, selected_paths) + }); + + tracing::debug!( + ?selected_index, + "activate_status_view: resolved selected_index" + ); + match selected_index { + Some(index) => { + let mut effects = self.select_status_item(index, false); + effects.insert(0, syntax_epoch_effect); + if let Some(effect) = self.syntax_pack_warmup_effect_for_paths( + &status_syntax_paths, + &selected_syntax_paths, + ) { + effects.insert(0, effect); + } + effects + } + None => { + tracing::debug!("activate_status_view: no selection, clearing pending"); + self.workspace + .status_operation_pending + .set(&self.store, false); + self.workspace.selected_file_index.set(&self.store, None); + self.workspace.selected_file_path.set(&self.store, None); + self.workspace.selected_change_bucket.set(&self.store, None); + self.workspace.active_file.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + self.editor_clear_document(); + vec![syntax_epoch_effect] + } + } + } + + pub(super) fn show_working_tree(&mut self) -> Vec { + let (left, right, mode) = self.vcs_ui_profile().working_copy_compare(); + self.compare.left_ref.set(&self.store, left.to_owned()); + self.compare.right_ref.set(&self.store, right.to_owned()); + self.compare.mode.set(&self.store, mode); + let mut effects = self.persist_settings_effect(); + effects.extend(self.activate_status_view(true)); + effects + } + + pub(super) fn select_status_item(&mut self, index: usize, reveal: bool) -> Vec { + let Some(file_change) = self + .workspace + .status_file_changes + .with(&self.store, |changes| changes.get(index).cloned()) + else { + tracing::warn!( + index, + "select_status_item: index out of range, returning empty" + ); + return Vec::new(); + }; + tracing::debug!( + index, + path = %file_change.path, + bucket = ?file_change.bucket, + status_gen = self.workspace.status_generation.get(&self.store), + "select_status_item: dispatching LoadStatusDiff" + ); + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + tracing::warn!("select_status_item: no repo_path"); + return Vec::new(); + }; + + self.workspace + .source + .set(&self.store, WorkspaceSource::Status); + // Keep the current document visible while the new diff loads — no + // Loading state, no tear-down. handle_status_diff_finished swaps the + // ActiveFile atomically when the fresh diff arrives. + self.workspace + .selected_file_index + .set(&self.store, Some(index)); + self.workspace + .selected_file_path + .set(&self.store, Some(file_change.path.clone())); + self.workspace + .selected_change_bucket + .set(&self.store, Some(file_change.bucket)); + let (left_ref, right_ref) = self.status_refs_for_bucket(file_change.bucket); + let active_matches_selection = self.workspace.active_file.with(&self.store, |af| { + af.as_ref().is_some_and(|active| { + active.index == index + && active.path == file_change.path + && active.left_ref == left_ref + && active.right_ref == right_ref + }) + }); + if active_matches_selection { + self.workspace.active_file_loading.set(&self.store, None); + self.clear_file_cache_loading(index); + self.file_list.hovered_index.set(&self.store, Some(index)); + if reveal { + self.reveal_file_list_row(index); + } + let mut effects = self.sync_editor_scroll_from_global(); + effects.push(ensure_syntax_packs_for_file_change_effect(&file_change)); + effects.extend(self.request_active_file_syntax_effect()); + return effects; + } else if let Some(mut active_file) = self.cached_status_file_at(index, &file_change) { + active_file.last_used_tick = self.next_file_working_set_tick(); + self.workspace.active_file_loading.set(&self.store, None); + self.workspace + .active_file + .set(&self.store, Some(active_file.clone())); + self.cache_active_file(active_file); + self.editor_clear_document(); + self.file_list.hovered_index.set(&self.store, Some(index)); + if reveal { + self.reveal_file_list_row(index); + } + let mut effects = self.sync_editor_scroll_from_global(); + effects.push(ensure_syntax_packs_for_file_change_effect(&file_change)); + effects.extend(self.request_active_file_syntax_effect()); + return effects; + } else { + let should_load = self.should_enqueue_file_load( + index, + &file_change.path, + CompareWorkPriority::InteractiveSelectedFile, + ); + self.workspace.active_file_loading.set( + &self.store, + Some(ActiveFileLoading { + index, + path: file_change.path.clone(), + priority: CompareWorkPriority::InteractiveSelectedFile, + }), + ); + self.mark_file_cache_loading( + index, + file_change.path.clone(), + CompareWorkPriority::InteractiveSelectedFile, + ); + self.file_list.hovered_index.set(&self.store, Some(index)); + if reveal { + self.reveal_file_list_row(index); + } + + let mut effects = vec![ensure_syntax_packs_for_file_change_effect(&file_change)]; + if should_load { + let generation = self.workspace.status_generation.get(&self.store); + let renderer = self.compare.renderer.get(&self.store); + effects.push( + RepositoryEffect::LoadStatusDiff { + task: Task { + generation, + request: StatusDiffRequest { + repo_path, + file_change, + renderer, + }, + }, + index, + } + .into(), + ); + } + return effects; + } + } + + pub(super) fn apply_selected_status_operation( + &mut self, + operation: FileOperation, + ) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.staging_area) + }) + { + self.push_error("This repository backend does not support staging operations."); + return Vec::new(); + } + if self.workspace.source.get(&self.store) != WorkspaceSource::Status { + return Vec::new(); + } + if self.workspace.status_operation_pending.get(&self.store) { + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + let Some(index) = self.workspace.selected_file_index.get(&self.store) else { + return Vec::new(); + }; + let Some(file_change) = self + .workspace + .status_file_changes + .with(&self.store, |changes| changes.get(index).cloned()) + else { + return Vec::new(); + }; + + self.workspace + .status_operation_pending + .set(&self.store, true); + vec![ + RepositoryEffect::ApplyFileOperation(FileOperationRequest { + repo_path, + file_change, + operation, + }) + .into(), + ] + } + + pub(super) fn apply_file_status_operation( + &mut self, + index: usize, + operation: FileOperation, + ) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.staging_area) + }) + { + self.push_error("This repository backend does not support staging operations."); + return Vec::new(); + } + if self.workspace.source.get(&self.store) != WorkspaceSource::Status { + return Vec::new(); + } + if self.workspace.status_operation_pending.get(&self.store) { + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + let Some(file_change) = self + .workspace + .status_file_changes + .with(&self.store, |changes| changes.get(index).cloned()) + else { + return Vec::new(); + }; + + self.workspace + .status_operation_pending + .set(&self.store, true); + vec![ + RepositoryEffect::ApplyFileOperation(FileOperationRequest { + repo_path, + file_change, + operation, + }) + .into(), + ] + } + + pub(super) fn apply_batch_scope_operation( + &mut self, + buckets: &[ChangeBucket], + operation: FileOperation, + ) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.staging_area) + }) + { + self.push_error("This repository backend does not support staging operations."); + return Vec::new(); + } + if self.workspace.source.get(&self.store) != WorkspaceSource::Status { + return Vec::new(); + } + if self.workspace.status_operation_pending.get(&self.store) { + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + let file_changes: Vec = + self.workspace + .status_file_changes + .with(&self.store, |changes| { + changes + .iter() + .filter(|change| buckets.contains(&change.bucket)) + .cloned() + .collect() + }); + if file_changes.is_empty() { + return Vec::new(); + } + + self.workspace + .status_operation_pending + .set(&self.store, true); + vec![ + RepositoryEffect::ApplyBatchFileOperation(BatchFileOperationRequest { + repo_path, + file_changes, + operation, + }) + .into(), + ] + } + + pub(super) fn start_fetch_remote(&mut self, remote: String) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.remotes) + }) + { + self.push_error("This repository backend does not support remotes."); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before fetching."); + return Vec::new(); + }; + let toast_id = self.push_progress_toast(&format!("Fetching {remote}\u{2026}")); + vec![ + RepositoryEffect::FetchRemote(FetchRemoteRequest { + repo_path, + remote, + toast_id, + }) + .into(), + ] + } + + pub(super) fn start_fetch_all_remotes(&mut self) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.remotes) + }) + { + self.push_error("This repository backend does not support remotes."); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before fetching."); + return Vec::new(); + }; + let remotes = self.repository.refs.with(&self.store, |refs| { + remote_names_from_refs(refs).into_iter().collect::>() + }); + if remotes.is_empty() { + self.push_error("No remotes are configured for this repository."); + return Vec::new(); + } + remotes + .into_iter() + .flat_map(|remote| { + let toast_id = self.push_progress_toast(&format!("Fetching {remote}\u{2026}")); + std::iter::once( + RepositoryEffect::FetchRemote(FetchRemoteRequest { + repo_path: repo_path.clone(), + remote, + toast_id, + }) + .into(), + ) + }) + .collect() + } + + pub(super) fn start_publish_default(&mut self) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.remotes) + }) + { + self.push_error("This repository backend does not support publishing."); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before publishing."); + return Vec::new(); + }; + let toast_id = self.push_progress_toast(&format!( + "{}\u{2026}", + self.vcs_ui_profile().publish_command_label() + )); + vec![ + RepositoryEffect::PublishDefault(PublishRequest { + repo_path, + action: None, + toast_id, + }) + .into(), + ] + } + + pub(super) fn start_open_publish_menu(&mut self) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.remotes) + }) + { + self.push_error("This repository backend does not support publishing."); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before publishing."); + return Vec::new(); + }; + self.push_overlay(OverlaySurface::PublishMenu, None); + vec![ + RepositoryEffect::LoadPublishPlan(PublishPlanRequest { + repo_path, + toast_id: None, + }) + .into(), + ] + } + + pub(super) fn start_publish_action(&mut self, action: PublishAction) -> Vec { + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before publishing."); + return Vec::new(); + }; + if self.overlays_top() == Some(OverlaySurface::PublishMenu) { + self.pop_overlay(); + } + let toast_id = self.push_progress_toast(&format!("{}\u{2026}", action.label)); + vec![ + RepositoryEffect::PublishDefault(PublishRequest { + repo_path, + action: Some(action), + toast_id, + }) + .into(), + ] + } + + pub(super) fn start_push_current_branch(&mut self, force_with_lease: bool) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.remotes) + }) + { + self.push_error("This repository backend does not support push."); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before pushing."); + return Vec::new(); + }; + let Some(branch_ref) = self + .repository + .refs + .with(&self.store, |refs| active_publish_ref(refs)) + else { + self.push_error("No active branch or bookmark to push."); + return Vec::new(); + }; + let branch = branch_ref.name; + let (remote, refspec) = match branch_ref.upstream.as_deref().and_then(upstream_pair) { + Some((remote, upstream_branch)) => ( + remote, + format!("refs/heads/{branch}:refs/heads/{upstream_branch}"), + ), + None => { + // No upstream configured yet — default to `origin/`. + let remotes = self.repository.refs.with(&self.store, |refs| { + remote_names_from_refs(refs).into_iter().collect::>() + }); + let remote = if remotes.iter().any(|n| n == "origin") { + "origin".to_owned() + } else if let Some(first) = remotes.first() { + first.clone() + } else { + self.push_error("No remotes are configured for this repository."); + return Vec::new(); + }; + (remote, format!("refs/heads/{branch}:refs/heads/{branch}")) + } + }; + let label = if force_with_lease { + format!("Force-pushing {branch} to {remote}\u{2026}") + } else { + format!("Pushing {branch} to {remote}\u{2026}") + }; + let toast_id = self.push_progress_toast(&label); + vec![ + RepositoryEffect::Push(PushRequest { + repo_path, + remote, + refspec, + force_with_lease, + toast_id, + }) + .into(), + ] + } + + pub(super) fn start_pull_current_branch(&mut self) -> Vec { + if !self + .repository + .capabilities + .with(&self.store, |capabilities| { + capabilities.is_some_and(|capabilities| capabilities.pull_fast_forward) + }) + { + self.push_error("This repository backend does not support fast-forward pull."); + return Vec::new(); + } + let Some(repo_path) = self.compare.repo_path.with(&self.store, |p| p.clone()) else { + self.push_error("Open a repository before pulling."); + return Vec::new(); + }; + let Some(branch_ref) = self + .repository + .refs + .with(&self.store, |refs| active_publish_ref(refs)) + else { + self.push_error("No active branch or bookmark to pull into."); + return Vec::new(); + }; + let branch = branch_ref.name; + let (remote, upstream_branch) = match branch_ref.upstream.as_deref().and_then(upstream_pair) + { + Some(pair) => pair, + None => { + self.push_error(&format!( + "No upstream configured for {branch}. Push once to set one." + )); + return Vec::new(); + } + }; + let toast_id = self.push_progress_toast(&format!("Pulling {branch} from {remote}\u{2026}")); + vec![ + RepositoryEffect::PullFf(PullFfRequest { + repo_path, + remote, + branch: upstream_branch, + toast_id, + }) + .into(), + ] + } +} diff --git a/src/ui/state/settings.rs b/src/ui/state/settings.rs index 2e5dc003..d3a67142 100644 --- a/src/ui/state/settings.rs +++ b/src/ui/state/settings.rs @@ -117,20 +117,21 @@ impl AppState { } OpenSettings => { self.clear_overlays(); - self.app_view.set(&self.store, AppView::Settings); + self.ui.app_view.set(&self.store, AppView::Settings); Vec::new() } OpenKeymaps => { self.clear_overlays(); - self.app_view.set(&self.store, AppView::Settings); - self.settings_section + self.ui.app_view.set(&self.store, AppView::Settings); + self.ui + .settings_section .set(&self.store, SettingsSection::Keymaps); - self.keymaps_scroll_top_px.set(&self.store, 0.0); + self.ui.keymaps_scroll_top_px.set(&self.store, 0.0); Vec::new() } CloseSettings => { - self.keymap_capture.set(&self.store, None); - self.app_view.set(&self.store, AppView::Workspace); + self.ui.keymap_capture.set(&self.store, None); + self.ui.app_view.set(&self.store, AppView::Workspace); Vec::new() } ToggleAutoUpdate => { @@ -142,41 +143,107 @@ impl AppState { effects } SetSettingsSection(section) => { - self.keymap_capture.set(&self.store, None); - self.settings_section.set(&self.store, section); - self.keymaps_scroll_top_px.set(&self.store, 0.0); + self.ui.keymap_capture.set(&self.store, None); + self.ui.settings_section.set(&self.store, section); + self.ui.keymaps_scroll_top_px.set(&self.store, 0.0); Vec::new() } BeginKeymapRebind(command) => { - self.keymap_capture.set(&self.store, Some(command)); + self.ui.keymap_capture.set(&self.store, Some(command)); Vec::new() } ApplyKeymapBinding { command, binding } => { crate::input::set_override(&mut self.settings.keymap_overrides, command, binding); - self.keymap_capture.set(&self.store, None); + self.ui.keymap_capture.set(&self.store, None); self.persist_settings_effect() } ResetKeymapBinding(command) => { crate::input::reset_override(&mut self.settings.keymap_overrides, command); - self.keymap_capture.set(&self.store, None); + self.ui.keymap_capture.set(&self.store, None); self.persist_settings_effect() } CancelKeymapRebind => { - self.keymap_capture.set(&self.store, None); + self.ui.keymap_capture.set(&self.store, None); Vec::new() } ScrollKeymapsPx(delta) => { - let cur = self.keymaps_scroll_top_px.get(&self.store); - self.keymaps_scroll_top_px + let cur = self.ui.keymaps_scroll_top_px.get(&self.store); + self.ui + .keymaps_scroll_top_px .set(&self.store, cur + delta as f32); self.clamp_keymaps_scroll(); Vec::new() } ScrollKeymapsToPx(target) => { - self.keymaps_scroll_top_px.set(&self.store, target as f32); + self.ui + .keymaps_scroll_top_px + .set(&self.store, target as f32); self.clamp_keymaps_scroll(); Vec::new() } } } } + +impl AppState { + pub fn keymaps_max_scroll_px(&self) -> f32 { + let content = self.ui.keymaps_content_height_px.get(&self.store); + let viewport = self.ui.keymaps_viewport_height_px.get(&self.store); + (content - viewport).max(0.0) + } + + pub fn clamp_keymaps_scroll(&mut self) { + let max = self.keymaps_max_scroll_px(); + let cur = self.ui.keymaps_scroll_top_px.get(&self.store); + self.ui + .keymaps_scroll_top_px + .set(&self.store, cur.clamp(0.0, max)); + } +} + +impl AppState { + pub(super) fn persist_settings_effect(&mut self) -> Vec { + self.sync_settings_snapshot(); + vec![SettingsEffect::SaveSettings(self.settings.clone()).into()] + } + + pub(super) fn sync_settings_snapshot(&mut self) { + self.settings.ui_scale_pct = self.clamp_ui_scale_pct(self.settings.ui_scale_pct); + self.settings.fonts = self.settings.fonts.normalized(); + self.settings.sidebar_width_px = self + .settings + .sidebar_width_px + .map(|width| self.clamp_sidebar_width_px(width)); + self.settings.viewport.wrap_enabled = self.editor.wrap_enabled.get(&self.store); + self.settings.viewport.wrap_column = self.editor.wrap_column.get(&self.store); + self.settings.viewport.layout = self.compare.layout.get(&self.store); + self.settings.last_compare = Some(PersistedCompare { + repo_path: self.compare.repo_path.get(&self.store), + left_ref: self.compare.left_ref.get(&self.store), + right_ref: self.compare.right_ref.get(&self.store), + mode: self.compare.mode.get(&self.store), + layout: self.compare.layout.get(&self.store), + renderer: self.compare.renderer.get(&self.store), + }); + } + + pub fn ui_scale_factor(&self) -> f32 { + self.clamp_ui_scale_pct(self.settings.ui_scale_pct) as f32 / DEFAULT_UI_SCALE_PCT as f32 + } + + pub(super) fn clamp_ui_scale_pct(&self, scale_pct: u16) -> u16 { + scale_pct.clamp(MIN_UI_SCALE_PCT, MAX_UI_SCALE_PCT) + } + + pub(super) fn adjust_ui_scale(&mut self, delta_pct: i16) -> Vec { + let current = i32::from(self.clamp_ui_scale_pct(self.settings.ui_scale_pct)); + let updated = (current + i32::from(delta_pct)) + .clamp(i32::from(MIN_UI_SCALE_PCT), i32::from(MAX_UI_SCALE_PCT)) + as u16; + if updated == self.settings.ui_scale_pct { + return Vec::new(); + } + self.settings.ui_scale_pct = updated; + self.persist_settings_effect() + } +} diff --git a/src/ui/state/syntax.rs b/src/ui/state/syntax.rs index 4c92491c..db4cce55 100644 --- a/src/ui/state/syntax.rs +++ b/src/ui/state/syntax.rs @@ -4,7 +4,7 @@ use crate::actions::SyntaxAction; use crate::effects::{Effect, LoadFileSyntaxRequest}; use crate::events::SyntaxEvent; -use super::{AppState, MAX_PENDING_SYNTAX_WINDOWS}; +use super::*; #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct SyntaxInflightKey { @@ -99,3 +99,673 @@ pub(super) fn reduce_event(state: &mut AppState, event: SyntaxEvent) -> Vec Option { + let window = next_missing_syntax_tile(active, window)?; + if active + .syntax_pending + .iter() + .any(|pending| pending.window.contains(window)) + || active + .syntax_covered + .iter() + .any(|covered| covered.contains(window)) + { + return None; + } + + active + .syntax_pending + .push(SyntaxPendingWindow { request_id, window }); + Some(LoadFileSyntaxRequest { + repo_path, + file_index: active.index, + path: active.path.clone(), + carbon_file: active.carbon_file.clone(), + carbon_expansion: active.carbon_expansion.clone(), + left_ref: active.left_ref.clone(), + right_ref: active.right_ref.clone(), + window, + request_id, + cache_generation: generation, + syntax_epoch, + }) +} + +pub(super) fn next_missing_syntax_tile( + active: &ActiveFile, + requested: SyntaxRowWindow, +) -> Option { + let line_count = active.render_doc.lines.len(); + let start = requested.start.min(line_count); + let end = requested.end.min(line_count); + if line_count == 0 || end <= start { + return None; + } + + let tile_rows = SYNTAX_INITIAL_ROWS.max(1); + let mut tile_start = (start / tile_rows) * tile_rows; + while tile_start < end { + let tile_end = tile_start.saturating_add(tile_rows).min(line_count); + let candidate = SyntaxRowWindow { + start: tile_start, + end: tile_end, + }; + let already_requested = active + .syntax_pending + .iter() + .any(|pending| pending.window.contains(candidate)) + || active + .syntax_covered + .iter() + .any(|covered| covered.contains(candidate)); + if !already_requested { + return Some(candidate); + } + if tile_end == line_count { + break; + } + tile_start = tile_end; + } + None +} + +pub(super) fn apply_syntax_tokens_to_file( + carbon_overlays: &mut CarbonStyleOverlays, + token_buffer: &mut TokenBuffer, + updates: &[SyntaxLineTokens], +) { + for update in updates { + if let (Some(side), Some(source_index)) = (update.side, update.source_index) { + if update.tokens.is_empty() { + continue; + } + let range = token_buffer.append(&update.tokens); + carbon_overlays.insert_syntax(update.hunk_index as u32, side, source_index, range); + } + } +} + +pub(super) fn active_file_matches_language( + active: &ActiveFile, + highlighter: &Highlighter, + language: &str, +) -> bool { + !active.carbon_file.is_binary + && [ + Some(active.path.as_str()), + active.carbon_file.old_path.as_deref(), + active.carbon_file.new_path.as_deref(), + ] + .into_iter() + .flatten() + .any(|path| { + highlighter + .resolve_language(path) + .is_some_and(|resolved| resolved.name() == language) + }) +} + +pub(super) fn file_change_syntax_paths(change: &FileChange) -> Vec { + let mut paths = Vec::with_capacity(2); + if let Some(old_path) = change.old_path.as_ref() { + paths.push(old_path.clone()); + } + if !paths.iter().any(|path| path == &change.path) { + paths.push(change.path.clone()); + } + paths +} + +pub(super) fn ensure_syntax_packs_for_file_change_effect(change: &FileChange) -> Effect { + let mut paths = file_change_syntax_paths(change); + if paths.len() == 1 { + return SyntaxEffect::EnsureSyntaxPackForPath { + path: paths.pop().unwrap_or_else(|| change.path.clone()), + } + .into(); + } + SyntaxEffect::EnsureSyntaxPacksForPaths { paths }.into() +} + +pub(super) fn reset_active_file_syntax(active: &mut ActiveFile) { + active.syntax_pending.clear(); + active.syntax_covered.clear(); + let preserve_change_tokens = active.carbon_overlays.has_change_tokens(); + active.carbon_overlays.clear_syntax(); + if !preserve_change_tokens { + active.token_buffer.clear(); + } + active.render_doc = Arc::new(build_render_doc_from_carbon( + &active.carbon_file, + active.index, + &active.carbon_expansion, + &active.carbon_overlays, + &active.token_buffer, + )); +} + +pub(super) fn push_syntax_covered_window( + windows: &mut Vec, + window: SyntaxRowWindow, +) { + if window.end <= window.start { + return; + } + windows.push(window); + windows.sort_by_key(|window| window.start); + let mut merged: Vec = Vec::with_capacity(windows.len()); + for window in windows.drain(..) { + if let Some(last) = merged.last_mut() + && window.start <= last.end + { + last.end = last.end.max(window.end); + continue; + } + merged.push(window); + } + *windows = merged; +} + +pub(super) fn remove_pending_syntax_window( + pending: &mut Vec, + request_id: u64, + window: SyntaxRowWindow, +) -> bool { + let Some(index) = pending + .iter() + .position(|pending| pending.request_id == request_id && pending.window == window) + else { + return false; + }; + pending.swap_remove(index); + true +} + +impl AppState { + pub(super) fn syntax_pending_window_count(&self) -> usize { + let active_count = self.workspace.active_file.with(&self.store, |active| { + active + .as_ref() + .map_or(0, |active| active.syntax_pending.len()) + }); + let cache_count = self.workspace.file_cache.with(&self.store, |files| { + files + .values() + .map(|file| file.syntax_pending.len()) + .sum::() + }); + active_count.saturating_add(cache_count) + } + + pub(super) fn syntax_outstanding_window_count(&self) -> usize { + self.syntax_requests + .outstanding_count(self.syntax_pending_window_count()) + } + + pub(super) fn syntax_request_budget_available(&self) -> bool { + self.syntax_requests + .budget_available(self.syntax_pending_window_count()) + } + + pub(super) fn track_syntax_request(&mut self, request: &LoadFileSyntaxRequest) { + self.syntax_requests.track(request); + } + + pub(super) fn finish_syntax_request(&mut self, generation: u64, request_id: u64) { + self.syntax_requests.finish(generation, request_id); + } + + pub(super) fn clear_syntax_pending_windows(&mut self) { + self.workspace.active_file.update(&self.store, |active| { + if let Some(active) = active.as_mut() { + active.syntax_pending.clear(); + } + }); + self.workspace.file_cache.update(&self.store, |files| { + for active in files.values_mut() { + active.syntax_pending.clear(); + } + }); + } + + pub(super) fn clear_syntax_inflight(&mut self) { + self.clear_syntax_pending_windows(); + self.syntax_requests.invalidate(); + } + + pub(super) fn syntax_epoch_effect(&self) -> Effect { + SyntaxEffect::SetFileSyntaxEpoch { + epoch: self.syntax_requests.epoch(), + } + .into() + } + + pub(super) fn invalidate_syntax_epoch_effect(&mut self) -> Effect { + self.clear_syntax_inflight(); + self.syntax_epoch_effect() + } +} + +impl AppState { + pub(super) fn handle_file_syntax_ready(&mut self, payload: FileSyntaxReady) -> Vec { + self.finish_syntax_request(payload.generation, payload.request_id); + if payload.generation != self.active_syntax_generation() { + return Vec::new(); + } + + let mut applied_file = None; + let mut applied_active = false; + let mut matched_active = false; + self.workspace.active_file.update(&self.store, |slot| { + let Some(active) = slot.as_mut() else { + return; + }; + if active.index != payload.file_index || active.path != payload.path { + return; + } + matched_active = true; + + if !remove_pending_syntax_window( + &mut active.syntax_pending, + payload.request_id, + payload.window, + ) { + return; + } + if active + .syntax_covered + .iter() + .any(|covered| covered.contains(payload.window)) + { + return; + } + push_syntax_covered_window(&mut active.syntax_covered, payload.window); + apply_syntax_tokens_to_file( + &mut active.carbon_overlays, + &mut active.token_buffer, + &payload.tokens, + ); + active.render_doc = Arc::new(build_render_doc_from_carbon( + &active.carbon_file, + active.index, + &active.carbon_expansion, + &active.carbon_overlays, + &active.token_buffer, + )); + applied_file = Some(active.clone()); + applied_active = true; + }); + if matched_active && applied_file.is_none() { + tracing::debug!( + file_index = payload.file_index, + path = %payload.path, + request_id = payload.request_id, + "stale active syntax response dropped" + ); + return Vec::new(); + } + + if applied_file.is_none() { + self.workspace.file_cache.update(&self.store, |files| { + let Some(active) = files.get_mut(&payload.file_index) else { + return; + }; + if active.index != payload.file_index || active.path != payload.path { + return; + } + + if !remove_pending_syntax_window( + &mut active.syntax_pending, + payload.request_id, + payload.window, + ) { + return; + } + if active + .syntax_covered + .iter() + .any(|covered| covered.contains(payload.window)) + { + return; + } + push_syntax_covered_window(&mut active.syntax_covered, payload.window); + apply_syntax_tokens_to_file( + &mut active.carbon_overlays, + &mut active.token_buffer, + &payload.tokens, + ); + active.render_doc = Arc::new(build_render_doc_from_carbon( + &active.carbon_file, + active.index, + &active.carbon_expansion, + &active.carbon_overlays, + &active.token_buffer, + )); + applied_file = Some(active.clone()); + }); + } + + let Some(active_file) = applied_file else { + return Vec::new(); + }; + self.cache_active_file(active_file); + self.viewport_document_cache = None; + + if applied_active { + self.request_active_file_syntax_effect() + .into_iter() + .collect() + } else { + Vec::new() + } + } + + pub(super) fn handle_syntax_pack_install_started(&mut self, language: &str) { + self.ui.syntax_pack_installs.update(&self.store, |active| { + if !active.iter().any(|item| item == language) { + active.push(language.to_owned()); + } + }); + } + + pub(super) fn handle_syntax_pack_install_finished(&mut self, language: &str) { + self.ui + .syntax_pack_installs + .update(&self.store, |active| active.retain(|item| item != language)); + } + + pub fn syntax_pack_install_active(&self) -> bool { + self.ui + .syntax_pack_installs + .with(&self.store, |active| !active.is_empty()) + } + + pub(super) fn syntax_pack_warmup_effect_for_compare( + &self, + exclude_paths: &[String], + ) -> Option { + let highlighter = phosphor::Highlighter::new(); + let excluded_languages = exclude_paths + .iter() + .filter_map(|path| highlighter.guess_language(Path::new(path))) + .collect::>(); + let active_languages = self.ui.syntax_pack_installs.with(&self.store, |active| { + active.iter().cloned().collect::>() + }); + + self.workspace.compare_output.with(&self.store, |output| { + let output = output.as_ref()?; + let mut seen = HashSet::new(); + let mut warmup_paths = Vec::new(); + output.for_each_summary(|_, summary| { + for path in [summary.paths.old_path(), summary.paths.new_path()] + .into_iter() + .flatten() + { + let Some(language) = highlighter.guess_language(Path::new(path.as_ref())) + else { + continue; + }; + if excluded_languages.contains(&language) + || active_languages.contains(language.name()) + || highlighter.is_parser_available(language) + { + continue; + } + if seen.insert(language) { + warmup_paths.push(path.into_owned()); + } + } + }); + + (!warmup_paths.is_empty()).then_some( + SyntaxEffect::EnsureSyntaxPacksForPaths { + paths: warmup_paths, + } + .into(), + ) + }) + } + + pub(super) fn syntax_pack_warmup_effect_for_paths( + &self, + paths: &[String], + exclude_paths: &[String], + ) -> Option { + let highlighter = phosphor::Highlighter::new(); + let excluded_languages = exclude_paths + .iter() + .filter_map(|path| highlighter.guess_language(Path::new(path))) + .collect::>(); + let active_languages = self.ui.syntax_pack_installs.with(&self.store, |active| { + active.iter().cloned().collect::>() + }); + + let mut seen = HashSet::new(); + let mut warmup_paths = Vec::new(); + for path in paths { + let Some(language) = highlighter.guess_language(Path::new(path)) else { + continue; + }; + if excluded_languages.contains(&language) + || active_languages.contains(language.name()) + || highlighter.is_parser_available(language) + { + continue; + } + if seen.insert(language) { + warmup_paths.push(path.clone()); + } + } + + (!warmup_paths.is_empty()).then_some( + SyntaxEffect::EnsureSyntaxPacksForPaths { + paths: warmup_paths, + } + .into(), + ) + } + + pub(super) fn handle_syntax_packs_installed(&mut self, languages: &[String]) -> Vec { + if languages.is_empty() { + return Vec::new(); + } + let mut effects = vec![self.invalidate_syntax_epoch_effect()]; + for language in languages { + effects.extend(self.refresh_active_file_syntax_for_language(language)); + effects.extend(self.request_cached_file_syntax_effects_for_language(language)); + } + effects + } + + pub(super) fn refresh_active_file_syntax_for_language( + &mut self, + language: &str, + ) -> Vec { + let highlighter = Highlighter::new(); + let mut refreshed = false; + self.workspace.active_file.update(&self.store, |slot| { + let Some(active) = slot.as_mut() else { + return; + }; + if !active_file_matches_language(active, &highlighter, language) { + return; + } + reset_active_file_syntax(active); + refreshed = true; + }); + if !refreshed { + return Vec::new(); + } + self.viewport_document_cache = None; + self.request_active_file_syntax_effect() + .into_iter() + .collect() + } + + pub(super) fn request_cached_file_syntax_effects_for_language( + &mut self, + language: &str, + ) -> Vec { + let Some(repo_path) = self.compare.repo_path.get(&self.store) else { + return Vec::new(); + }; + let generation = self.active_syntax_generation(); + let syntax_epoch = self.syntax_requests.epoch(); + let mut remaining_budget = + MAX_PENDING_SYNTAX_WINDOWS.saturating_sub(self.syntax_outstanding_window_count()); + if remaining_budget == 0 { + return Vec::new(); + } + let active_key = self.workspace.active_file.with(&self.store, |active| { + active.as_ref().map(ActiveFile::working_set_key) + }); + let highlighter = Highlighter::new(); + let mut requests = Vec::new(); + let mut next_request_id = self.syntax_requests.last_request_id(); + + self.workspace.file_cache.update(&self.store, |files| { + for active in files.values_mut() { + if remaining_budget == 0 { + break; + } + if active_key + .as_ref() + .is_some_and(|key| key == &active.working_set_key()) + { + continue; + } + if !active_file_matches_language(active, &highlighter, language) { + continue; + } + let line_count = active.render_doc.lines.len(); + if line_count == 0 { + continue; + } + reset_active_file_syntax(active); + let window = SyntaxRowWindow { + start: 0, + end: line_count.min(SYNTAX_INITIAL_ROWS), + }; + next_request_id = next_request_id.saturating_add(1); + if let Some(request) = request_syntax_for_active_file( + active, + repo_path.clone(), + generation, + syntax_epoch, + window, + next_request_id, + ) { + requests.push(request); + remaining_budget = remaining_budget.saturating_sub(1); + } + } + }); + self.syntax_requests.set_last_request_id(next_request_id); + + requests + .into_iter() + .map(|request| { + self.track_syntax_request(&request); + SyntaxEffect::LoadFileSyntax(Task { + generation, + request, + }) + .into() + }) + .collect() + } + + pub(super) fn request_active_file_syntax_effect(&mut self) -> Option { + if !self.syntax_request_budget_available() { + return None; + } + let repo_path = self.compare.repo_path.get(&self.store)?; + let window = self.desired_syntax_window()?; + let generation = self.active_syntax_generation(); + let syntax_epoch = self.syntax_requests.epoch(); + let mut request = None; + let request_id = self.syntax_requests.next_request_id(); + let mut active_to_cache = None; + + self.workspace.active_file.update(&self.store, |active| { + let Some(active) = active.as_mut() else { + return; + }; + if let Some(next_request) = request_syntax_for_active_file( + active, + repo_path, + generation, + syntax_epoch, + window, + request_id, + ) { + active_to_cache = Some(active.clone()); + request = Some(next_request); + } + }); + if let Some(active_file) = active_to_cache { + self.cache_active_file(active_file); + } + + request.map(|request| { + self.track_syntax_request(&request); + SyntaxEffect::LoadFileSyntax(Task { + generation, + request, + }) + .into() + }) + } + + pub(super) fn active_syntax_generation(&self) -> u64 { + match self.workspace.source.get(&self.store) { + WorkspaceSource::Status => self.workspace.status_generation.get(&self.store), + _ => self.workspace.compare_generation.get(&self.store), + } + } + + pub(super) fn desired_syntax_window(&self) -> Option { + let line_count = self.workspace.active_file.with(&self.store, |active| { + active.as_ref().map(|active| active.render_doc.lines.len()) + })?; + if line_count == 0 { + return None; + } + + if let (Some(start), Some(end)) = ( + self.editor.visible_row_start.get(&self.store), + self.editor.visible_row_end.get(&self.store), + ) && end > start + { + return Some(SyntaxRowWindow { + start: start.saturating_sub(SYNTAX_OVERSCAN_ROWS), + end: end.saturating_add(SYNTAX_OVERSCAN_ROWS).min(line_count), + }); + } + + let scroll = self.editor.scroll_top_px.get(&self.store) as usize; + let viewport = self.editor.viewport_height_px.get(&self.store) as usize; + let approx_row_height = 20usize; + let start = scroll / approx_row_height; + let visible = (viewport / approx_row_height).saturating_add(SYNTAX_INITIAL_ROWS); + Some(SyntaxRowWindow { + start: start.saturating_sub(SYNTAX_OVERSCAN_ROWS), + end: start.saturating_add(visible).min(line_count), + }) + } +} diff --git a/src/ui/state/tests.rs b/src/ui/state/tests.rs new file mode 100644 index 00000000..7bf762df --- /dev/null +++ b/src/ui/state/tests.rs @@ -0,0 +1,3360 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use clap::Parser; + +use super::{ + ActiveFile, ActiveFileLoading, AppState, AsyncStatus, CarbonStyleOverlays, CardTextSelection, + CompareField, FILE_HEIGHT_SPARSE_MIN_COUNT, FileHeightIndex, FileListEntry, FocusTarget, + OverlayEntry, OverlaySurface, PickerItem, PickerLabelStyle, PreparedActiveFile, SidebarMode, + SidebarTab, TextCompareLanguage, TextCompareView, ViewportAnchorBias, VirtualDiffItemKind, + WorkspaceMode, WorkspaceSource, prepare_active_file, vcs_compare_request, +}; +use crate::core::compare::{ + CompareFileSummary, CompareMode, CompareOutput, LayoutMode, RendererKind, +}; +use crate::core::text::TokenBuffer; +use crate::core::vcs::model::{ + ChangeBucket, ChangeFlags, FileChange, FileChangeStatus, JjOperation, RefKind, + RepoCapabilities, RepoLocation, RevisionId, VcsChange, VcsKind, VcsOperation, + VcsOperationLogEntry, VcsRef, +}; +use crate::editor::EditorMode; +use crate::editor::diff::render_doc::{RenderDoc, build_render_doc_from_carbon}; +use crate::effects::{ + AiEffect, CompareEffect, CompareWorkPriority, Effect, GitHubEffect, RepositoryEffect, + SettingsEffect, SyntaxEffect, +}; +use crate::events::{ + AppEvent, CompareEvent, CompareFileFinished, CompareFileStat, CompareFileStatsReady, + CompareStatsReady, GitHubEvent, RepositoryEvent, TextCompareFinished, +}; +use crate::platform::persistence::Settings; +use crate::platform::startup::{Args, StartupOptions}; + +fn carbon_summary_for_path(index: usize, path: &str) -> carbon::FileDiff { + carbon::FileDiff { + id: carbon::FileId(index as u32), + old_path: Some(path.to_owned()), + new_path: Some(path.to_owned()), + is_partial: true, + ..carbon::FileDiff::default() + } +} + +fn carbon_context_file(index: usize, path: &str, text: &str) -> carbon::FileDiff { + carbon::parse_unified_patch(&format!( + "diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -1 +1 @@\n {text}\n" + )) + .unwrap() + .files + .into_iter() + .next() + .map(|mut file| { + file.id = carbon::FileId(index as u32); + file + }) + .unwrap() +} + +#[test] +fn new_text_compare_enters_text_workspace_with_left_focus() { + let mut state = AppState::default(); + + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + + assert_eq!( + state.workspace.source.get(&state.store), + WorkspaceSource::TextCompare + ); + assert_eq!(state.text_compare.view, TextCompareView::Edit); + assert_eq!(state.text_compare.left_editor.mode(), EditorMode::CodeInput); + assert_eq!( + state.text_compare.right_editor.mode(), + EditorMode::CodeInput + ); + assert_eq!(state.text_compare.language, TextCompareLanguage::Auto); + assert_eq!(state.text_compare.path_hint, "text.txt"); + assert_eq!( + state.ui.focus.get(&state.store), + Some(FocusTarget::TextCompareLeft) + ); +} + +#[test] +fn text_compare_paste_routes_to_focused_side() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + + state.apply_action(crate::actions::TextEditAction::Paste("left".to_owned())); + state.apply_action(crate::actions::AppAction::SetFocus(Some( + FocusTarget::TextCompareRight, + ))); + state.apply_action(crate::actions::TextEditAction::Paste("right".to_owned())); + + assert_eq!(state.text_compare.left_editor.text(), "left"); + assert_eq!(state.text_compare.right_editor.text(), "right"); + assert_eq!(state.text_compare.left_editor.line_count(), 1); + assert_eq!(state.text_compare.right_editor.line_count(), 1); +} + +#[test] +fn text_compare_auto_language_detects_pasted_rust() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + + state.apply_action(crate::actions::TextEditAction::Paste( + "pub fn main() {\n println!(\"hi\");\n}\n".to_owned(), + )); + + assert_eq!( + state.text_compare.detected_language, + Some(TextCompareLanguage::Rust) + ); + assert_eq!(state.text_compare.path_hint, "scratch.rs"); +} + +#[test] +fn text_compare_auto_language_detects_pasted_typescript() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + + state.apply_action(crate::actions::TextEditAction::Paste( + "const answer: number = 42;\nexport { answer };\n".to_owned(), + )); + + assert_eq!( + state.text_compare.detected_language, + Some(TextCompareLanguage::TypeScript) + ); + assert_eq!(state.text_compare.path_hint, "scratch.ts"); +} + +#[test] +fn text_compare_language_override_sets_compare_path() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + + state.apply_action(crate::actions::TextCompareAction::SetLanguage( + TextCompareLanguage::TypeScript, + )); + state.apply_action(crate::actions::TextEditAction::Paste( + "pub fn main() {}\n".to_owned(), + )); + let effects = state.apply_action(crate::actions::TextCompareAction::CompareNow); + let request_path = effects + .iter() + .find_map(|effect| match effect { + Effect::Compare(CompareEffect::RunText(task)) => { + Some(task.request.display_path.as_str()) + } + _ => None, + }) + .unwrap(); + + assert_eq!(state.text_compare.language, TextCompareLanguage::TypeScript); + assert_eq!(state.text_compare.path_hint, "scratch.ts"); + assert_eq!(request_path, "scratch.ts"); +} + +#[test] +fn text_compare_swap_sides_preserves_text_and_marks_stale() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + state.text_compare.left_editor.set_text("old"); + state.text_compare.right_editor.set_text("new"); + let generation = state.text_compare.generation; + + state.apply_action(crate::actions::TextCompareAction::SwapSides); + + assert_eq!(state.text_compare.left_editor.text(), "new"); + assert_eq!(state.text_compare.right_editor.text(), "old"); + assert!(state.text_compare.generation > generation); + assert!(state.text_compare_is_stale()); +} + +#[test] +fn stale_text_compare_finished_event_is_ignored() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + let effects = state.apply_action(crate::actions::TextCompareAction::CompareNow); + let generation = effects + .iter() + .find_map(|effect| match effect { + Effect::Compare(CompareEffect::RunText(task)) => Some(task.generation), + _ => None, + }) + .unwrap(); + state.apply_action(crate::actions::TextEditAction::Paste("newer".to_owned())); + + state.apply_event(AppEvent::from(CompareEvent::TextCompareFinished( + TextCompareFinished { + generation, + display_path: "text.txt".to_owned(), + renderer: RendererKind::Builtin, + layout: LayoutMode::Unified, + output: CompareOutput::default(), + }, + ))); + + assert!(state.workspace.compare_output.get(&state.store).is_none()); + assert_eq!(state.text_compare.view, TextCompareView::Edit); +} + +// Regression test: `CompareScheduler` keeps a monotonic epoch high-water +// mark, so a text compare that rewinds `workspace.compare_generation` below +// it makes every later repo file/stats job get dropped silently (perpetual +// "Loading diff..."). Text compares must bump the shared counter forward. +#[test] +fn text_compare_generation_never_rewinds_workspace_generation() { + let mut state = AppState::default(); + // Simulate prior repo compares having advanced the shared counter (and + // with it the scheduler epoch). + state.workspace.compare_generation.set(&state.store, 5); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + let effects = state.apply_action(crate::actions::TextCompareAction::CompareNow); + let generation = effects + .iter() + .find_map(|effect| match effect { + Effect::Compare(CompareEffect::RunText(task)) => Some(task.generation), + _ => None, + }) + .unwrap(); + + assert!(generation > 5); + assert_eq!( + state.workspace.compare_generation.get(&state.store), + generation + ); + assert_eq!(state.text_compare.generation, generation); +} + +#[test] +fn text_compare_finished_installs_diff_view() { + let mut state = AppState::default(); + state.apply_action(crate::actions::WorkspaceAction::NewTextCompare); + let generation = state.text_compare.generation.saturating_add(1); + state.text_compare.generation = generation; + state + .workspace + .compare_generation + .set(&state.store, generation); + let output = crate::core::compare::compare_text( + "old\n", + "new\n", + "text.txt", + RendererKind::Builtin, + LayoutMode::Unified, + ) + .unwrap(); + + state.apply_event(AppEvent::from(CompareEvent::TextCompareFinished( + TextCompareFinished { + generation, + display_path: "text.txt".to_owned(), + renderer: RendererKind::Builtin, + layout: LayoutMode::Unified, + output, + }, + ))); + + assert_eq!(state.text_compare.view, TextCompareView::Diff); + assert_eq!( + state.workspace.source.get(&state.store), + WorkspaceSource::TextCompare + ); + assert!(state.workspace.active_file.get(&state.store).is_some()); +} + +fn status_state_with_two_hunks() -> AppState { + let state = AppState::default(); + let repo_path = PathBuf::from("/repo"); + let path = "src/lib.rs".to_owned(); + let token_buffer = TokenBuffer::default(); + let carbon_file = carbon::parse_unified_patch( + "\ +diff --git a/src/lib.rs b/src/lib.rs +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,3 +1,2 @@ + fn one() { +- old_first(); + } +@@ -8,3 +7,2 @@ + fn two() { +- old_second(); + } +", + ) + .unwrap() + .files + .into_iter() + .next() + .unwrap(); + let carbon_expansion = carbon::ExpansionState::default(); + let render_doc = build_render_doc_from_carbon( + &carbon_file, + 0, + &carbon_expansion, + &CarbonStyleOverlays::default(), + &token_buffer, + ); + let (left_ref, right_ref) = + crate::ui::vcs::profile(None).status_compare_refs(ChangeBucket::Unstaged); + + state.compare.repo_path.set(&state.store, Some(repo_path)); + state + .repository + .capabilities + .set(&state.store, Some(RepoCapabilities::git())); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Status); + state.workspace.status.set(&state.store, AsyncStatus::Ready); + state + .workspace + .status_operation_pending + .set(&state.store, false); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); + state.workspace.files.set( + &state.store, + vec![FileListEntry { + path: path.as_str().into(), + }], + ); + state.workspace.status_file_changes.set( + &state.store, + vec![FileChange { + path: path.clone(), + old_path: None, + status: FileChangeStatus::Modified, + bucket: ChangeBucket::Unstaged, + }], + ); + state + .workspace + .selected_file_index + .set(&state.store, Some(0)); + state + .workspace + .selected_file_path + .set(&state.store, Some(path.clone())); + state + .workspace + .selected_change_bucket + .set(&state.store, Some(ChangeBucket::Unstaged)); + state.workspace.active_file.set( + &state.store, + Some(ActiveFile { + index: 0, + path, + carbon_file: Arc::new(carbon_file.clone()), + carbon_expansion, + carbon_overlays: CarbonStyleOverlays::default(), + render_doc: Arc::new(render_doc), + token_buffer, + left_ref, + right_ref, + file_line_count: None, + old_file_lines: None, + file_lines: None, + syntax_pending: Vec::new(), + syntax_covered: Vec::new(), + last_used_tick: 0, + }), + ); + + state +} + +fn loaded_state_with_files(paths: &[&str]) -> AppState { + let state = AppState::default(); + let carbon_files: Vec = paths + .iter() + .enumerate() + .map(|(index, path)| carbon_context_file(index, path, "loaded")) + .collect(); + let entries: Vec = carbon_files + .iter() + .map(|file| FileListEntry { + path: file.path().into(), + }) + .collect(); + + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + carbon: carbon::DiffDocument { + files: carbon_files, + }, + ..CompareOutput::default() + }), + ); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.workspace.files.set(&state.store, entries); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); + state.file_list.row_height.set(&state.store, 36.0); + state.file_list.gap.set(&state.store, 4.0); + state.file_list.viewport_height.set(&state.store, 80.0); + state +} + +#[test] +fn bootstrap_with_no_repo_starts_empty_workspace() { + let startup = StartupOptions::from_parts( + Args::parse_from(["diffy"]), + None, + "client".to_owned(), + false, + ); + + let (state, effects) = AppState::bootstrap(startup, Settings::default()); + assert_eq!(state.workspace.mode.get(&state.store), WorkspaceMode::Empty); + assert_eq!( + state.ui.focus.get(&state.store), + Some(FocusTarget::WorkspacePrimaryButton) + ); + assert!(effects.iter().all(|e| matches!( + e, + Effect::GitHub(GitHubEffect::LoadGitHubToken) + | Effect::Ai(AiEffect::LoadAiKeys) + | Effect::Syntax(SyntaxEffect::InstallCommonSyntaxPacks) + ))); +} + +#[test] +fn bootstrap_with_repo_starts_repo_sync() { + let startup = StartupOptions::from_parts( + Args { + repo: Some("C:\\repo".into()), + left: Some("main".to_owned()), + right: None, + compare_mode: Some(CompareMode::TwoDot), + layout: Some(LayoutMode::Unified), + renderer: Some(RendererKind::Builtin), + file_index: None, + file_path: None, + open_pr: None, + }, + None, + "client".to_owned(), + false, + ); + + let (state, effects) = AppState::bootstrap(startup, Settings::default()); + assert_eq!(state.workspace.mode.get(&state.store), WorkspaceMode::Empty); + assert_eq!(state.active_overlay_name(), None); + assert_eq!( + effects + .iter() + .filter(|e| matches!( + e, + Effect::Repository(RepositoryEffect::SyncRepository { .. }) + | Effect::Repository(RepositoryEffect::WatchRepository { .. }) + )) + .count(), + 2 + ); +} + +#[test] +fn overlay_close_restores_prior_focus() { + let startup = StartupOptions::from_parts( + Args::parse_from(["diffy"]), + None, + "client".to_owned(), + false, + ); + let (mut state, _) = AppState::bootstrap(startup, Settings::default()); + state.apply_action(crate::actions::AppAction::SetFocus(Some( + FocusTarget::TitleBar, + ))); + state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); + assert_eq!(state.overlays_top(), Some(OverlaySurface::CommandPalette)); + state.apply_action(crate::actions::OverlayAction::CloseOverlay); + assert_eq!( + state.ui.focus.get(&state.store), + Some(FocusTarget::TitleBar) + ); +} + +#[test] +fn pixel_scroll_actions_clamp_file_list_and_viewport() { + let mut state = AppState::default(); + + state.workspace.files.set( + &state.store, + vec![ + FileListEntry { + path: "a.rs".into(), + }, + FileListEntry { + path: "b.rs".into(), + }, + FileListEntry { + path: "c.rs".into(), + }, + FileListEntry { + path: "d.rs".into(), + }, + FileListEntry { + path: "e.rs".into(), + }, + ], + ); + state.file_list.row_height.set(&state.store, 36.0); + state.file_list.gap.set(&state.store, 4.0); + state.file_list.viewport_height.set(&state.store, 80.0); + + state.apply_action(crate::actions::FileListAction::ScrollFileListPx(50)); + assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 50.0); + + state.apply_action(crate::actions::FileListAction::ScrollFileListPx(500)); + assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 116.0); + + state.apply_action(crate::actions::FileListAction::ScrollFileListPx(-500)); + assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 0.0); + + state.editor.content_height_px.set(&state.store, 600); + state.editor.viewport_height_px.set(&state.store, 200); + + state.apply_action(crate::actions::EditorAction::ScrollViewportPx(75)); + assert_eq!(state.editor.scroll_top_px.get(&state.store), 75); + + state.apply_action(crate::actions::EditorAction::ScrollViewportPx(500)); + assert_eq!(state.editor.scroll_top_px.get(&state.store), 400); + + state.apply_action(crate::actions::EditorAction::ScrollViewportPx(-500)); + assert_eq!(state.editor.scroll_top_px.get(&state.store), 0); +} + +#[test] +fn file_height_index_keeps_uniform_large_lists_sparse() { + let mut index = FileHeightIndex::default(); + index.rebuild(vec![192; FILE_HEIGHT_SPARSE_MIN_COUNT + 1]); + + assert_eq!(index.len(), FILE_HEIGHT_SPARSE_MIN_COUNT + 1); + assert_eq!( + index.total_u32(), + ((FILE_HEIGHT_SPARSE_MIN_COUNT + 1) as u32) * 192 + ); + assert!(matches!(index, FileHeightIndex::Sparse { .. })); + assert_eq!(index.locate(192 * 7 + 12), Some((7, 12))); +} + +#[test] +fn sparse_file_height_index_updates_prefix_and_locate() { + let mut index = FileHeightIndex::default(); + index.rebuild(vec![100; FILE_HEIGHT_SPARSE_MIN_COUNT + 2]); + index.update(3, 250); + index.update(7, 40); + + assert_eq!(index.prefix_u32(4), 550); + assert_eq!(index.prefix_u32(8), 890); + assert_eq!(index.locate(549), Some((3, 249))); + assert_eq!(index.locate(550), Some((4, 0))); + assert_eq!(index.locate(849), Some((6, 99))); + assert_eq!(index.locate(850), Some((7, 0))); + assert_eq!(index.locate(889), Some((7, 39))); + assert_eq!(index.locate(890), Some((8, 0))); +} + +#[test] +fn clicking_a_visible_file_does_not_force_sidebar_reveal() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.file_list.scroll_offset_px.set(&state.store, 10.0); + + state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + assert_eq!( + state.workspace.selected_file_index.get(&state.store), + Some(0) + ); + assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 10.0); +} + +#[test] +fn keyboard_file_navigation_still_reveals_selection() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs", "d.rs"]); + state + .workspace + .selected_file_index + .set(&state.store, Some(0)); + state + .workspace + .selected_file_path + .set(&state.store, Some("a.rs".into())); + state.file_list.scroll_offset_px.set(&state.store, 50.0); + + state.apply_action(crate::actions::FileListAction::SelectNextFile); + + assert_eq!( + state.workspace.selected_file_index.get(&state.store), + Some(1) + ); + assert_eq!(state.file_list.scroll_offset_px.get(&state.store), 40.0); +} + +#[test] +fn next_file_action_selects_adjacent_file_in_single_file_mode() { + let mut state = loaded_state_with_files(&["src/ui/state/mod.rs", "src/ui/state/text_edit.rs"]); + state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + state.apply_action(crate::actions::EditorAction::GoToNextFile); + state.sync_editor_scroll_from_global(); + + assert_eq!( + state.workspace.selected_file_index.get(&state.store), + Some(1) + ); + assert_eq!( + state + .workspace + .selected_file_path + .get(&state.store) + .as_deref(), + Some("src/ui/state/text_edit.rs") + ); + assert_eq!( + state + .workspace + .active_file + .get(&state.store) + .as_ref() + .map(|file| file.path.as_str()), + Some("src/ui/state/text_edit.rs") + ); + assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 0); +} + +#[test] +fn next_file_action_selects_next_file_when_tail_is_short() { + let mut state = loaded_state_with_files(&["src/ui/state/mod.rs", "src/ui/state/text_edit.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 10_000); + state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + state.apply_action(crate::actions::EditorAction::GoToNextFile); + + assert_eq!( + state.workspace.selected_file_index.get(&state.store), + Some(1) + ); + assert_eq!( + state + .workspace + .selected_file_path + .get(&state.store) + .as_deref(), + Some("src/ui/state/text_edit.rs") + ); + assert_eq!( + state + .workspace + .active_file + .get(&state.store) + .as_ref() + .map(|file| file.path.as_str()), + Some("src/ui/state/text_edit.rs") + ); +} + +#[test] +fn continuous_scroll_keeps_short_tail_at_natural_bottom() { + let state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.editor.viewport_height_px.set(&state.store, 10_000); + + assert_eq!(state.global_max_scroll_top_px(), 0); +} + +#[test] +fn continuous_scroll_first_height_measurement_keeps_total_cache_in_sync_with_index() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.recompute_file_scroll_total_height_px(); + + assert_eq!(state.workspace.measured_px_per_row_q16.get(&state.store), 0); + + assert!(state.update_file_content_height_px(0, 1_200)); + + assert_eq!( + state + .workspace + .file_scroll_total_height_px + .get(&state.store), + state.virtual_diff_document.total_u32() + ); +} + +#[test] +fn continuous_scroll_keeps_bottom_anchor_when_visible_file_height_grows() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 100); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(200), Some(200), Some(200)]); + state.recompute_file_scroll_total_height_px(); + + let old_max = state.global_max_scroll_top_px(); + assert_eq!(old_max, 500); + state + .workspace + .global_scroll_top_px + .set(&state.store, old_max); + + assert!(state.update_file_content_height_px(2, 350)); + + assert_eq!(state.global_max_scroll_top_px(), 650); + assert_eq!( + state.workspace.global_scroll_top_px.get(&state.store), + state.global_max_scroll_top_px() + ); +} + +#[test] +fn continuous_scroll_follow_end_anchor_is_explicit_after_scrolling_to_bottom() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 100); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(200), Some(200), Some(200)]); + state.recompute_file_scroll_total_height_px(); + + let old_max = state.global_max_scroll_top_px(); + state.scroll_viewport_to_global(old_max); + + let anchor = state.virtual_scroll.anchor.expect("bottom anchor"); + assert_eq!(anchor.bias, ViewportAnchorBias::FollowEnd); + + assert!(state.update_file_content_height_px(2, 350)); + + assert_eq!( + state.workspace.global_scroll_top_px.get(&state.store), + state.global_max_scroll_top_px() + ); +} + +#[test] +fn continuous_scroll_preserves_top_anchor_when_prior_file_height_changes() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 100); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(200), Some(200), Some(200)]); + state.recompute_file_scroll_total_height_px(); + + state.scroll_viewport_to_global(250); + let anchor = state.virtual_scroll.anchor.expect("top anchor"); + assert_eq!(anchor.item_id.index, 1); + assert_eq!(anchor.intra_item_offset_px, 50); + assert_eq!(anchor.bias, ViewportAnchorBias::PreserveTop); + + assert!(state.update_file_content_height_px(0, 300)); + + assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 350); + let anchor = state.virtual_scroll.anchor.expect("rebased anchor"); + assert_eq!(anchor.item_id.index, 1); + assert_eq!(anchor.intra_item_offset_px, 50); +} + +#[test] +fn continuous_scroll_preserves_bottom_anchor_when_prior_file_height_changes() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 100); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(200), Some(200), Some(200)]); + state.recompute_file_scroll_total_height_px(); + + state.set_viewport_anchor_for_global(350, ViewportAnchorBias::PreserveBottom); + let anchor = state.virtual_scroll.anchor.expect("bottom-edge anchor"); + assert_eq!(anchor.item_id.index, 2); + assert_eq!(anchor.intra_item_offset_px, 50); + + assert!(state.update_file_content_height_px(0, 300)); + + assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 450); + let anchor = state.virtual_scroll.anchor.expect("rebased anchor"); + assert_eq!(anchor.bias, ViewportAnchorBias::PreserveBottom); + assert_eq!(anchor.item_id.index, 2); + assert_eq!(anchor.intra_item_offset_px, 50); +} + +#[test] +fn continuous_scroll_keeps_bottom_anchor_after_pending_scrollbar_drag_height_update() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 100); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(200), Some(200), Some(200)]); + state.recompute_file_scroll_total_height_px(); + + let old_max = state.global_max_scroll_top_px(); + assert_eq!(old_max, 500); + state + .workspace + .global_scroll_top_px + .set(&state.store, old_max); + state.begin_viewport_scrollbar_drag(600, 100, old_max, old_max); + + assert!(!state.update_file_content_height_px(2, 350)); + state.end_viewport_scrollbar_drag(); + + assert_eq!(state.global_max_scroll_top_px(), 650); + assert_eq!( + state.workspace.global_scroll_top_px.get(&state.store), + state.global_max_scroll_top_px() + ); +} + +#[test] +fn continuous_scroll_does_not_treat_zero_max_as_bottom_anchor() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 1_000); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(200), Some(200), Some(200)]); + state.recompute_file_scroll_total_height_px(); + + assert_eq!(state.global_max_scroll_top_px(), 0); + assert!(state.update_file_content_height_px(2, 700)); + + assert_eq!(state.global_max_scroll_top_px(), 100); + assert_eq!(state.workspace.global_scroll_top_px.get(&state.store), 0); +} + +#[test] +fn virtual_diff_document_keeps_large_compare_ranges_sparse_and_anchorable() { + let count = FILE_HEIGHT_SPARSE_MIN_COUNT + 32; + let summaries = (0..count) + .map(|index| { + let path = format!("kernel/file_{index}.c"); + CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ) + }) + .collect::>(); + let mut state = AppState::default(); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 900); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + state.recompute_file_scroll_total_height_px(); + + assert!(matches!( + state.virtual_diff_document.height_index, + FileHeightIndex::Sparse { .. } + )); + + let target = state.global_max_scroll_top_px() / 2; + state.scroll_viewport_to_global(target); + let anchor = state.virtual_scroll.anchor.expect("compare anchor"); + + assert_eq!(anchor.item_id.source, WorkspaceSource::Compare); + assert_eq!( + anchor.item_id.generation, + state.workspace_render_generation() + ); + assert!(anchor.item_id.index < count); +} + +#[test] +fn virtual_diff_document_rejects_stale_measurement_item_ids() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs"]); + state.settings.continuous_scroll = true; + state.recompute_file_scroll_total_height_px(); + let item_id = state.virtual_diff_document.item_id(1).expect("item id"); + + assert!(state.update_virtual_diff_item_height_px(item_id, 300)); + state.workspace.compare_generation.set(&state.store, 1); + + assert!(!state.update_virtual_diff_item_height_px(item_id, 500)); + assert_eq!( + state + .workspace + .file_content_heights + .with(&state.store, |heights| heights.get(1).copied().flatten()), + Some(300) + ); +} + +#[test] +fn continuous_compare_count_keeps_sidebar_files_when_output_is_partially_hydrated() { + let mut state = AppState::default(); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 10_000); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + carbon: carbon::DiffDocument { + files: vec![carbon_context_file(0, "a.rs", "loaded")], + }, + ..CompareOutput::default() + }), + ); + state.workspace.files.set( + &state.store, + vec![ + FileListEntry { + path: "a.rs".into(), + }, + FileListEntry { + path: "b.rs".into(), + }, + FileListEntry { + path: "c.rs".into(), + }, + ], + ); + + assert_eq!(state.workspace_file_count(), 3); + + let (doc, _effects) = state.build_continuous_viewport_document(); + let doc = doc.expect("viewport doc"); + + assert_eq!(doc.slot_indices, vec![0, 1, 2]); + assert_eq!(doc.slot_loading, vec![false, true, true]); +} + +#[test] +fn continuous_viewport_document_exposes_virtual_stream_rows() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 900); + state + .cache_compare_file_from_output(0, "a.rs") + .expect("cached first file"); + state + .cache_compare_file_from_output(1, "b.rs") + .expect("cached second file"); + + let (doc, _effects) = state.build_continuous_viewport_document(); + let doc = doc.expect("viewport doc"); + + assert!( + doc.stream_items + .iter() + .any(|item| item.id.kind == VirtualDiffItemKind::FileHeader) + ); + assert!( + doc.stream_items + .iter() + .any(|item| item.id.kind == VirtualDiffItemKind::Hunk) + ); + assert!( + doc.stream_items + .iter() + .any(|item| item.id.kind == VirtualDiffItemKind::DiffRow) + ); + assert!( + doc.stream_items + .windows(2) + .all(|items| items[0].sort_key <= items[1].sort_key) + ); + assert!( + doc.stream_items + .iter() + .all(|item| item.estimated_height_px > 0) + ); +} + +#[test] +fn continuous_viewport_document_backfills_before_tail_file() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 500); + state + .workspace + .file_content_heights + .set(&state.store, vec![Some(800), Some(800), Some(800)]); + state.recompute_file_scroll_total_height_px(); + state + .workspace + .global_scroll_top_px + .set(&state.store, 1_700); + + let (doc, _effects) = state.build_continuous_viewport_document(); + let doc = doc.expect("viewport doc"); + + assert_eq!(doc.slot_indices, vec![1, 2]); + assert_eq!(doc.start_index, 1); + assert_eq!(doc.start_offset_px, 800); + assert_eq!(doc.scroll_top_px, 900); +} + +#[test] +fn continuous_viewport_document_follow_end_builds_from_tail() { + let mut state = + loaded_state_with_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs", "tail.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 500); + state.workspace.file_content_heights.set( + &state.store, + vec![ + Some(1_000), + Some(1_000), + Some(1_000), + Some(1_000), + Some(1_000), + Some(1_000), + Some(100), + ], + ); + state.recompute_file_scroll_total_height_px(); + + state.scroll_viewport_to_global(state.global_max_scroll_top_px()); + + let (doc, _effects) = state.build_continuous_viewport_document(); + let doc = doc.expect("viewport doc"); + + assert_eq!(doc.slot_indices, vec![5, 6]); + assert_eq!(doc.start_index, 5); + assert_eq!(doc.start_offset_px, 5_000); + assert_eq!(doc.scroll_top_px, 600); +} + +#[test] +fn next_file_action_resolves_current_file_from_selected_path() { + let mut state = loaded_state_with_files(&[ + "src/core/compare/backends/git_diff.rs", + "src/core/compare/mod.rs", + "src/core/compare/service.rs", + "src/core/compare/stats.rs", + "src/core/frecency.rs", + "src/ui/state/mod.rs", + "src/ui/state/text_edit.rs", + "src/ui/toolbar.rs", + ]); + state.settings.continuous_scroll = true; + state + .workspace + .selected_file_index + .set(&state.store, Some(0)); + state + .workspace + .selected_file_path + .set(&state.store, Some("src/ui/state/mod.rs".to_owned())); + + state.apply_action(crate::actions::EditorAction::GoToNextFile); + + assert_eq!( + state.workspace.selected_file_index.get(&state.store), + Some(6) + ); + assert_eq!( + state + .workspace + .selected_file_path + .get(&state.store) + .as_deref(), + Some("src/ui/state/text_edit.rs") + ); +} + +#[test] +fn selecting_a_file_requests_async_syntax_without_mutating_compare_output() { + let mut state = AppState::default(); + let mut output = CompareOutput::default(); + output.carbon.files = vec![carbon_context_file( + 0, + "src/lib.rs", + "fn answer() -> i32 { 42 }", + )]; + state + .workspace + .compare_output + .set(&state.store, Some(output)); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.workspace.files.set( + &state.store, + vec![FileListEntry { + path: "src/lib.rs".into(), + }], + ); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/tmp/repo"))); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); + + let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + assert!(effects.iter().any(|effect| { + matches!( + effect, + Effect::Syntax(SyntaxEffect::LoadFileSyntax(task)) + if task.request.path == "src/lib.rs" + && task.request.window.start == 0 + && task.request.window.end > 0 + ) + })); + state.workspace.compare_output.with(&state.store, |co| { + let output = co.as_ref().expect("compare output"); + assert_eq!(output.carbon.files[0].path(), "src/lib.rs"); + assert_eq!(output.carbon.files[0].hunks.len(), 1); + }); +} + +#[test] +fn prepare_active_file_builds_from_carbon_text() { + let carbon_file = carbon::parse_unified_patch( + "\ +diff --git a/src/lib.rs b/src/lib.rs +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1 +1 @@ + fn answer() -> i32 { 42 } +", + ) + .unwrap() + .files + .into_iter() + .next() + .unwrap(); + + let prepared = prepare_active_file(0, &carbon_file); + + assert_eq!(prepared.carbon_file.path(), "src/lib.rs"); + assert!(prepared.render_doc.lines.iter().any(|render_line| { + prepared.render_doc.line_text(render_line.left_text) == "fn answer() -> i32 { 42 }" + || prepared.render_doc.line_text(render_line.right_text) == "fn answer() -> i32 { 42 }" + })); +} + +#[test] +fn small_compare_file_selection_stays_synchronous() { + let mut state = AppState::default(); + let mut output = CompareOutput::default(); + let mut carbon_file = carbon_context_file(0, "src/lib.rs", "fn answer() -> i32 { 42 }"); + carbon_file.additions = 10; + carbon_file.deletions = 5; + output.carbon.files = vec![carbon_file]; + + state + .workspace + .compare_output + .set(&state.store, Some(output)); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.workspace.files.set( + &state.store, + vec![FileListEntry { + path: "src/lib.rs".into(), + }], + ); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + + let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + assert!(effects.iter().any(|effect| { + matches!(effect, Effect::Syntax(SyntaxEffect::EnsureSyntaxPackForPath { path }) if path == "src/lib.rs") + })); + assert!( + !effects + .iter() + .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadFile(_)))) + ); + assert!( + state + .workspace + .active_file_loading + .get(&state.store) + .is_none() + ); + assert_eq!( + state + .workspace + .active_file + .get(&state.store) + .as_ref() + .map(|file| file.path.as_str()), + Some("src/lib.rs") + ); +} + +#[test] +fn selecting_large_compare_file_dispatches_async_load() { + let mut state = loaded_state_with_files(&["src/big.rs"]); + state + .workspace + .compare_output + .update(&state.store, |output| { + let file = &mut output.as_mut().expect("compare output").carbon.files[0]; + file.additions = 1_500; + }); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + state.compare.left_ref.set(&state.store, "v5.5".to_owned()); + state.compare.right_ref.set(&state.store, "v5.6".to_owned()); + state + .compare + .renderer + .set(&state.store, RendererKind::Builtin); + state.compare.layout.set(&state.store, LayoutMode::Unified); + state.compare.mode.set(&state.store, CompareMode::TwoDot); + + let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + assert!(matches!( + effects.as_slice(), + [ + Effect::Syntax(SyntaxEffect::EnsureSyntaxPackForPath { path }), + Effect::Compare(CompareEffect::LoadFile(task)) + ] + if path == "src/big.rs" + && task.request.index == 0 + && task.request.path == "src/big.rs" + )); + assert_eq!( + state.workspace.active_file_loading.get(&state.store), + Some(ActiveFileLoading { + index: 0, + path: "src/big.rs".to_owned(), + priority: CompareWorkPriority::InteractiveSelectedFile, + }) + ); + assert!(state.workspace.active_file.get(&state.store).is_none()); +} + +#[test] +fn selecting_deferred_compare_file_dispatches_async_load() { + let mut state = loaded_state_with_files(&["src/kernel.c"]); + state + .workspace + .compare_output + .update(&state.store, |output| { + let file = &mut output.as_mut().expect("compare output").carbon.files[0]; + file.is_partial = true; + file.hunks.clear(); + file.blocks.clear(); + }); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + + let effects = state.apply_action(crate::actions::FileListAction::SelectFile(0)); + + assert!(effects.iter().any(|effect| { + matches!( + effect, + Effect::Compare(CompareEffect::LoadFile(task)) + if task.request.index == 0 + && task.request.path == "src/kernel.c" + && task.request.deferred_file.as_ref().is_some_and(|file| file.is_partial) + ) + })); + assert_eq!( + state.workspace.active_file_loading.get(&state.store), + Some(ActiveFileLoading { + index: 0, + path: "src/kernel.c".to_owned(), + priority: CompareWorkPriority::InteractiveSelectedFile, + }) + ); + assert!(state.workspace.active_file.get(&state.store).is_none()); +} + +#[test] +fn scrollbar_drag_loads_visible_compare_files_without_selecting_them() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state.settings.continuous_scroll = true; + state.editor.viewport_height_px.set(&state.store, 240); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + state + .workspace + .compare_output + .update(&state.store, |output| { + for file in &mut output.as_mut().expect("compare output").carbon.files { + file.is_partial = true; + file.hunks.clear(); + file.blocks.clear(); + } + }); + state.begin_viewport_scrollbar_drag(900, 240, 300, 660); + + let (_doc, effects) = state.build_continuous_viewport_document(); + + assert!( + effects + .iter() + .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadFile(_)))) + ); + assert!( + state + .workspace + .active_file_loading + .get(&state.store) + .is_none() + ); +} + +#[test] +fn overscan_prefetch_does_not_enqueue_syntax_work() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs", "c.rs"]); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + + let effects = state.prefetch_compare_files_forward(0, 1_000); + + assert!( + !effects + .iter() + .any(|effect| matches!(effect, Effect::Syntax(SyntaxEffect::LoadFileSyntax(_)))), + "overscan should warm file diffs without adding syntax windows" + ); + state.workspace.file_cache.with(&state.store, |files| { + assert!(files.values().all(|file| file.syntax_pending.is_empty())); + }); +} + +#[test] +fn offscreen_viewport_slots_do_not_enqueue_syntax_work() { + let mut state = loaded_state_with_files(&["a.rs"]); + state + .cache_compare_file_from_output(0, "a.rs") + .expect("cached file"); + let key = state.compare_slot_key_at(0, "a.rs"); + + let window = state.viewport_slot_syntax_window(&key, 1_000, 120, 0, 240); + + assert_eq!(window, None); +} + +#[test] +fn syntax_budget_counts_inflight_requests_after_cache_eviction() { + let mut state = loaded_state_with_files(&["a.rs"]); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + state + .cache_compare_file_from_output(0, "a.rs") + .expect("cached file"); + let key = state.compare_slot_key_at(0, "a.rs"); + + let effect = state.request_viewport_slot_syntax_window( + &key, + crate::core::syntax::annotator::SyntaxRowWindow { start: 0, end: 32 }, + ); + + assert!(matches!( + effect, + Some(Effect::Syntax(SyntaxEffect::LoadFileSyntax(_))) + )); + state.workspace.file_cache.update(&state.store, |files| { + files.clear(); + }); + assert_eq!(state.syntax_pending_window_count(), 0); + assert_eq!(state.syntax_requests.inflight_len(), 1); +} + +#[test] +fn syntax_epoch_invalidation_clears_attached_pending_windows() { + let mut state = loaded_state_with_files(&["a.rs", "b.rs"]); + state + .cache_compare_file_from_output(0, "a.rs") + .expect("cached file"); + state + .cache_compare_file_from_output(1, "b.rs") + .expect("cached file"); + let active = state + .workspace + .file_cache + .with(&state.store, |files| files.get(&0).cloned()) + .expect("cached active"); + state.workspace.active_file.set(&state.store, Some(active)); + let pending = super::SyntaxPendingWindow { + request_id: 1, + window: crate::core::syntax::annotator::SyntaxRowWindow { start: 0, end: 32 }, + }; + state.workspace.active_file.update(&state.store, |active| { + active + .as_mut() + .expect("active file") + .syntax_pending + .push(pending); + }); + state.workspace.file_cache.update(&state.store, |files| { + for file in files.values_mut() { + file.syntax_pending.push(pending); + } + }); + state.syntax_requests.insert_inflight(0, 1); + + let effect = state.invalidate_syntax_epoch_effect(); + + assert!(matches!( + effect, + Effect::Syntax(SyntaxEffect::SetFileSyntaxEpoch { .. }) + )); + assert_eq!(state.syntax_pending_window_count(), 0); + assert_eq!(state.syntax_requests.inflight_len(), 0); +} + +#[test] +fn context_expansion_invalidates_existing_syntax_windows() { + let mut state = status_state_with_two_hunks(); + let stale_window = crate::core::syntax::annotator::SyntaxRowWindow { start: 0, end: 8 }; + + state.workspace.active_file.update(&state.store, |active| { + let active = active.as_mut().expect("active file"); + active.syntax_pending.push(super::SyntaxPendingWindow { + request_id: 7, + window: stale_window, + }); + active.syntax_covered.push(stale_window); + let range = active + .token_buffer + .append(&[crate::core::text::DiffTokenSpan { + offset: 0, + length: 2, + kind: Default::default(), + intensity: Default::default(), + }]); + active + .carbon_overlays + .insert_syntax(0, carbon::DiffSide::Old, 0, range); + }); + + state.apply_context_expansion( + crate::events::ContextDirection::All, + 0, + 0, + Arc::new((0..12).map(|index| format!("old {index}")).collect()), + Arc::new((0..12).map(|index| format!("new {index}")).collect()), + ); + + state.workspace.active_file.with(&state.store, |active| { + let active = active.as_ref().expect("active file"); + assert!(active.syntax_pending.is_empty()); + assert!(active.syntax_covered.is_empty()); + assert_eq!(active.token_buffer.len(), 0); + }); +} + +#[test] +fn context_expansion_retires_old_syntax_epoch_before_requeue() { + let mut state = status_state_with_two_hunks(); + state.workspace.active_file.update(&state.store, |active| { + let active = active.as_mut().expect("active file"); + active.old_file_lines = Some(Arc::new( + (0..12).map(|index| format!("old {index}")).collect(), + )); + active.file_lines = Some(Arc::new( + (0..12).map(|index| format!("new {index}")).collect(), + )); + }); + state.workspace.compare_generation.set(&state.store, 1); + for request_id in 0..super::MAX_PENDING_SYNTAX_WINDOWS as u64 { + state.syntax_requests.insert_inflight(0, request_id); + } + + let effects = state.dispatch_context_expansion(0, crate::events::ContextDirection::All, 0); + + assert!(matches!( + effects.first(), + Some(Effect::Syntax(SyntaxEffect::SetFileSyntaxEpoch { .. })) + )); + assert!(effects.iter().any(|effect| { + matches!( + effect, + Effect::Syntax(SyntaxEffect::LoadFileSyntax(task)) + if task.request.syntax_epoch == state.syntax_requests.epoch() + ) + })); + assert_eq!(state.syntax_requests.inflight_len(), 1); +} + +#[test] +fn syntax_pack_install_retires_old_epoch_before_refresh() { + let mut state = status_state_with_two_hunks(); + for request_id in 0..super::MAX_PENDING_SYNTAX_WINDOWS as u64 { + state.syntax_requests.insert_inflight(0, request_id); + } + + let effects = state.handle_syntax_packs_installed(&["rust".to_owned()]); + + assert!(matches!( + effects.first(), + Some(Effect::Syntax(SyntaxEffect::SetFileSyntaxEpoch { .. })) + )); + assert!(effects.iter().any(|effect| { + matches!( + effect, + Effect::Syntax(SyntaxEffect::LoadFileSyntax(task)) + if task.request.syntax_epoch == state.syntax_requests.epoch() + ) + })); + assert_eq!(state.syntax_requests.inflight_len(), 1); +} + +#[test] +fn compare_file_finished_ignores_stale_path() { + let mut state = loaded_state_with_files(&["src/lib.rs"]); + state.workspace.compare_generation.set(&state.store, 7); + state + .workspace + .selected_file_index + .set(&state.store, Some(0)); + state + .workspace + .selected_file_path + .set(&state.store, Some("src/lib.rs".to_owned())); + state.workspace.active_file_loading.set( + &state.store, + Some(ActiveFileLoading { + index: 0, + path: "src/lib.rs".to_owned(), + priority: CompareWorkPriority::InteractiveSelectedFile, + }), + ); + + state.apply_event(AppEvent::from(CompareEvent::CompareFileFinished( + CompareFileFinished { + generation: 7, + index: 0, + path: "src/other.rs".to_owned(), + prepared: PreparedActiveFile { + carbon_file: carbon::FileDiff::default(), + carbon_expansion: carbon::ExpansionState::default(), + carbon_overlays: CarbonStyleOverlays::default(), + render_doc: Arc::new(RenderDoc::default()), + token_buffer: TokenBuffer::default(), + }, + }, + ))); + + assert!(state.workspace.active_file.get(&state.store).is_none()); + assert_eq!( + state.workspace.active_file_loading.get(&state.store), + Some(ActiveFileLoading { + index: 0, + path: "src/lib.rs".to_owned(), + priority: CompareWorkPriority::InteractiveSelectedFile, + }) + ); +} + +#[test] +fn overlay_list_pixel_scroll_action_clamps_active_overlay() { + let mut state = AppState::default(); + state.overlays.stack.update(&state.store, |stack| { + stack.push(super::OverlayEntry { + surface: OverlaySurface::RepoPicker, + focus_return: None, + }); + }); + let picker_entries: Vec = (0..12) + .map(|index| super::PickerEntry { + label: format!("repo-{index}"), + detail: format!("C:\\repo-{index}"), + value: format!("C:\\repo-{index}"), + highlights: Vec::new(), + label_style: PickerLabelStyle::Default, + icon: None, + section_header: false, + }) + .collect(); + state + .overlays + .picker + .entries + .set(&state.store, picker_entries); + state + .overlays + .picker + .list + .update(&state.store, |l| l.viewport_height_px = 120); + + state.apply_action(crate::actions::OverlayAction::ScrollActiveOverlayListPx(50)); + assert_eq!( + state + .overlays + .picker + .list + .with(&state.store, |l| l.scroll_top_px), + 50 + ); + + state.apply_action(crate::actions::OverlayAction::ScrollActiveOverlayListPx( + 1_000, + )); + assert_eq!( + state + .overlays + .picker + .list + .with(&state.store, |l| l.scroll_top_px), + 312 + ); + + state.apply_action(crate::actions::OverlayAction::ScrollActiveOverlayListPx( + -1_000, + )); + assert_eq!( + state + .overlays + .picker + .list + .with(&state.store, |l| l.scroll_top_px), + 0 + ); +} + +#[test] +fn closing_overlays_restores_previous_focus() { + let mut state = AppState::default(); + state.apply_action(crate::actions::AppAction::SetFocus(Some( + FocusTarget::FileList, + ))); + + state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); + assert_eq!( + state.ui.focus.get(&state.store), + Some(FocusTarget::CommandPaletteInput) + ); + + // Each nested overlay records its own restore target. + state.apply_action(crate::actions::OverlayAction::OpenGitHubAuthModal); + assert_eq!( + state.ui.focus.get(&state.store), + Some(FocusTarget::AuthPrimaryAction) + ); + + state.apply_action(crate::actions::OverlayAction::CloseOverlay); + assert_eq!(state.overlays_top(), Some(OverlaySurface::CommandPalette)); + assert_eq!( + state.ui.focus.get(&state.store), + Some(FocusTarget::CommandPaletteInput) + ); + + state.apply_action(crate::actions::OverlayAction::CloseOverlay); + assert_eq!(state.overlays_top(), None); + assert_eq!( + state.ui.focus.get(&state.store), + Some(FocusTarget::FileList) + ); +} + +#[test] +fn clearing_overlay_stack_restores_pre_overlay_focus() { + let mut state = AppState::default(); + state.apply_action(crate::actions::AppAction::SetFocus(Some( + FocusTarget::FileList, + ))); + state.apply_action(crate::actions::OverlayAction::OpenCommandPalette); + state.apply_action(crate::actions::OverlayAction::OpenGitHubAuthModal); + + state.clear_overlays(); + + assert_eq!(state.overlays_top(), None); + assert_eq!( + state.ui.focus.get(&state.store), + Some(FocusTarget::FileList) + ); +} + +#[test] +fn stage_hunk_at_stages_the_given_index() { + let mut state = status_state_with_two_hunks(); + + let effects = state.apply_action(crate::actions::RepositoryAction::StageHunkAt(1)); + + let [Effect::Repository(RepositoryEffect::ApplyPatchOperation(request))] = effects.as_slice() + else { + panic!("expected one patch effect, got {:?}", effects); + }; + assert!(request.patch.contains("old_second();")); + assert!(!request.patch.contains("old_first();")); +} + +#[test] +fn stage_hunk_reads_the_hovered_hunk_index() { + let mut state = status_state_with_two_hunks(); + state.editor.hovered_hunk_index.set(&state.store, Some(1)); + + let effects = state.apply_action(crate::actions::RepositoryAction::StageHunk); + + let [Effect::Repository(RepositoryEffect::ApplyPatchOperation(request))] = effects.as_slice() + else { + panic!("expected one patch effect"); + }; + assert!(request.patch.contains("old_second();")); +} + +#[test] +fn stage_hunk_without_partial_hunk_capability_is_ignored() { + let mut state = status_state_with_two_hunks(); + let mut capabilities = RepoCapabilities::git(); + capabilities.staging_area = false; + capabilities.partial_hunk_mutation = false; + state + .repository + .capabilities + .set(&state.store, Some(capabilities)); + + let effects = state.apply_action(crate::actions::RepositoryAction::StageHunkAt(0)); + + assert!(effects.is_empty()); +} + +#[test] +fn status_operation_failure_clears_the_pending_flag() { + let mut state = status_state_with_two_hunks(); + let _ = state.apply_action(crate::actions::RepositoryAction::StageHunkAt(0)); + assert!(state.workspace.status_operation_pending.get(&state.store)); + + let _ = state.apply_event(AppEvent::from(RepositoryEvent::FileOperationFailed { + path: PathBuf::from("/repo"), + message: "patch failed".to_owned(), + })); + + assert!(!state.workspace.status_operation_pending.get(&state.store)); +} + +#[test] +fn ref_picker_rebuilds_matches_while_typing_and_keeps_raw_git_revisions_selectable() { + let mut state = AppState::default(); + state.repository.refs.set( + &state.store, + vec![VcsRef { + name: "main".to_owned(), + kind: RefKind::Branch, + target: RevisionId::git("0000000000000000000000000000000000000000"), + active: true, + upstream: None, + ahead_behind: None, + }], + ); + + state.open_ref_picker(CompareField::Left); + state.apply_action(crate::actions::TextEditAction::InsertText("mai".to_owned())); + + let branch_highlights = state.overlays.picker.entries.with(&state.store, |entries| { + entries + .iter() + .find(|entry| entry.value == "main") + .expect("main branch entry") + .highlights + .clone() + }); + assert_eq!(branch_highlights, vec![(0, 3)]); + + let mut state = AppState::default(); + state.open_ref_picker(CompareField::Left); + state.apply_action(crate::actions::TextEditAction::InsertText( + "HEAD~2".to_owned(), + )); + + let (typed_value, typed_highlights) = + state.overlays.picker.entries.with(&state.store, |entries| { + let typed_entry = entries.first().expect("typed ref entry"); + (typed_entry.value.clone(), typed_entry.highlights.clone()) + }); + assert_eq!(typed_value, "HEAD~2"); + assert_eq!(typed_highlights, vec![(0, "HEAD~2".len())]); + + state.apply_action(crate::actions::OverlayAction::ConfirmOverlaySelection); + assert_eq!(state.compare.left_ref.get(&state.store), "HEAD~2"); +} + +#[test] +fn ref_picker_uses_jj_refs_and_change_ids_without_git_workdir() { + let mut state = AppState::default(); + let working_commit = "3e2d7a6e55221e519e3efb86e4f8fbb324980427".to_owned(); + let change_id = "xxyzvpwmsuxytmqltlzwzqpylvlqqyso".to_owned(); + + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: PathBuf::from("/repo"), + store_root: Some(PathBuf::from("/repo/.jj")), + }), + ); + state.repository.refs.set( + &state.store, + vec![ + VcsRef { + name: "@".to_owned(), + kind: RefKind::WorkingCopy, + target: RevisionId { + backend: VcsKind::JJ, + id: working_commit.clone(), + }, + active: true, + upstream: None, + ahead_behind: None, + }, + VcsRef { + name: "main".to_owned(), + kind: RefKind::Bookmark, + target: RevisionId { + backend: VcsKind::JJ, + id: "a4c9f6e8b1d24036a78610a332e12ca25e97c315".to_owned(), + }, + active: false, + upstream: None, + ahead_behind: None, + }, + ], + ); + state.repository.changes.set( + &state.store, + vec![VcsChange { + revision: RevisionId { + backend: VcsKind::JJ, + id: working_commit, + }, + change_id: Some(change_id.clone()), + short_change_id: Some("xsvsonvs".to_owned()), + short_change_id_prefix_len: Some(2), + short_revision: "3e2d7a6e5522".to_owned(), + summary: "Working copy".to_owned(), + author_name: "ro".to_owned(), + timestamp: 0, + flags: ChangeFlags { + current: true, + working_copy: true, + ..ChangeFlags::default() + }, + }], + ); + + state.open_ref_picker(CompareField::Left); + + state.overlays.picker.entries.with(&state.store, |entries| { + assert!(!entries.iter().any(|entry| entry.value == "@workdir")); + + let working_copy = entries + .iter() + .find(|entry| entry.value == "@") + .expect("working copy ref"); + assert_eq!( + working_copy.detail, + "Working copy change \u{2022} current / xsvsonvs 3e2d7a6e5522" + ); + + let bookmark = entries + .iter() + .find(|entry| entry.value == "main") + .expect("bookmark ref"); + assert_eq!(bookmark.detail, "Bookmark"); + + let change = entries + .iter() + .find(|entry| entry.value == change_id) + .expect("change id entry"); + assert_eq!(change.label, "xsvsonvs"); + assert!(change.highlights.is_empty()); + assert_eq!( + change.label_style(), + PickerLabelStyle::JjChangeId { + prefix_len: 2, + working_copy: true, + } + ); + }); +} + +#[test] +fn command_palette_uses_actual_match_indices_for_highlighting() { + let mut state = AppState::default(); + state + .overlays + .command_palette + .query + .set(&state.store, "them".to_owned()); + + state.rebuild_command_palette(); + + let highlights = state + .overlays + .command_palette + .entries + .with(&state.store, |entries| { + entries + .iter() + .find(|entry| entry.label == "Change Theme") + .expect("Change Theme entry") + .highlights + .clone() + }); + assert_eq!(highlights, vec![(7, 11)]); +} + +#[test] +fn command_palette_surfaces_jj_operations_for_jj_repositories() { + let mut state = AppState::default(); + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: PathBuf::from("/repo"), + store_root: Some(PathBuf::from("/repo/.jj")), + }), + ); + state + .overlays + .command_palette + .query + .set(&state.store, "jj".to_owned()); + + state.rebuild_command_palette(); + + let entries = state + .overlays + .command_palette + .entries + .with(&state.store, |entries| entries.clone()); + for operation in JjOperation::ALL { + let label = format!("jj: {}", operation.label()); + let entry = entries + .iter() + .find(|entry| entry.label == label) + .unwrap_or_else(|| panic!("missing {label} command")); + assert!(matches!( + entry.kind, + super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( + VcsOperation::Jj(found) + )) if found == operation + )); + } + + let mut state = AppState::default(); + state + .overlays + .command_palette + .query + .set(&state.store, "jj".to_owned()); + state.rebuild_command_palette(); + let has_jj_operation = state + .overlays + .command_palette + .entries + .with(&state.store, |entries| { + entries.iter().any(|entry| { + JjOperation::ALL + .into_iter() + .any(|operation| entry.label == format!("jj: {}", operation.label())) + }) + }); + assert!(!has_jj_operation); +} + +#[test] +fn jj_operation_action_emits_repository_effect() { + let mut state = AppState::default(); + let repo_path = PathBuf::from("/repo"); + let operation = VcsOperation::Jj(JjOperation::NewChange); + state + .compare + .repo_path + .set(&state.store, Some(repo_path.clone())); + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: repo_path.clone(), + store_root: Some(repo_path.join(".jj")), + }), + ); + + let effects = state.apply_action(crate::actions::RepositoryAction::RunOperation( + operation.clone(), + )); + + let [Effect::Repository(RepositoryEffect::RunOperation(request))] = effects.as_slice() else { + panic!("expected RunOperation effect, got {effects:?}"); + }; + assert_eq!(request.repo_path, repo_path); + assert_eq!(request.operation, operation); +} + +#[test] +fn destructive_jj_palette_operation_requires_confirmation() { + let mut state = AppState::default(); + let repo_path = PathBuf::from("/repo"); + let operation = VcsOperation::Jj(JjOperation::AbandonChange); + state + .compare + .repo_path + .set(&state.store, Some(repo_path.clone())); + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: repo_path.clone(), + store_root: Some(repo_path.join(".jj")), + }), + ); + state + .overlays + .command_palette + .query + .set(&state.store, "abandon".to_owned()); + state.overlays.stack.update(&state.store, |stack| { + stack.push(OverlayEntry { + surface: OverlaySurface::CommandPalette, + focus_return: None, + }); + }); + state.rebuild_command_palette(); + + let effects = state.apply_action(crate::actions::OverlayAction::ConfirmOverlaySelection); + + assert!(effects.is_empty()); + assert_eq!(state.overlays_top(), Some(OverlaySurface::Confirmation)); + assert_eq!( + state.overlays.confirmation.action.get(&state.store), + Some(crate::actions::RepositoryAction::RunOperation(operation.clone()).into()) + ); + + let effects = state.apply_action(crate::actions::OverlayAction::ConfirmOverlaySelection); + + let [Effect::Repository(RepositoryEffect::RunOperation(request))] = effects.as_slice() else { + panic!("expected RunOperation effect, got {effects:?}"); + }; + assert_eq!(request.repo_path, repo_path); + assert_eq!(request.operation, operation); + assert_eq!(state.overlays_top(), None); +} + +#[test] +fn command_palette_surfaces_jj_rebase_destinations() { + let mut state = AppState::default(); + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: PathBuf::from("/repo"), + store_root: Some(PathBuf::from("/repo/.jj")), + }), + ); + state.repository.refs.set( + &state.store, + vec![ + VcsRef { + name: "@".to_owned(), + kind: RefKind::WorkingCopy, + target: RevisionId { + backend: VcsKind::JJ, + id: "current".to_owned(), + }, + active: true, + upstream: None, + ahead_behind: None, + }, + VcsRef { + name: "main".to_owned(), + kind: RefKind::Bookmark, + target: RevisionId { + backend: VcsKind::JJ, + id: "main-revision".to_owned(), + }, + active: false, + upstream: None, + ahead_behind: None, + }, + ], + ); + state + .overlays + .command_palette + .query + .set(&state.store, "rebase main".to_owned()); + + state.rebuild_command_palette(); + + let entry = state + .overlays + .command_palette + .entries + .with(&state.store, |entries| entries.first().cloned()) + .expect("rebase entry"); + assert_eq!(entry.label, "jj: Rebase @ Onto main"); + assert!(matches!( + entry.kind, + super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( + VcsOperation::JjRebaseCurrentChangeOnto { ref destination } + )) if destination == "main" + )); +} + +#[test] +fn command_palette_surfaces_jj_editable_changes() { + let mut state = AppState::default(); + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: PathBuf::from("/repo"), + store_root: Some(PathBuf::from("/repo/.jj")), + }), + ); + state.repository.changes.set( + &state.store, + vec![ + VcsChange { + revision: RevisionId { + backend: VcsKind::JJ, + id: "current-revision".to_owned(), + }, + change_id: Some("current-change".to_owned()), + short_change_id: Some("cur".to_owned()), + short_change_id_prefix_len: Some(3), + short_revision: "currev".to_owned(), + summary: "current".to_owned(), + author_name: "ro".to_owned(), + timestamp: 0, + flags: ChangeFlags { + current: true, + working_copy: true, + ..ChangeFlags::default() + }, + }, + VcsChange { + revision: RevisionId { + backend: VcsKind::JJ, + id: "target-revision".to_owned(), + }, + change_id: Some("target-change".to_owned()), + short_change_id: Some("tgt".to_owned()), + short_change_id_prefix_len: Some(3), + short_revision: "tgt123".to_owned(), + summary: "target change".to_owned(), + author_name: "ro".to_owned(), + timestamp: 0, + flags: ChangeFlags::default(), + }, + ], + ); + state + .overlays + .command_palette + .query + .set(&state.store, "edit tgt".to_owned()); + + state.rebuild_command_palette(); + + let entry = state + .overlays + .command_palette + .entries + .with(&state.store, |entries| entries.first().cloned()) + .expect("edit entry"); + assert_eq!(entry.label, "jj: Edit tgt"); + assert!(matches!( + entry.kind, + super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( + VcsOperation::JjEditRevision { + ref revision, + ref label + } + )) if revision == "target-revision" && label == "tgt" + )); +} + +#[test] +fn command_palette_surfaces_jj_operation_log_restore_targets() { + let mut state = AppState::default(); + state.repository.location.set( + &state.store, + Some(RepoLocation { + kind: VcsKind::JJ, + profile: crate::core::vcs::model::VCS_PROFILE_JJ, + workspace_root: PathBuf::from("/repo"), + store_root: Some(PathBuf::from("/repo/.jj")), + }), + ); + state.repository.operation_log.set( + &state.store, + vec![ + VcsOperationLogEntry { + operation_id: "current-operation".to_owned(), + short_operation_id: "current".to_owned(), + user: "ro".to_owned(), + time: "later".to_owned(), + description: "snapshot working copy".to_owned(), + }, + VcsOperationLogEntry { + operation_id: "target-operation".to_owned(), + short_operation_id: "target".to_owned(), + user: "ro".to_owned(), + time: "earlier".to_owned(), + description: "describe change".to_owned(), + }, + ], + ); + state + .overlays + .command_palette + .query + .set(&state.store, "restore target".to_owned()); + + state.rebuild_command_palette(); + + let entries = state + .overlays + .command_palette + .entries + .with(&state.store, |entries| entries.clone()); + assert!( + !entries + .iter() + .any(|entry| entry.label == "jj: Restore Operation current") + ); + let entry = entries + .iter() + .find(|entry| entry.label == "jj: Restore Operation target") + .expect("restore entry"); + assert_eq!(entry.detail, "describe change - ro - earlier"); + assert!(matches!( + entry.kind, + super::PaletteEntryKind::Command(super::PaletteCommand::RunOperation( + VcsOperation::JjRestoreOperation { + ref operation_id, + ref label + } + )) if operation_id == "target-operation" && label == "target" + )); +} + +#[test] +fn sidebar_width_action_clamps_and_stores_manual_preference() { + let mut state = AppState::default(); + + state.apply_action(crate::actions::SettingsAction::SetSidebarWidthPx(40)); + assert_eq!(state.settings.sidebar_width_px, Some(179)); + + state.apply_action(crate::actions::SettingsAction::SetSidebarWidthPx(420)); + assert_eq!(state.settings.sidebar_width_px, Some(420)); +} + +#[test] +fn ui_scale_actions_step_and_persist_within_bounds() { + let mut state = AppState::default(); + + let effects = state.apply_action(crate::actions::SettingsAction::IncreaseUiScale); + assert_eq!(state.settings.ui_scale_pct, 110); + assert_eq!(effects.len(), 1); + + for _ in 0..20 { + state.apply_action(crate::actions::SettingsAction::IncreaseUiScale); + } + assert_eq!(state.settings.ui_scale_pct, 180); + + for _ in 0..20 { + state.apply_action(crate::actions::SettingsAction::DecreaseUiScale); + } + assert_eq!(state.settings.ui_scale_pct, 70); +} + +#[test] +fn avatar_url_sized_appends_or_replaces_s_param() { + use super::avatar_url_sized; + assert_eq!( + avatar_url_sized("https://avatars.githubusercontent.com/u/1?v=4", 128), + Some("https://avatars.githubusercontent.com/u/1?v=4&s=128".to_owned()) + ); + assert_eq!( + avatar_url_sized("https://avatars.githubusercontent.com/u/1", 64), + Some("https://avatars.githubusercontent.com/u/1?s=64".to_owned()) + ); + assert_eq!( + avatar_url_sized("https://avatars.githubusercontent.com/u/1?s=40&v=4", 128), + Some("https://avatars.githubusercontent.com/u/1?v=4&s=128".to_owned()) + ); + assert_eq!(avatar_url_sized("", 128), None); +} + +#[test] +fn card_text_selection_slices_normalized_range() { + let body = "the quick brown fox".to_owned(); + // Forward selection. + let mut sel = CardTextSelection::new(7, body.clone(), 4); + sel.focus = 9; + assert_eq!(sel.normalized(), (4, 9)); + assert_eq!(sel.selected_text().as_deref(), Some("quick")); + assert!(!sel.is_collapsed()); + + // Reversed drag yields the same substring. + let mut rev = CardTextSelection::new(7, body.clone(), 9); + rev.focus = 4; + assert_eq!(rev.normalized(), (4, 9)); + assert_eq!(rev.selected_text().as_deref(), Some("quick")); + + // Collapsed selection copies nothing. + let collapsed = CardTextSelection::new(7, body.clone(), 4); + assert!(collapsed.is_collapsed()); + assert_eq!(collapsed.selected_text(), None); + + // Out-of-range anchor is clamped at construction (no panic / no copy). + let clamped = CardTextSelection::new(7, body, 999); + assert!(clamped.is_collapsed()); +} + +#[test] +fn command_palette_detects_pr_url_and_emits_peek_effect() { + let mut state = AppState::default(); + state.overlays.command_palette.query.set( + &state.store, + "https://github.com/foo/bar/pull/42".to_owned(), + ); + + let effects = state.rebuild_command_palette(); + + // A peek effect was fired for the parsed key. + assert!(effects.iter().any(|e| matches!( + e, + Effect::GitHub(GitHubEffect::PeekPullRequest { + owner, repo, number, .. + }) if owner == "foo" && repo == "bar" && *number == 42 + ))); + + // Palette has the synthesized PR entry as the top row with key intact. + let top = state + .overlays + .command_palette + .entries + .with(&state.store, |e| e.first().cloned()) + .expect("palette has at least one entry"); + assert!(matches!( + top.kind, + super::PaletteEntryKind::PullRequest((ref o, ref r, n)) + if o == "foo" && r == "bar" && n == 42 + )); + + // Cache entry is initialized to Loading. + let cached = state.github.pull_request.cache.with(&state.store, |c| { + c.get(&("foo".to_owned(), "bar".to_owned(), 42)).cloned() + }); + let cached = cached.expect("cache entry"); + assert!(matches!(cached.meta, super::PrPeekMeta::Loading)); +} + +#[test] +fn pr_peeked_event_transitions_cache_meta_to_ready() { + use crate::core::forge::github::PullRequestInfo; + use crate::events::AppEvent; + + let mut state = AppState::default(); + state + .overlays + .command_palette + .query + .set(&state.store, "https://github.com/foo/bar/pull/7".to_owned()); + let _ = state.rebuild_command_palette(); + + let info = PullRequestInfo { + title: "Fix thing".to_owned(), + state: "open".to_owned(), + author_login: "alice".to_owned(), + number: 7, + additions: 12, + deletions: 3, + changed_files: 1, + base_branch: "main".to_owned(), + head_branch: "fix".to_owned(), + base_sha: "a".to_owned(), + head_sha: "b".to_owned(), + base_repo_url: String::new(), + head_repo_url: String::new(), + }; + state.apply_event(AppEvent::from(GitHubEvent::PullRequestPeeked { + owner: "foo".to_owned(), + repo: "bar".to_owned(), + number: 7, + info: info.clone(), + })); + + let meta = state.github.pull_request.cache.with(&state.store, |c| { + c.get(&("foo".to_owned(), "bar".to_owned(), 7)) + .map(|e| e.meta.clone()) + }); + assert!(matches!(meta, Some(super::PrPeekMeta::Ready(_)))); +} + +// ----------------------------------------------------------------- +// Compare progress — end-to-end through the event lifecycle +// ----------------------------------------------------------------- + +use super::{ComparePhase, CompareProgress, LoadingSubject}; +use crate::events::{CompareFinished, RepositorySyncReason}; + +fn compare_ready_state() -> AppState { + let state = AppState::default(); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + state.compare.left_ref.set(&state.store, "v5.0".to_owned()); + state.compare.right_ref.set(&state.store, "v5.1".to_owned()); + state.compare.mode.set(&state.store, CompareMode::TwoDot); + state +} + +#[test] +fn kickoff_compare_seeds_progress_with_labels_and_started_at() { + let mut state = compare_ready_state(); + state.clock_ms = 1_000; + let _ = state.kickoff_compare(); + + let progress = state + .workspace + .compare_progress + .with(&state.store, |p| p.clone()) + .expect("progress should be populated"); + match &progress.subject { + LoadingSubject::Compare { + left_label, + right_label, + } => { + assert_eq!(left_label, "v5.0"); + assert_eq!(right_label, "v5.1"); + } + other => panic!("expected Compare subject, got {other:?}"), + } + assert_eq!(progress.started_at_ms, 1_000); + assert_eq!(progress.phase, ComparePhase::OpeningRepo); + assert_eq!(progress.file_count_total, None); + assert_eq!( + state.workspace.mode.get(&state.store), + WorkspaceMode::Loading, + "viewport should flip to loading so the panel actually renders" + ); +} + +#[test] +fn compare_progress_update_applies_only_when_generation_matches() { + let mut state = compare_ready_state(); + let _ = state.kickoff_compare(); + let generation = state.workspace.compare_generation.get(&state.store); + + // Stale reporter — must be ignored. + state.apply_event(AppEvent::from(CompareEvent::CompareProgressUpdate { + generation: generation.wrapping_sub(1), + phase: ComparePhase::EnumeratingChanges, + })); + assert_eq!( + state + .workspace + .compare_progress + .with(&state.store, |p| p.as_ref().unwrap().phase), + ComparePhase::OpeningRepo, + "stale generation must not advance the phase" + ); + + // Fresh reporter — applies. + state.apply_event(AppEvent::from(CompareEvent::CompareProgressUpdate { + generation, + phase: ComparePhase::EnumeratingChanges, + })); + assert_eq!( + state + .workspace + .compare_progress + .with(&state.store, |p| p.as_ref().unwrap().phase), + ComparePhase::EnumeratingChanges, + ); +} + +#[test] +fn loading_files_phase_updates_counts_on_struct() { + let mut state = compare_ready_state(); + let _ = state.kickoff_compare(); + let generation = state.workspace.compare_generation.get(&state.store); + + state.apply_event(AppEvent::from(CompareEvent::CompareProgressUpdate { + generation, + phase: ComparePhase::LoadingFiles { + files_seen: 142, + files_total: 3_891, + }, + })); + + let progress = state + .workspace + .compare_progress + .with(&state.store, |p| p.clone()) + .expect("progress exists"); + assert_eq!(progress.files_loaded, 142); + assert_eq!(progress.file_count_total, Some(3_891)); + assert!(matches!(progress.phase, ComparePhase::LoadingFiles { .. })); +} + +#[test] +fn kickoff_with_prior_state_reveals_loading_immediately() { + let mut state = compare_ready_state(); + // Simulate a previously loaded compare (files present). + state.workspace.files.set( + &state.store, + vec![FileListEntry { + path: "old.rs".into(), + }], + ); + state.clock_ms = 10_000; + + let _ = state.kickoff_compare(); + let progress = state + .workspace + .compare_progress + .with(&state.store, |p| p.clone()) + .expect("progress populated"); + assert_eq!(progress.started_at_ms, 10_000); + assert_eq!( + progress.reveal_at_ms, 10_000, + "compare loading should be visible immediately" + ); + assert_ne!( + state.workspace.mode.get(&state.store), + WorkspaceMode::Loading + ); + // Prior files are preserved so fast compares don't cause a flash. + assert_eq!(state.workspace.files.with(&state.store, |f| f.len()), 1); +} + +#[test] +fn open_repository_seeds_repo_subject_progress() { + let mut state = AppState::default(); + state.clock_ms = 500; + + let effects = state.open_repository(PathBuf::from("/tmp/linux")); + + let progress = state + .workspace + .compare_progress + .with(&state.store, |p| p.clone()) + .expect("progress seeded for repo open"); + match &progress.subject { + LoadingSubject::RepoOpen { name } => { + assert_eq!(name, "linux"); + } + other => panic!("expected RepoOpen subject, got {other:?}"), + } + assert_eq!(progress.phase, ComparePhase::OpeningRepo); + assert_eq!( + progress.reveal_at_ms, + 500 + super::COMPARE_REVEAL_DELAY_MS, + "every repo open delays reveal so sub-threshold opens don't flash" + ); + // Reporter generation is threaded through the SyncRepository effect + // so the worker's phase events stamp the matching generation. + let sync_gen = effects.iter().find_map(|eff| match eff { + Effect::Repository(RepositoryEffect::SyncRepository { + reporter_generation, + .. + }) => *reporter_generation, + _ => None, + }); + assert_eq!(sync_gen, Some(progress.generation)); +} + +#[test] +fn open_repository_with_prior_diff_delays_reveal() { + let mut state = AppState::default(); + state.workspace.files.set( + &state.store, + vec![FileListEntry { + path: "old.rs".into(), + }], + ); + state.clock_ms = 10_000; + + let _ = state.open_repository(PathBuf::from("/tmp/other")); + + let progress = state + .workspace + .compare_progress + .with(&state.store, |p| p.clone()) + .expect("progress seeded"); + assert_eq!( + progress.reveal_at_ms, 10_500, + "re-open with prior diff delays reveal by COMPARE_REVEAL_DELAY_MS" + ); +} + +#[test] +fn open_repository_resets_stale_compare_refs_before_snapshot() { + let mut state = AppState::default(); + state.compare.left_ref.set(&state.store, "@-".to_owned()); + state.compare.right_ref.set(&state.store, "@".to_owned()); + state.compare.mode.set(&state.store, CompareMode::TwoDot); + + let path = PathBuf::from("/tmp/git-repo"); + let effects = state.open_repository(path.clone()); + + assert_eq!(state.compare.left_ref.get(&state.store), ""); + assert_eq!(state.compare.right_ref.get(&state.store), ""); + assert_eq!(state.compare.mode.get(&state.store), CompareMode::default()); + let saved = effects.iter().find_map(|effect| match effect { + Effect::Settings(SettingsEffect::SaveSettings(settings)) => settings.last_compare.as_ref(), + _ => None, + }); + let saved = saved.expect("open_repository should persist settings"); + assert_eq!(saved.repo_path.as_ref(), Some(&path)); + assert_eq!(saved.left_ref, ""); + assert_eq!(saved.right_ref, ""); +} + +#[test] +fn git_snapshot_after_jj_refs_uses_git_defaults() { + let mut state = AppState::default(); + state.compare.left_ref.set(&state.store, "@-".to_owned()); + state.compare.right_ref.set(&state.store, "@".to_owned()); + state.compare.mode.set(&state.store, CompareMode::TwoDot); + + let path = PathBuf::from("/tmp/git-repo"); + let _ = state.open_repository(path.clone()); + state.apply_event(AppEvent::from(RepositoryEvent::RepositorySnapshotReady( + crate::events::RepositorySnapshot::from_vcs_snapshot( + crate::core::vcs::model::VcsSnapshot { + location: RepoLocation { + kind: VcsKind::GIT, + profile: crate::core::vcs::model::VCS_PROFILE_GIT, + workspace_root: path, + store_root: None, + }, + reason: RepositorySyncReason::Open, + change_kind: None, + capabilities: RepoCapabilities::git(), + refs: Vec::new(), + changes: Vec::new(), + operation_log: Vec::new(), + file_changes: Vec::new(), + }, + ), + ))); + + let (left, right, mode) = + crate::ui::vcs::profile(state.repository.location.get(&state.store).as_ref()) + .default_compare(); + assert_eq!(state.compare.left_ref.get(&state.store), left); + assert_eq!(state.compare.right_ref.get(&state.store), right); + assert_eq!(state.compare.mode.get(&state.store), mode); +} + +#[test] +fn large_compare_stats_stream_offscreen_background_rows_after_visible_rows() { + let state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + state.file_list.row_height.set(&state.store, 36.0); + state.file_list.gap.set(&state.store, 4.0); + state.file_list.viewport_height.set(&state.store, 80.0); + + let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) + .map(|index| { + let path = format!("src/file-{index}.rs"); + let mut summary = CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ); + if index < 128 { + summary.stats_deferred = false; + } + summary + }) + .collect(); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + + let effect = state + .next_compare_stats_hydration_effect() + .expect("huge compares should keep streaming offscreen stats"); + + match effect { + Effect::Compare(CompareEffect::LoadFileStats(task)) => { + assert_eq!(task.request.priority, CompareWorkPriority::Warmup); + assert_eq!(task.request.files.first().map(|item| item.index), Some(128)); + } + other => panic!("expected LoadFileStats effect, got {other:?}"), + } +} + +#[test] +fn large_compare_still_loads_exact_total_stats() { + let mut state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + + let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) + .map(|index| { + let path = format!("src/file-{index}.rs"); + CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ) + }) + .collect(); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + + let effect = state + .start_compare_total_stats_if_needed() + .expect("large deferred compares should request one bounded total-stats job"); + + match effect { + Effect::Compare(CompareEffect::LoadStats(task)) => { + assert_eq!(task.request.priority, CompareWorkPriority::TotalStats); + assert!( + state + .workspace + .compare_total_stats_loading + .get(&state.store) + ); + } + other => panic!("expected LoadStats effect, got {other:?}"), + } +} + +#[test] +fn filtered_compare_stats_hydrates_filtered_visible_raw_indices() { + let state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.file_list.row_height.set(&state.store, 36.0); + state.file_list.gap.set(&state.store, 4.0); + state.file_list.viewport_height.set(&state.store, 80.0); + state + .file_list + .filter + .set(&state.store, "target-only".to_owned()); + + let summaries = (0..50) + .map(|index| { + let path = if index == 40 { + "src/target-only.rs".to_owned() + } else { + format!("src/file-{index}.rs") + }; + CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ) + }) + .collect(); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + + let items = state.visible_compare_stats_hydration_items(); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].index, 40); +} + +#[test] +fn tree_compare_stats_hydrates_visible_tree_file_indices() { + let state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state + .file_list + .mode + .set(&state.store, SidebarMode::TreeView); + state + .file_list + .expanded_folders + .set(&state.store, ["a".to_owned()].into_iter().collect()); + state.file_list.row_height.set(&state.store, 36.0); + state.file_list.gap.set(&state.store, 4.0); + state.file_list.viewport_height.set(&state.store, 80.0); + + let summaries = (0..50) + .map(|index| { + let path = if index == 40 { + "a/target-visible.rs".to_owned() + } else { + format!("z/file-{index}.rs") + }; + CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ) + }) + .collect(); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + + let items = state.visible_compare_stats_hydration_items(); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].index, 40); +} + +#[test] +fn loaded_compare_stats_update_sidebar_meta() { + let mut state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state + .file_list + .mode + .set(&state.store, SidebarMode::TreeView); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: vec![CompareFileSummary::from_paths_status( + None, + Some("arch/arm64/boot/dts/mediatek/mt8183-kukui-jacuzzi-kenzo.dts"), + carbon::FileStatus::Added, + true, + )], + ..CompareOutput::default() + }), + ); + + let effects = state.handle_compare_file_stats_ready(CompareFileStatsReady { + generation: state.workspace.compare_generation.get(&state.store), + stats: vec![CompareFileStat { + index: 0, + path: "arch/arm64/boot/dts/mediatek/mt8183-kukui-jacuzzi-kenzo.dts".to_owned(), + additions: 13, + deletions: 0, + }], + request_complete: false, + }); + + assert!(effects.is_empty()); + let meta = state.file_list_entry_meta(0); + assert_eq!(meta.additions, 13); + assert_eq!(meta.deletions, 0); + assert!( + !state.workspace.compare_output.with(&state.store, |output| { + output + .as_ref() + .and_then(|output| output.file_summaries.first()) + .is_none_or(|summary| summary.stats_deferred) + }), + "loaded stats must clear the deferred marker used by sidebar rows", + ); +} + +#[test] +fn expanding_tree_folder_starts_visible_stats_hydration() { + let mut state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state + .compare + .repo_path + .set(&state.store, Some(PathBuf::from("/repo"))); + state + .file_list + .mode + .set(&state.store, SidebarMode::TreeView); + state.file_list.row_height.set(&state.store, 36.0); + state.file_list.gap.set(&state.store, 4.0); + state.file_list.viewport_height.set(&state.store, 80.0); + + let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) + .map(|index| { + let path = if index == 40 { + "a/target-visible.rs".to_owned() + } else { + format!("z/file-{index}.rs") + }; + CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ) + }) + .collect(); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + state.set_compare_stats_hydration(super::CompareStatsHydrationState::Running); + + let effects = state.apply_action(crate::actions::FileListAction::ToggleFolder("a".to_owned())); + + assert!(effects.iter().any(|effect| { + matches!( + effect, + Effect::Compare(CompareEffect::LoadFileStats(task)) + if task.request.priority == CompareWorkPriority::VisibleSidebarStats + && task.request.files.iter().any(|item| item.index == 40) + ) + })); +} + +#[test] +fn compare_stats_ready_drains_history_when_hydration_has_no_visible_work() { + let mut state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.file_list.tab.set(&state.store, SidebarTab::Commits); + state.workspace.compare_history_pending.set( + &state.store, + Some(crate::effects::CompareHistoryRequest { + repo_path: PathBuf::from("/repo"), + left_ref: "v5.0".to_owned(), + right_ref: "v5.1".to_owned(), + }), + ); + let summaries = (0..=super::COMPARE_STATS_VISIBLE_ONLY_FILE_LIMIT) + .map(|index| { + let path = format!("src/file-{index}.rs"); + CompareFileSummary::from_paths_status( + Some(&path), + Some(&path), + carbon::FileStatus::Modified, + true, + ) + }) + .collect(); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: summaries, + ..CompareOutput::default() + }), + ); + + let effects = state.handle_compare_stats_ready(CompareStatsReady { + generation: state.workspace.compare_generation.get(&state.store), + additions: 0, + deletions: 0, + }); + + assert!( + effects + .iter() + .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadHistory(_)))) + ); + assert!( + state + .workspace + .compare_history_pending + .get(&state.store) + .is_none() + ); +} + +#[test] +fn compare_file_stats_failure_does_not_retry_same_chunk() { + let mut state = compare_ready_state(); + state + .workspace + .source + .set(&state.store, WorkspaceSource::Compare); + state.set_compare_stats_hydration(super::CompareStatsHydrationState::Running); + state.workspace.compare_history_pending.set( + &state.store, + Some(crate::effects::CompareHistoryRequest { + repo_path: PathBuf::from("/repo"), + left_ref: "v5.0".to_owned(), + right_ref: "v5.1".to_owned(), + }), + ); + state.workspace.compare_output.set( + &state.store, + Some(CompareOutput { + file_summaries: vec![CompareFileSummary::from_paths_status( + Some("src/file.rs"), + Some("src/file.rs"), + carbon::FileStatus::Modified, + true, + )], + ..CompareOutput::default() + }), + ); + + let effects = state.apply_event(AppEvent::from(CompareEvent::CompareFileStatsFailed { + generation: state.workspace.compare_generation.get(&state.store), + message: "backend failed".to_owned(), + })); + + assert!( + !effects + .iter() + .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadFileStats(_)))), + "failed stats hydration should not immediately retry the same deferred chunk" + ); + assert!( + effects + .iter() + .any(|effect| matches!(effect, Effect::Compare(CompareEffect::LoadHistory(_)))) + ); + assert!( + state + .workspace + .compare_history_pending + .get(&state.store) + .is_none() + ); + assert!(state.compare_stats_hydration_failed()); +} + +#[test] +fn repository_snapshot_ready_clears_repo_open_progress() { + let mut state = AppState::default(); + let path = PathBuf::from("/tmp/linux"); + let _ = state.open_repository(path.clone()); + assert!( + state + .workspace + .compare_progress + .with(&state.store, |p| p.is_some()) + ); + + state.apply_event(AppEvent::from(RepositoryEvent::RepositorySnapshotReady( + crate::events::RepositorySnapshot::from_vcs_snapshot( + crate::core::vcs::model::VcsSnapshot { + location: RepoLocation { + kind: VcsKind::GIT, + profile: crate::core::vcs::model::VCS_PROFILE_GIT, + workspace_root: path, + store_root: None, + }, + reason: RepositorySyncReason::Open, + change_kind: None, + capabilities: RepoCapabilities::git(), + refs: Vec::new(), + changes: Vec::new(), + operation_log: Vec::new(), + file_changes: Vec::new(), + }, + ), + ))); + + assert!( + state + .workspace + .compare_progress + .with(&state.store, |p| p.is_none()), + "snapshot-ready must tear down the repo-open progress panel" + ); +} + +#[test] +fn kickoff_without_prior_state_reveals_loading_immediately() { + let mut state = compare_ready_state(); + state.clock_ms = 5_000; + + let _ = state.kickoff_compare(); + let progress = state + .workspace + .compare_progress + .with(&state.store, |p| p.clone()) + .expect("progress populated"); + assert_eq!(progress.started_at_ms, 5_000); + assert_eq!( + progress.reveal_at_ms, 5_000, + "compare loading should be visible immediately" + ); + // With no prior state to preserve, workspace_mode flips to Loading + // up front so the editor/ready-hint stops rendering in the background. + assert_eq!( + state.workspace.mode.get(&state.store), + WorkspaceMode::Loading + ); +} + +#[test] +fn cancel_compare_bumps_generation_and_drops_stale_result() { + let mut state = compare_ready_state(); + let _ = state.kickoff_compare(); + let generation = state.workspace.compare_generation.get(&state.store); + + let _ = state.cancel_compare(); + + assert!( + state + .workspace + .compare_progress + .with(&state.store, |p| p.is_none()), + "progress should be cleared after cancel" + ); + let new_gen = state.workspace.compare_generation.get(&state.store); + assert!(new_gen > generation, "generation should be bumped"); + assert_eq!( + state.workspace.mode.get(&state.store), + WorkspaceMode::Empty, + "fresh-state cancel should revert the Loading flip" + ); + + // A stale CompareFinished arriving after cancel must be silently dropped. + state.apply_event(AppEvent::from(CompareEvent::CompareFinished( + CompareFinished { + generation, + request: vcs_compare_request( + CompareMode::TwoDot, + "v5.0".to_owned(), + "v5.1".to_owned(), + LayoutMode::Unified, + RendererKind::Builtin, + ), + resolved_left: "deadbeef".to_owned(), + resolved_right: "cafefeed".to_owned(), + output: CompareOutput::default(), + range_commits: Vec::new(), + }, + ))); + assert_eq!( + state.workspace.mode.get(&state.store), + WorkspaceMode::Empty, + "stale finished result must not promote workspace to Ready", + ); + assert!( + state + .workspace + .compare_progress + .with(&state.store, |p| p.is_none()), + "stale finished result must not re-seed progress", + ); +} + +#[test] +fn cancel_compare_preserves_previous_diff_on_recompare() { + let mut state = compare_ready_state(); + // Prior state: an existing file in the workspace. + state.workspace.files.set( + &state.store, + vec![FileListEntry { + path: "old.rs".into(), + }], + ); + state.workspace.mode.set(&state.store, WorkspaceMode::Ready); + + let _ = state.kickoff_compare(); + let _ = state.cancel_compare(); + + assert!( + state + .workspace + .compare_progress + .with(&state.store, |p| p.is_none()), + "progress cleared on cancel" + ); + assert_eq!( + state.workspace.mode.get(&state.store), + WorkspaceMode::Ready, + "previous workspace state is preserved on cancel — no blanking" + ); + assert_eq!( + state.workspace.files.with(&state.store, |f| f.len()), + 1, + "prior file list must not be wiped by cancel" + ); +} + +#[test] +fn compare_finished_advances_phase_and_records_file_count() { + let mut state = compare_ready_state(); + let _ = state.kickoff_compare(); + let generation = state.workspace.compare_generation.get(&state.store); + + // Simulate a successful compare with 3 files. + let files = ["a.rs", "b.rs", "c.rs"]; + let output = CompareOutput { + carbon: carbon::DiffDocument { + files: files + .iter() + .enumerate() + .map(|(index, path)| carbon_summary_for_path(index, path)) + .collect(), + }, + ..CompareOutput::default() + }; + + state.apply_event(AppEvent::from(CompareEvent::CompareFinished( + CompareFinished { + generation, + request: vcs_compare_request( + CompareMode::TwoDot, + "v5.0".to_owned(), + "v5.1".to_owned(), + LayoutMode::Unified, + RendererKind::Builtin, + ), + resolved_left: "deadbeef".to_owned(), + resolved_right: "cafefeed".to_owned(), + output, + range_commits: Vec::new(), + }, + ))); + + // Small files load synchronously, so progress is already cleared by the + // time handle_compare_finished returns. We at least know the workspace + // is Ready and the compare file view is populated from CompareOutput. + assert_eq!(state.workspace.mode.get(&state.store), WorkspaceMode::Ready,); + assert_eq!(state.workspace_file_count(), 3); +} + +#[test] +fn compare_failed_clears_progress_and_marks_workspace_empty() { + let mut state = compare_ready_state(); + let _ = state.kickoff_compare(); + let generation = state.workspace.compare_generation.get(&state.store); + + state.apply_event(AppEvent::from(CompareEvent::CompareFailed { + generation, + message: "boom".to_owned(), + })); + + assert_eq!(state.workspace.mode.get(&state.store), WorkspaceMode::Empty,); + assert!( + state + .workspace + .compare_progress + .with(&state.store, |p| p.is_none()), + "progress panel must tear down on compare failure", + ); +} + +#[test] +fn compare_progress_label_does_not_panic_for_all_phases() { + // Non-empty labels matter for the title-bar fallback. Cheap to + // check exhaustively. + let phases = [ + ComparePhase::OpeningRepo, + ComparePhase::ResolvingRefs, + ComparePhase::EnumeratingChanges, + ComparePhase::LoadingFiles { + files_seen: 142, + files_total: 3_891, + }, + ComparePhase::FetchingHistory, + ComparePhase::PopulatingList, + ComparePhase::RenderingFirstFile, + ]; + for phase in phases { + let label = phase.label(); + assert!(!label.is_empty()); + } + // LoadingFiles label should interpolate counts. + assert!( + ComparePhase::LoadingFiles { + files_seen: 142, + files_total: 3_891, + } + .label() + .contains("142"), + "file counts must appear in the label" + ); + + let _ = CompareProgress { + generation: 0, + phase: ComparePhase::default(), + subject: LoadingSubject::Compare { + left_label: String::new(), + right_label: String::new(), + }, + started_at_ms: 0, + reveal_at_ms: 0, + file_count_total: None, + files_loaded: 0, + }; +} diff --git a/src/ui/state/text_compare.rs b/src/ui/state/text_compare.rs index a889eafd..38f19e18 100644 --- a/src/ui/state/text_compare.rs +++ b/src/ui/state/text_compare.rs @@ -57,8 +57,8 @@ impl AppState { .source .set(&self.store, WorkspaceSource::TextCompare); self.workspace.status.set(&self.store, AsyncStatus::Ready); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); - self.compare_progress.set(&self.store, None); + self.workspace.mode.set(&self.store, WorkspaceMode::Ready); + self.workspace.compare_progress.set(&self.store, None); self.github.pull_request.active.set(&self.store, None); self.github .pull_request @@ -102,16 +102,26 @@ impl AppState { .source .set(&self.store, WorkspaceSource::TextCompare); } - let generation = self.text_compare.generation.saturating_add(1); + // Text and repo compares share one workspace-wide generation space: + // `CompareScheduler`'s epoch is a monotonic high-water mark, so seed + // the bump from whichever counter is ahead. Deriving it from + // `text_compare.generation` alone would rewind + // `workspace.compare_generation` below the scheduler epoch and every + // later repo file/stats job would be silently dropped as stale. + let generation = self + .text_compare + .generation + .max(self.workspace.compare_generation.get(&self.store)) + .saturating_add(1); self.text_compare.generation = generation; self.text_compare.status = AsyncStatus::Loading; self.workspace .compare_generation .set(&self.store, generation); self.workspace.status.set(&self.store, AsyncStatus::Loading); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); + self.workspace.mode.set(&self.store, WorkspaceMode::Ready); self.workspace.active_file_loading.set(&self.store, None); - self.compare_progress.set(&self.store, None); + self.workspace.compare_progress.set(&self.store, None); self.clear_overlays(); self.sync_text_compare_syntax_paths(); @@ -137,7 +147,14 @@ impl AppState { &mut self, payload: TextCompareFinished, ) -> Vec { - if payload.generation != self.text_compare.generation { + // Drop results superseded by a newer text compare (text generation + // moved on) or by any newer workspace compare (repo compare, cancel, + // or repo open bumped `compare_generation` past us). Rewinding the + // workspace generation here would strand it below the scheduler's + // monotonic epoch. + if payload.generation != self.text_compare.generation + || payload.generation != self.workspace.compare_generation.get(&self.store) + { return Vec::new(); } @@ -149,10 +166,7 @@ impl AppState { .source .set(&self.store, WorkspaceSource::TextCompare); self.workspace.status.set(&self.store, AsyncStatus::Ready); - self.workspace_mode.set(&self.store, WorkspaceMode::Ready); - self.workspace - .compare_generation - .set(&self.store, payload.generation); + self.workspace.mode.set(&self.store, WorkspaceMode::Ready); self.compare.layout.set(&self.store, payload.layout); self.compare.renderer.set(&self.store, payload.renderer); self.compare @@ -309,3 +323,143 @@ fn looks_like_json(source: &str) -> bool { && trimmed.contains(':') && trimmed.contains('"') } + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum TextCompareView { + #[default] + Edit, + Diff, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextCompareSide { + Left, + Right, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum TextCompareLanguage { + #[default] + Auto, + PlainText, + Rust, + TypeScript, + JavaScript, + Python, + Go, + Json, + Toml, + Shell, + Nix, + C, + Cpp, + Zig, +} + +impl TextCompareLanguage { + pub const OPTIONS: &'static [Self] = &[ + Self::Auto, + Self::PlainText, + Self::Rust, + Self::TypeScript, + Self::JavaScript, + Self::Python, + Self::Go, + Self::Json, + Self::Toml, + Self::Shell, + Self::Nix, + Self::C, + Self::Cpp, + Self::Zig, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::Auto => "Auto", + Self::PlainText => "Plain text", + Self::Rust => "Rust", + Self::TypeScript => "TypeScript", + Self::JavaScript => "JavaScript", + Self::Python => "Python", + Self::Go => "Go", + Self::Json => "JSON", + Self::Toml => "TOML", + Self::Shell => "Shell", + Self::Nix => "Nix", + Self::C => "C", + Self::Cpp => "C++", + Self::Zig => "Zig", + } + } + + pub fn short_label(self) -> &'static str { + match self { + Self::Auto => "Auto", + Self::PlainText => "Text", + Self::Rust => "Rust", + Self::TypeScript => "TS", + Self::JavaScript => "JS", + Self::Python => "Py", + Self::Go => "Go", + Self::Json => "JSON", + Self::Toml => "TOML", + Self::Shell => "Sh", + Self::Nix => "Nix", + Self::C => "C", + Self::Cpp => "C++", + Self::Zig => "Zig", + } + } + + pub fn scratch_path(self) -> &'static str { + match self { + Self::Auto | Self::PlainText => "text.txt", + Self::Rust => "scratch.rs", + Self::TypeScript => "scratch.ts", + Self::JavaScript => "scratch.js", + Self::Python => "scratch.py", + Self::Go => "scratch.go", + Self::Json => "scratch.json", + Self::Toml => "scratch.toml", + Self::Shell => "scratch.sh", + Self::Nix => "scratch.nix", + Self::C => "scratch.c", + Self::Cpp => "scratch.cpp", + Self::Zig => "scratch.zig", + } + } +} + +#[derive(Debug, Clone)] +pub struct TextCompareState { + pub left_editor: Editor, + pub right_editor: Editor, + pub language: TextCompareLanguage, + pub detected_language: Option, + pub path_hint: String, + pub view: TextCompareView, + pub generation: u64, + pub last_compared_generation: Option, + pub status: AsyncStatus, +} + +impl Default for TextCompareState { + fn default() -> Self { + let mut left_editor = Editor::new(EditorMode::CodeInput); + let mut right_editor = Editor::new(EditorMode::CodeInput); + left_editor.set_syntax_path("text.txt"); + right_editor.set_syntax_path("text.txt"); + Self { + left_editor, + right_editor, + language: TextCompareLanguage::Auto, + detected_language: None, + path_hint: "text.txt".to_owned(), + view: TextCompareView::default(), + generation: 0, + last_compared_generation: None, + status: AsyncStatus::Idle, + } + } +} diff --git a/src/ui/state/text_edit.rs b/src/ui/state/text_edit.rs index 66d6e326..0c69128e 100644 --- a/src/ui/state/text_edit.rs +++ b/src/ui/state/text_edit.rs @@ -4,7 +4,7 @@ use crate::actions::TextEditAction; use crate::effects::{AiEffect, Effect, UiEffect}; use crate::platform::secrets::AiKeyKind; -use super::{AppState, CompareField, FocusTarget, PickerKind}; +use super::*; pub(super) fn reduce_action(state: &mut AppState, action: TextEditAction) -> Vec { state.apply_text_edit_action(action) @@ -38,7 +38,7 @@ impl AppState { /// Called after text mutation to sync compare fields and rebuild pickers. pub(super) fn after_text_mutation(&mut self) -> Vec { - match self.focus.get(&self.store) { + match self.ui.focus.get(&self.store) { Some(FocusTarget::PickerInput) => match self.overlays.picker.kind.get(&self.store) { PickerKind::Repository => self.rebuild_repo_picker(), PickerKind::LeftRef => { @@ -77,7 +77,7 @@ impl AppState { /// Should we persist settings after editing the current field? pub(super) fn needs_persist(&self) -> bool { matches!( - self.focus.get(&self.store), + self.ui.focus.get(&self.store), Some(FocusTarget::PickerInput) if matches!(self.overlays.picker.kind.get(&self.store), PickerKind::LeftRef | PickerKind::RightRef) ) @@ -264,7 +264,10 @@ impl AppState { } } // No text selection — copy the selected picker/palette entry's value. - if matches!(self.focus.get(&self.store), Some(FocusTarget::PickerInput)) { + if matches!( + self.ui.focus.get(&self.store), + Some(FocusTarget::PickerInput) + ) { let selected = self.overlays.picker.selected_index.get(&self.store); let value = self.overlays.picker.entries.with(&self.store, |entries| { entries.get(selected).map(|e| e.value.clone()) @@ -277,7 +280,7 @@ impl AppState { } } if matches!( - self.focus.get(&self.store), + self.ui.focus.get(&self.store), Some(FocusTarget::CommandPaletteInput) ) { let selected = self @@ -389,17 +392,17 @@ fn ai_key_save_effect(kind: AiKeyKind, value: &str) -> Effect { impl AppState { pub(super) fn apply_text_edit_action(&mut self, action: TextEditAction) -> Vec { use TextEditAction::*; - if self.focus.get(&self.store) == Some(FocusTarget::CommitEditor) { + if self.ui.focus.get(&self.store) == Some(FocusTarget::CommitEditor) { return self.apply_commit_editor_action(action); } - if self.focus.get(&self.store) == Some(FocusTarget::ReviewCommentEditor) { + if self.ui.focus.get(&self.store) == Some(FocusTarget::ReviewCommentEditor) { return self.apply_review_comment_editor_action(action); } - if self.focus.get(&self.store) == Some(FocusTarget::SettingsSteeringPrompt) { + if self.ui.focus.get(&self.store) == Some(FocusTarget::SettingsSteeringPrompt) { return self.apply_steering_prompt_action(action); } if matches!( - self.focus.get(&self.store), + self.ui.focus.get(&self.store), Some(FocusTarget::TextCompareLeft | FocusTarget::TextCompareRight) ) { return self.apply_text_compare_editor_action(action); @@ -712,7 +715,7 @@ impl AppState { } fn apply_text_compare_editor_action(&mut self, action: TextEditAction) -> Vec { - let target = self.focus.get(&self.store); + let target = self.ui.focus.get(&self.store); let changed = { let Some(editor) = (match target { Some(FocusTarget::TextCompareLeft) => Some(&mut self.text_compare.left_editor), @@ -800,3 +803,163 @@ impl AppState { Vec::new() } } + +/// Cursor/selection state for the currently focused text field. +#[derive(Debug, Clone, Default, PartialEq, Eq, Store)] +pub struct TextEditState { + /// Byte offset of the caret. + pub cursor: usize, + /// Byte offset of the selection anchor. Equal to `cursor` when nothing is selected. + pub anchor: usize, + /// Timestamp (clock_ms) when the cursor last moved — used to reset blink phase. + pub cursor_moved_at_ms: u64, +} + +impl AppState { + /// Set cursor and anchor to the same offset and refresh the blink timestamp. + pub(super) fn reset_text_edit(&mut self, offset: usize) { + self.text_edit.cursor.set(&self.store, offset); + self.text_edit.anchor.set(&self.store, offset); + self.text_edit + .cursor_moved_at_ms + .set(&self.store, self.clock_ms); + } + + /// Run `f` against the text string for the given focus target, if it's a text field. + pub(super) fn with_text_for_focus( + &self, + target: FocusTarget, + f: impl FnOnce(&str) -> R, + ) -> Option { + match target { + FocusTarget::PickerInput => match self.overlays.picker.kind.get(&self.store) { + PickerKind::Repository + | PickerKind::Theme + | PickerKind::UiFont + | PickerKind::MonoFont => { + Some(self.overlays.picker.query.with(&self.store, |s| f(s))) + } + PickerKind::LeftRef => Some(self.compare.left_ref.with(&self.store, |s| f(s))), + PickerKind::RightRef => Some(self.compare.right_ref.with(&self.store, |s| f(s))), + }, + FocusTarget::CommandPaletteInput => Some( + self.overlays + .command_palette + .query + .with(&self.store, |s| f(s)), + ), + FocusTarget::SidebarSearch => Some(self.file_list.filter.with(&self.store, |s| f(s))), + FocusTarget::SearchInput => Some(self.editor.search.query.with(&self.store, |s| f(s))), + FocusTarget::CommitEditor => None, + FocusTarget::SettingsOpenAiKey => Some(f(&self.ai_openai_key)), + FocusTarget::SettingsAnthropicKey => Some(f(&self.ai_anthropic_key)), + FocusTarget::SettingsSteeringPrompt => None, + FocusTarget::TextCompareLeft | FocusTarget::TextCompareRight => None, + _ => None, + } + } + + pub(super) fn with_focused_text(&self, f: impl FnOnce(&str) -> R) -> Option { + let target = self.ui.focus.get(&self.store)?; + self.with_text_for_focus(target, f) + } + + pub(super) fn update_focused_text(&mut self, f: impl FnOnce(&mut String) -> R) -> Option { + match self.ui.focus.get(&self.store) { + Some(FocusTarget::PickerInput) => match self.overlays.picker.kind.get(&self.store) { + PickerKind::Repository + | PickerKind::Theme + | PickerKind::UiFont + | PickerKind::MonoFont => { + let mut out = None; + self.overlays + .picker + .query + .update(&self.store, |s| out = Some(f(s))); + out + } + PickerKind::LeftRef => { + let mut out = None; + self.compare + .left_ref + .update(&self.store, |s| out = Some(f(s))); + out + } + PickerKind::RightRef => { + let mut out = None; + self.compare + .right_ref + .update(&self.store, |s| out = Some(f(s))); + out + } + }, + Some(FocusTarget::CommandPaletteInput) => { + let mut out = None; + self.overlays + .command_palette + .query + .update(&self.store, |s| out = Some(f(s))); + out + } + Some(FocusTarget::SidebarSearch) => { + let mut out = None; + self.file_list + .filter + .update(&self.store, |s| out = Some(f(s))); + out + } + Some(FocusTarget::SearchInput) => { + let mut out = None; + self.editor + .search + .query + .update(&self.store, |s| out = Some(f(s))); + out + } + Some(FocusTarget::CommitEditor) => None, + Some(FocusTarget::SettingsOpenAiKey) => { + if !self.ai_key_editable(AiKeyKind::OpenAi) { + return None; + } + let result = f(&mut self.ai_openai_key); + Some(result) + } + Some(FocusTarget::SettingsAnthropicKey) => { + if !self.ai_key_editable(AiKeyKind::Anthropic) { + return None; + } + let result = f(&mut self.ai_anthropic_key); + Some(result) + } + Some(FocusTarget::SettingsSteeringPrompt) => None, + _ => None, + } + } + + pub(super) fn touch_cursor(&mut self) { + self.text_edit + .cursor_moved_at_ms + .set(&self.store, self.clock_ms); + } + + pub(super) fn clamp_cursor(&mut self) { + let cursor_now = self.text_edit.cursor.get(&self.store); + let anchor_now = self.text_edit.anchor.get(&self.store); + let Some((cursor, anchor)) = self.with_focused_text(|text| { + let len = text.len(); + let mut cursor = cursor_now.min(len); + while cursor > 0 && !text.is_char_boundary(cursor) { + cursor -= 1; + } + let mut anchor = anchor_now.min(len); + while anchor > 0 && !text.is_char_boundary(anchor) { + anchor -= 1; + } + (cursor, anchor) + }) else { + return; + }; + self.text_edit.cursor.set(&self.store, cursor); + self.text_edit.anchor.set(&self.store, anchor); + } +} diff --git a/src/ui/state/ui.rs b/src/ui/state/ui.rs new file mode 100644 index 00000000..07627bd8 --- /dev/null +++ b/src/ui/state/ui.rs @@ -0,0 +1,413 @@ +//! App-chrome state: view routing, settings sections, focus targets, and +//! toasts. Pure code motion from `mod.rs`. + +use super::*; + +pub(super) const MAX_VISIBLE_TOASTS: usize = 5; + +pub(super) const TOAST_LIFETIME_MS: u64 = 5_000; + +pub(super) const TOAST_ANIM_MS: u64 = 150; + +pub(super) const CURSOR_BLINK_INTERVAL_MS: u64 = 530; + +pub(super) const DEFAULT_UI_SCALE_PCT: u16 = 100; + +pub(super) const MIN_UI_SCALE_PCT: u16 = 70; + +pub(super) const MAX_UI_SCALE_PCT: u16 = 180; + +pub(super) const UI_SCALE_STEP_PCT: u16 = 10; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum AppView { + #[default] + Workspace, + Settings, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SettingsSection { + #[default] + Appearance, + Editor, + Behavior, + Keymaps, + Clankers, + About, +} + +impl SettingsSection { + pub fn label(self) -> &'static str { + match self { + Self::Appearance => "Appearance", + Self::Editor => "Editor", + Self::Behavior => "Behavior", + Self::Keymaps => "Keymaps", + Self::Clankers => "Clankers", + Self::About => "About", + } + } + + pub fn icon(self) -> &'static str { + match self { + Self::Appearance => lucide::SUN, + Self::Editor => lucide::FILE_CODE, + Self::Behavior => lucide::SETTINGS, + Self::Keymaps => lucide::KEY, + Self::Clankers => lucide::SPARKLES, + Self::About => lucide::INFO, + } + } + + pub const ALL: [Self; 6] = [ + Self::Appearance, + Self::Editor, + Self::Behavior, + Self::Keymaps, + Self::Clankers, + Self::About, + ]; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusTarget { + WorkspacePrimaryButton, + TitleBar, + ThemeToggle, + FileList, + Editor, + PickerInput, + PickerList, + CommandPaletteInput, + CommandPaletteList, + AuthPrimaryAction, + SidebarSearch, + SearchInput, + CommitEditor, + ReviewCommentEditor, + TextCompareLeft, + TextCompareRight, + SettingsOpenAiKey, + SettingsAnthropicKey, + SettingsSteeringPrompt, +} + +impl FocusTarget { + pub fn is_text_field(self) -> bool { + matches!( + self, + Self::PickerInput + | Self::CommandPaletteInput + | Self::SidebarSearch + | Self::SearchInput + | Self::CommitEditor + | Self::ReviewCommentEditor + | Self::TextCompareLeft + | Self::TextCompareRight + | Self::SettingsOpenAiKey + | Self::SettingsAnthropicKey + | Self::SettingsSteeringPrompt + ) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Toast { + pub id: u64, + pub kind: ToastKind, + pub message: String, + pub description: Option, + pub created_at_ms: u64, + pub hovered: bool, + /// When `Some`, the toast renders an externally-driven progress bar in + /// place of the time-based one and is pinned (not auto-dismissed). + pub progress: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastKind { + Info, + Error, +} + +/// App-chrome reactive state: view routing, focus, toasts, errors, +/// settings-page scroll metrics, theme preview, and the update lifecycle. +/// `#[derive(Store)]` turns every field into a `Signal` in the generated +/// `UiStateStore` held by `AppState`. +#[derive(Debug, Clone, Store)] +pub struct UiState { + pub app_view: AppView, + pub settings_section: SettingsSection, + pub keymap_capture: Option, + pub keymaps_scroll_top_px: f32, + pub keymaps_viewport_height_px: f32, + pub keymaps_content_height_px: f32, + pub focus: Option, + pub last_error: Option, + pub toasts: Vec, + pub syntax_pack_installs: Vec, + pub update: UpdateState, + pub sidebar_visible: bool, + pub theme_preview_original: Option, +} + +impl Default for UiState { + fn default() -> Self { + Self { + app_view: AppView::default(), + settings_section: SettingsSection::default(), + keymap_capture: None, + keymaps_scroll_top_px: 0.0, + keymaps_viewport_height_px: 0.0, + keymaps_content_height_px: 0.0, + focus: None, + last_error: None, + toasts: Vec::new(), + syntax_pack_installs: Vec::new(), + update: UpdateState::default(), + sidebar_visible: true, + theme_preview_original: None, + } + } +} + +impl AppState { + pub fn window_title(&self) -> String { + let workspace_mode = if self + .workspace + .compare_progress + .with(&self.store, |p| p.is_some()) + { + "loading" + } else { + workspace_mode_name(self.workspace.mode.get(&self.store)) + }; + let title_prefix = crate::platform::startup::window_title_prefix(); + if self.workspace.source.get(&self.store) == WorkspaceSource::TextCompare { + return format!("{title_prefix} - Text Compare [{workspace_mode}]"); + } + let repo = self.compare.repo_path.with(&self.store, |p| { + p.as_deref() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .unwrap_or("native") + .to_owned() + }); + let selected_path = self.workspace.selected_file_path.get(&self.store); + if let Some(path) = selected_path.as_deref() { + format!("{title_prefix} - {repo} [{workspace_mode}] {path}") + } else { + format!("{title_prefix} - {repo} [{workspace_mode}]") + } + } + + pub fn update_time(&mut self, now_ms: u64) { + self.clock_ms = now_ms; + self.animation.tick(now_ms); + let has_expired_toast = self.ui.toasts.with(&self.store, |toasts| { + toasts.iter().any(|toast| { + !toast.hovered + && toast.progress.is_none() + && now_ms.saturating_sub(toast.created_at_ms) >= TOAST_LIFETIME_MS + }) + }); + if has_expired_toast { + self.ui.toasts.update(&self.store, |toasts| { + toasts.retain(|toast| { + toast.hovered + || toast.progress.is_some() + || now_ms.saturating_sub(toast.created_at_ms) < TOAST_LIFETIME_MS + }); + }); + } + } + + pub fn update_polling_enabled(&self) -> bool { + self.settings.auto_update + && crate::core::update::updates_configured() + && !cfg!(debug_assertions) + && !matches!( + self.ui.update.get(&self.store), + UpdateState::Downloading(_) + | UpdateState::ReadyToRestart(_) + | UpdateState::Restarting(_) + ) + } + + pub fn cursor_blink_epoch(&self) -> Option { + self.is_text_focused().then(|| { + self.clock_ms + .saturating_sub(self.text_edit.cursor_moved_at_ms.get(&self.store)) + / CURSOR_BLINK_INTERVAL_MS + }) + } + + pub fn next_cursor_blink_at_ms(&self) -> Option { + self.is_text_focused().then(|| { + let moved_at = self.text_edit.cursor_moved_at_ms.get(&self.store); + let elapsed = self.clock_ms.saturating_sub(moved_at); + let next_epoch = elapsed / CURSOR_BLINK_INTERVAL_MS + 1; + moved_at.saturating_add(next_epoch.saturating_mul(CURSOR_BLINK_INTERVAL_MS)) + }) + } + + pub fn next_toast_expiry_at_ms(&self) -> Option { + self.ui.toasts.with(&self.store, |toasts| { + toasts + .iter() + .filter(|toast| !toast.hovered && toast.progress.is_none()) + .map(|toast| toast.created_at_ms.saturating_add(TOAST_LIFETIME_MS)) + .min() + }) + } + + pub(super) fn set_focus(&mut self, target: Option) { + if target != self.ui.focus.get(&self.store) { + // Reset cursor to end of the new field + let len = target + .and_then(|t| self.with_text_for_focus(t, |s| s.len())) + .unwrap_or(0); + self.reset_text_edit(len); + } + self.ui.focus.set(&self.store, target); + self.editor + .focused + .set(&self.store, target == Some(FocusTarget::Editor)); + } + + /// Returns true if the current focus target is a text editing field. + /// Backed by a memo; `focus` writes invalidate it automatically. + pub fn is_text_focused(&self) -> bool { + self.text_focused.get(&self.store) + } + + pub(super) fn push_error(&mut self, message: &str) -> u64 { + self.ui + .last_error + .set(&self.store, Some(message.to_owned())); + self.push_toast(ToastKind::Error, message, None, None) + } + + pub(super) fn push_info(&mut self, message: &str) -> u64 { + self.push_toast(ToastKind::Info, message, None, None) + } + + #[allow(dead_code)] + pub(super) fn push_error_with_description(&mut self, message: &str, description: &str) -> u64 { + self.ui + .last_error + .set(&self.store, Some(message.to_owned())); + self.push_toast( + ToastKind::Error, + message, + Some(description.to_owned()), + None, + ) + } + + #[allow(dead_code)] + pub(super) fn push_info_with_description(&mut self, message: &str, description: &str) -> u64 { + self.push_toast(ToastKind::Info, message, Some(description.to_owned()), None) + } + + /// Create an info toast with an externally-driven progress bar (0.0-1.0). + /// The toast is pinned until `finish_progress_toast` or `fail_progress_toast` + /// is called — it does not auto-dismiss based on time. + pub(super) fn push_progress_toast(&mut self, message: &str) -> u64 { + self.push_toast(ToastKind::Info, message, None, Some(0.0)) + } + + /// Convert a pinned progress toast into a normal info toast and let it + /// auto-dismiss. Also updates its message and description. + pub(super) fn finish_progress_toast( + &mut self, + toast_id: u64, + message: &str, + description: Option, + ) { + let now = self.clock_ms; + self.ui.toasts.update(&self.store, |toasts| { + if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { + toast.kind = ToastKind::Info; + toast.message = message.to_owned(); + toast.description = description; + toast.created_at_ms = now; + toast.progress = None; + } + }); + } + + /// Convert a pinned progress toast into an error toast. + pub(super) fn fail_progress_toast( + &mut self, + toast_id: u64, + message: &str, + description: Option, + ) { + let now = self.clock_ms; + self.ui + .last_error + .set(&self.store, Some(message.to_owned())); + self.ui.toasts.update(&self.store, |toasts| { + if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { + toast.kind = ToastKind::Error; + toast.message = message.to_owned(); + toast.description = description; + toast.created_at_ms = now; + toast.progress = None; + } + }); + } + + pub(super) fn update_toast_progress(&mut self, toast_id: u64, fraction: f32) { + let clamped = fraction.clamp(0.0, 1.0); + self.ui.toasts.update(&self.store, |toasts| { + if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { + toast.progress = Some(clamped); + } + }); + } + + pub(super) fn update_toast_message(&mut self, toast_id: u64, message: &str) { + self.ui.toasts.update(&self.store, |toasts| { + if let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) { + toast.message = message.to_owned(); + } + }); + } + + pub(super) fn push_toast( + &mut self, + kind: ToastKind, + message: &str, + description: Option, + progress: Option, + ) -> u64 { + use crate::ui::animation::AnimationKey; + let id = self.next_toast_id; + self.next_toast_id = self.next_toast_id.saturating_add(1); + self.animation.set_target( + AnimationKey::ToastEntrance(id), + 1.0, + TOAST_ANIM_MS, + self.clock_ms, + ); + let now = self.clock_ms; + self.ui.toasts.update(&self.store, |toasts| { + toasts.push(Toast { + id, + kind, + message: message.to_owned(), + description, + created_at_ms: now, + hovered: false, + progress, + }); + if toasts.len() > MAX_VISIBLE_TOASTS { + toasts.remove(0); + } + }); + id + } +} diff --git a/src/ui/state/update.rs b/src/ui/state/update.rs index 53b81410..00a44d67 100644 --- a/src/ui/state/update.rs +++ b/src/ui/state/update.rs @@ -12,6 +12,7 @@ pub(super) fn reduce_event(state: &mut AppState, event: UpdateEvent) -> Vec { state + .ui .update .set(&state.store, UpdateState::Downloading(update.clone())); if !silent { @@ -20,7 +21,7 @@ pub(super) fn reduce_event(state: &mut AppState, event: UpdateEvent) -> Vec { - state.update.set(&state.store, UpdateState::Idle); + state.ui.update.set(&state.store, UpdateState::Idle); if !silent { state.push_info("Diffy is up to date."); } @@ -29,6 +30,7 @@ pub(super) fn reduce_event(state: &mut AppState, event: UpdateEvent) -> Vec { if !silent { state + .ui .update .set(&state.store, UpdateState::Failed(message.clone())); state.push_error(&message); @@ -38,6 +40,7 @@ pub(super) fn reduce_event(state: &mut AppState, event: UpdateEvent) -> Vec { let version = staged.update.version.clone(); state + .ui .update .set(&state.store, UpdateState::ReadyToRestart(staged)); if !silent { @@ -47,9 +50,10 @@ pub(super) fn reduce_event(state: &mut AppState, event: UpdateEvent) -> Vec { if silent { - state.update.set(&state.store, UpdateState::Idle); + state.ui.update.set(&state.store, UpdateState::Idle); } else { state + .ui .update .set(&state.store, UpdateState::Failed(message.clone())); state.push_error(&message); @@ -63,13 +67,14 @@ impl AppState { pub(super) fn apply_update_action(&mut self, action: UpdateAction) -> Vec { match action { UpdateAction::CheckForUpdates => { - self.update.set(&self.store, UpdateState::Checking); + self.ui.update.set(&self.store, UpdateState::Checking); vec![UpdateEffect::CheckForUpdates { silent: false }.into()] } UpdateAction::InstallUpdate => { - let update = self.update.get(&self.store); + let update = self.ui.update.get(&self.store); if let UpdateState::Available(update) = update { - self.update + self.ui + .update .set(&self.store, UpdateState::Downloading(update.clone())); vec![ UpdateEffect::StageUpdate { @@ -83,9 +88,10 @@ impl AppState { } } UpdateAction::RestartToUpdate => { - let update = self.update.get(&self.store); + let update = self.ui.update.get(&self.store); if let UpdateState::ReadyToRestart(staged) = update { - self.update + self.ui + .update .set(&self.store, UpdateState::Restarting(staged.clone())); vec![UpdateEffect::ApplyStagedUpdate(staged).into()] } else { @@ -95,3 +101,15 @@ impl AppState { } } } + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum UpdateState { + #[default] + Idle, + Checking, + Available(AvailableUpdate), + Downloading(AvailableUpdate), + ReadyToRestart(StagedUpdate), + Restarting(StagedUpdate), + Failed(String), +} diff --git a/src/ui/state/working_set.rs b/src/ui/state/working_set.rs index cdecfc2e..54504490 100644 --- a/src/ui/state/working_set.rs +++ b/src/ui/state/working_set.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -use super::ViewportSlotKey; +use super::*; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(super) struct WorkingSetFileKey { @@ -48,3 +48,671 @@ impl FileWorkingSet { self.protected.clone() } } + +pub(super) const COMPARE_WORKING_SET_MAX_FILES: usize = 96; + +pub(super) const COMPARE_WORKING_SET_MIN_FILES: usize = 24; + +pub(super) const COMPARE_WORKING_SET_BYTE_BUDGET: usize = 64 * 1024 * 1024; + +pub(super) const COMPARE_WORKING_SET_PREFETCH_PAGES: u32 = 3; + +pub(super) const COMPARE_WORKING_SET_TRAILING_PAGES: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveFileLoading { + pub index: usize, + pub path: String, + pub priority: CompareWorkPriority, +} + +#[derive(Debug, Clone)] +pub struct PreparedActiveFile { + pub carbon_file: carbon::FileDiff, + pub carbon_expansion: carbon::ExpansionState, + pub carbon_overlays: CarbonStyleOverlays, + pub render_doc: Arc, + pub token_buffer: TokenBuffer, +} + +pub(super) fn append_active_file_doc(out: &mut RenderDoc, active: &ActiveFile) { + if active.carbon_file.is_binary { + out.append_doc(&build_placeholder_render_doc( + &active.path, + "Binary file. Diffy only shows text diffs here.", + )); + } else { + out.append_doc(&active.render_doc); + } +} + +pub(super) fn apply_compare_stat_to_active_file( + active: &mut ActiveFile, + stat: &CompareFileStat, +) -> bool { + if active.index != stat.index || active.path != stat.path { + return false; + } + + let additions = i32_to_u32_nonnegative(stat.additions); + let deletions = i32_to_u32_nonnegative(stat.deletions); + let carbon_file = Arc::make_mut(&mut active.carbon_file); + if carbon_file.additions == additions + && carbon_file.deletions == deletions + && !carbon_file.stats_deferred + { + return false; + } + + carbon_file.additions = additions; + carbon_file.deletions = deletions; + carbon_file.stats_deferred = false; + active.render_doc = Arc::new(build_render_doc_from_carbon( + &active.carbon_file, + active.index, + &active.carbon_expansion, + &active.carbon_overlays, + &active.token_buffer, + )); + true +} + +pub(super) fn hydrate_carbon_full_text( + file: &mut carbon::FileDiff, + old_lines: &[String], + new_lines: &[String], +) { + if !old_lines.is_empty() { + file.old_text = Some(carbon::TextStore::from_text(lines_to_text(old_lines))); + } + if !new_lines.is_empty() { + file.new_text = Some(carbon::TextStore::from_text(lines_to_text(new_lines))); + } + for block in &mut file.blocks { + block.old.start = block.old_line_start.saturating_sub(1); + block.new.start = block.new_line_start.saturating_sub(1); + } + file.is_partial = false; +} + +pub(super) fn lines_to_text(lines: &[String]) -> String { + if lines.is_empty() { + return String::new(); + } + let mut text = + String::with_capacity(lines.iter().map(|line| line.len().saturating_add(1)).sum()); + for line in lines { + text.push_str(line); + text.push('\n'); + } + text +} + +pub(super) fn text_store_estimated_bytes(text: &carbon::TextStore) -> usize { + text.as_bytes() + .len() + .saturating_add(text.line_count() as usize * std::mem::size_of::()) +} + +pub(super) fn render_doc_estimated_bytes(doc: &RenderDoc) -> usize { + doc.text_bytes + .len() + .saturating_add( + doc.style_runs.len() * std::mem::size_of::(), + ) + .saturating_add( + doc.lines.len() * std::mem::size_of::(), + ) + .saturating_add( + doc.file_metadata + .iter() + .map(|meta| { + meta.path + .len() + .saturating_add(meta.old_path.as_ref().map_or(0, String::len)) + }) + .sum::(), + ) +} + +pub(super) fn carbon_file_estimated_bytes(file: &carbon::FileDiff) -> usize { + file.old_path + .as_ref() + .map_or(0, String::len) + .saturating_add(file.new_path.as_ref().map_or(0, String::len)) + .saturating_add(file.old_oid.as_ref().map_or(0, |oid| oid.0.len())) + .saturating_add(file.new_oid.as_ref().map_or(0, |oid| oid.0.len())) + .saturating_add(file.old_mode.as_ref().map_or(0, |mode| mode.0.len())) + .saturating_add(file.new_mode.as_ref().map_or(0, |mode| mode.0.len())) + .saturating_add(file.old_text.as_ref().map_or(0, text_store_estimated_bytes)) + .saturating_add(file.new_text.as_ref().map_or(0, text_store_estimated_bytes)) + .saturating_add(file.hunks.len() * std::mem::size_of::()) + .saturating_add( + file.hunks + .iter() + .map(|hunk| hunk.header.len()) + .sum::(), + ) + .saturating_add(file.blocks.len() * std::mem::size_of::()) + .saturating_add( + file.blocks + .iter() + .map(|block| { + block.old_inline.len() * std::mem::size_of::() + + block.new_inline.len() * std::mem::size_of::() + }) + .sum::(), + ) +} + +pub(super) fn line_vec_estimated_bytes(lines: &Arc>) -> usize { + lines + .iter() + .map(|line| { + std::mem::size_of::() + .saturating_add(line.len()) + .saturating_add(1) + }) + .fold(0usize, usize::saturating_add) +} + +pub(super) fn i32_to_u32_nonnegative(value: i32) -> u32 { + u32::try_from(value).unwrap_or_default() +} + +#[derive(Debug, Clone)] +pub struct ActiveFile { + pub index: usize, + pub path: String, + pub carbon_file: Arc, + pub carbon_expansion: carbon::ExpansionState, + pub carbon_overlays: CarbonStyleOverlays, + pub render_doc: Arc, + pub token_buffer: TokenBuffer, + pub left_ref: String, + pub right_ref: String, + pub file_line_count: Option, + pub old_file_lines: Option>>, + pub file_lines: Option>>, + pub syntax_pending: Vec, + pub syntax_covered: Vec, + pub last_used_tick: u64, +} + +impl ActiveFile { + pub(super) fn working_set_key(&self) -> WorkingSetFileKey { + WorkingSetFileKey::new( + self.index, + self.path.clone(), + self.left_ref.clone(), + self.right_ref.clone(), + ) + } + + pub(super) fn working_set_bytes(&self) -> usize { + self.path + .len() + .saturating_add(self.left_ref.len()) + .saturating_add(self.right_ref.len()) + .saturating_add(render_doc_estimated_bytes(&self.render_doc)) + .saturating_add( + self.token_buffer + .len() + .saturating_mul(std::mem::size_of::()), + ) + .saturating_add(carbon_file_estimated_bytes(&self.carbon_file)) + .saturating_add( + self.old_file_lines + .as_ref() + .map_or(0, line_vec_estimated_bytes), + ) + .saturating_add(self.file_lines.as_ref().map_or(0, line_vec_estimated_bytes)) + } +} + +pub(crate) fn prepare_active_file( + file_index: usize, + carbon_file: &carbon::FileDiff, +) -> PreparedActiveFile { + let token_buffer = TokenBuffer::default(); + let carbon_overlays = CarbonStyleOverlays::default(); + + let carbon_expansion = carbon::ExpansionState::default(); + let render_doc = build_render_doc_from_carbon( + carbon_file, + file_index, + &carbon_expansion, + &carbon_overlays, + &token_buffer, + ); + PreparedActiveFile { + carbon_file: carbon_file.clone(), + carbon_expansion, + carbon_overlays, + render_doc: Arc::new(render_doc), + token_buffer, + } +} + +impl AppState { + pub(super) fn build_active_file( + &self, + index: usize, + path: String, + prepared: PreparedActiveFile, + left_ref: String, + right_ref: String, + ) -> ActiveFile { + ActiveFile { + index, + path, + carbon_file: Arc::new(prepared.carbon_file), + carbon_expansion: prepared.carbon_expansion.clone(), + carbon_overlays: prepared.carbon_overlays, + render_doc: prepared.render_doc, + token_buffer: prepared.token_buffer, + left_ref, + right_ref, + file_line_count: None, + old_file_lines: None, + file_lines: None, + syntax_pending: Vec::new(), + syntax_covered: Vec::new(), + last_used_tick: 0, + } + } + + pub(super) fn clear_file_cache(&mut self) { + self.workspace.file_cache.set(&self.store, HashMap::new()); + self.workspace + .file_cache_loading + .set(&self.store, HashMap::new()); + self.viewport_document_cache = None; + self.last_virtual_scroll_top_px = None; + self.file_working_set.reset(); + } + + pub(super) fn next_file_working_set_tick(&mut self) -> u64 { + self.file_working_set.next_tick() + } + + pub(super) fn protect_working_set_slots(&mut self, slots: &[ViewportSlotKey]) { + self.file_working_set.protect_slots(slots); + } + + pub(super) fn cache_active_file(&mut self, mut active_file: ActiveFile) -> ActiveFile { + let index = active_file.index; + active_file.last_used_tick = self.next_file_working_set_tick(); + let cached = active_file.clone(); + self.workspace.file_cache.update(&self.store, |files| { + files.insert(index, cached); + }); + self.workspace + .file_cache_loading + .update(&self.store, |files| { + files.remove(&index); + }); + self.trim_file_working_set(); + active_file + } + + pub(super) fn touch_viewport_slot(&mut self, key: &ViewportSlotKey) { + let tick = self.next_file_working_set_tick(); + self.workspace.active_file.update(&self.store, |slot| { + if let Some(active) = slot.as_mut() + && active.index == key.index + && active.path == key.path + && active.left_ref == key.left_ref + && active.right_ref == key.right_ref + { + active.last_used_tick = tick; + } + }); + self.workspace.file_cache.update(&self.store, |files| { + if let Some(active) = files.get_mut(&key.index) + && active.index == key.index + && active.path == key.path + && active.left_ref == key.left_ref + && active.right_ref == key.right_ref + { + active.last_used_tick = tick; + } + }); + } + + pub(super) fn trim_file_working_set(&mut self) { + let mut keep = self.file_working_set.protected_snapshot(); + if let Some(active) = self.workspace.active_file.with(&self.store, |active| { + active.as_ref().map(ActiveFile::working_set_key) + }) { + keep.insert(active); + } + if let Some(cache) = self.viewport_document_cache.as_ref() { + keep.extend( + cache + .key + .slots + .iter() + .filter_map(ViewportSlotKey::working_set_key), + ); + } + + self.workspace.file_cache.update(&self.store, |files| { + let mut bytes = files + .values() + .map(ActiveFile::working_set_bytes) + .fold(0usize, usize::saturating_add); + if files.len() <= COMPARE_WORKING_SET_MAX_FILES + && bytes <= COMPARE_WORKING_SET_BYTE_BUDGET + { + return; + } + + let mut victims = files + .iter() + .filter(|(_, file)| !keep.contains(&file.working_set_key())) + .map(|(index, file)| (*index, file.last_used_tick)) + .collect::>(); + victims.sort_by_key(|(_, last_used)| *last_used); + + for (index, _) in victims { + if files.len() <= COMPARE_WORKING_SET_MAX_FILES + && (files.len() <= COMPARE_WORKING_SET_MIN_FILES + || bytes <= COMPARE_WORKING_SET_BYTE_BUDGET) + { + break; + } + if let Some(file) = files.remove(&index) { + bytes = bytes.saturating_sub(file.working_set_bytes()); + } + } + }); + } + + pub(super) fn cached_file_at(&self, index: usize) -> Option { + self.workspace + .file_cache + .with(&self.store, |files| files.get(&index).cloned()) + } + + pub(crate) fn viewport_file_snapshot(&self, index: usize) -> Option { + if let Some(active) = self.workspace.active_file.with(&self.store, |file| { + file.as_ref() + .filter(|active| active.index == index) + .cloned() + }) { + return Some(active); + } + self.cached_file_at(index) + } + + pub(super) fn file_load_pending_priority( + &self, + index: usize, + path: &str, + ) -> Option { + self.workspace + .active_file_loading + .with(&self.store, |loading| { + loading + .as_ref() + .filter(|loading| loading.index == index && loading.path == path) + .map(|loading| loading.priority) + }) + .or_else(|| { + self.workspace + .file_cache_loading + .with(&self.store, |loading| { + loading + .get(&index) + .filter(|loading| loading.path == path) + .map(|loading| loading.priority) + }) + }) + } + + pub(super) fn should_enqueue_file_load( + &self, + index: usize, + path: &str, + priority: CompareWorkPriority, + ) -> bool { + self.file_load_pending_priority(index, path) + .is_none_or(|pending| priority.rank() > pending.rank()) + } + + pub(super) fn mark_file_cache_loading( + &mut self, + index: usize, + path: String, + priority: CompareWorkPriority, + ) { + self.workspace + .file_cache_loading + .update(&self.store, |loading| { + loading.insert( + index, + ActiveFileLoading { + index, + path, + priority, + }, + ); + }); + } + + pub(super) fn clear_file_cache_loading(&mut self, index: usize) { + self.workspace + .file_cache_loading + .update(&self.store, |loading| { + loading.remove(&index); + }); + } + + pub(super) fn cached_compare_file_at(&self, index: usize, path: &str) -> Option { + let (left_ref, right_ref) = self.compare_refs(); + if let Some(active_file) = self.workspace.active_file.with(&self.store, |file| { + file.as_ref() + .filter(|file| { + file.index == index + && file.path == path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + .cloned() + }) { + return Some(active_file); + } + self.cached_file_at(index).filter(|file| { + file.index == index + && file.path == path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + } + + pub(super) fn cached_status_file_at( + &self, + index: usize, + change: &FileChange, + ) -> Option { + let (left_ref, right_ref) = self.status_refs_for_bucket(change.bucket); + if let Some(active_file) = self.workspace.active_file.with(&self.store, |file| { + file.as_ref() + .filter(|file| { + file.index == index + && file.path == change.path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + .cloned() + }) { + return Some(active_file); + } + self.cached_file_at(index).filter(|file| { + file.index == index + && file.path == change.path + && file.left_ref == left_ref + && file.right_ref == right_ref + }) + } + + pub(super) fn cache_compare_file_from_output( + &mut self, + index: usize, + path: &str, + ) -> Option { + let carbon_file = self.workspace.compare_output.with(&self.store, |output| { + output + .as_ref() + .and_then(|output| output.carbon.files.get(index)) + .filter(|file| file.path() == path) + .filter(|file| !(file.is_partial && file.hunks.is_empty())) + .cloned() + })?; + let prepared = prepare_active_file(index, &carbon_file); + let (left_ref, right_ref) = self.compare_refs(); + let active_file = + self.build_active_file(index, path.to_owned(), prepared, left_ref, right_ref); + let active_file = self.cache_active_file(active_file); + Some(active_file) + } + + pub(super) fn install_compare_active_file( + &mut self, + index: usize, + path: String, + prepared: PreparedActiveFile, + ) { + let left_ref = self + .compare + .resolved_left + .get(&self.store) + .unwrap_or_else(|| self.compare.left_ref.get(&self.store)); + let right_ref = self + .compare + .resolved_right + .get(&self.store) + .unwrap_or_else(|| self.compare.right_ref.get(&self.store)); + let active_file = + self.build_active_file(index, path.clone(), prepared, left_ref, right_ref); + let active_file = self.cache_active_file(active_file); + let stats = CompareFileStat { + index, + path: path.clone(), + additions: u32_to_i32_saturating(active_file.carbon_file.additions), + deletions: u32_to_i32_saturating(active_file.carbon_file.deletions), + }; + + self.workspace + .selected_file_index + .set(&self.store, Some(index)); + self.workspace + .selected_file_path + .set(&self.store, Some(path)); + self.workspace.selected_change_bucket.set(&self.store, None); + self.workspace.active_file_loading.set(&self.store, None); + self.workspace + .active_file + .set(&self.store, Some(active_file)); + self.apply_compare_file_stats(&[stats]); + // The first real file has landed — tear down the progress panel. + // Subsequent file loads use the sidebar row spinner, not this. + self.workspace.compare_progress.set(&self.store, None); + self.editor_clear_document(); + self.editor + .line_selection + .update(&self.store, |ls| ls.clear()); + if self.editor.search.open.get(&self.store) { + self.recompute_search_matches(); + } + self.file_list.hovered_index.set(&self.store, Some(index)); + } +} + +impl AppState { + pub(super) fn prefetch_compare_working_set( + &mut self, + render_start_index: usize, + render_end_index: usize, + direction: ScrollDirection, + viewport_height_px: u32, + ) -> Vec { + if self.workspace.source.get(&self.store) != WorkspaceSource::Compare { + return Vec::new(); + } + let count = self.workspace_file_count(); + if count == 0 { + return Vec::new(); + } + + let forward_pages = if direction == ScrollDirection::Forward { + COMPARE_WORKING_SET_PREFETCH_PAGES + } else { + COMPARE_WORKING_SET_TRAILING_PAGES + }; + let backward_pages = if direction == ScrollDirection::Backward { + COMPARE_WORKING_SET_PREFETCH_PAGES + } else { + COMPARE_WORKING_SET_TRAILING_PAGES + }; + + let mut effects = Vec::new(); + effects.extend(self.prefetch_compare_files_forward( + render_end_index, + viewport_height_px.saturating_mul(forward_pages).max(1), + )); + effects.extend(self.prefetch_compare_files_backward( + render_start_index, + viewport_height_px.saturating_mul(backward_pages).max(1), + )); + effects + } + + pub(super) fn prefetch_compare_files_forward( + &mut self, + start_index: usize, + target_height: u32, + ) -> Vec { + let count = self.workspace_file_count(); + let mut effects = Vec::new(); + let mut accumulated = 0_u32; + let mut index = start_index; + while index < count && accumulated < target_height { + if let Some(path) = self.workspace_file_path_at(index) { + effects.extend(self.ensure_compare_file_cached_for_viewport( + index, + &path, + CompareWorkPriority::Overscan, + )); + } + accumulated = + accumulated.saturating_add(self.viewport_file_scroll_height_px(index).max(1)); + index += 1; + } + effects + } + + pub(super) fn prefetch_compare_files_backward( + &mut self, + start_index: usize, + target_height: u32, + ) -> Vec { + let mut effects = Vec::new(); + let mut accumulated = 0_u32; + let mut index = start_index; + while index > 0 && accumulated < target_height { + index -= 1; + if let Some(path) = self.workspace_file_path_at(index) { + effects.extend(self.ensure_compare_file_cached_for_viewport( + index, + &path, + CompareWorkPriority::Overscan, + )); + } + accumulated = + accumulated.saturating_add(self.viewport_file_scroll_height_px(index).max(1)); + } + effects + } +} diff --git a/src/ui/state/workspace.rs b/src/ui/state/workspace.rs index 153da76c..da83397b 100644 --- a/src/ui/state/workspace.rs +++ b/src/ui/state/workspace.rs @@ -37,3 +37,84 @@ impl AppState { } } } + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum WorkspaceMode { + #[default] + Empty, + Loading, + Ready, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum WorkspaceSource { + #[default] + None, + Status, + Compare, + TextCompare, +} + +#[derive(Debug, Clone, Default, Store)] +pub struct WorkspaceState { + pub mode: WorkspaceMode, + /// Arc-wrapped so per-frame UI snapshots clone a pointer, not the + /// label strings inside. + pub compare_progress: Option>, + pub source: WorkspaceSource, + pub status: AsyncStatus, + pub status_operation_pending: bool, + /// Shared generation counter for repo *and* text compares. Must only move + /// forward within a session: `CompareScheduler` keeps a monotonic epoch + /// high-water mark and silently drops jobs stamped below it, so any path + /// that writes this signal must bump from the current value (or take a + /// max), never assign an independent counter. + pub compare_generation: u64, + pub status_generation: u64, + pub files: Vec, + pub status_file_changes: Vec, + pub selected_file_index: Option, + pub selected_file_path: Option, + pub selected_change_bucket: Option, + pub compare_output: Option, + pub compare_total_stats: Option<(i32, i32)>, + pub compare_hydrated_stats: Option<(i32, i32)>, + pub compare_deferred_stats_remaining: Option, + pub compare_deferred_stats_cursor: usize, + pub compare_total_stats_loading: bool, + pub compare_stats_hydration: CompareStatsHydrationState, + pub active_file: Option, + pub active_file_loading: Option, + pub file_cache: HashMap, + pub file_cache_loading: HashMap, + pub raw_diff_len: usize, + pub used_fallback: bool, + pub fallback_message: String, + pub sidebar_auto_width: Option, + pub range_commits: Vec, + pub compare_history_pending: Option, + pub pre_drill_compare: Option<(String, String, CompareMode)>, + pub expansions: HashMap, + pub file_content_heights: Vec>, + pub file_scroll_total_height_px: u32, + pub pending_file_content_heights: HashMap, + pub file_scroll_recompute_pending: bool, + pub global_scroll_top_px: u32, + pub measured_px_per_row_q16: u32, + pub viewport_scrollbar_drag: Option, +} + +pub fn workspace_mode_name(mode: WorkspaceMode) -> &'static str { + match mode { + WorkspaceMode::Empty => "empty", + WorkspaceMode::Loading => "loading", + WorkspaceMode::Ready => "ready", + } +} + +impl AppState { + /// Returns true when the workspace is in `Ready` mode. + pub fn is_workspace_ready(&self) -> bool { + self.workspace.mode.get(&self.store) == WorkspaceMode::Ready + } +} diff --git a/src/ui/status_bar.rs b/src/ui/status_bar.rs index c3164e4e..216f8772 100644 --- a/src/ui/status_bar.rs +++ b/src/ui/status_bar.rs @@ -51,13 +51,9 @@ pub(crate) fn status_bar(state: &AppState, theme: &Theme) -> AnyElement { ) }); let vcs_identity = profile.repository_identity_from_changes(&changes); - let has_remotes = state - .repository - .capabilities - .with(&state.store, |capabilities| { - capabilities.is_some_and(|capabilities| capabilities.remotes) - }); - let publish_status = profile.publish_status_ui(&changes, &refs, has_remotes); + let publish_status = state.repository.publish_plan.with(&state.store, |plan| { + profile.publish_status_ui(&changes, &refs, plan.as_ref()) + }); let branch_children = if workspace_source == WorkspaceSource::TextCompare { None @@ -105,6 +101,7 @@ pub(crate) fn status_bar(state: &AppState, theme: &Theme) -> AnyElement { .active_pr_review_status() .map(|summary| review_status(summary, theme, scale)); let syntax_pack_child = state + .ui .syntax_pack_installs .with(&state.store, |active| !active.is_empty()) .then(|| syntax_pack_status(state.clock_ms, theme, scale)); diff --git a/src/ui/title_bar.rs b/src/ui/title_bar.rs index 8e201f98..6f8c20fb 100644 --- a/src/ui/title_bar.rs +++ b/src/ui/title_bar.rs @@ -60,8 +60,8 @@ pub(crate) fn compare_cluster_view(state: &AppState, theme: &Theme) -> Option= p.reveal_at_ms); @@ -91,7 +94,7 @@ pub(crate) fn main_surface( progress, state, theme, )) } else { - match state.workspace_mode.get(&state.store) { + match state.workspace.mode.get(&state.store) { // Loading mode is now always accompanied by a `compare_progress` // entry (either compare or repo-open). Reaching this arm means // the reveal delay hasn't elapsed — preserve the current view @@ -551,7 +554,7 @@ fn text_compare_editor_pane( ) -> AnyElement { let tc = &theme.colors; let scale = theme.metrics.ui_scale(); - let focused = state.focus.get(&state.store) == Some(focus_target); + let focused = state.ui.focus.get(&state.store) == Some(focus_target); let editor = match side { TextCompareSide::Left => &state.text_compare.left_editor, TextCompareSide::Right => &state.text_compare.right_editor, @@ -626,7 +629,7 @@ fn search_bar(state: &AppState, theme: &Theme) -> AnyElement { let search_query = state.editor.search.query.with(&state.store, |s| s.clone()); let match_count = state.editor.search.matches.with(&state.store, |m| m.len()); let active_index = state.editor.search.active_index.get(&state.store); - let search_focused = state.focus.get(&state.store) == Some(FocusTarget::SearchInput); + let search_focused = state.ui.focus.get(&state.store) == Some(FocusTarget::SearchInput); let input = text_input("", &search_query) .placeholder("Find in diff\u{2026}") diff --git a/src/ui/vcs.rs b/src/ui/vcs.rs index 83d99944..f8e4120a 100644 --- a/src/ui/vcs.rs +++ b/src/ui/vcs.rs @@ -1,7 +1,7 @@ use crate::core::compare::CompareMode; use crate::core::vcs::model::{ - ChangeBucket, ChangeIdToken, RefKind, RepoLocation, VCS_PROFILE_GIT, VCS_PROFILE_JJ, VcsChange, - VcsRef, + ChangeBucket, ChangeIdToken, PublishActionKind, PublishPlan, RefKind, RepoLocation, + VCS_PROFILE_GIT, VCS_PROFILE_JJ, VcsChange, VcsRef, }; use crate::ui::icons::lucide; @@ -49,7 +49,7 @@ struct VcsUiDescriptor { status_view_label: fn(Option) -> String, current_change_preset_label: fn(&VcsChange) -> Option, repository_identity_from_changes: fn(&[VcsChange]) -> Option, - publish_status_ui: fn(&[VcsChange], &[VcsRef], bool) -> PublishStatusUi, + publish_status_ui: fn(&[VcsChange], &[VcsRef], Option<&PublishPlan>) -> PublishStatusUi, working_copy_ref_suffix: fn(&[VcsChange]) -> Option<(String, String)>, change_ref_entry: fn(&VcsChange) -> ChangeRefUi, } @@ -317,9 +317,9 @@ impl VcsUiProfile { self, changes: &[VcsChange], refs: &[VcsRef], - has_remotes: bool, + plan: Option<&PublishPlan>, ) -> PublishStatusUi { - (self.descriptor.publish_status_ui)(changes, refs, has_remotes) + (self.descriptor.publish_status_ui)(changes, refs, plan) } pub fn working_copy_ref_suffix(self, changes: &[VcsChange]) -> Option<(String, String)> { @@ -414,7 +414,7 @@ fn git_repository_identity_from_changes(_changes: &[VcsChange]) -> Option, ) -> PublishStatusUi { PublishStatusUi::default() } @@ -459,11 +459,9 @@ fn jj_repository_identity_from_changes(changes: &[VcsChange]) -> Option, ) -> PublishStatusUi { - let hint = has_remotes - .then(|| jj_publish_target_hint(changes, refs)) - .flatten(); + let hint = plan.and_then(publish_hint_from_plan); PublishStatusUi { show_menu: hint.is_some(), hint, @@ -471,91 +469,35 @@ fn jj_publish_status_ui( } } -fn jj_publish_target_hint(changes: &[VcsChange], refs: &[VcsRef]) -> Option { - let wc_idx = changes - .iter() - .position(|change| change.flags.working_copy || change.flags.current)?; - // Mirror backend publish targeting: undescribed working-copy changes are - // not pushed directly, so `@-` becomes the target when possible. - let head_described = changes - .get(wc_idx) - .is_some_and(|change| !change.summary.trim().is_empty()); - let (target, target_revision) = if head_described { - (changes.get(wc_idx)?, "@") - } else if let Some(parent) = changes.get(wc_idx + 1) { - (parent, "@-") - } else { - return None; - }; - if target.summary.trim().is_empty() { - return None; - } - let remote = default_remote_from_refs(refs).unwrap_or_else(|| "origin".to_owned()); - - let bookmark_name = refs - .iter() - .filter(|reference| matches!(reference.kind, RefKind::Bookmark)) - .find(|reference| reference.target.id == target.revision.id) - .map(|reference| reference.name.clone()); - - if let Some(name) = bookmark_name { - return Some(PublishHintUi { - label: name.clone(), - change_id_token: None, - tooltip: format!("Push bookmark {name} at {target_revision} to {remote}"), - }); - } - - let short_id = target - .short_change_id - .clone() - .unwrap_or_else(|| target.short_revision.clone()); - if short_id.is_empty() { +/// Formats the backend's publish plan for the status bar. The plan is the +/// single source of truth for what a push would do; this only shortens its +/// primary action to a label. A disabled primary (e.g. already on the +/// remote) hides the button. +fn publish_hint_from_plan(plan: &PublishPlan) -> Option { + let primary = &plan.primary; + if primary.disabled_reason.is_some() { return None; } - let prefix_len = target.short_change_id_prefix_len.unwrap_or(1).max(1); + let label = match &primary.kind { + PublishActionKind::PushBookmark { bookmark, .. } + | PublishActionKind::MoveBookmarkAndPush { bookmark, .. } + | PublishActionKind::CreateBookmarkAndPush { bookmark, .. } => bookmark.clone(), + PublishActionKind::PushChange { revision, .. } => primary + .change_id_token + .as_ref() + .map(|token| token.text.clone()) + .unwrap_or_else(|| revision.clone()), + PublishActionKind::PushRef { .. } | PublishActionKind::PushTracked { .. } => { + primary.label.clone() + } + }; Some(PublishHintUi { - label: short_id.clone(), - change_id_token: Some(ChangeIdToken { - text: short_id.clone(), - prefix_len, - }), - tooltip: format!("Push change {short_id} at {target_revision} to {remote}"), + label, + change_id_token: primary.change_id_token.clone(), + tooltip: primary.description.clone(), }) } -fn default_remote_from_refs(refs: &[VcsRef]) -> Option { - let mut remotes: Vec = refs - .iter() - .filter_map(|reference| { - reference - .upstream - .as_deref() - .and_then(|u| u.split_once('/').map(|(remote, _)| remote.to_owned())) - .or_else(|| { - matches!( - reference.kind, - RefKind::RemoteBranch | RefKind::RemoteBookmark - ) - .then(|| { - reference - .name - .split_once('/') - .map(|(remote, _)| remote.to_owned()) - }) - .flatten() - }) - }) - .collect(); - remotes.sort(); - remotes.dedup(); - remotes - .iter() - .find(|remote| remote.as_str() == "origin") - .cloned() - .or_else(|| remotes.into_iter().next()) -} - fn publish_ref_chips(changes: &[VcsChange], refs: &[VcsRef]) -> Vec { let publish_targets: Vec<&str> = changes .iter() @@ -643,7 +585,79 @@ fn jj_change_ref_entry(change: &VcsChange) -> ChangeRefUi { #[cfg(test)] mod tests { - use super::pretty_ref_label; + use super::{publish_hint_from_plan, pretty_ref_label}; + use crate::core::vcs::model::{ + ChangeIdToken, PublishAction, PublishActionKind, PublishPlan, + }; + + fn plan(kind: PublishActionKind, disabled: bool, token: Option<&str>) -> PublishPlan { + PublishPlan { + primary: PublishAction { + label: "Push bookmark feat".to_owned(), + description: "Move jj bookmark feat to abc123 and push it to origin".to_owned(), + kind, + disabled_reason: disabled.then(|| "feat is already on origin.".to_owned()), + change_id_token: token.map(|text| ChangeIdToken { + text: text.to_owned(), + prefix_len: 2, + }), + }, + alternatives: Vec::new(), + } + } + + #[test] + fn publish_hint_hides_when_primary_is_disabled() { + let plan = plan( + PublishActionKind::PushBookmark { + remote: "origin".to_owned(), + bookmark: "feat".to_owned(), + }, + true, + None, + ); + assert!(publish_hint_from_plan(&plan).is_none()); + } + + #[test] + fn publish_hint_labels_bookmark_actions_with_the_bookmark() { + let plan = plan( + PublishActionKind::MoveBookmarkAndPush { + remote: "origin".to_owned(), + bookmark: "feat".to_owned(), + revision: "@-".to_owned(), + allow_backwards: false, + track_remote: Some("origin".to_owned()), + }, + false, + None, + ); + let hint = publish_hint_from_plan(&plan).expect("hint"); + assert_eq!(hint.label, "feat"); + assert!(hint.change_id_token.is_none()); + assert_eq!( + hint.tooltip, + "Move jj bookmark feat to abc123 and push it to origin" + ); + } + + #[test] + fn publish_hint_labels_change_push_with_the_change_id() { + let plan = plan( + PublishActionKind::PushChange { + remote: "origin".to_owned(), + revision: "@-".to_owned(), + }, + false, + Some("zuwkussw"), + ); + let hint = publish_hint_from_plan(&plan).expect("hint"); + assert_eq!(hint.label, "zuwkussw"); + assert_eq!( + hint.change_id_token.expect("token").text, + "zuwkussw" + ); + } #[test] fn pr_ref_collapses_to_branch() { diff --git a/src/ui/virtual_list.rs b/src/ui/virtual_list.rs index ffdc36c3..34116caa 100644 --- a/src/ui/virtual_list.rs +++ b/src/ui/virtual_list.rs @@ -18,6 +18,83 @@ pub(crate) fn virtual_list_total_extent(item_count: usize, item_extent: f32, ite item_count as f32 * (item_extent + item_gap) - item_gap } +/// Extent reserved by the wrapper around one windowed row: every row keeps +/// its full stride (row + gap) except the last, which drops the trailing gap +/// so the column height matches `virtual_list_total_extent`. +pub(crate) fn virtual_row_wrapper_extent( + global_index: usize, + total_rows: usize, + row_extent: f32, + stride: f32, +) -> f32 { + if global_index + 1 == total_rows { + row_extent + } else { + stride + } +} + +/// Build a flat row list from filtered item indices, inserting a section +/// header row whenever the section key changes between consecutive items. +/// Indices whose item fails to resolve are skipped without affecting the +/// current section. +pub(crate) fn build_sectioned_rows( + filtered_indices: &[usize], + mut section_of: impl FnMut(usize) -> Option, + mut section_row: impl FnMut(&S) -> R, + mut item_row: impl FnMut(usize) -> Option, +) -> Vec { + let mut rows = Vec::with_capacity(filtered_indices.len()); + let mut last_section: Option = None; + + for &index in filtered_indices { + let Some(row) = item_row(index) else { + continue; + }; + let section = section_of(index); + if section != last_section { + if let Some(section) = §ion { + rows.push(section_row(section)); + } + last_section = section; + } + rows.push(row); + } + + rows +} + +/// Step a list selection by `delta` rows, clamping to bounds and skipping +/// section-header rows in the direction of travel. Returns `None` when the +/// list is empty. +pub(crate) fn step_selection( + current: usize, + delta: i32, + len: usize, + mut is_header: impl FnMut(usize) -> bool, +) -> Option { + if len == 0 { + return None; + } + let max = len.saturating_sub(1) as i32; + let mut idx = (current as i32 + delta).clamp(0, max) as usize; + while idx < len && is_header(idx) { + if delta > 0 { + let next = (idx + 1).min(len.saturating_sub(1)); + if next == idx { + break; + } + idx = next; + } else { + if idx == 0 { + break; + } + idx -= 1; + } + } + Some(idx) +} + pub(crate) fn virtual_list_window( item_count: usize, scroll_offset: f32, @@ -67,7 +144,10 @@ pub(crate) fn virtual_list_window( #[cfg(test)] mod tests { - use super::{virtual_list_total_extent, virtual_list_window}; + use super::{ + build_sectioned_rows, step_selection, virtual_list_total_extent, virtual_list_window, + virtual_row_wrapper_extent, + }; #[test] fn virtual_list_window_overscans_and_clamps() { @@ -94,4 +174,38 @@ mod tests { assert_eq!(virtual_list_total_extent(1, 36.0, 4.0), 36.0); assert_eq!(virtual_list_total_extent(3, 36.0, 4.0), 116.0); } + + #[test] + fn last_row_wrapper_drops_trailing_gap() { + assert_eq!(virtual_row_wrapper_extent(0, 3, 36.0, 40.0), 40.0); + assert_eq!(virtual_row_wrapper_extent(2, 3, 36.0, 40.0), 36.0); + } + + #[test] + fn sectioned_rows_insert_headers_and_skip_missing_items() { + let sections = [Some(1_u8), Some(1), None, Some(2)]; + let rows = build_sectioned_rows( + &[0, 1, 2, 3], + |index| sections[index], + |section| format!("section {section}"), + |index| (index != 2).then(|| format!("item {index}")), + ); + + assert_eq!( + rows, + ["section 1", "item 0", "item 1", "section 2", "item 3"] + ); + } + + #[test] + fn step_selection_clamps_and_skips_headers() { + let headers = [true, false, false, true, false]; + let is_header = |i: usize| headers[i]; + + assert_eq!(step_selection(0, 1, 0, is_header), None); + assert_eq!(step_selection(2, 1, headers.len(), is_header), Some(4)); + assert_eq!(step_selection(4, -1, headers.len(), is_header), Some(2)); + assert_eq!(step_selection(1, -1, headers.len(), is_header), Some(0)); + assert_eq!(step_selection(4, 10, headers.len(), is_header), Some(4)); + } } diff --git a/src/ui/window_chrome.rs b/src/ui/window_chrome.rs index 4715d832..35a087ce 100644 --- a/src/ui/window_chrome.rs +++ b/src/ui/window_chrome.rs @@ -198,7 +198,7 @@ fn repo_chip(label: &str, tc: &ThemeColors, scale: f32) -> AnyElement { } fn update_chip(state: &AppState) -> Option { - match state.update.get(&state.store) { + match state.ui.update.get(&state.store) { UpdateState::Available(update) => Some( Button::new(crate::actions::UpdateAction::InstallUpdate.into()) .icon(lucide::ARROW_DOWN)