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:
@@ -1,5 +1,6 @@
|
|||||||
- [x] syntax highlighting for Honey (honeylang)
|
- [x] syntax highlighting for Honey (honeylang)
|
||||||
- honeylang treesitter project located at `/Users/valdemar/Developer/Personal/Languages/tree-sitter-honey`.
|
- 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.
|
- [ ] 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.
|
- 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.
|
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
||||||
|
|||||||
+2
-1
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling.
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
+255
-57
@@ -1,8 +1,10 @@
|
|||||||
use crate::editor::{BackspaceKind, Input};
|
use crate::editor::{BackspaceKind, Input};
|
||||||
use crate::files::{FilePicker, FilePresentation, FileView, fuzzy_match};
|
use crate::files::{FilePicker, FilePresentation, FileView, fuzzy_match};
|
||||||
use std::{
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
@@ -281,11 +283,11 @@ pub(crate) struct ColumnLine<'a> {
|
|||||||
pub changed: bool,
|
pub changed: bool,
|
||||||
pub emphasize: bool,
|
pub emphasize: bool,
|
||||||
pub newline: bool,
|
pub newline: bool,
|
||||||
changes: &'a [[std::ops::Range<usize>; 2]],
|
changes: Cow<'a, [[std::ops::Range<usize>; 2]]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ColumnLine<'a> {
|
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 side = usize::from(self.side == Focus::After);
|
||||||
let emphasize = self.emphasize;
|
let emphasize = self.emphasize;
|
||||||
self.changes
|
self.changes
|
||||||
@@ -363,7 +365,21 @@ impl<'a> ColumnView<'a> {
|
|||||||
|| (!before && row.kind == RowKind::Added),
|
|| (!before && row.kind == RowKind::Added),
|
||||||
emphasize: !matches!(self.file.status, FileStatus::Added | FileStatus::Removed),
|
emphasize: !matches!(self.file.status, FileStatus::Added | FileStatus::Removed),
|
||||||
newline: source_row + 1 < source.len() || eof,
|
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 {
|
for column in &mut columns {
|
||||||
column.display_rows = (0..column.rows.len()).map(DisplayRow::Line).collect();
|
column.display_rows = (0..column.rows.len()).map(DisplayRow::Line).collect();
|
||||||
|
column.rebuild_source_ranges();
|
||||||
}
|
}
|
||||||
Self { columns }
|
Self { columns }
|
||||||
}
|
}
|
||||||
@@ -561,6 +578,8 @@ pub struct DiffState {
|
|||||||
pub rows: Vec<AlignedRow>,
|
pub rows: Vec<AlignedRow>,
|
||||||
pub display_rows: Vec<DisplayRow>,
|
pub display_rows: Vec<DisplayRow>,
|
||||||
display_sides: Vec<Option<Focus>>,
|
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.
|
/// Native empty terminal rows explicitly supplied by resolver text.
|
||||||
terminal_rows: [Option<usize>; 2],
|
terminal_rows: [Option<usize>; 2],
|
||||||
excerpt_ranges: Vec<(usize, usize)>,
|
excerpt_ranges: Vec<(usize, usize)>,
|
||||||
@@ -621,6 +640,8 @@ impl DiffState {
|
|||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
display_rows: Vec::new(),
|
display_rows: Vec::new(),
|
||||||
display_sides: Vec::new(),
|
display_sides: Vec::new(),
|
||||||
|
source_ranges: Default::default(),
|
||||||
|
lazy_changes: false,
|
||||||
terminal_rows: [None; 2],
|
terminal_rows: [None; 2],
|
||||||
excerpt_ranges: Vec::new(),
|
excerpt_ranges: Vec::new(),
|
||||||
live_buffer: None,
|
live_buffer: None,
|
||||||
@@ -1931,8 +1952,15 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
|
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
|
||||||
(0..self.display_rows.len())
|
let ranges = self.source_ranges.get(usize::from(focus == Focus::After))?;
|
||||||
.position(|display| self.display_source_row(focus, display) == Some(source_row))
|
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> {
|
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> + '_ {
|
pub(crate) fn source_rows_in(
|
||||||
self.excerpt_ranges.iter().flat_map(move |&(start, end)| {
|
&self,
|
||||||
(start..end).filter_map(move |index| self.source_row(focus, index))
|
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> {
|
pub(crate) fn corresponding_row(&self, from: Focus, to: Focus, row: usize) -> Option<usize> {
|
||||||
let aligned = self.rows.iter().position(|aligned| match from {
|
let aligned = self
|
||||||
Focus::Before => aligned.before == Some(row),
|
.source_display_row(from, row)
|
||||||
Focus::After => aligned.after == Some(row),
|
.and_then(|display| match self.display_rows[display] {
|
||||||
Focus::Files => false,
|
DisplayRow::Line(index) => Some(index),
|
||||||
})?;
|
|
||||||
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)),
|
|
||||||
DisplayRow::Fold { .. } => None,
|
DisplayRow::Fold { .. } => None,
|
||||||
})
|
})
|
||||||
.min_by_key(|&(distance, row)| (distance, row))
|
.or_else(|| {
|
||||||
.map(|(_, row)| row)
|
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> {
|
pub(crate) fn clamp_source_row(&self, focus: Focus, row: usize) -> Option<usize> {
|
||||||
let mut previous = None;
|
let ranges = &self.source_ranges[usize::from(focus == Focus::After)];
|
||||||
for visible in self.source_rows(focus) {
|
if focus == Focus::Files {
|
||||||
if visible >= row {
|
return None;
|
||||||
return Some(match previous {
|
}
|
||||||
Some(previous) if row - previous < visible - row => previous,
|
let index = ranges.partition_point(|(range, _)| range.end <= row);
|
||||||
_ => visible,
|
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) {
|
||||||
previous = Some(visible);
|
(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>) {
|
fn restore_top(&mut self, anchor: Option<DisplayRow>, side: Option<Focus>) {
|
||||||
@@ -2147,7 +2207,12 @@ impl DiffState {
|
|||||||
old.push("");
|
old.push("");
|
||||||
}
|
}
|
||||||
let new = lines.iter().map(String::as_str).collect::<Vec<_>>();
|
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()];
|
let mut mapping = vec![new.len().saturating_sub(1); old.len()];
|
||||||
for op in &ops {
|
for op in &ops {
|
||||||
let (_, old_range, new_range) = op.as_tag_tuple();
|
let (_, old_range, new_range) = op.as_tag_tuple();
|
||||||
@@ -2257,11 +2322,13 @@ impl DiffState {
|
|||||||
self.terminal_rows = [None; 2];
|
self.terminal_rows = [None; 2];
|
||||||
self.display_rows.clear();
|
self.display_rows.clear();
|
||||||
self.display_sides.clear();
|
self.display_sides.clear();
|
||||||
|
self.source_ranges.iter_mut().for_each(Vec::clear);
|
||||||
self.excerpt_ranges.clear();
|
self.excerpt_ranges.clear();
|
||||||
if reset {
|
if reset {
|
||||||
self.top = 0;
|
self.top = 0;
|
||||||
self.left_before = 0;
|
self.left_before = 0;
|
||||||
self.left_after = 0;
|
self.left_after = 0;
|
||||||
|
self.lazy_changes = false;
|
||||||
self.max_before = 0;
|
self.max_before = 0;
|
||||||
self.max_after = 0;
|
self.max_after = 0;
|
||||||
self.after_widths.clear();
|
self.after_widths.clear();
|
||||||
@@ -2277,6 +2344,7 @@ impl DiffState {
|
|||||||
self.max_after = 0;
|
self.max_after = 0;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
self.lazy_changes = file.before.len().saturating_add(file.after.len()) > 20_000;
|
||||||
if reset {
|
if reset {
|
||||||
self.max_before = file
|
self.max_before = file
|
||||||
.before
|
.before
|
||||||
@@ -2387,9 +2455,22 @@ impl DiffState {
|
|||||||
self.excerpt_ranges.truncate(count);
|
self.excerpt_ranges.truncate(count);
|
||||||
self.display_rows.clear();
|
self.display_rows.clear();
|
||||||
self.display_sides.clear();
|
self.display_sides.clear();
|
||||||
|
self.source_ranges.iter_mut().for_each(Vec::clear);
|
||||||
if self.excerpt_ranges.is_empty() {
|
if self.excerpt_ranges.is_empty() {
|
||||||
return;
|
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;
|
let mut end = 0;
|
||||||
for &(start, next_end) in &self.excerpt_ranges {
|
for &(start, next_end) in &self.excerpt_ranges {
|
||||||
if end < start {
|
if end < start {
|
||||||
@@ -2436,6 +2517,27 @@ impl DiffState {
|
|||||||
self.display_sides.push(None);
|
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) {
|
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,
|
file: &ChangedFile,
|
||||||
after_rows: std::ops::Range<usize>,
|
after_rows: std::ops::Range<usize>,
|
||||||
) {
|
) {
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(20);
|
||||||
for row in rows {
|
for row in rows {
|
||||||
if row.kind == RowKind::Changed
|
if row.kind == RowKind::Changed
|
||||||
&& let Some(after) = row.after.filter(|index| after_rows.contains(index))
|
&& let Some(after) = row.after.filter(|index| after_rows.contains(index))
|
||||||
{
|
{
|
||||||
row.changes = character_changes(
|
if file.before.len().saturating_add(file.after.len()) <= 20_000 {
|
||||||
&file.before[row.before.expect("paired changed row")],
|
row.changes = character_changes_deadline(
|
||||||
&file.after[after],
|
&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]> {
|
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 {
|
if before == after {
|
||||||
return Vec::new();
|
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 before: Vec<_> = before.graphemes(true).collect();
|
||||||
let after: Vec<_> = after.graphemes(true).collect();
|
let after: Vec<_> = after.graphemes(true).collect();
|
||||||
let mut changes: Vec<[std::ops::Range<usize>; 2]> = Vec::new();
|
let mut changes: Vec<[std::ops::Range<usize>; 2]> = Vec::new();
|
||||||
let (mut before_offset, mut after_offset) = (0, 0);
|
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();
|
let (tag, before, after) = op.as_tag_tuple();
|
||||||
// similar's optimized empty-range anchors can be stale; consume lengths in order.
|
// similar's optimized empty-range anchors can be stale; consume lengths in order.
|
||||||
let before = before_offset..before_offset + before.len();
|
let before = before_offset..before_offset + before.len();
|
||||||
@@ -2626,6 +2756,19 @@ fn short_match_between_large_changes(
|
|||||||
fn line_width(line: &str) -> usize {
|
fn line_width(line: &str) -> usize {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
tests::WIDTH_SCANS.with(|scans| scans.set(scans.get() + 1));
|
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| {
|
line.graphemes(true).fold(0, |column, grapheme| {
|
||||||
column
|
column
|
||||||
+ if grapheme == "\t" {
|
+ if grapheme == "\t" {
|
||||||
@@ -2720,27 +2863,82 @@ pub(crate) fn line_hunks(
|
|||||||
after: &[String],
|
after: &[String],
|
||||||
after_eof: bool,
|
after_eof: bool,
|
||||||
) -> Vec<Hunk> {
|
) -> Vec<Hunk> {
|
||||||
fn tokens(lines: &[String], eof: bool) -> Vec<(&str, bool)> {
|
// Empty sides need neither a token table nor a Myers frontier.
|
||||||
lines
|
if before.is_empty() || after.is_empty() {
|
||||||
.iter()
|
return if before.is_empty() && after.is_empty() {
|
||||||
.enumerate()
|
Vec::new()
|
||||||
.map(|(index, line)| (line.as_str(), index + 1 < lines.len() || eof))
|
} 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()
|
.collect()
|
||||||
}
|
}
|
||||||
let before = tokens(before, before_eof);
|
let old = tokens(before, before_eof, start..before_end);
|
||||||
let after = tokens(after, after_eof);
|
let new = tokens(after, after_eof, start..after_end);
|
||||||
similar::capture_diff_slices(similar::Algorithm::Myers, &before, &after)
|
// ponytail: after 100ms Myers may conservatively replace unresolved lines;
|
||||||
.into_iter()
|
// every source line remains present, never mislabeled as equal.
|
||||||
.filter_map(|op| {
|
let mut old_offset = start;
|
||||||
let (tag, before, after) = op.as_tag_tuple();
|
let mut new_offset = start;
|
||||||
(tag != similar::DiffTag::Equal).then_some(Hunk {
|
similar::capture_diff_slices_deadline(
|
||||||
before_start: before.start,
|
similar::Algorithm::Myers,
|
||||||
before_len: before.len(),
|
&old,
|
||||||
after_start: after.start,
|
&new,
|
||||||
after_len: after.len(),
|
Some(Instant::now() + Duration::from_millis(100)),
|
||||||
})
|
)
|
||||||
})
|
.into_iter()
|
||||||
.collect()
|
.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 {
|
fn file_is_changed(file: &ChangedFile) -> bool {
|
||||||
|
|||||||
+1
-2
@@ -11797,8 +11797,7 @@ impl Editor {
|
|||||||
.and_then(|focus| self.diff().map(|diff| (diff, focus)));
|
.and_then(|focus| self.diff().map(|diff| (diff, focus)));
|
||||||
(start..if diff.is_none() { end } else { start }).chain(
|
(start..if diff.is_none() { end } else { start }).chain(
|
||||||
diff.into_iter()
|
diff.into_iter()
|
||||||
.flat_map(|(diff, focus)| diff.source_rows(focus))
|
.flat_map(move |(diff, focus)| diff.source_rows_in(focus, start, end)),
|
||||||
.filter(move |row| start <= *row && *row < end),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+116
-113
@@ -21,6 +21,10 @@ use std::{
|
|||||||
|
|
||||||
const OUTPUT_LIMIT: usize = 64 * 1024 * 1024;
|
const OUTPUT_LIMIT: usize = 64 * 1024 * 1024;
|
||||||
const FILE_LIMIT: usize = 16 * 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);
|
const TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
@@ -1948,94 +1952,54 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn materialize(&self, snapshot: &mut Snapshot) -> Result<(), String> {
|
fn materialize(&self, snapshot: &mut Snapshot) -> Result<(), String> {
|
||||||
let stores: BTreeSet<_> = snapshot
|
for (path, file) in snapshot.iter_mut() {
|
||||||
.values()
|
self.check_cancelled()?;
|
||||||
.filter(|file| file.projection.is_none())
|
if let Some((root, id)) = &file.projection {
|
||||||
.filter_map(|file| file.object.as_ref().map(|(store, _)| store.clone()))
|
let revset = format!("commit_id({})", serde_json::to_string(id).unwrap());
|
||||||
.collect();
|
let pattern =
|
||||||
for store in stores {
|
jj_file_pattern(path.to_str().ok_or("Non-UTF-8 Jujutsu conflict path")?);
|
||||||
let mut request = Vec::new();
|
file.bytes = self
|
||||||
for file in snapshot.values() {
|
.jj(
|
||||||
if let Some((source, id)) = &file.object
|
root,
|
||||||
&& source == &store
|
&["file", "show", "-r", &revset, "-T", "", "--", &pattern],
|
||||||
&& file.projection.is_none()
|
)
|
||||||
{
|
.map_err(|error| format!("{}: {error}", path.display()))?;
|
||||||
request.extend_from_slice(id.as_bytes());
|
} else if let Some((store, id)) = &file.object {
|
||||||
request.push(b'\n');
|
let size = text(self.git(store, &["cat-file", "-s", id])?)?
|
||||||
}
|
.trim()
|
||||||
}
|
.parse::<usize>()
|
||||||
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()
|
|
||||||
.map_err(|_| "Invalid Git object size")?;
|
.map_err(|_| "Invalid Git object size")?;
|
||||||
if size > FILE_LIMIT {
|
if size > COMPARISON_FILE_LIMIT {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"{} exceeds the 16 MiB comparison file limit",
|
"{} exceeds the 64 MiB per-side comparison file limit",
|
||||||
path.display()
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
offset = end + 1;
|
// One blob per subprocess output: never concatenate both sides or
|
||||||
if size >= data.len().saturating_sub(offset) {
|
// copy a cat-file batch into a second allocation.
|
||||||
return Err("Truncated Git object content".into());
|
file.bytes = self.git(store, &["cat-file", "blob", id])?;
|
||||||
}
|
} else {
|
||||||
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 {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
}
|
||||||
let revset = format!("commit_id({})", serde_json::to_string(id).unwrap());
|
if file.bytes.len() > COMPARISON_FILE_LIMIT {
|
||||||
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 {
|
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"{} exceeds the 16 MiB comparison file limit",
|
"{} exceeds the 64 MiB per-side comparison file limit",
|
||||||
path.display()
|
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.object = None;
|
||||||
file.projection = None;
|
file.projection = None;
|
||||||
}
|
}
|
||||||
Ok(())
|
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 tracked = BTreeSet::new();
|
||||||
let mut submodules = BTreeSet::new();
|
let mut submodules = BTreeSet::new();
|
||||||
if self.kind == RepoKind::Git {
|
if self.kind == RepoKind::Git {
|
||||||
@@ -2220,39 +2184,46 @@ impl Repository {
|
|||||||
let bytes = target.to_string_lossy().as_bytes().to_vec();
|
let bytes = target.to_string_lossy().as_bytes().to_vec();
|
||||||
("120000".into(), bytes, None)
|
("120000".into(), bytes, None)
|
||||||
} else if metadata.is_file() {
|
} else if metadata.is_file() {
|
||||||
if metadata.len() > FILE_LIMIT as u64 {
|
if metadata.len() > COMPARISON_FILE_LIMIT as u64 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"{} exceeds the 16 MiB comparison file limit",
|
"{} exceeds the 64 MiB per-side comparison file limit",
|
||||||
path.display()
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let bytes = fs::File::open(&full)
|
let mut source = fs::File::open(&full)
|
||||||
.and_then(|f| {
|
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||||
let mut bytes = Vec::new();
|
.take(COMPARISON_FILE_LIMIT as u64 + 1);
|
||||||
f.take(FILE_LIMIT as u64 + 1).read_to_end(&mut bytes)?;
|
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
||||||
Ok(bytes)
|
let mut chunk = [0; 64 * 1024];
|
||||||
})
|
loop {
|
||||||
.map_err(|e| format!("{}: {e}", path.display()))?;
|
self.check_cancelled()?;
|
||||||
if bytes.len() > FILE_LIMIT {
|
let count = source
|
||||||
return Err(format!(
|
.read(&mut chunk)
|
||||||
"{} grew beyond the 16 MiB comparison file limit",
|
.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||||
path.display()
|
if count == 0 {
|
||||||
));
|
break;
|
||||||
}
|
}
|
||||||
let bytes = if !bytes.contains(&0)
|
if bytes.len() + count > COMPARISON_FILE_LIMIT {
|
||||||
&& std::str::from_utf8(&bytes).is_ok()
|
|
||||||
&& let Some(text) = overlays.get(&path)
|
|
||||||
{
|
|
||||||
if text.len() > FILE_LIMIT {
|
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"{} exceeds the 16 MiB comparison file limit",
|
"{} grew beyond the 64 MiB per-side comparison file limit",
|
||||||
path.display()
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
text.as_bytes().to_vec()
|
bytes.extend_from_slice(&chunk[..count]);
|
||||||
} else {
|
}
|
||||||
bytes
|
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)]
|
#[cfg(unix)]
|
||||||
let executable = {
|
let executable = {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
@@ -2273,20 +2244,45 @@ impl Repository {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
total += bytes.len();
|
total += bytes.len();
|
||||||
if total > OUTPUT_LIMIT {
|
if total > WORKING_SNAPSHOT_LIMIT {
|
||||||
return Err("Working snapshot exceeds the 64 MiB comparison limit".into());
|
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(
|
snapshot.insert(
|
||||||
path,
|
path,
|
||||||
FileData {
|
FileData {
|
||||||
mode,
|
mode,
|
||||||
bytes,
|
bytes: vec![],
|
||||||
object: None,
|
object,
|
||||||
projection: None,
|
projection: None,
|
||||||
notice,
|
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)
|
Ok(snapshot)
|
||||||
}
|
}
|
||||||
fn snapshot_revision(
|
fn snapshot_revision(
|
||||||
@@ -2299,13 +2295,16 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
self.ensure_private()?;
|
self.ensure_private()?;
|
||||||
let store = &self.private.as_ref().unwrap().1;
|
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 {
|
for (path, file) in snapshot {
|
||||||
self.check_cancelled()?;
|
self.check_cancelled()?;
|
||||||
for field in [
|
for field in [
|
||||||
path.as_os_str().as_encoded_bytes(),
|
path.as_os_str().as_encoded_bytes(),
|
||||||
file.mode.as_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(),
|
file.notice.as_deref().unwrap_or("").as_bytes(),
|
||||||
] {
|
] {
|
||||||
input.extend_from_slice(&(field.len() as u64).to_be_bytes());
|
input.extend_from_slice(&(field.len() as u64).to_be_bytes());
|
||||||
@@ -2760,15 +2759,6 @@ impl Repository {
|
|||||||
) -> Result<Comparison, String> {
|
) -> Result<Comparison, String> {
|
||||||
let left = &mut prepared.left;
|
let left = &mut prepared.left;
|
||||||
let right = &mut prepared.right;
|
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 right_view = if same { &*left } else { &*right };
|
||||||
let mut files = vec![];
|
let mut files = vec![];
|
||||||
let paths: BTreeSet<_> = left.keys().chain(right_view.keys()).collect();
|
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());
|
notices.push("Binary or non-UTF-8 content; text diff is unavailable".into());
|
||||||
(vec![], vec![], vec![])
|
(vec![], vec![], vec![])
|
||||||
} else {
|
} 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")) {
|
if (!a.is_empty() && !a.ends_with(b"\n")) || (!b.is_empty() && !b.ends_with(b"\n")) {
|
||||||
notices.push(format!(
|
notices.push(format!(
|
||||||
"EOF newline: Before {}, After {}",
|
"EOF newline: Before {}, After {}",
|
||||||
|
|||||||
+10
-2
@@ -3976,13 +3976,21 @@ impl WheelPanels {
|
|||||||
} else {
|
} else {
|
||||||
let content = diff_pane_panel(frame, diff, selected, panes, true, resolver);
|
let content = diff_pane_panel(frame, diff, selected, panes, true, resolver);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(if diff.loading {
|
Paragraph::new(display_text(if diff.loading {
|
||||||
"Loading file…"
|
"Loading file…"
|
||||||
|
} else if let Some(error) = diff.error.as_deref() {
|
||||||
|
error
|
||||||
} else if selected.is_some() {
|
} else if selected.is_some() {
|
||||||
"File details unavailable · select the file to retry"
|
"File details unavailable · select the file to retry"
|
||||||
} else {
|
} else {
|
||||||
"No changed files"
|
"No changed files"
|
||||||
}),
|
}))
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.style(Style::default().fg(if diff.error.is_some() {
|
||||||
|
Color::Red
|
||||||
|
} else {
|
||||||
|
Color::Reset
|
||||||
|
})),
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+267
-18
@@ -1728,18 +1728,30 @@ fn conflicted_catalog_preserves_symlinks_and_defers_unselected_contents() {
|
|||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
let root = fixture.dir("lazy-conflict");
|
let root = fixture.dir("lazy-conflict");
|
||||||
init_jj(&root, false);
|
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();
|
fs::write(root.join("unselected"), "old\n").unwrap();
|
||||||
symlink("unselected", root.join("unchanged-link")).unwrap();
|
symlink("unselected", root.join("unchanged-link")).unwrap();
|
||||||
jj(&root, &["describe", "-m", "base"]);
|
jj(&root, &["describe", "-m", "base"]);
|
||||||
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||||
jj(&root, &["new", &base]);
|
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();
|
fs::write(root.join("unselected"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
|
||||||
jj(&root, &["describe", "-m", "left"]);
|
jj(&root, &["describe", "-m", "left"]);
|
||||||
let left = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
let left = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||||
jj(&root, &["new", &base]);
|
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"]);
|
jj(&root, &["describe", "-m", "right"]);
|
||||||
let right = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
let right = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||||
jj(&root, &["new", &left, &right, "-m", "conflicted merge"]);
|
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")]
|
[Path::new("conflict"), Path::new("unselected")]
|
||||||
);
|
);
|
||||||
let conflict = repository.load_file(1, 0).unwrap();
|
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(
|
let projection = jj(
|
||||||
&root,
|
&root,
|
||||||
&[
|
&[
|
||||||
@@ -1779,9 +1793,13 @@ fn conflicted_catalog_preserves_symlinks_and_defers_unselected_contents() {
|
|||||||
"conflict",
|
"conflict",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
assert!(projection.len() > 16 * 1024 * 1024);
|
||||||
assert_eq!(conflict.after, projection.lines().collect::<Vec<_>>());
|
assert_eq!(conflict.after, projection.lines().collect::<Vec<_>>());
|
||||||
assert!(!conflict.hunks.is_empty());
|
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);
|
assert_eq!(disk_state(&root), state);
|
||||||
|
|
||||||
jj(&root, &["new", &merged]);
|
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());
|
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]
|
#[test]
|
||||||
fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() {
|
fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() {
|
||||||
let fixture = Fixture::new();
|
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());
|
assert!(repository.refresh_checkout(10, 11, true, &[]).is_err());
|
||||||
cancelled.store(false, Ordering::Release);
|
cancelled.store(false, Ordering::Release);
|
||||||
assert_eq!(repository.load_file(10, 0).unwrap().after, ["frozen after"]);
|
assert_eq!(repository.load_file(10, 0).unwrap().after, ["frozen after"]);
|
||||||
fs::write(root.join("oversized"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
|
fs::File::create(root.join("oversized"))
|
||||||
assert!(repository.refresh_checkout(10, 11, true, &[]).is_err());
|
.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();
|
let original = repository.load_file(10, 0).unwrap();
|
||||||
assert_eq!(original.before, ["frozen before"]);
|
assert_eq!(original.before, ["frozen before"]);
|
||||||
assert_eq!(original.after, ["frozen after"]);
|
assert_eq!(original.after, ["frozen after"]);
|
||||||
@@ -2194,7 +2434,7 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 fixture = Fixture::new();
|
||||||
let root = fixture.dir("failed-materialization");
|
let root = fixture.dir("failed-materialization");
|
||||||
init_git(&root);
|
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("a-file")).unwrap();
|
||||||
fs::remove_file(root.join("z-large")).unwrap();
|
fs::remove_file(root.join("z-large")).unwrap();
|
||||||
let mut repository = open(&root);
|
let mut repository = open(&root);
|
||||||
let original = repository
|
repository
|
||||||
.compare(
|
.compare(
|
||||||
1,
|
1,
|
||||||
&base,
|
&base,
|
||||||
@@ -2215,20 +2455,29 @@ fn refresh_materialization_failure_restores_prepared_file_sources() {
|
|||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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
|
.files
|
||||||
.iter()
|
.iter()
|
||||||
.position(|file| file.path == Path::new("a-file"))
|
.position(|file| file.path == Path::new("a-file"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::write(root.join("a-file"), "new checkout\n").unwrap();
|
let file = repository.load_file(2, index).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();
|
|
||||||
assert_eq!(file.before, ["frozen source"]);
|
assert_eq!(file.before, ["frozen source"]);
|
||||||
assert!(file.after.is_empty());
|
assert_eq!(file.after, ["new checkout"]);
|
||||||
assert_eq!(file.status, FileStatus::Removed);
|
assert_eq!(file.status, FileStatus::Modified);
|
||||||
assert!(repository.load_file(2, index).is_err());
|
assert!(repository.load_file(1, index).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+256
-3
@@ -2240,7 +2240,7 @@ fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() {
|
|||||||
);
|
);
|
||||||
assert!(state.after_editable());
|
assert!(state.after_editable());
|
||||||
for focus in [Focus::Before, Focus::After] {
|
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();
|
let display = state.source_display_row(focus, source).unwrap();
|
||||||
assert_eq!(state.display_source_row(focus, display), Some(source));
|
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_eq!(state.display_source_row(Focus::Before, state.top), Some(20));
|
||||||
assert!(state.source_display_row(Focus::After, 17).is_some());
|
assert!(state.source_display_row(Focus::After, 17).is_some());
|
||||||
state.reveal_source_row(Focus::After, 35);
|
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::Split);
|
||||||
state.set_view_mode(ViewMode::Unified);
|
state.set_view_mode(ViewMode::Unified);
|
||||||
assert_eq!(state.source_rows(Focus::After).collect::<Vec<_>>(), visible);
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.source_rows_in(Focus::After, 0, usize::MAX)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
visible
|
||||||
|
);
|
||||||
assert_eq!(state.display_source_row(Focus::After, state.top), Some(35));
|
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();
|
state.comparison.as_mut().unwrap().revisions[0] = "other".into();
|
||||||
assert!(!state.review_anchor_current(&before));
|
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::<Vec<_>>(), [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::<Vec<_>>();
|
||||||
|
let after = (0..25_000)
|
||||||
|
.map(|row| format!("new {row}"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
expected
|
||||||
|
.iter()
|
||||||
|
.map(|(row, _)| *row)
|
||||||
|
.filter(|row| (9..40).contains(row))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::<Vec<String>>()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
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::<Vec<_>>()
|
||||||
|
};
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
actual, expected,
|
||||||
|
"{before:?} ({before_eof}) -> {after:?} ({after_eof})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+14
-3
@@ -135,10 +135,21 @@ fn resolver_shares_native_excerpt_search_motions_and_explicit_writable_side() {
|
|||||||
type_text(editor, "/needle");
|
type_text(editor, "/needle");
|
||||||
editor.handle(Input::Enter);
|
editor.handle(Input::Enter);
|
||||||
assert_eq!(editor.cursor().row, 70);
|
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();
|
let cursor = editor.cursor();
|
||||||
editor.handle(Input::Char(']'));
|
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);
|
assert_eq!(editor.cursor(), cursor);
|
||||||
type_text(editor, "21g");
|
type_text(editor, "21g");
|
||||||
assert_eq!(editor.cursor().row, 20);
|
assert_eq!(editor.cursor().row, 20);
|
||||||
@@ -4644,7 +4655,7 @@ fn editable_diff_brackets_expand_caret_excerpt_without_moving_source_anchor() {
|
|||||||
editor
|
editor
|
||||||
.diff()
|
.diff()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.source_rows(Focus::After)
|
.source_rows_in(Focus::After, 0, usize::MAX)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -4612,6 +4612,68 @@ fn hiding_diff_explorer_reclaims_workspace_and_restores_its_geometry() {
|
|||||||
assert_eq!(panels.files.unwrap().0, original[0].0);
|
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::<Vec<_>>();
|
||||||
|
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]
|
#[test]
|
||||||
fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() {
|
fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() {
|
||||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user