From b9f29d258c490de2a6b371e0d96ac3142374ff6a Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sat, 17 Oct 2026 00:03:43 +0200 Subject: [PATCH] fix: support large files in comparisons Replace the 16 MiB comparison rejection with independent bounded per-side loading and frozen Working blobs. Bound diff computation, defer large inline highlighting, index source-row navigation, and expose actual load failures. Verified the real 52.3 MB parser through repository/session APIs and terminal rendering with EOF/start navigation. --- TODO.md | 1 + docs/development.md | 3 +- docs/editing.md | 2 + src/diff.rs | 312 +++++++++++++++++++++++++++++++++++-------- src/editor.rs | 3 +- src/repository.rs | 229 +++++++++++++++---------------- src/tui.rs | 12 +- tests/repository.rs | 285 ++++++++++++++++++++++++++++++++++++--- tests/unit/diff.rs | 259 ++++++++++++++++++++++++++++++++++- tests/unit/editor.rs | 17 ++- tests/unit/tui.rs | 62 +++++++++ 11 files changed, 986 insertions(+), 199 deletions(-) diff --git a/TODO.md b/TODO.md index d4eda86..b1dc38f 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,6 @@ - [x] syntax highlighting for Honey (honeylang) - honeylang treesitter project located at `/Users/valdemar/Developer/Personal/Languages/tree-sitter-honey`. +- [ ] bug: `x` cut action does not copy the hovered character if not in selection mode, though should always copy. - [ ] diff view: GitHub code review integration after the local review UX is usable. - publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes. - preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission. diff --git a/docs/development.md b/docs/development.md index e34fc8d..f20463b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -101,7 +101,8 @@ Diff styling checks cover independent snapshot language/shebang/injection detect - PTY throughput alone cannot prove terminal presentation stays smooth: capture the editor and terminal while the user reproduces a pause. Synchronized-output commands must not reach the terminal during frame computation, even when individual frames and queued-input catch-up appear fast. - Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work. - Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing. -- Repository diff currently requires Unix subprocess-group cancellation and Git-backed jj (or Git alone). Each file is limited to 16 MiB; each subprocess output stream and Working/conflict snapshot to 64 MiB; each command to 60 seconds. Limit, transport, and non-commit-tag errors are explicit rather than silently truncated. Temporary comparison stores are removed when the diff closes. +- Repository diff requires Unix subprocess-group cancellation and Git-backed jj (or Git alone). Selected comparison sides support 64 MiB and 4,000,000 source lines each; frozen Working snapshots support 512 MiB total. Subprocess streams remain bounded to 64 MiB and commands to 60 seconds. Working text is frozen into private Git blobs; catalogs stay lazy and selected blobs load separately without concatenated batch copies. Limits and transport errors are explicit; temporary stores are removed on close. Picker/gutter annotation budgets remain separate. +- Large line comparisons skip equal prefixes/suffixes before allocating diff tokens; added/removed files bypass the diff algorithm. Myers work has a 100 ms deadline, after which unresolved regions can be conservatively shown as replacements without dropping text. Inline highlighting is viewport-lazy above 20,000 combined source rows; large/pathological lines use whole-line change shading. Source/display navigation uses indexed contiguous ranges rather than scanning all preceding rows. - Normal-picker status uses the gutter worker's five-second command and 16 MiB stream limits. Catalogs are bounded to 100,000 paths; dirty-buffer batches to 1,024 files/64 MiB; individual overlays, conflict files, and rename candidates to 16 MiB; conflict/rename data to 64 MiB. Temporary baseline stores are retained for warm metadata polling and removed when the worker stops. See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling. diff --git a/docs/editing.md b/docs/editing.md index 3cc3832..acdeced 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -517,6 +517,8 @@ Both diff panes apply available semantic tokens from their own buffer, independe Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file. +Large comparisons load each selected side independently, up to 64 MiB and 4,000,000 source lines per side. Frozen Working snapshots have a 512 MiB aggregate budget. Exceeding a limit displays the actual error in the document pane. Large files use the normal plain-text syntax fallback; pathological comparisons may show broader replacement regions or whole-line highlights rather than block input searching for a minimal diff. Full source text remains available. + ## Jujutsu conflict resolution Run **`:resolve`** in Normal mode for the current jj working copy. It opens a dedicated tab and selects the current conflicted file (otherwise the first conflict). Historical comparisons remain read-only; resolving never implicitly checks out or creates another change. diff --git a/src/diff.rs b/src/diff.rs index 1521ffd..07ca7d2 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -1,8 +1,10 @@ use crate::editor::{BackspaceKind, Input}; use crate::files::{FilePicker, FilePresentation, FileView, fuzzy_match}; use std::{ + borrow::Cow, collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}, + time::{Duration, Instant}, }; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; @@ -281,11 +283,11 @@ pub(crate) struct ColumnLine<'a> { pub changed: bool, pub emphasize: bool, pub newline: bool, - changes: &'a [[std::ops::Range; 2]], + changes: Cow<'a, [[std::ops::Range; 2]]>, } impl<'a> ColumnLine<'a> { - pub fn changes(&self) -> impl Iterator> + 'a { + pub fn changes(&self) -> impl Iterator> + '_ { let side = usize::from(self.side == Focus::After); let emphasize = self.emphasize; self.changes @@ -363,7 +365,21 @@ impl<'a> ColumnView<'a> { || (!before && row.kind == RowKind::Added), emphasize: !matches!(self.file.status, FileStatus::Added | FileStatus::Removed), newline: source_row + 1 < source.len() || eof, - changes: &row.changes, + changes: if self.diff.lazy_changes && row.kind == RowKind::Changed { + match (row.before, row.after) { + (Some(_), Some(_)) if self.file.hunks.is_empty() => { + // A stitched History base is changed by another column. + Cow::Owned(vec![[0..usize::MAX, 0..usize::MAX]]) + } + (Some(before), Some(after)) => Cow::Owned(character_changes( + &self.file.before[before], + &self.file.after[after], + )), + _ => Cow::Borrowed(&row.changes), + } + } else { + Cow::Borrowed(&row.changes) + }, })) } @@ -480,6 +496,7 @@ impl HistoryComparison { } for column in &mut columns { column.display_rows = (0..column.rows.len()).map(DisplayRow::Line).collect(); + column.rebuild_source_ranges(); } Self { columns } } @@ -561,6 +578,8 @@ pub struct DiffState { pub rows: Vec, pub display_rows: Vec, display_sides: Vec>, + source_ranges: [Vec<(std::ops::Range, usize)>; 2], + lazy_changes: bool, /// Native empty terminal rows explicitly supplied by resolver text. terminal_rows: [Option; 2], excerpt_ranges: Vec<(usize, usize)>, @@ -621,6 +640,8 @@ impl DiffState { rows: Vec::new(), display_rows: Vec::new(), display_sides: Vec::new(), + source_ranges: Default::default(), + lazy_changes: false, terminal_rows: [None; 2], excerpt_ranges: Vec::new(), live_buffer: None, @@ -1931,8 +1952,15 @@ impl DiffState { } pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option { - (0..self.display_rows.len()) - .position(|display| self.display_source_row(focus, display) == Some(source_row)) + let ranges = self.source_ranges.get(usize::from(focus == Focus::After))?; + if focus == Focus::Files { + return None; + } + let index = ranges.partition_point(|(range, _)| range.end <= source_row); + let (range, display) = ranges.get(index)?; + range + .contains(&source_row) + .then(|| display + source_row - range.start) } pub(crate) fn display_focus(&self, display: usize) -> Option { @@ -1979,9 +2007,24 @@ impl DiffState { } } - pub(crate) fn source_rows(&self, focus: Focus) -> impl DoubleEndedIterator + '_ { - self.excerpt_ranges.iter().flat_map(move |&(start, end)| { - (start..end).filter_map(move |index| self.source_row(focus, index)) + pub(crate) fn source_rows_in( + &self, + focus: Focus, + start: usize, + end: usize, + ) -> impl DoubleEndedIterator + '_ { + let ranges = &self.source_ranges[usize::from(focus == Focus::After)]; + let first = ranges.partition_point(|(range, _)| range.end <= start); + let last = ranges + .partition_point(|(range, _)| range.start < end) + .max(first); + ranges[first..last].iter().flat_map(move |(range, _)| { + let start = range.start.max(start); + start..if focus == Focus::Files { + start + } else { + range.end.min(end) + } }) } @@ -2038,36 +2081,53 @@ impl DiffState { } pub(crate) fn corresponding_row(&self, from: Focus, to: Focus, row: usize) -> Option { - let aligned = self.rows.iter().position(|aligned| match from { - Focus::Before => aligned.before == Some(row), - Focus::After => aligned.after == Some(row), - Focus::Files => false, - })?; - self.display_rows - .iter() - .enumerate() - .filter_map(|(display, row)| match *row { - DisplayRow::Line(index) => self - .display_source_row(to, display) - .map(|row| (index.abs_diff(aligned), row)), + let aligned = self + .source_display_row(from, row) + .and_then(|display| match self.display_rows[display] { + DisplayRow::Line(index) => Some(index), DisplayRow::Fold { .. } => None, }) - .min_by_key(|&(distance, row)| (distance, row)) - .map(|(_, row)| row) + .or_else(|| { + self.rows.iter().position(|aligned| match from { + Focus::Before => aligned.before == Some(row), + Focus::After => aligned.after == Some(row), + Focus::Files => false, + }) + })?; + if to == Focus::Files { + return None; + } + let mut nearest = None; + for (range, display) in &self.source_ranges[usize::from(to == Focus::After)] { + let visible = &self.display_rows[*display..*display + range.len()]; + let offset = visible + .partition_point(|row| matches!(row, DisplayRow::Line(index) if *index < aligned)); + for offset in [offset.saturating_sub(1), offset.min(visible.len() - 1)] { + let DisplayRow::Line(index) = visible[offset] else { + unreachable!() + }; + let candidate = (index.abs_diff(aligned), range.start + offset); + if nearest.is_none_or(|previous| candidate < previous) { + nearest = Some(candidate); + } + } + } + nearest.map(|(_, row)| row) } pub(crate) fn clamp_source_row(&self, focus: Focus, row: usize) -> Option { - let mut previous = None; - for visible in self.source_rows(focus) { - if visible >= row { - return Some(match previous { - Some(previous) if row - previous < visible - row => previous, - _ => visible, - }); - } - previous = Some(visible); + let ranges = &self.source_ranges[usize::from(focus == Focus::After)]; + if focus == Focus::Files { + return None; + } + let index = ranges.partition_point(|(range, _)| range.end <= row); + let next = ranges.get(index).map(|(range, _)| row.max(range.start)); + let previous = index.checked_sub(1).map(|index| ranges[index].0.end - 1); + match (previous, next) { + (Some(previous), Some(next)) if row - previous < next - row => Some(previous), + (_, Some(next)) => Some(next), + (previous, None) => previous, } - previous } fn restore_top(&mut self, anchor: Option, side: Option) { @@ -2147,7 +2207,12 @@ impl DiffState { old.push(""); } let new = lines.iter().map(String::as_str).collect::>(); - let ops = similar::capture_diff_slices(similar::Algorithm::Myers, &old, &new); + let ops = similar::capture_diff_slices_deadline( + similar::Algorithm::Myers, + &old, + &new, + Some(Instant::now() + Duration::from_millis(100)), + ); let mut mapping = vec![new.len().saturating_sub(1); old.len()]; for op in &ops { let (_, old_range, new_range) = op.as_tag_tuple(); @@ -2257,11 +2322,13 @@ impl DiffState { self.terminal_rows = [None; 2]; self.display_rows.clear(); self.display_sides.clear(); + self.source_ranges.iter_mut().for_each(Vec::clear); self.excerpt_ranges.clear(); if reset { self.top = 0; self.left_before = 0; self.left_after = 0; + self.lazy_changes = false; self.max_before = 0; self.max_after = 0; self.after_widths.clear(); @@ -2277,6 +2344,7 @@ impl DiffState { self.max_after = 0; return; }; + self.lazy_changes = file.before.len().saturating_add(file.after.len()) > 20_000; if reset { self.max_before = file .before @@ -2387,9 +2455,22 @@ impl DiffState { self.excerpt_ranges.truncate(count); self.display_rows.clear(); self.display_sides.clear(); + self.source_ranges.iter_mut().for_each(Vec::clear); if self.excerpt_ranges.is_empty() { return; } + let visible = self + .excerpt_ranges + .iter() + .map(|&(start, end)| end - start) + .sum::(); + let capacity = visible + .saturating_add(self.excerpt_ranges.len()) + .saturating_add(1); + self.display_rows.reserve(capacity); + if self.view_mode == ViewMode::Unified { + self.display_sides.reserve(capacity); + } let mut end = 0; for &(start, next_end) in &self.excerpt_ranges { if end < start { @@ -2436,6 +2517,27 @@ impl DiffState { self.display_sides.push(None); } } + self.rebuild_source_ranges(); + } + + fn rebuild_source_ranges(&mut self) { + self.source_ranges.iter_mut().for_each(Vec::clear); + for display in 0..self.display_rows.len() { + for (side, focus) in [Focus::Before, Focus::After].into_iter().enumerate() { + let Some(source) = self.display_source_row(focus, display) else { + continue; + }; + let ranges = &mut self.source_ranges[side]; + if let Some((range, first)) = ranges.last_mut() + && range.end == source + && *first + range.len() == display + { + range.end += 1; + } else { + ranges.push((source..source + 1, display)); + } + } + } } pub(crate) fn expand_source_excerpt(&mut self, focus: Focus, below: bool, source_row: usize) { @@ -2526,27 +2628,55 @@ fn refresh_character_changes( file: &ChangedFile, after_rows: std::ops::Range, ) { + let deadline = Instant::now() + Duration::from_millis(20); for row in rows { if row.kind == RowKind::Changed && let Some(after) = row.after.filter(|index| after_rows.contains(index)) { - row.changes = character_changes( - &file.before[row.before.expect("paired changed row")], - &file.after[after], - ); + if file.before.len().saturating_add(file.after.len()) <= 20_000 { + row.changes = character_changes_deadline( + &file.before[row.before.expect("paired changed row")], + &file.after[after], + deadline, + ); + } else { + row.changes.clear(); + } } } } fn character_changes(before: &str, after: &str) -> Vec<[std::ops::Range; 2]> { + character_changes_deadline(before, after, Instant::now() + Duration::from_millis(2)) +} + +fn character_changes_deadline( + before: &str, + after: &str, + deadline: Instant, +) -> Vec<[std::ops::Range; 2]> { if before == after { return Vec::new(); } + // Large/minified lines keep their complete text and line-level change shading, + // without allocating a grapheme table proportional to their size. + if before.len().saturating_add(after.len()) > 64 * 1024 || Instant::now() >= deadline { + return vec![[0..usize::MAX, 0..usize::MAX]]; + } let before: Vec<_> = before.graphemes(true).collect(); let after: Vec<_> = after.graphemes(true).collect(); let mut changes: Vec<[std::ops::Range; 2]> = Vec::new(); let (mut before_offset, mut after_offset) = (0, 0); - for op in similar::capture_diff_slices(similar::Algorithm::Myers, &before, &after) { + for op in similar::capture_diff_slices_deadline( + similar::Algorithm::Myers, + &before, + &after, + if before.len().saturating_add(after.len()) <= 2048 { + None + } else { + Some(deadline.min(Instant::now() + Duration::from_millis(2))) + }, + ) { let (tag, before, after) = op.as_tag_tuple(); // similar's optimized empty-range anchors can be stale; consume lengths in order. let before = before_offset..before_offset + before.len(); @@ -2626,6 +2756,19 @@ fn short_match_between_large_changes( fn line_width(line: &str) -> usize { #[cfg(test)] tests::WIDTH_SCANS.with(|scans| scans.set(scans.get() + 1)); + if line.is_ascii() { + if !line.bytes().any(|byte| byte < 32 || byte == 127) { + return line.len(); + } + return line.bytes().fold(0, |column, byte| { + column + + match byte { + b'\t' => 4 - column % 4, + 0..=31 | 127 => 0, + _ => 1, + } + }); + } line.graphemes(true).fold(0, |column, grapheme| { column + if grapheme == "\t" { @@ -2720,27 +2863,82 @@ pub(crate) fn line_hunks( after: &[String], after_eof: bool, ) -> Vec { - fn tokens(lines: &[String], eof: bool) -> Vec<(&str, bool)> { - lines - .iter() - .enumerate() - .map(|(index, line)| (line.as_str(), index + 1 < lines.len() || eof)) + // Empty sides need neither a token table nor a Myers frontier. + if before.is_empty() || after.is_empty() { + return if before.is_empty() && after.is_empty() { + Vec::new() + } else { + vec![Hunk { + before_start: 0, + before_len: before.len(), + after_start: 0, + after_len: after.len(), + }] + }; + } + let same = |old: usize, new: usize| { + before[old] == after[new] + && (old + 1 < before.len() || before_eof) == (new + 1 < after.len() || after_eof) + }; + let large = before.len().saturating_add(after.len()) > 20_000; + // Keep ordinary Myers/Compact anchoring unchanged; large comparisons avoid + // tokenizing their already-equal prefix and suffix. + let (start, suffix) = if large { + let start = (0..before.len().min(after.len())) + .take_while(|&index| same(index, index)) + .count(); + let suffix = (0..(before.len() - start).min(after.len() - start)) + .take_while(|&offset| same(before.len() - 1 - offset, after.len() - 1 - offset)) + .count(); + (start, suffix) + } else { + (0, 0) + }; + let before_end = before.len() - suffix; + let after_end = after.len() - suffix; + if start == before_end || start == after_end { + return if start == before_end && start == after_end { + Vec::new() + } else { + vec![Hunk { + before_start: start, + before_len: before_end - start, + after_start: start, + after_len: after_end - start, + }] + }; + } + fn tokens(lines: &[String], eof: bool, range: std::ops::Range) -> Vec<(&str, bool)> { + range + .map(|index| (lines[index].as_str(), index + 1 < lines.len() || eof)) .collect() } - let before = tokens(before, before_eof); - let after = tokens(after, after_eof); - similar::capture_diff_slices(similar::Algorithm::Myers, &before, &after) - .into_iter() - .filter_map(|op| { - let (tag, before, after) = op.as_tag_tuple(); - (tag != similar::DiffTag::Equal).then_some(Hunk { - before_start: before.start, - before_len: before.len(), - after_start: after.start, - after_len: after.len(), - }) - }) - .collect() + let old = tokens(before, before_eof, start..before_end); + let new = tokens(after, after_eof, start..after_end); + // ponytail: after 100ms Myers may conservatively replace unresolved lines; + // every source line remains present, never mislabeled as equal. + let mut old_offset = start; + let mut new_offset = start; + similar::capture_diff_slices_deadline( + similar::Algorithm::Myers, + &old, + &new, + Some(Instant::now() + Duration::from_millis(100)), + ) + .into_iter() + .filter_map(|op| { + let (tag, old, new) = op.as_tag_tuple(); + let hunk = Hunk { + before_start: if large { old_offset } else { old.start }, + before_len: old.len(), + after_start: if large { new_offset } else { new.start }, + after_len: new.len(), + }; + old_offset += old.len(); + new_offset += new.len(); + (tag != similar::DiffTag::Equal).then_some(hunk) + }) + .collect() } fn file_is_changed(file: &ChangedFile) -> bool { diff --git a/src/editor.rs b/src/editor.rs index 6f6f074..bcbfae1 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -11797,8 +11797,7 @@ impl Editor { .and_then(|focus| self.diff().map(|diff| (diff, focus))); (start..if diff.is_none() { end } else { start }).chain( diff.into_iter() - .flat_map(|(diff, focus)| diff.source_rows(focus)) - .filter(move |row| start <= *row && *row < end), + .flat_map(move |(diff, focus)| diff.source_rows_in(focus, start, end)), ) } diff --git a/src/repository.rs b/src/repository.rs index 425a31e..5edc074 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -21,6 +21,10 @@ use std::{ const OUTPUT_LIMIT: usize = 64 * 1024 * 1024; const FILE_LIMIT: usize = 16 * 1024 * 1024; +// Comparison payloads are independent of picker/gutter annotation budgets. +const COMPARISON_FILE_LIMIT: usize = 64 * 1024 * 1024; +const COMPARISON_LINE_LIMIT: usize = 4_000_000; +const WORKING_SNAPSHOT_LIMIT: usize = 512 * 1024 * 1024; const TIMEOUT: Duration = Duration::from_secs(60); static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); @@ -1948,94 +1952,54 @@ impl Repository { } fn materialize(&self, snapshot: &mut Snapshot) -> Result<(), String> { - let stores: BTreeSet<_> = snapshot - .values() - .filter(|file| file.projection.is_none()) - .filter_map(|file| file.object.as_ref().map(|(store, _)| store.clone())) - .collect(); - for store in stores { - let mut request = Vec::new(); - for file in snapshot.values() { - if let Some((source, id)) = &file.object - && source == &store - && file.projection.is_none() - { - request.extend_from_slice(id.as_bytes()); - request.push(b'\n'); - } - } - let mut cmd = git_command(&store); - cmd.args(["cat-file", "--batch"]); - let data = checked(run(cmd, request, &self.cancelled)?)?; - let mut offset = 0; - for (path, file) in snapshot.iter_mut() { - if file.projection.is_some() - || file - .object - .as_ref() - .is_none_or(|(source, _)| source != &store) - { - continue; - } - self.check_cancelled()?; - let end = offset - + data[offset..] - .iter() - .position(|b| *b == b'\n') - .ok_or("Truncated Git object header")?; - let header = std::str::from_utf8(&data[offset..end]) - .map_err(|_| "Invalid Git object header")?; - let size: usize = header - .rsplit(' ') - .next() - .ok_or("Missing Git object size")? - .parse() + for (path, file) in snapshot.iter_mut() { + self.check_cancelled()?; + if let Some((root, id)) = &file.projection { + let revset = format!("commit_id({})", serde_json::to_string(id).unwrap()); + let pattern = + jj_file_pattern(path.to_str().ok_or("Non-UTF-8 Jujutsu conflict path")?); + file.bytes = self + .jj( + root, + &["file", "show", "-r", &revset, "-T", "", "--", &pattern], + ) + .map_err(|error| format!("{}: {error}", path.display()))?; + } else if let Some((store, id)) = &file.object { + let size = text(self.git(store, &["cat-file", "-s", id])?)? + .trim() + .parse::() .map_err(|_| "Invalid Git object size")?; - if size > FILE_LIMIT { + if size > COMPARISON_FILE_LIMIT { return Err(format!( - "{} exceeds the 16 MiB comparison file limit", + "{} exceeds the 64 MiB per-side comparison file limit", path.display() )); } - offset = end + 1; - if size >= data.len().saturating_sub(offset) { - return Err("Truncated Git object content".into()); - } - file.bytes = data[offset..offset + size].to_vec(); - file.object = None; - offset += size + 1; - } - } - let mut total = 0; - for (path, file) in snapshot.iter_mut() { - let Some((root, id)) = &file.projection else { + // One blob per subprocess output: never concatenate both sides or + // copy a cat-file batch into a second allocation. + file.bytes = self.git(store, &["cat-file", "blob", id])?; + } else { continue; - }; - let revset = format!("commit_id({})", serde_json::to_string(id).unwrap()); - let pattern = jj_file_pattern(path.to_str().ok_or("Non-UTF-8 Jujutsu conflict path")?); - let bytes = self.jj( - root, - &["file", "show", "-r", &revset, "-T", "", "--", &pattern], - )?; - if bytes.len() > FILE_LIMIT { + } + if file.bytes.len() > COMPARISON_FILE_LIMIT { return Err(format!( - "{} exceeds the 16 MiB comparison file limit", + "{} exceeds the 64 MiB per-side comparison file limit", path.display() )); } - total += bytes.len(); - if total > OUTPUT_LIMIT { - return Err( - "Jujutsu conflict projection exceeds the 64 MiB comparison limit".into(), - ); - } - file.bytes = bytes; file.object = None; file.projection = None; } Ok(()) } - fn working_snapshot(&self, buffers: &[(PathBuf, String)]) -> Result { + fn working_snapshot(&mut self, buffers: &[(PathBuf, String)]) -> Result { + self.ensure_private()?; + let store = self.private.as_ref().unwrap().1.clone(); + // Freeze each file once, then let Git ingest them in one batch. Keeping + // object identities makes catalog comparison lazy even against Working. + let frozen = Temporary::new()?; + let mut frozen_paths = Vec::new(); + let mut hash_input = Vec::new(); let mut tracked = BTreeSet::new(); let mut submodules = BTreeSet::new(); if self.kind == RepoKind::Git { @@ -2220,39 +2184,46 @@ impl Repository { let bytes = target.to_string_lossy().as_bytes().to_vec(); ("120000".into(), bytes, None) } else if metadata.is_file() { - if metadata.len() > FILE_LIMIT as u64 { + if metadata.len() > COMPARISON_FILE_LIMIT as u64 { return Err(format!( - "{} exceeds the 16 MiB comparison file limit", + "{} exceeds the 64 MiB per-side comparison file limit", path.display() )); } - let bytes = fs::File::open(&full) - .and_then(|f| { - let mut bytes = Vec::new(); - f.take(FILE_LIMIT as u64 + 1).read_to_end(&mut bytes)?; - Ok(bytes) - }) - .map_err(|e| format!("{}: {e}", path.display()))?; - if bytes.len() > FILE_LIMIT { - return Err(format!( - "{} grew beyond the 16 MiB comparison file limit", - path.display() - )); - } - let bytes = if !bytes.contains(&0) - && std::str::from_utf8(&bytes).is_ok() - && let Some(text) = overlays.get(&path) - { - if text.len() > FILE_LIMIT { + let mut source = fs::File::open(&full) + .map_err(|e| format!("{}: {e}", path.display()))? + .take(COMPARISON_FILE_LIMIT as u64 + 1); + let mut bytes = Vec::with_capacity(metadata.len() as usize); + let mut chunk = [0; 64 * 1024]; + loop { + self.check_cancelled()?; + let count = source + .read(&mut chunk) + .map_err(|e| format!("{}: {e}", path.display()))?; + if count == 0 { + break; + } + if bytes.len() + count > COMPARISON_FILE_LIMIT { return Err(format!( - "{} exceeds the 16 MiB comparison file limit", + "{} grew beyond the 64 MiB per-side comparison file limit", path.display() )); } - text.as_bytes().to_vec() - } else { - bytes - }; + bytes.extend_from_slice(&chunk[..count]); + } + if !bytes.contains(&0) + && std::str::from_utf8(&bytes).is_ok() + && let Some(text) = overlays.get(&path) + { + if text.len() > COMPARISON_FILE_LIMIT { + return Err(format!( + "{} exceeds the 64 MiB per-side comparison file limit", + path.display() + )); + } + bytes.clear(); + bytes.extend_from_slice(text.as_bytes()); + } #[cfg(unix)] let executable = { use std::os::unix::fs::PermissionsExt; @@ -2273,20 +2244,45 @@ impl Repository { ) }; total += bytes.len(); - if total > OUTPUT_LIMIT { - return Err("Working snapshot exceeds the 64 MiB comparison limit".into()); + if total > WORKING_SNAPSHOT_LIMIT { + return Err("Working snapshot exceeds the 512 MiB comparison limit".into()); } + let object = if notice.is_none() { + let name = frozen_paths.len().to_string(); + fs::write(frozen.0.join(&name), &bytes) + .map_err(|e| format!("{}: {e}", path.display()))?; + writeln!(&mut hash_input, "{name}").unwrap(); + frozen_paths.push(path.clone()); + Some((store.clone(), String::new())) + } else { + None + }; snapshot.insert( path, FileData { mode, - bytes, - object: None, + bytes: vec![], + object, projection: None, notice, }, ); } + if !frozen_paths.is_empty() { + let mut hash = git_command(&store); + hash.args(["hash-object", "--no-filters", "-w", "--stdin-paths"]) + .current_dir(&frozen.0); + let hashes = text(checked(run(hash, hash_input, &self.cancelled)?)?)?; + let mut hashes = hashes.lines(); + for path in frozen_paths { + self.check_cancelled()?; + let id = hashes.next().ok_or("Truncated Working snapshot hashes")?; + snapshot.get_mut(&path).unwrap().object = Some((store.clone(), id.into())); + } + if hashes.next().is_some() { + return Err("Unexpected Working snapshot hashes".into()); + } + } Ok(snapshot) } fn snapshot_revision( @@ -2299,13 +2295,16 @@ impl Repository { } self.ensure_private()?; let store = &self.private.as_ref().unwrap().1; - let mut input = b"vamp-working-snapshot-v1\0".to_vec(); + let mut input = b"vamp-working-snapshot-v2\0".to_vec(); for (path, file) in snapshot { self.check_cancelled()?; for field in [ path.as_os_str().as_encoded_bytes(), file.mode.as_bytes(), - file.bytes.as_slice(), + file.object + .as_ref() + .map_or("", |(_, id)| id.as_str()) + .as_bytes(), file.notice.as_deref().unwrap_or("").as_bytes(), ] { input.extend_from_slice(&(field.len() as u64).to_be_bytes()); @@ -2760,15 +2759,6 @@ impl Repository { ) -> Result { let left = &mut prepared.left; let right = &mut prepared.right; - // Frozen checkout bytes have no object identity. Compare them in a single - // batch; immutable blobs and deferred conflict projections retain IDs. - let frozen = |file: &FileData| { - file.object.is_none() && matches!(file.mode.as_str(), "100644" | "100755" | "120000") - }; - if !same && (left.values().any(frozen) || right.values().any(frozen)) { - self.materialize(left)?; - self.materialize(right)?; - } let right_view = if same { &*left } else { &*right }; let mut files = vec![]; let paths: BTreeSet<_> = left.keys().chain(right_view.keys()).collect(); @@ -2950,6 +2940,19 @@ impl Repository { notices.push("Binary or non-UTF-8 content; text diff is unavailable".into()); (vec![], vec![], vec![]) } else { + // Bound line-header allocations as well as payload bytes. The real + // generated Honey parser has more than 1.5 million lines. + for bytes in [a, b] { + let count = bytes.iter().filter(|&&byte| byte == b'\n').count() + + usize::from(!bytes.is_empty() && !bytes.ends_with(b"\n")); + if count > COMPARISON_LINE_LIMIT { + return Err(format!( + "{} exceeds the 4000000-line per-side comparison limit", + path.display() + )); + } + self.check_cancelled()?; + } if (!a.is_empty() && !a.ends_with(b"\n")) || (!b.is_empty() && !b.ends_with(b"\n")) { notices.push(format!( "EOF newline: Before {}, After {}", diff --git a/src/tui.rs b/src/tui.rs index e8098fb..ae30f2b 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -3976,13 +3976,21 @@ impl WheelPanels { } else { let content = diff_pane_panel(frame, diff, selected, panes, true, resolver); frame.render_widget( - Paragraph::new(if diff.loading { + Paragraph::new(display_text(if diff.loading { "Loading file…" + } else if let Some(error) = diff.error.as_deref() { + error } else if selected.is_some() { "File details unavailable · select the file to retry" } else { "No changed files" - }), + })) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(if diff.error.is_some() { + Color::Red + } else { + Color::Reset + })), content, ); } diff --git a/tests/repository.rs b/tests/repository.rs index 1d6155a..df92f4d 100644 --- a/tests/repository.rs +++ b/tests/repository.rs @@ -1728,18 +1728,30 @@ fn conflicted_catalog_preserves_symlinks_and_defers_unselected_contents() { let fixture = Fixture::new(); let root = fixture.dir("lazy-conflict"); init_jj(&root, false); - fs::write(root.join("conflict"), "base\n").unwrap(); + jj( + &root, + &[ + "config", + "set", + "--repo", + "snapshot.max-new-file-size", + "100MiB", + ], + ); + let tail_line = "x".repeat(1023); + let tail = format!("{tail_line}\n").repeat(17 * 1024 + 1); + fs::write(root.join("conflict"), format!("base\n{tail}")).unwrap(); fs::write(root.join("unselected"), "old\n").unwrap(); symlink("unselected", root.join("unchanged-link")).unwrap(); jj(&root, &["describe", "-m", "base"]); let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["new", &base]); - fs::write(root.join("conflict"), "left\n").unwrap(); + fs::write(root.join("conflict"), format!("left\n{tail}")).unwrap(); fs::write(root.join("unselected"), vec![b'x'; 17 * 1024 * 1024]).unwrap(); jj(&root, &["describe", "-m", "left"]); let left = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["new", &base]); - fs::write(root.join("conflict"), "right\n").unwrap(); + fs::write(root.join("conflict"), format!("right\n{tail}")).unwrap(); jj(&root, &["describe", "-m", "right"]); let right = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["new", &left, &right, "-m", "conflicted merge"]); @@ -1764,7 +1776,9 @@ fn conflicted_catalog_preserves_symlinks_and_defers_unselected_contents() { [Path::new("conflict"), Path::new("unselected")] ); let conflict = repository.load_file(1, 0).unwrap(); - assert_eq!(conflict.before, ["base"]); + assert_eq!(conflict.before[0], "base"); + assert_eq!(conflict.before.len(), 17 * 1024 + 2); + assert!(conflict.before[1..].iter().all(|line| line == &tail_line)); let projection = jj( &root, &[ @@ -1779,9 +1793,13 @@ fn conflicted_catalog_preserves_symlinks_and_defers_unselected_contents() { "conflict", ], ); + assert!(projection.len() > 16 * 1024 * 1024); assert_eq!(conflict.after, projection.lines().collect::>()); assert!(!conflict.hunks.is_empty()); - assert!(repository.load_file(1, 1).is_err()); + let large = repository.load_file(1, 1).unwrap(); + assert_eq!(large.after.len(), 1); + assert_eq!(large.after[0].len(), 17 * 1024 * 1024); + assert!(large.after[0].bytes().all(|byte| byte == b'x')); assert_eq!(disk_state(&root), state); jj(&root, &["new", &merged]); @@ -1945,6 +1963,220 @@ fn immutable_catalog_skips_unchanged_blobs_and_prepares_only_requested_details() assert!(repository.load_file(100, index).is_err()); } +#[test] +fn comparison_size_and_line_ceilings_are_lazy_and_explicit() { + let fixture = Fixture::new(); + let root = fixture.dir("ceilings"); + init_git(&root); + git(&root, &["commit", "--allow-empty", "-m", "before"]); + let before = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git); + fs::File::create(root.join("too-many-bytes")) + .unwrap() + .set_len(64 * 1024 * 1024 + 1) + .unwrap(); + fs::write(root.join("too-many-lines"), vec![b'\n'; 4_000_001]).unwrap(); + fs::write(root.join("selected"), "still available\n").unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-m", "after"]); + let after = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git); + let mut repository = open(&root); + let catalog = repository + .compare(1, &before, &after, ComparisonMode::Snapshots, &[]) + .unwrap(); + assert_eq!(catalog.files.len(), 3); + assert!(catalog.files.iter().all(|file| !file.loaded)); + for (index, entry) in catalog.files.iter().enumerate() { + match entry.path.to_str().unwrap() { + "selected" => assert_eq!( + repository.load_file(1, index).unwrap().after, + ["still available"] + ), + "too-many-bytes" => assert!( + repository + .load_file(1, index) + .unwrap_err() + .contains("64 MiB per-side comparison file limit") + ), + "too-many-lines" => assert!( + repository + .load_file(1, index) + .unwrap_err() + .contains("4000000-line per-side comparison limit") + ), + path => panic!("Unexpected file {path}"), + } + } +} + +#[test] +fn large_git_and_jj_comparisons_load_exact_snapshots_and_frozen_working_overlays() { + let fixture = Fixture::new(); + // Both modified sides exceed 52.3 MB; the Working pair exceeds the old + // aggregate 64 MiB budget as well as its old 16 MiB per-file limit. + let body_line = "x".repeat(1023); + let mut contents = format!( + "before\n{}", + format!("{body_line}\n").repeat(52_300_000 / 1024 + 1) + ); + let line_count = contents.lines().count(); + for kind in [RepoKind::Git, RepoKind::Jujutsu] { + let root = fixture + .dir(if kind == RepoKind::Git { "git" } else { "jj" }) + .canonicalize() + .unwrap(); + match kind { + RepoKind::Git => init_git(&root), + RepoKind::Jujutsu => { + init_jj(&root, false); + jj( + &root, + &[ + "config", + "set", + "--repo", + "snapshot.max-new-file-size", + "100MiB", + ], + ); + } + } + let set_header = |contents: &mut String, header: &str| { + let end = contents.find('\n').unwrap(); + contents.replace_range(..end, header); + }; + set_header(&mut contents, "before"); + fs::write(root.join("modified"), &contents).unwrap(); + set_header(&mut contents, "removed"); + fs::write(root.join("removed"), &contents).unwrap(); + let before = match kind { + RepoKind::Git => { + git(&root, &["add", "."]); + git(&root, &["commit", "-m", "large before"]); + git(&root, &["rev-parse", "HEAD"]) + } + RepoKind::Jujutsu => { + jj(&root, &["describe", "-m", "large before"]); + let id = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); + jj(&root, &["new", &id]); + id + } + }; + set_header(&mut contents, "after"); + fs::write(root.join("modified"), &contents).unwrap(); + fs::remove_file(root.join("removed")).unwrap(); + set_header(&mut contents, "added"); + fs::write(root.join("added"), &contents).unwrap(); + let after = match kind { + RepoKind::Git => { + git(&root, &["add", "."]); + git(&root, &["commit", "-m", "large after"]); + git(&root, &["rev-parse", "HEAD"]) + } + RepoKind::Jujutsu => { + jj(&root, &["describe", "-m", "large after"]); + jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]) + } + }; + let assert_contents = |lines: &[String], header: &str| { + assert_eq!(lines.len(), line_count); + assert_eq!(lines[0], header); + assert!(lines[1..].iter().all(|line| line == &body_line)); + }; + let cancelled = Arc::new(AtomicBool::new(false)); + let mut repository = Repository::open(&root, cancelled.clone()).unwrap(); + let catalog = repository + .compare( + 1, + &revision(&before, kind), + &revision(&after, kind), + ComparisonMode::Snapshots, + &[], + ) + .unwrap(); + assert_eq!(catalog.files.len(), 3); + assert!(catalog.files.iter().all(|file| !file.loaded + && file.before.is_empty() + && file.after.is_empty() + && file.hunks.is_empty())); + for (index, entry) in catalog.files.iter().enumerate() { + let file = repository.load_file(1, index).unwrap(); + assert!(file.loaded); + assert_eq!(file.hunks.len(), 1); + let hunk = &file.hunks[0]; + let expected = match entry.path.to_str().unwrap() { + "added" => { + assert_eq!(file.status, FileStatus::Added); + assert!(file.before.is_empty()); + assert_contents(&file.after, "added"); + (0, 0, 0, line_count) + } + "removed" => { + assert_eq!(file.status, FileStatus::Removed); + assert_contents(&file.before, "removed"); + assert!(file.after.is_empty()); + (0, line_count, 0, 0) + } + "modified" => { + assert_eq!(file.status, FileStatus::Modified); + assert_contents(&file.before, "before"); + assert_contents(&file.after, "after"); + (0, 1, 0, 1) + } + path => panic!("Unexpected file {path}"), + }; + assert_eq!( + ( + hunk.before_start, + hunk.before_len, + hunk.after_start, + hunk.after_len + ), + expected + ); + assert!(file.before.is_empty() || file.before_eof); + assert!(file.after.is_empty() || file.after_eof); + } + set_header(&mut contents, "overlay"); + let buffers = [(root.join("modified"), contents)]; + let working = repository + .compare( + 2, + &revision(&after, kind), + &working(kind), + ComparisonMode::Snapshots, + &buffers, + ) + .unwrap(); + assert_eq!(working.files.len(), 1); + assert!(!working.files[0].loaded); + assert!(working.editable_paths.contains(Path::new("modified"))); + fs::write(root.join("modified"), "later disk contents\n").unwrap(); + cancelled.store(true, Ordering::Release); + assert!( + repository + .load_file(2, 0) + .unwrap_err() + .contains("cancelled") + ); + cancelled.store(false, Ordering::Release); + let file = repository.load_file(2, 0).unwrap(); + assert_contents(&file.before, "after"); + assert_contents(&file.after, "overlay"); + assert_eq!(file.hunks.len(), 1); + let hunk = &file.hunks[0]; + assert_eq!( + ( + hunk.before_start, + hunk.before_len, + hunk.after_start, + hunk.after_len + ), + (0, 1, 0, 1) + ); + contents = buffers.into_iter().next().unwrap().1; + } +} + #[test] fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() { let fixture = Fixture::new(); @@ -2112,8 +2344,16 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure( assert!(repository.refresh_checkout(10, 11, true, &[]).is_err()); cancelled.store(false, Ordering::Release); assert_eq!(repository.load_file(10, 0).unwrap().after, ["frozen after"]); - fs::write(root.join("oversized"), vec![b'x'; 17 * 1024 * 1024]).unwrap(); - assert!(repository.refresh_checkout(10, 11, true, &[]).is_err()); + fs::File::create(root.join("oversized")) + .unwrap() + .set_len(64 * 1024 * 1024 + 1) + .unwrap(); + assert!( + repository + .refresh_checkout(10, 11, true, &[]) + .unwrap_err() + .contains("64 MiB per-side comparison file limit") + ); let original = repository.load_file(10, 0).unwrap(); assert_eq!(original.before, ["frozen before"]); assert_eq!(original.after, ["frozen after"]); @@ -2194,7 +2434,7 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure( } #[test] -fn refresh_materialization_failure_restores_prepared_file_sources() { +fn refresh_keeps_historical_blob_sources_lazy_even_with_working_files() { let fixture = Fixture::new(); let root = fixture.dir("failed-materialization"); init_git(&root); @@ -2206,7 +2446,7 @@ fn refresh_materialization_failure_restores_prepared_file_sources() { fs::remove_file(root.join("a-file")).unwrap(); fs::remove_file(root.join("z-large")).unwrap(); let mut repository = open(&root); - let original = repository + repository .compare( 1, &base, @@ -2215,20 +2455,29 @@ fn refresh_materialization_failure_restores_prepared_file_sources() { &[], ) .unwrap(); - let index = original + assert_eq!( + repository.load_file(1, 0).unwrap().before, + ["frozen source"] + ); + fs::write(root.join("a-file"), "new checkout\n").unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-m", "descendant"]); + let refreshed = repository + .refresh_checkout(1, 2, true, &[]) + .unwrap() + .map(checkout_comparison) + .unwrap(); + assert!(refreshed.files.iter().all(|file| !file.loaded)); + let index = refreshed .files .iter() .position(|file| file.path == Path::new("a-file")) .unwrap(); - fs::write(root.join("a-file"), "new checkout\n").unwrap(); - git(&root, &["add", "."]); - git(&root, &["commit", "-m", "descendant"]); - assert!(repository.refresh_checkout(1, 2, true, &[]).is_err()); - let file = repository.load_file(1, index).unwrap(); + let file = repository.load_file(2, index).unwrap(); assert_eq!(file.before, ["frozen source"]); - assert!(file.after.is_empty()); - assert_eq!(file.status, FileStatus::Removed); - assert!(repository.load_file(2, index).is_err()); + assert_eq!(file.after, ["new checkout"]); + assert_eq!(file.status, FileStatus::Modified); + assert!(repository.load_file(1, index).is_err()); } #[test] diff --git a/tests/unit/diff.rs b/tests/unit/diff.rs index 3781314..2c88fe1 100644 --- a/tests/unit/diff.rs +++ b/tests/unit/diff.rs @@ -2240,7 +2240,7 @@ fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() { ); assert!(state.after_editable()); for focus in [Focus::Before, Focus::After] { - for source in state.source_rows(focus) { + for source in state.source_rows_in(focus, 0, usize::MAX) { let display = state.source_display_row(focus, source).unwrap(); assert_eq!(state.display_source_row(focus, display), Some(source)); } @@ -2271,10 +2271,15 @@ fn unified_live_reflow_preserves_removed_top_and_expanded_source_excerpts() { assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20)); assert!(state.source_display_row(Focus::After, 17).is_some()); state.reveal_source_row(Focus::After, 35); - let visible: Vec<_> = state.source_rows(Focus::After).collect(); + let visible: Vec<_> = state.source_rows_in(Focus::After, 0, usize::MAX).collect(); state.set_view_mode(ViewMode::Split); state.set_view_mode(ViewMode::Unified); - assert_eq!(state.source_rows(Focus::After).collect::>(), visible); + assert_eq!( + state + .source_rows_in(Focus::After, 0, usize::MAX) + .collect::>(), + visible + ); assert_eq!(state.display_source_row(Focus::After, state.top), Some(35)); } @@ -2750,3 +2755,251 @@ fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() { state.comparison.as_mut().unwrap().revisions[0] = "other".into(); assert!(!state.review_anchor_current(&before)); } + +#[test] +fn million_row_additions_and_removals_keep_complete_sources_and_compact_navigation() { + let lines = vec!["static const unsigned int parser_state = 1234567890;".to_owned(); 1_000_000]; + let mut state = live_state(Vec::new(), lines, true); + state.comparison.as_mut().unwrap().files[0].status = FileStatus::Added; + state.align_file(); + assert_eq!(state.rows.len(), 1_000_000); + assert!(state.rows.iter().all(|row| row.changes.is_empty())); + assert_eq!(state.source_ranges[1], [(0..1_000_000, 0)]); + assert_eq!(state.after_display_row(999_999), Some(999_999)); + assert_eq!( + state.clamp_source_row(Focus::After, usize::MAX), + Some(999_999) + ); + let mut visible = state.source_rows_in(Focus::After, 999_990, 999_999); + assert_eq!(visible.next(), Some(999_990)); + assert_eq!(visible.next_back(), Some(999_998)); + drop(visible); + let ColumnRow::Text(last) = state + .column_view(Focus::After) + .unwrap() + .row(999_999) + .unwrap() + else { + panic!("the final added source line must remain available"); + }; + assert_eq!( + last.text, + Some("static const unsigned int parser_state = 1234567890;") + ); + assert_eq!(last.source_row, 999_999); + let file = &mut state.comparison.as_mut().unwrap().files[0]; + std::mem::swap(&mut file.before, &mut file.after); + file.status = FileStatus::Removed; + file.hunks = line_hunks(&file.before, true, &file.after, false); + assert_eq!( + (file.hunks[0].before_len, file.hunks[0].after_len), + (1_000_000, 0) + ); + state.align_file(); + assert_eq!(state.source_ranges[0], [(0..1_000_000, 0)]); + assert_eq!( + state.source_display_row(Focus::Before, 999_999), + Some(999_999) + ); + assert_eq!(state.source_display_row(Focus::After, 0), None); +} + +#[test] +fn large_localized_modifications_keep_exact_hunks_and_only_diff_visible_characters() { + let before = vec!["unchanged generated parser body".to_owned(); 25_000]; + let mut after = before.clone(); + after[20_000] = "unchanged generated parser body!".into(); + let state = live_state(before, after, true); + let file = &state.comparison.as_ref().unwrap().files[0]; + assert_eq!(file.hunks.len(), 1); + let hunk = &file.hunks[0]; + assert_eq!( + ( + hunk.before_start, + hunk.before_len, + hunk.after_start, + hunk.after_len + ), + (20_000, 1, 20_000, 1) + ); + assert!(state.rows.iter().all(|row| row.changes.is_empty())); + let display = state.after_display_row(20_000).unwrap(); + let ColumnRow::Text(line) = state + .column_view(Focus::After) + .unwrap() + .row(display) + .unwrap() + else { + panic!("the modified source row must remain visible"); + }; + assert_eq!(line.changes().cloned().collect::>(), [31..32]); +} + +#[test] +fn pathological_modifications_preserve_every_line_when_diff_budget_is_exhausted() { + let before = (0..25_000) + .map(|row| format!("old {row}")) + .collect::>(); + let after = (0..25_000) + .map(|row| format!("new {row}")) + .collect::>(); + let state = live_state(before, after, false); + let file = &state.comparison.as_ref().unwrap().files[0]; + let (mut old, mut new) = (0, 0); + for hunk in &file.hunks { + assert_eq!( + &file.before[old..hunk.before_start], + &file.after[new..hunk.after_start] + ); + old = hunk.before_start + hunk.before_len; + new = hunk.after_start + hunk.after_len; + } + assert_eq!(&file.before[old..], &file.after[new..]); + assert_eq!(state.rows.len(), 25_000); + assert!( + state + .rows + .iter() + .all(|row| row.kind == RowKind::Changed && row.changes.is_empty()) + ); + assert_eq!(state.after_display_row(24_999), Some(24_999)); + let giant = "界".repeat(64 * 1024); + assert_eq!( + character_changes(&giant, "replacement"), + [[0..usize::MAX, 0..usize::MAX]] + ); +} + +#[test] +fn indexed_source_ranges_match_projection_with_folds_gaps_and_unified_runs() { + let before = (0..60).map(|row| format!("row {row}")).collect::>(); + let mut after = before.clone(); + after.splice(10..14, ["inserted".into()]); + after.splice(35..35, ["extra 1".into(), "extra 2".into()]); + let mut state = live_state(before, after, false); + state.set_context_options(2, 5); + for mode in [ViewMode::Split, ViewMode::Unified] { + state.set_view_mode(mode); + for focus in [Focus::Before, Focus::After] { + let expected = (0..state.display_rows.len()) + .filter_map(|display| { + state + .display_source_row(focus, display) + .map(|row| (row, display)) + }) + .collect::>(); + for source in 0..65 { + assert_eq!( + state.source_display_row(focus, source), + expected + .iter() + .find(|(row, _)| *row == source) + .map(|(_, display)| *display) + ); + assert_eq!( + state.clamp_source_row(focus, source), + expected + .iter() + .map(|(row, _)| *row) + .min_by_key(|row| (row.abs_diff(source), *row)) + ); + let target = if focus == Focus::Before { + Focus::After + } else { + Focus::Before + }; + let aligned = state.rows.iter().position(|row| match focus { + Focus::Before => row.before == Some(source), + _ => row.after == Some(source), + }); + let nearest = aligned.and_then(|aligned| { + (0..state.display_rows.len()) + .filter_map(|display| { + let DisplayRow::Line(index) = state.display_rows[display] else { + return None; + }; + state + .display_source_row(target, display) + .map(|row| (index.abs_diff(aligned), row)) + }) + .min() + .map(|(_, row)| row) + }); + assert_eq!(state.corresponding_row(focus, target, source), nearest); + } + assert_eq!( + state.source_rows_in(focus, 9, 40).collect::>(), + expected + .iter() + .map(|(row, _)| *row) + .filter(|row| (9..40).contains(row)) + .collect::>() + ); + } + } +} + +#[test] +fn ordinary_hunks_keep_similar_anchoring_for_repeated_lines_and_eof_changes() { + let sources = (0..=3) + .flat_map(|len| { + (0..1 << len).map(move |bits| { + (0..len) + .map(|bit| { + if bits & (1 << bit) == 0 { + "a".into() + } else { + "b".into() + } + }) + .collect::>() + }) + }) + .collect::>(); + for before in &sources { + for after in &sources { + for before_eof in [false, true] { + for after_eof in [false, true] { + let tokens = |lines: &[String], eof| { + lines + .iter() + .enumerate() + .map(|(index, line)| (line.clone(), index + 1 < lines.len() || eof)) + .collect::>() + }; + let expected = similar::capture_diff_slices( + similar::Algorithm::Myers, + &tokens(before, before_eof), + &tokens(after, after_eof), + ) + .into_iter() + .filter_map(|op| { + let (tag, old, new) = op.as_tag_tuple(); + (tag != similar::DiffTag::Equal).then_some(( + old.start, + old.len(), + new.start, + new.len(), + )) + }) + .collect::>(); + let actual = line_hunks(before, before_eof, after, after_eof) + .iter() + .map(|hunk| { + ( + hunk.before_start, + hunk.before_len, + hunk.after_start, + hunk.after_len, + ) + }) + .collect::>(); + assert_eq!( + actual, expected, + "{before:?} ({before_eof}) -> {after:?} ({after_eof})" + ); + } + } + } + } +} diff --git a/tests/unit/editor.rs b/tests/unit/editor.rs index 83f8f27..53449ac 100644 --- a/tests/unit/editor.rs +++ b/tests/unit/editor.rs @@ -135,10 +135,21 @@ fn resolver_shares_native_excerpt_search_motions_and_explicit_writable_side() { type_text(editor, "/needle"); editor.handle(Input::Enter); assert_eq!(editor.cursor().row, 70); - let rows = editor.diff().unwrap().source_rows(Focus::Before).count(); + let rows = editor + .diff() + .unwrap() + .source_rows_in(Focus::Before, 0, usize::MAX) + .count(); let cursor = editor.cursor(); editor.handle(Input::Char(']')); - assert!(editor.diff().unwrap().source_rows(Focus::Before).count() > rows); + assert!( + editor + .diff() + .unwrap() + .source_rows_in(Focus::Before, 0, usize::MAX) + .count() + > rows + ); assert_eq!(editor.cursor(), cursor); type_text(editor, "21g"); assert_eq!(editor.cursor().row, 20); @@ -4644,7 +4655,7 @@ fn editable_diff_brackets_expand_caret_excerpt_without_moving_source_anchor() { editor .diff() .unwrap() - .source_rows(Focus::After) + .source_rows_in(Focus::After, 0, usize::MAX) .collect::>() }; assert_eq!( diff --git a/tests/unit/tui.rs b/tests/unit/tui.rs index b7dc161..26289f2 100644 --- a/tests/unit/tui.rs +++ b/tests/unit/tui.rs @@ -4612,6 +4612,68 @@ fn hiding_diff_explorer_reclaims_workspace_and_restores_its_geometry() { assert_eq!(panels.files.unwrap().0, original[0].0); } +#[test] +fn unavailable_file_documents_show_the_actual_error_in_managed_workspaces() { + let mut file = deletion_file(); + file.loaded = false; + let mut diff = diff_comparison(vec![file]); + diff.loading = false; + diff.error = Some("parser.c exceeds 128 MiB file limit".into()); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + let mut panels = WheelPanels { + workspace_managed: true, + ..WheelPanels::default() + }; + terminal + .draw(|frame| { + panels.draw_documents(frame, &diff, frame.area(), None, None); + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("parser.c exceeds 128 MiB file limit")); + assert!(!text.contains("select the file to retry")); +} + +#[test] +fn large_generated_additions_render_first_scroll_and_final_rows_without_reparsing() { + let after = (0..25_000) + .map(|row| format!("state_row_{row:05} = 1234567890123456789012345678901234567890;")) + .collect::>(); + let hunks = crate::diff::line_hunks(&[], false, &after, true); + let mut diff = diff_comparison(vec![ChangedFile { + path: "parser.c".into(), + old_path: None, + status: FileStatus::Added, + loaded: true, + before: Vec::new(), + after, + before_eof: false, + after_eof: true, + hunks, + notice: None, + }]); + diff.files_visible = false; + diff.focus = Focus::After; + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); + let mut panels = WheelPanels::default(); + let mut counts = None; + for row in [0, 3, 12_500, 24_999] { + diff.reveal_source_row(Focus::After, row); + render_diff(&mut terminal, &diff, &mut panels); + assert!(screen_text(&terminal).contains(&format!("state_row_{row:05}"))); + let syntax = panels.diff_syntax.as_ref().unwrap(); + assert_eq!( + *counts.get_or_insert(syntax.after.parse_counts()), + syntax.after.parse_counts() + ); + assert!(syntax.after.error().is_some()); + assert_eq!( + diff.comparison.as_ref().unwrap().files[0].after.len(), + 25_000 + ); + } +} + #[test] fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();