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.
This commit is contained in:
+255
-57
@@ -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<usize>; 2]],
|
||||
changes: Cow<'a, [[std::ops::Range<usize>; 2]]>,
|
||||
}
|
||||
|
||||
impl<'a> ColumnLine<'a> {
|
||||
pub fn changes(&self) -> impl Iterator<Item = &'a std::ops::Range<usize>> + 'a {
|
||||
pub fn changes(&self) -> impl Iterator<Item = &std::ops::Range<usize>> + '_ {
|
||||
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<AlignedRow>,
|
||||
pub display_rows: Vec<DisplayRow>,
|
||||
display_sides: Vec<Option<Focus>>,
|
||||
source_ranges: [Vec<(std::ops::Range<usize>, usize)>; 2],
|
||||
lazy_changes: bool,
|
||||
/// Native empty terminal rows explicitly supplied by resolver text.
|
||||
terminal_rows: [Option<usize>; 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<usize> {
|
||||
(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<Focus> {
|
||||
@@ -1979,9 +2007,24 @@ impl DiffState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_rows(&self, focus: Focus) -> impl DoubleEndedIterator<Item = usize> + '_ {
|
||||
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<Item = usize> + '_ {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<DisplayRow>, side: Option<Focus>) {
|
||||
@@ -2147,7 +2207,12 @@ impl DiffState {
|
||||
old.push("");
|
||||
}
|
||||
let new = lines.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
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::<usize>();
|
||||
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<usize>,
|
||||
) {
|
||||
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<usize>; 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<usize>; 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<usize>; 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<Hunk> {
|
||||
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<usize>) -> 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 {
|
||||
|
||||
+1
-2
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+116
-113
@@ -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::<usize>()
|
||||
.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<Snapshot, String> {
|
||||
fn working_snapshot(&mut self, buffers: &[(PathBuf, String)]) -> Result<Snapshot, String> {
|
||||
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<Comparison, String> {
|
||||
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 {}",
|
||||
|
||||
+10
-2
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user