Compare commits

..

3 Commits

Author SHA1 Message Date
hl-valdemar d8be35e3bd fix: route normal-mode cuts through clipboard pipeline 2026-10-17 00:50:00 +02:00
hl-valdemar b9f29d258c 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.
2026-10-17 00:46:15 +02:00
hl-valdemar adbd2c3f81 fix: keep large-file editing responsive
Bound synchronous full-source syntax parsing, cache dirty comparisons, edit ordinary inline changes in place, retain changed-row undo history, and buffer atomic saves. Skip repository snapshots beyond existing worker limits before copying their text.
2026-10-16 21:16:53 +02:00
21 changed files with 1727 additions and 276 deletions
+1
View File
@@ -1,5 +1,6 @@
- [x] syntax highlighting for Honey (honeylang)
- honeylang treesitter project located at `/Users/valdemar/Developer/Personal/Languages/tree-sitter-honey`.
- [x] bug: `x` cut action does not copy the hovered character if not in selection mode, though should always copy.
- [ ] diff view: GitHub code review integration after the local review UX is usable.
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
+2
View File
@@ -115,6 +115,8 @@ The same cancellable worker collects normal-picker status for the startup projec
Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skips comparison and parsing; unchanged text at a later revision also avoids parsing. Actual text changes produce a minimal UTF-8-safe byte edit with newline-aware Tree-sitter Points, edit the prior tree, then parse using that tree for incremental reuse. Grammar changes reset the tree, including TypeScript versus TSX despite their shared display name. Native highlight/local/injection queries resolve capture precedence and scoped definitions into nonoverlapping original-line spans; unchanged injected trees are reused. Buffer switching restores its cache, and closing retires it. Incomplete text and grammar ERROR nodes remain highlightable; genuine language/query/parser failures expose a status error and readable plain text. Full-tree highlight queries still run after incremental parsing—a deliberate ceiling to revisit if measured costly. There is no local semantic inference engine or runtime grammar download.
Full-source syntax work is bounded to 1 MiB, with a maximum 64 KiB source line (UTF-8 bytes, including normalized line separators in the total). Oversized buffers retain language detection and an explicit plain-text status, but allocate no source copy, per-row spans, scopes, or parse tree. Crossing either budget releases existing parser/cache allocations; unchanged oversized revisions return immediately, and shrinking below both budgets resumes normal parsing. The same policy covers ordinary buffers, diff sources including their separately stored final newline, and text-only hover caches.
Diff highlighting lazily owns two instances of the same syntax cache, keyed by the accepted comparison request ID and selected file. Before detects language from the old path when renamed; After uses the new path. Scrolling, focus changes, and explorer toggling reuse parsed spans without copying/reparsing snapshots. A fresh comparison identity prevents stale Working-checkout colors even when labels and paths are unchanged. Closing the view releases snapshot caches. Syntax styles patch foreground/modifiers over diff backgrounds; historical panes never receive live-buffer semantic tokens.
Scope context is a lazily built row index over the existing primary/injected syntax trees, invalidated by source or grammar changes and warmed by diff updates. Ordinary buffer highlighting does not build it. The frontend's visual-row projection inserts inline/sticky scope labels in wrapped and unwrapped diffs; source alignment and excerpt ranges contain no label rows. Coloring, native selection/caret overlays, and source navigation skip these presentation rows. Before/After labels use their independent trees rather than live-workspace symbol analysis.
+7 -3
View File
@@ -60,7 +60,7 @@ Rust tests live under [`tests/`](../tests/). Private unit tests are in [`tests/u
- Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state. Rendered checks cover longer/wider documentation visibility, Markdown heading and fenced-code colors, scrolled color coordinates, and replacement content without stale highlighting.
- Semantic-token checks cover full/range requests, legend/modifier remapping, UTF-16 boundaries and grapheme rounding, ASCII tab and Unicode column bounds, refresh/change coalescing, stale rename/close replies, null/empty/error fallback, primary/sibling retirement, and rendered cell precedence with syntax, diagnostics, search, selections, and carets. Retention checks cover typing/undo/redo in editable diffs, column/newline/Unicode remapping, overlapping-token removal, and session polling with delayed/stale server replies. Live PTY smoke holds semantic replies while typing and undoing/redoing, checking that untouched colored text is not repainted in the fallback color.
Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard.
Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard. Normal `x`/Delete regressions cover Unicode graphemes, remapped actions, multiple carets with empty rows, characterwise paste, undo/redo, dot-repeat publication and failure atomicity, and unchanged Insert Delete behavior. Live PTY smoke cuts a combining grapheme, repeats over a ZWJ emoji, pastes, undoes/redoes, and checks exact saved bytes using Separate mode.
Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed.
@@ -92,13 +92,17 @@ Diff styling checks cover independent snapshot language/shebang/injection detect
## Performance and process limits
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
- Retained undo/redo history stores only changed rows. Insert/replay/completion transactions still use temporary full-buffer snapshots, and undo/redo reconstructs the buffer. Ordinary single-line edits with one pane and no semantic overlay modify the line in place; multiline, multi-edit, shared-pane, and semantic-overlay batches retain the general whole-buffer path. Dirty-state comparisons are cached until text or the saved baseline changes. Movement and wrapping still scan the current line.
- Syntax falls back to plain text above 1 MiB of source or a 64 KiB line, before source copies, tree construction, or per-row syntax allocations. The detected language and fallback reason remain visible; reducing the buffer below both budgets restores highlighting. These are optional syntax limits, not file-opening or editing limits.
- Saves use buffered writes before flushing, syncing, and atomically replacing the destination. External-change checks remain independent of dirty-state caching. Large-file verification must include exact saved bytes and edit/undo/redo round-trips, not startup alone.
- Blame and gutter snapshots are size-checked before joining buffer lines; files above their existing 16 MiB limits retain editing but have no annotations. Eligibility is cached with the source revision and recovers after shrinking.
- Rapid-wheel verification must replay bursts against the running editor and measure arrival at the final viewport after input stops, not just individual frame duration. Cheap frames can still accumulate a visible backlog if each queued report requires its own redraw.
- Keyboard viewport scrolling coalesces up to 64 consecutive inputs for the same resolved scroll action into one redraw. Reversals, changes between row/configured-step scrolling, and cursor motions end the batch so boundary clamping and caret reveal happen before the next action. Burst checks must include direction changes at the start/end of the buffer, not just throughput.
- PTY throughput alone cannot prove terminal presentation stays smooth: capture the editor and terminal while the user reproduces a pause. Synchronized-output commands must not reach the terminal during frame computation, even when individual frames and queued-input catch-up appear fast.
- Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work.
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
- Repository diff currently requires Unix subprocess-group cancellation and Git-backed jj (or Git alone). Each file is limited to 16 MiB; each subprocess output stream and Working/conflict snapshot to 64 MiB; each command to 60 seconds. Limit, transport, and non-commit-tag errors are explicit rather than silently truncated. Temporary comparison stores are removed when the diff closes.
- Repository diff requires Unix subprocess-group cancellation and Git-backed jj (or Git alone). Selected comparison sides support 64 MiB and 4,000,000 source lines each; frozen Working snapshots support 512 MiB total. Subprocess streams remain bounded to 64 MiB and commands to 60 seconds. Working text is frozen into private Git blobs; catalogs stay lazy and selected blobs load separately without concatenated batch copies. Limits and transport errors are explicit; temporary stores are removed on close. Picker/gutter annotation budgets remain separate.
- Large line comparisons skip equal prefixes/suffixes before allocating diff tokens; added/removed files bypass the diff algorithm. Myers work has a 100 ms deadline, after which unresolved regions can be conservatively shown as replacements without dropping text. Inline highlighting is viewport-lazy above 20,000 combined source rows; large/pathological lines use whole-line change shading. Source/display navigation uses indexed contiguous ranges rather than scanning all preceding rows.
- Normal-picker status uses the gutter worker's five-second command and 16 MiB stream limits. Catalogs are bounded to 100,000 paths; dirty-buffer batches to 1,024 files/64 MiB; individual overlays, conflict files, and rename candidates to 16 MiB; conflict/rename data to 64 MiB. Temporary baseline stores are retained for warm metadata polling and removed when the worker stops.
See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling.
+7 -1
View File
@@ -33,7 +33,7 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count |
| Normal, Visual | Ctrl+u/Ctrl+d scroll up/down without moving carets or selections; half a viewport by default, configurable with `config.scroll_step_lines`; counts multiply the distance |
| Normal | `J` (Shift+j) joins each targeted line with the next line |
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo |
| Normal | `x`/Delete cut the hovered grapheme at every caret; `u` undo; `U` (Shift+u) redo |
| Normal, Visual | `:` enters command mode; Visual selections remain available to selection-aware commands |
| Normal | `yy` copies whole lines without moving; `dd` deletes them; `cc` changes them and enters Insert; counts work (`3yy`, `2dd`, `2cc`) |
| Normal | `D` deletes through line end; `C` changes that suffix and enters Insert; `Y` copies that suffix characterwise |
@@ -144,6 +144,8 @@ Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut
## Clipboard policy
Normal `x`/Delete (`config.core.delete`) copies each hovered grapheme to the configured clipboard before removing it. Combining sequences and emoji stay intact; empty lines are unchanged, and an entirely empty cut preserves the clipboard. Dot-repeat cuts and copies the current graphemes. Insert-mode Delete and its replay do not copy.
`config.clipboard_mode = "system"` is the default: ordinary Visual `y`/`d`/`x`/`c` and Normal/Visual `p`/`P` use the OS clipboard. External clipboard changes are read on each paste. Set `"separate"` to keep ordinary operations on vamp's internal fragment register instead. Both registers remain independent across buffers; changing the policy does not overwrite either.
Explicit `config.core.copy_system` (Visual) and `config.core.paste_system_before` / `paste_system_after` (Normal/Visual) always use the system clipboard in either policy. They appear in the command palette and can be bound without replacing ordinary internal operations:
@@ -517,6 +519,8 @@ Both diff panes apply available semantic tokens from their own buffer, independe
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
Large comparisons load each selected side independently, up to 64 MiB and 4,000,000 source lines per side. Frozen Working snapshots have a 512 MiB aggregate budget. Exceeding a limit displays the actual error in the document pane. Large files use the normal plain-text syntax fallback; pathological comparisons may show broader replacement regions or whole-line highlights rather than block input searching for a minimal diff. Full source text remains available.
## Jujutsu conflict resolution
Run **`:resolve`** in Normal mode for the current jj working copy. It opens a dedicated tab and selects the current conflicted file (otherwise the first conflict). Historical comparisons remain read-only; resolving never implicitly checks out or creates another change.
@@ -590,6 +594,8 @@ config.bind "leader f", config.core.follow_link
Buffers highlight automatically using [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) and compiled-in grammar/query packages. The selected language set is Rust, Ruby, Python, JSON, Bash, Makefile, Markdown, JavaScript/JSX, TypeScript/TSX, HTML, CSS, YAML, TOML, C, C++, Go, Zig (`.zig`), and Honey (`.hon`). Detection uses filename/extension or the in-memory first-line shebang, including common special filenames such as Makefile, Gemfile, and Cargo.lock; no file reread occurs. Unsupported formats use the normal foreground. The detected language appears in status, and save-as redetects it.
Files remain editable above the syntax budget: sources over 1 MiB, or with any line over 64 KiB, use plain text and show the reason in the language status. Syntax highlighting resumes automatically after shrinking below both limits. This avoids building a full parse tree for huge generated files; navigation, search, selections, undo/redo, and saving remain available.
Colors retain the terminal palette: comments dark gray, strings green, keywords magenta, numbers yellow, types/markup cyan, and functions light blue. Grammar queries handle multiline/nested constructs, even when their opening is above the viewport. Markdown uses its inline grammar and named fenced-code injections; HTML script/style content uses JavaScript/CSS. Known tagged-template and raw-string injections use the same selected grammar registry; unknown injected languages retain host highlighting or plain text. Highlighting styles whole graphemes and expanded tab cells; partially clipped wide placeholders remain unchanged. Search, Visual selections, and secondary carets retain foreground/background precedence.
Honey uses its bundled grammar and highlight queries, including declarations, primitive types, literals, and comments. Markdown fences accept `honey`, `honeylang`, or `hon`; the language ID is `honey`.
+1 -1
View File
@@ -13,7 +13,7 @@ use std::{
time::{Duration, Instant},
};
const LIMIT: usize = 16 * 1024 * 1024;
pub(crate) const LIMIT: usize = 16 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(5);
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>;
+245 -47
View File
@@ -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 {
let aligned = self
.source_display_row(from, row)
.and_then(|display| match self.display_rows[display] {
DisplayRow::Line(index) => Some(index),
DisplayRow::Fold { .. } => None,
})
.or_else(|| {
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)),
DisplayRow::Fold { .. } => None,
})
.min_by_key(|&(distance, row)| (distance, row))
.map(|(_, row)| row)
})?;
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,
});
let ranges = &self.source_ranges[usize::from(focus == Focus::After)];
if focus == Focus::Files {
return None;
}
previous = Some(visible);
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(
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,25 +2863,80 @@ 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)
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, 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(),
})
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()
}
+168 -35
View File
@@ -295,6 +295,40 @@ struct Snapshot {
primary: usize,
}
/// Retain only changed rows between undo groups, not another copy of the file.
#[cfg_attr(test, derive(Debug))]
struct HistoryEntry {
start: usize,
end: usize,
before: Snapshot,
}
impl HistoryEntry {
fn new(mut before: Snapshot, after: &[String]) -> Self {
let start = before
.lines
.iter()
.zip(after)
.take_while(|(a, b)| a == b)
.count();
let suffix = before.lines[start..]
.iter()
.rev()
.zip(after[start..].iter().rev())
.take_while(|(a, b)| a == b)
.count();
before.lines = before
.lines
.drain(start..before.lines.len() - suffix)
.collect();
Self {
start,
end: after.len() - suffix,
before,
}
}
}
struct Edit<'a> {
target: Option<usize>,
start: usize,
@@ -334,7 +368,6 @@ enum EditStart {
target: char,
selector: SelectionIntent,
},
Delete,
Join,
Paste {
after: bool,
@@ -1868,6 +1901,8 @@ struct Buffer {
snapshot_root: Option<PathBuf>,
lines: Vec<String>,
saved_lines: Vec<String>,
// Invalidate whenever text or the saved baseline changes, including temporary swaps.
dirty_cache: Cell<Option<bool>>,
disk_stamp: Option<FileStamp>,
reload_error: Option<String>,
text_revision: u64,
@@ -1878,9 +1913,8 @@ struct Buffer {
view: ViewState,
path: Option<PathBuf>,
identity: Option<PathBuf>,
// ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files.
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
undo: Vec<HistoryEntry>,
redo: Vec<HistoryEntry>,
}
impl Buffer {
@@ -1926,6 +1960,7 @@ impl Buffer {
snapshot_document_path: None,
snapshot_root: None,
saved_lines: lines.clone(),
dirty_cache: Cell::new(Some(false)),
disk_stamp,
reload_error: None,
lines,
@@ -1946,7 +1981,15 @@ impl Buffer {
}
fn dirty(&self) -> bool {
!self.resolver_readonly && self.diff_snapshot.is_none() && self.lines != self.saved_lines
if self.resolver_readonly || self.diff_snapshot.is_some() {
return false;
}
if let Some(dirty) = self.dirty_cache.get() {
return dirty;
}
let dirty = self.lines != self.saved_lines;
self.dirty_cache.set(Some(dirty));
dirty
}
fn refresh_file(&mut self) -> io::Result<bool> {
@@ -1994,6 +2037,7 @@ impl Buffer {
}
self.replace_lines(split_lines(&text));
self.saved_lines.clone_from(&self.lines);
self.dirty_cache.set(Some(false));
self.undo.clear();
self.redo.clear();
self.view.reload(&self.lines);
@@ -2029,12 +2073,17 @@ impl Buffer {
if let Some(tokens) = &mut self.semantic_tokens {
retain_semantic_tokens(tokens, &self.lines, &lines);
}
self.text_revision = self.text_revision.wrapping_add(1);
self.diagnostics.clear();
self.view.search_match = None;
self.text_changed();
}
std::mem::replace(&mut self.lines, lines)
}
fn text_changed(&mut self) {
self.text_revision = self.text_revision.wrapping_add(1);
self.dirty_cache.set(None);
self.diagnostics.clear();
self.view.search_match = None;
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -2601,7 +2650,8 @@ impl Editor {
let seed = split_lines(projection.seed());
if buffer.lines != seed {
let previous = buffer.replace_lines(seed);
buffer.undo.push(Snapshot {
buffer.undo.push(HistoryEntry::new(
Snapshot {
lines: previous,
targets: if buffer.view.targets.is_empty() {
vec![Target::caret(Cursor::default())]
@@ -2609,7 +2659,9 @@ impl Editor {
buffer.view.targets.clone()
},
primary: buffer.view.primary,
});
},
&buffer.lines,
));
buffer.redo.clear();
buffer.view.clamp(&buffer.lines);
for pane in self.panes.values_mut() {
@@ -2675,12 +2727,14 @@ impl Editor {
buffer.view.clamp(&buffer.lines);
}
buffer.saved_lines = saved;
buffer.dirty_cache.set(None);
buffer.identity = Some(target_path.clone());
buffer.path = Some(target_path);
buffer.resolver_readonly = false;
} else if !buffer.dirty() {
buffer.replace_lines(vec!["Non-text resolution saved; reopen the file to inspect its current type.".into()]);
buffer.saved_lines.clone_from(&buffer.lines);
buffer.dirty_cache.set(Some(false));
buffer.path = None;
buffer.identity = None;
buffer.resolver_readonly = true;
@@ -2983,6 +3037,7 @@ impl Editor {
if buffer.lines != alternative {
buffer.replace_lines(alternative.clone());
buffer.saved_lines = alternative;
buffer.dirty_cache.set(Some(false));
buffer.view.clamp(&buffer.lines);
for pane in self.panes.values_mut() {
if let Some(view) = pane.views.get_mut(&alternative_buffer) {
@@ -5024,6 +5079,7 @@ impl Editor {
buffer.snapshot_document_path = Some(document_path);
buffer.snapshot_root = Some(root);
buffer.lines = lines;
buffer.dirty_cache.set(None);
buffer
}
@@ -8600,21 +8656,16 @@ impl Editor {
);
}
Action::Delete => {
if self.active.view.mode == Mode::Normal {
self.edit_normal(action, 1);
return None;
}
if self.active.view.mode == Mode::Insert && self.edit_snippet("", Some(action)) {
return None;
}
let before = (self.active.view.mode != Mode::Insert).then(|| self.snapshot());
let mask = self.deletion_mask(None);
self.delete_forward(&mask);
if let Some(before) = before {
self.record_native(
before,
EditRecipe {
start: EditStart::Delete,
steps: Vec::new(),
},
);
} else if mask.iter().any(|active| *active) {
if mask.iter().any(|active| *active) {
self.push_insert_step(InsertStep::Delete(mask));
}
}
@@ -10028,6 +10079,7 @@ impl Editor {
match atomic_save(&path, &self.active.lines, expected) {
Ok(stamp) => {
self.active.saved_lines.clone_from(&self.active.lines);
self.active.dirty_cache.set(Some(false));
self.active.save_revision = self.active.save_revision.wrapping_add(1);
self.active.disk_stamp = Some(stamp);
self.active.reload_error = None;
@@ -10063,7 +10115,9 @@ impl Editor {
fn record(&mut self, before: Snapshot) {
if before.lines != self.active.lines {
self.active.view.search_match = None;
self.active.undo.push(before);
self.active
.undo
.push(HistoryEntry::new(before, &self.active.lines));
self.active.redo.clear();
}
}
@@ -10629,7 +10683,7 @@ impl Editor {
Action::DeleteWordBackward | Action::ChangeWordBackward => {
self.word_destination(cursor, Action::DeleteWordBackward, count)
}
Action::SubstituteCharacter => Cursor {
Action::Delete | Action::SubstituteCharacter => Cursor {
row: cursor.row,
column: cursor
.column
@@ -11369,6 +11423,7 @@ impl Editor {
let linewise = self.active.view.linewise;
let search_match = self.active.view.search_match;
let revision = self.active.text_revision;
let dirty_cache = self.active.dirty_cache.get();
let diagnostics = std::mem::take(&mut self.active.diagnostics);
let semantic_tokens = self.active.semantic_tokens.clone();
let mut restored = false;
@@ -11380,6 +11435,7 @@ impl Editor {
// Validate the entire replay first, then publish while the original
// document is restored, and only then commit the computed deletion.
std::mem::swap(&mut self.active.lines, &mut before.lines);
self.active.dirty_cache.set(None);
std::mem::swap(&mut self.active.view.targets, &mut before.targets);
std::mem::swap(&mut self.active.view.primary, &mut before.primary);
restored = true;
@@ -11389,6 +11445,7 @@ impl Editor {
shape,
)?;
std::mem::swap(&mut self.active.lines, &mut before.lines);
self.active.dirty_cache.set(None);
std::mem::swap(&mut self.active.view.targets, &mut before.targets);
std::mem::swap(&mut self.active.view.primary, &mut before.primary);
restored = false;
@@ -11412,6 +11469,7 @@ impl Editor {
self.active.view.primary = before.primary;
}
self.active.text_revision = revision;
self.active.dirty_cache.set(dirty_cache);
self.active.diagnostics = diagnostics;
self.active.semantic_tokens = semantic_tokens;
self.active.view.mode = mode;
@@ -11473,10 +11531,6 @@ impl Editor {
self.normalize_targets();
}
}
EditStart::Delete => {
self.active.view.mode = Mode::Normal;
self.delete_forward(&[true]);
}
EditStart::Join => {
self.active.view.mode = Mode::Normal;
self.join_lines();
@@ -11677,13 +11731,35 @@ impl Editor {
self.normalize_targets();
}
fn restore_history(&mut self, entry: HistoryEntry) -> HistoryEntry {
let mut lines = self.active.lines.clone();
let end = entry.start + entry.before.lines.len();
let before = Snapshot {
lines: lines
.splice(entry.start..entry.end, entry.before.lines)
.collect(),
targets: self.active.view.targets.clone(),
primary: self.active.view.primary,
};
self.restore(Snapshot {
lines,
targets: entry.before.targets,
primary: entry.before.primary,
});
HistoryEntry {
start: entry.start,
end,
before,
}
}
fn undo(&mut self) {
if self.mutation_blocked() {
return;
}
if let Some(before) = self.active.undo.pop() {
self.active.redo.push(self.snapshot());
self.restore(before);
let reverse = self.restore_history(before);
self.active.redo.push(reverse);
} else {
self.set_message("Nothing to undo");
}
@@ -11694,8 +11770,8 @@ impl Editor {
return;
}
if let Some(after) = self.active.redo.pop() {
self.active.undo.push(self.snapshot());
self.restore(after);
let reverse = self.restore_history(after);
self.active.undo.push(reverse);
} else {
self.set_message("Nothing to redo");
}
@@ -11711,8 +11787,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)),
)
}
@@ -11951,6 +12026,61 @@ impl Editor {
if edits.is_empty() {
return;
}
// Ordinary typing/deletion can edit one line without copying the entire file.
// Snapshot previews, shared panes and semantic overlays use the general remapping path.
if original.is_none()
&& edits.len() == 1
&& self.panes.len() == 1
&& self.active.semantic_tokens.is_none()
&& !edits[0].text.contains('\n')
{
let edit = &edits[0];
let mut offsets = line_offsets(&self.active.lines);
let row = offsets
.partition_point(|offset| *offset <= edit.start)
.saturating_sub(1);
let start = edit.start - offsets[row];
let end = edit.end - offsets[row];
if end <= self.active.lines[row].len() {
let mut destinations: Vec<_> = self
.active
.view
.targets
.iter()
.map(|target| {
(
translate_edit_offset(
cursor_offset(&self.active.lines, &offsets, target.active),
true,
std::iter::once(edit),
),
true,
)
})
.collect();
if let Some(target) = edit.target {
destinations[target] = (edit.start + edit.caret, edit.round_up);
}
if self.active.lines[row][start..end] != *edit.text {
self.active.lines[row].replace_range(start..end, &edit.text);
self.active.text_changed();
}
for offset in &mut offsets[row + 1..] {
*offset = *offset - (end - start) + edit.text.len();
}
for (target, (byte, round_up)) in
self.active.view.targets.iter_mut().zip(destinations)
{
target.active = offset_cursor(&self.active.lines, &offsets, byte, round_up);
target.anchor = target.active;
target.preferred_columns = None;
}
if !preserve_targets {
self.normalize_targets();
}
return;
}
}
edits.sort_by_key(|edit| (edit.start, edit.end));
let mut unique: Vec<usize> = Vec::with_capacity(edits.len());
for (index, edit) in edits.iter().enumerate() {
@@ -13115,7 +13245,7 @@ fn atomic_save(
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let (temporary, mut file) = loop {
let (temporary, file) = loop {
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
let temporary = parent.join(format!(".vamp-{}-{id}.tmp", std::process::id()));
let mut options = OpenOptions::new();
@@ -13129,12 +13259,15 @@ fn atomic_save(
}
};
let result = (|| {
let mut writer = io::BufWriter::new(&file);
for (index, line) in lines.iter().enumerate() {
if index > 0 {
file.write_all(b"\n")?;
writer.write_all(b"\n")?;
}
file.write_all(line.as_bytes())?;
writer.write_all(line.as_bytes())?;
}
writer.flush()?;
drop(writer);
if let Some(metadata) = metadata {
file.set_permissions(metadata.permissions())?;
}
+3
View File
@@ -109,6 +109,7 @@ impl Editor {
.is_some_and(|binding| binding.owner == self.tab_id)
{
buffer.saved_lines.clone_from(&buffer.lines);
buffer.dirty_cache.set(Some(false));
}
}
}
@@ -268,6 +269,7 @@ impl Editor {
}) {
buffer.review_buffer = None;
buffer.saved_lines.clone_from(&buffer.lines);
buffer.dirty_cache.set(Some(false));
}
}
self.autosave_local_review()?;
@@ -849,6 +851,7 @@ impl Editor {
self.active.lines = split_lines(text);
self.active.saved_lines.clone_from(&self.active.lines);
self.active.text_revision = self.active.text_revision.wrapping_add(1);
self.active.dirty_cache.set(Some(false));
self.normalize_targets();
}
} else {
+1 -1
View File
@@ -19,7 +19,7 @@ use std::{
thread::{self, JoinHandle},
};
const FILE_LIMIT: usize = 16 * 1024 * 1024;
pub(crate) const FILE_LIMIT: usize = 16 * 1024 * 1024;
const BATCH_LIMIT: usize = 64 * 1024 * 1024;
const FILE_COUNT_LIMIT: usize = 1024;
type Files = Vec<(usize, PathBuf, Arc<str>)>;
+2 -2
View File
@@ -25,7 +25,7 @@ Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by defaul
i filters; Esc retains filter and returns NORMAL, then editor; Ctrl+u clears in either mode
Ctrl+t view, Ctrl+w close, Ctrl+r refresh
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
h/j/k/l move, 0/$ line ends, x delete, J join lines
h/j/k/l move, 0/$ line ends, x/Delete cut hovered graphemes, J join lines
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
Normal/Visual: f<char>/F<char> seek next/previous on current line; 2fx seeks second x
gf follows Markdown file links/reference links and heading fragments (no LSP)
@@ -43,7 +43,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
Linewise p/P paste below/above the current row; characterwise p/P paste after/before the grapheme
Normal/Visual: . repeats last native edit at current targets (one undo group)
v characterwise select, V linewise select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
y/d/x/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
Esc keeps only the primary caret
Visual: motions extend selections, y copy, d/x/Delete cut, c change, p/P replace selections
: opens command input with the selection preserved; Esc returns to that selection
+108 -105
View File
@@ -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()
.map_err(|_| "Invalid Git object size")?;
if size > FILE_LIMIT {
return Err(format!(
"{} exceeds the 16 MiB 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 {
continue;
};
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")?);
let bytes = self.jj(
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],
)?;
if bytes.len() > FILE_LIMIT {
)
.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 > 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(),
);
// 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;
}
if file.bytes.len() > COMPARISON_FILE_LIMIT {
return Err(format!(
"{} exceeds the 64 MiB per-side comparison file limit",
path.display()
));
}
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)
})
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 bytes.len() > FILE_LIMIT {
if count == 0 {
break;
}
if bytes.len() + count > COMPARISON_FILE_LIMIT {
return Err(format!(
"{} grew beyond the 16 MiB comparison file limit",
"{} grew beyond the 64 MiB per-side comparison file limit",
path.display()
));
}
let bytes = if !bytes.contains(&0)
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() > FILE_LIMIT {
if text.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()
));
}
text.as_bytes().to_vec()
} else {
bytes
};
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 {}",
+37 -9
View File
@@ -425,7 +425,18 @@ struct GutterSource {
save_revision: u64,
path: PathBuf,
dirty: bool,
contents: Arc<str>,
contents: Option<Arc<str>>,
}
fn text_fits(lines: &[String], limit: usize) -> bool {
lines
.iter()
.try_fold(lines.len().saturating_sub(1), |bytes, line| {
bytes
.checked_add(line.len())
.filter(|bytes| *bytes <= limit)
})
.is_some()
}
/// Owns command eligibility and execution hosts together. Frontends can inspect
@@ -1479,8 +1490,11 @@ impl EditorSession {
source.revision == document.revision && source.path == path
})
.map_or_else(
|| Arc::from(document.lines.join("\n")),
|source| Arc::clone(&source.contents),
|| {
text_fits(document.lines, crate::gutter::FILE_LIMIT)
.then(|| Arc::from(document.lines.join("\n")))
},
|source| source.contents.clone(),
);
Some(GutterSource {
buffer: document.id,
@@ -1492,6 +1506,16 @@ impl EditorSession {
})
})
.collect();
for source in &self.gutter_sources {
if source.contents.is_none() {
self.editor.set_gutter_changes(
source.buffer,
source.revision,
&source.path,
Vec::new(),
);
}
}
}
self.gutter_request_id = self.gutter_request_id.wrapping_add(1);
self.last_gutter_refresh = Some(now);
@@ -1508,12 +1532,12 @@ impl EditorSession {
.gutter_sources
.iter()
.filter(|_| line_gutter)
.map(|source| {
(
.filter_map(|source| {
Some((
source.buffer,
source.path.clone(),
Arc::clone(&source.contents),
)
Arc::clone(source.contents.as_ref()?),
))
})
.collect();
let picker = self.picker_source.as_ref().map(|root| {
@@ -1521,7 +1545,9 @@ impl EditorSession {
.gutter_sources
.iter()
.filter(|source| source.dirty)
.map(|source| (source.path.clone(), Arc::clone(&source.contents)))
.filter_map(|source| {
Some((source.path.clone(), Arc::clone(source.contents.as_ref()?)))
})
.collect();
(root.clone(), buffers)
});
@@ -1624,7 +1650,9 @@ impl EditorSession {
self.blame_request_id = self.blame_request_id.wrapping_add(1);
self.blame_lines = None;
changed |= self.editor.clear_line_blame();
if let Some(source) = &self.blame_source {
if let Some(source) = &self.blame_source
&& text_fits(self.editor.lines(), crate::blame::LIMIT)
{
if self.blame_worker.is_none() {
self.blame_worker = BlameWorker::start().ok();
}
+109 -6
View File
@@ -14,6 +14,12 @@ pub(crate) const RUST_ID: &str = "rust";
pub(crate) const RUBY_ID: &str = "ruby";
pub(crate) const PYTHON_ID: &str = "python";
// Full trees and their indexes are optional; editing remains available beyond these budgets.
const MAX_SYNTAX_BYTES: usize = 1024 * 1024;
const MAX_SYNTAX_LINE_BYTES: usize = 64 * 1024;
const SOURCE_BUDGET_NOTICE: &str = "source exceeds 1 MiB syntax budget";
const LINE_BUDGET_NOTICE: &str = "line exceeds 64 KiB syntax budget";
pub(crate) fn is_markdown_path(path: &Path) -> bool {
languages::detect(Some(path), None).is_some_and(|syntax| syntax.name == "Markdown")
}
@@ -69,6 +75,7 @@ pub struct SyntaxCache {
path: Option<PathBuf>,
final_newline: bool,
syntax: Option<&'static LanguageSpec>,
budget_exceeded: Option<&'static str>,
source: String,
next_source: String,
line_starts: Vec<usize>,
@@ -100,7 +107,34 @@ impl SyntaxCache {
}
pub(crate) fn update_text(&mut self, text: &str, path: &Path) {
if self.source == text
// Check lengths before equality, splitting into owned lines, or copying the source.
let budget_exceeded = if text.len() > MAX_SYNTAX_BYTES {
Some(SOURCE_BUDGET_NOTICE)
} else if text
.split('\n')
.any(|line| line.len() > MAX_SYNTAX_LINE_BYTES)
{
Some(LINE_BUDGET_NOTICE)
} else {
None
};
if let Some(reason) = budget_exceeded {
let syntax = languages::detect(Some(path), None)
.or_else(|| languages::detect(None, Some(syntax_prefix(text))));
let language_changed = !same_language(self.syntax, syntax);
self.revision = None;
self.final_newline = false;
if self.path.as_deref() != Some(path) {
self.path = Some(path.to_path_buf());
}
self.syntax = syntax;
if self.budget_exceeded != Some(reason) || language_changed {
self.use_plain_text(reason);
}
return;
}
if self.budget_exceeded.is_none()
&& self.source == text
&& self.path.as_deref() == Some(path)
&& self.highlighted.iter().all(|highlighted| *highlighted)
{
@@ -168,8 +202,11 @@ impl SyntaxCache {
let path_changed = self.path.as_deref() != path;
let revision_changed =
self.revision != Some(revision) || self.final_newline != final_newline;
if self.budget_exceeded.is_some() && !path_changed && !revision_changed {
return;
}
let syntax = if path_changed || revision_changed {
languages::detect(path, lines.first().map(String::as_str))
languages::detect(path, lines.first().map(|line| syntax_prefix(line)))
} else {
self.syntax
};
@@ -181,12 +218,29 @@ impl SyntaxCache {
}
self.syntax = syntax;
let length = if revision_changed {
syntax_source_length(lines, final_newline)
} else if let Some(reason) = self.budget_exceeded {
Err(reason)
} else {
Ok(self.source.len())
};
let length = match length {
Ok(length) => length,
Err(reason) => {
if self.budget_exceeded != Some(reason) || language_changed {
self.use_plain_text(reason);
}
return;
}
};
if self.budget_exceeded.take().is_some() {
self.error = None;
}
let mut edit = None;
if revision_changed {
self.next_source.clear();
let length = lines.iter().map(String::len).sum::<usize>()
+ lines.len().saturating_sub(1)
+ usize::from(final_newline);
self.next_source.reserve(length);
for (row, line) in lines.iter().enumerate() {
if row > 0 {
@@ -289,6 +343,27 @@ impl SyntaxCache {
}
}
fn use_plain_text(&mut self, reason: &'static str) {
// Drop parser arenas as well as source/span capacities when crossing the budget.
*self = Self {
revision: self.revision,
path: self.path.take(),
final_newline: self.final_newline,
syntax: self.syntax,
budget_exceeded: Some(reason),
error: self
.syntax
.map(|syntax| format!("{}: plain text ({reason})", syntax.name)),
#[cfg(test)]
parses: self.parses,
#[cfg(test)]
incremental_parses: self.incremental_parses,
#[cfg(test)]
injection_parses: self.injection_parses,
..Self::default()
};
}
/// Sorted, non-overlapping, end-exclusive ranges. Unclassified text uses the normal foreground.
pub fn line(&self, row: usize) -> &[SyntaxSpan] {
self.lines.get(row).map_or(&[], Vec::as_slice)
@@ -352,7 +427,7 @@ impl SyntaxCache {
self.syntax.map(|syntax| syntax.name)
}
/// Genuine grammar/query failures disable highlighting; ERROR nodes in user text do not.
/// Why highlighting is unavailable: a deliberate plain-text fallback or grammar/query failure.
pub fn error(&self) -> Option<&str> {
self.error.as_deref()
}
@@ -546,6 +621,34 @@ impl SyntaxCache {
}
}
fn syntax_prefix(text: &str) -> &str {
let mut end = text.len().min(MAX_SYNTAX_LINE_BYTES);
while !text.is_char_boundary(end) {
end -= 1;
}
text[..end].split('\n').next().unwrap_or_default()
}
fn syntax_source_length(lines: &[String], final_newline: bool) -> Result<usize, &'static str> {
let mut length = lines
.len()
.saturating_sub(1)
.saturating_add(usize::from(final_newline));
if length > MAX_SYNTAX_BYTES {
return Err(SOURCE_BUDGET_NOTICE);
}
for line in lines {
if line.len() > MAX_SYNTAX_LINE_BYTES {
return Err(LINE_BUDGET_NOTICE);
}
length = length.saturating_add(line.len());
if length > MAX_SYNTAX_BYTES {
return Err(SOURCE_BUDGET_NOTICE);
}
}
Ok(length)
}
fn has_scope_language(language: &str) -> bool {
matches!(
language,
+10 -2
View File
@@ -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,
);
}
+66
View File
@@ -42,6 +42,72 @@ fn keys(editor: &mut Editor, text: &str) {
}
}
#[test]
fn normal_x_cuts_the_hovered_grapheme_for_paste() {
for grapheme in ["b", "e\u{301}", "👩‍🚀"] {
let fixture = Fixture::new();
let mut editor = fixture.editor(&format!("a{grapheme}z\n"));
keys(&mut editor, "lx");
assert_eq!(editor.context().text, "az\n");
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "P");
assert_eq!(editor.context().text, format!("a{grapheme}z\n"));
}
}
#[test]
fn normal_delete_and_remapped_action_copy_characterwise() {
for input in [Input::Delete, Input::Char('q')] {
let fixture = Fixture::new();
let mut editor = fixture.editor("abz\n");
editor
.bind("normal", "q", Some("core.delete".into()))
.unwrap();
keys(&mut editor, "yyl");
editor.handle(input);
assert_eq!(editor.context().text, "az\n");
keys(&mut editor, "uUP");
assert_eq!(editor.context().text, "abz\n");
}
}
#[test]
fn normal_x_repeat_copies_current_graphemes_and_preserves_empty_line_clipboard() {
let fixture = Fixture::new();
let mut editor = fixture.editor("abc\n\nxyz\n");
keys(&mut editor, "x.");
assert_eq!(editor.context().text, "c\n\nxyz\n");
keys(&mut editor, "jxjP");
assert_eq!(editor.context().text, "c\n\nbxyz\n");
}
#[test]
fn normal_x_cuts_at_every_caret_without_crossing_empty_lines() {
let fixture = Fixture::new();
let source = "a👩‍🚀z\n\nbe\u{301}y\n";
let mut editor = fixture.editor(source);
keys(&mut editor, "l");
editor.handle(Input::Ctrl('e'));
editor.handle(Input::Ctrl('e'));
keys(&mut editor, "x");
assert_eq!(editor.context().text, "az\n\nby\n");
keys(&mut editor, "P");
assert_eq!(editor.context().text, source);
}
#[test]
fn insert_delete_and_its_repeat_do_not_overwrite_clipboard() {
let fixture = Fixture::new();
let mut editor = fixture.editor("abc\nxyz\n");
keys(&mut editor, "vyi");
editor.handle(Input::Delete);
editor.handle(Input::Escape);
keys(&mut editor, "j.");
assert_eq!(editor.context().text, "bc\nyz\n");
keys(&mut editor, "P");
assert_eq!(editor.context().text, "bc\nayz\n");
}
#[test]
fn visual_r_replaces_selected_graphemes_as_one_undo_edit() {
let fixture = Fixture::new();
+267 -18
View File
@@ -1728,18 +1728,30 @@ fn conflicted_catalog_preserves_symlinks_and_defers_unselected_contents() {
let fixture = Fixture::new();
let root = fixture.dir("lazy-conflict");
init_jj(&root, false);
fs::write(root.join("conflict"), "base\n").unwrap();
jj(
&root,
&[
"config",
"set",
"--repo",
"snapshot.max-new-file-size",
"100MiB",
],
);
let tail_line = "x".repeat(1023);
let tail = format!("{tail_line}\n").repeat(17 * 1024 + 1);
fs::write(root.join("conflict"), format!("base\n{tail}")).unwrap();
fs::write(root.join("unselected"), "old\n").unwrap();
symlink("unselected", root.join("unchanged-link")).unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &base]);
fs::write(root.join("conflict"), "left\n").unwrap();
fs::write(root.join("conflict"), format!("left\n{tail}")).unwrap();
fs::write(root.join("unselected"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
jj(&root, &["describe", "-m", "left"]);
let left = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &base]);
fs::write(root.join("conflict"), "right\n").unwrap();
fs::write(root.join("conflict"), format!("right\n{tail}")).unwrap();
jj(&root, &["describe", "-m", "right"]);
let right = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &left, &right, "-m", "conflicted merge"]);
@@ -1764,7 +1776,9 @@ fn conflicted_catalog_preserves_symlinks_and_defers_unselected_contents() {
[Path::new("conflict"), Path::new("unselected")]
);
let conflict = repository.load_file(1, 0).unwrap();
assert_eq!(conflict.before, ["base"]);
assert_eq!(conflict.before[0], "base");
assert_eq!(conflict.before.len(), 17 * 1024 + 2);
assert!(conflict.before[1..].iter().all(|line| line == &tail_line));
let projection = jj(
&root,
&[
@@ -1779,9 +1793,13 @@ fn conflicted_catalog_preserves_symlinks_and_defers_unselected_contents() {
"conflict",
],
);
assert!(projection.len() > 16 * 1024 * 1024);
assert_eq!(conflict.after, projection.lines().collect::<Vec<_>>());
assert!(!conflict.hunks.is_empty());
assert!(repository.load_file(1, 1).is_err());
let large = repository.load_file(1, 1).unwrap();
assert_eq!(large.after.len(), 1);
assert_eq!(large.after[0].len(), 17 * 1024 * 1024);
assert!(large.after[0].bytes().all(|byte| byte == b'x'));
assert_eq!(disk_state(&root), state);
jj(&root, &["new", &merged]);
@@ -1945,6 +1963,220 @@ fn immutable_catalog_skips_unchanged_blobs_and_prepares_only_requested_details()
assert!(repository.load_file(100, index).is_err());
}
#[test]
fn comparison_size_and_line_ceilings_are_lazy_and_explicit() {
let fixture = Fixture::new();
let root = fixture.dir("ceilings");
init_git(&root);
git(&root, &["commit", "--allow-empty", "-m", "before"]);
let before = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
fs::File::create(root.join("too-many-bytes"))
.unwrap()
.set_len(64 * 1024 * 1024 + 1)
.unwrap();
fs::write(root.join("too-many-lines"), vec![b'\n'; 4_000_001]).unwrap();
fs::write(root.join("selected"), "still available\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "after"]);
let after = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
let mut repository = open(&root);
let catalog = repository
.compare(1, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap();
assert_eq!(catalog.files.len(), 3);
assert!(catalog.files.iter().all(|file| !file.loaded));
for (index, entry) in catalog.files.iter().enumerate() {
match entry.path.to_str().unwrap() {
"selected" => assert_eq!(
repository.load_file(1, index).unwrap().after,
["still available"]
),
"too-many-bytes" => assert!(
repository
.load_file(1, index)
.unwrap_err()
.contains("64 MiB per-side comparison file limit")
),
"too-many-lines" => assert!(
repository
.load_file(1, index)
.unwrap_err()
.contains("4000000-line per-side comparison limit")
),
path => panic!("Unexpected file {path}"),
}
}
}
#[test]
fn large_git_and_jj_comparisons_load_exact_snapshots_and_frozen_working_overlays() {
let fixture = Fixture::new();
// Both modified sides exceed 52.3 MB; the Working pair exceeds the old
// aggregate 64 MiB budget as well as its old 16 MiB per-file limit.
let body_line = "x".repeat(1023);
let mut contents = format!(
"before\n{}",
format!("{body_line}\n").repeat(52_300_000 / 1024 + 1)
);
let line_count = contents.lines().count();
for kind in [RepoKind::Git, RepoKind::Jujutsu] {
let root = fixture
.dir(if kind == RepoKind::Git { "git" } else { "jj" })
.canonicalize()
.unwrap();
match kind {
RepoKind::Git => init_git(&root),
RepoKind::Jujutsu => {
init_jj(&root, false);
jj(
&root,
&[
"config",
"set",
"--repo",
"snapshot.max-new-file-size",
"100MiB",
],
);
}
}
let set_header = |contents: &mut String, header: &str| {
let end = contents.find('\n').unwrap();
contents.replace_range(..end, header);
};
set_header(&mut contents, "before");
fs::write(root.join("modified"), &contents).unwrap();
set_header(&mut contents, "removed");
fs::write(root.join("removed"), &contents).unwrap();
let before = match kind {
RepoKind::Git => {
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "large before"]);
git(&root, &["rev-parse", "HEAD"])
}
RepoKind::Jujutsu => {
jj(&root, &["describe", "-m", "large before"]);
let id = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &id]);
id
}
};
set_header(&mut contents, "after");
fs::write(root.join("modified"), &contents).unwrap();
fs::remove_file(root.join("removed")).unwrap();
set_header(&mut contents, "added");
fs::write(root.join("added"), &contents).unwrap();
let after = match kind {
RepoKind::Git => {
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "large after"]);
git(&root, &["rev-parse", "HEAD"])
}
RepoKind::Jujutsu => {
jj(&root, &["describe", "-m", "large after"]);
jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"])
}
};
let assert_contents = |lines: &[String], header: &str| {
assert_eq!(lines.len(), line_count);
assert_eq!(lines[0], header);
assert!(lines[1..].iter().all(|line| line == &body_line));
};
let cancelled = Arc::new(AtomicBool::new(false));
let mut repository = Repository::open(&root, cancelled.clone()).unwrap();
let catalog = repository
.compare(
1,
&revision(&before, kind),
&revision(&after, kind),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(catalog.files.len(), 3);
assert!(catalog.files.iter().all(|file| !file.loaded
&& file.before.is_empty()
&& file.after.is_empty()
&& file.hunks.is_empty()));
for (index, entry) in catalog.files.iter().enumerate() {
let file = repository.load_file(1, index).unwrap();
assert!(file.loaded);
assert_eq!(file.hunks.len(), 1);
let hunk = &file.hunks[0];
let expected = match entry.path.to_str().unwrap() {
"added" => {
assert_eq!(file.status, FileStatus::Added);
assert!(file.before.is_empty());
assert_contents(&file.after, "added");
(0, 0, 0, line_count)
}
"removed" => {
assert_eq!(file.status, FileStatus::Removed);
assert_contents(&file.before, "removed");
assert!(file.after.is_empty());
(0, line_count, 0, 0)
}
"modified" => {
assert_eq!(file.status, FileStatus::Modified);
assert_contents(&file.before, "before");
assert_contents(&file.after, "after");
(0, 1, 0, 1)
}
path => panic!("Unexpected file {path}"),
};
assert_eq!(
(
hunk.before_start,
hunk.before_len,
hunk.after_start,
hunk.after_len
),
expected
);
assert!(file.before.is_empty() || file.before_eof);
assert!(file.after.is_empty() || file.after_eof);
}
set_header(&mut contents, "overlay");
let buffers = [(root.join("modified"), contents)];
let working = repository
.compare(
2,
&revision(&after, kind),
&working(kind),
ComparisonMode::Snapshots,
&buffers,
)
.unwrap();
assert_eq!(working.files.len(), 1);
assert!(!working.files[0].loaded);
assert!(working.editable_paths.contains(Path::new("modified")));
fs::write(root.join("modified"), "later disk contents\n").unwrap();
cancelled.store(true, Ordering::Release);
assert!(
repository
.load_file(2, 0)
.unwrap_err()
.contains("cancelled")
);
cancelled.store(false, Ordering::Release);
let file = repository.load_file(2, 0).unwrap();
assert_contents(&file.before, "after");
assert_contents(&file.after, "overlay");
assert_eq!(file.hunks.len(), 1);
let hunk = &file.hunks[0];
assert_eq!(
(
hunk.before_start,
hunk.before_len,
hunk.after_start,
hunk.after_len
),
(0, 1, 0, 1)
);
contents = buffers.into_iter().next().unwrap().1;
}
}
#[test]
fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() {
let fixture = Fixture::new();
@@ -2112,8 +2344,16 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
assert!(repository.refresh_checkout(10, 11, true, &[]).is_err());
cancelled.store(false, Ordering::Release);
assert_eq!(repository.load_file(10, 0).unwrap().after, ["frozen after"]);
fs::write(root.join("oversized"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
assert!(repository.refresh_checkout(10, 11, true, &[]).is_err());
fs::File::create(root.join("oversized"))
.unwrap()
.set_len(64 * 1024 * 1024 + 1)
.unwrap();
assert!(
repository
.refresh_checkout(10, 11, true, &[])
.unwrap_err()
.contains("64 MiB per-side comparison file limit")
);
let original = repository.load_file(10, 0).unwrap();
assert_eq!(original.before, ["frozen before"]);
assert_eq!(original.after, ["frozen after"]);
@@ -2194,7 +2434,7 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
}
#[test]
fn refresh_materialization_failure_restores_prepared_file_sources() {
fn refresh_keeps_historical_blob_sources_lazy_even_with_working_files() {
let fixture = Fixture::new();
let root = fixture.dir("failed-materialization");
init_git(&root);
@@ -2206,7 +2446,7 @@ fn refresh_materialization_failure_restores_prepared_file_sources() {
fs::remove_file(root.join("a-file")).unwrap();
fs::remove_file(root.join("z-large")).unwrap();
let mut repository = open(&root);
let original = repository
repository
.compare(
1,
&base,
@@ -2215,20 +2455,29 @@ fn refresh_materialization_failure_restores_prepared_file_sources() {
&[],
)
.unwrap();
let index = original
assert_eq!(
repository.load_file(1, 0).unwrap().before,
["frozen source"]
);
fs::write(root.join("a-file"), "new checkout\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "descendant"]);
let refreshed = repository
.refresh_checkout(1, 2, true, &[])
.unwrap()
.map(checkout_comparison)
.unwrap();
assert!(refreshed.files.iter().all(|file| !file.loaded));
let index = refreshed
.files
.iter()
.position(|file| file.path == Path::new("a-file"))
.unwrap();
fs::write(root.join("a-file"), "new checkout\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "descendant"]);
assert!(repository.refresh_checkout(1, 2, true, &[]).is_err());
let file = repository.load_file(1, index).unwrap();
let file = repository.load_file(2, index).unwrap();
assert_eq!(file.before, ["frozen source"]);
assert!(file.after.is_empty());
assert_eq!(file.status, FileStatus::Removed);
assert!(repository.load_file(2, index).is_err());
assert_eq!(file.after, ["new checkout"]);
assert_eq!(file.status, FileStatus::Modified);
assert!(repository.load_file(1, index).is_err());
}
#[test]
+260 -4
View File
@@ -1844,7 +1844,10 @@ fn column_view_preserves_split_gaps_unified_sides_and_folded_coordinates() {
let ColumnRow::Text(line) = before.row(1).unwrap() else {
panic!("unified insertion must paint the after source");
};
assert_eq!((line.text, line.side, line.source_row), (Some("added"), Focus::After, 1));
assert_eq!(
(line.text, line.side, line.source_row),
(Some("added"), Focus::After, 1)
);
assert_eq!(line.numbers, [None, Some(1)]);
assert!(line.changed);
assert_eq!(before.native_row(1), None);
@@ -2237,7 +2240,7 @@ fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() {
);
assert!(state.after_editable());
for focus in [Focus::Before, Focus::After] {
for source in state.source_rows(focus) {
for source in state.source_rows_in(focus, 0, usize::MAX) {
let display = state.source_display_row(focus, source).unwrap();
assert_eq!(state.display_source_row(focus, display), Some(source));
}
@@ -2268,10 +2271,15 @@ fn unified_live_reflow_preserves_removed_top_and_expanded_source_excerpts() {
assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20));
assert!(state.source_display_row(Focus::After, 17).is_some());
state.reveal_source_row(Focus::After, 35);
let visible: Vec<_> = state.source_rows(Focus::After).collect();
let visible: Vec<_> = state.source_rows_in(Focus::After, 0, usize::MAX).collect();
state.set_view_mode(ViewMode::Split);
state.set_view_mode(ViewMode::Unified);
assert_eq!(state.source_rows(Focus::After).collect::<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));
}
@@ -2747,3 +2755,251 @@ fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() {
state.comparison.as_mut().unwrap().revisions[0] = "other".into();
assert!(!state.review_anchor_current(&before));
}
#[test]
fn million_row_additions_and_removals_keep_complete_sources_and_compact_navigation() {
let lines = vec!["static const unsigned int parser_state = 1234567890;".to_owned(); 1_000_000];
let mut state = live_state(Vec::new(), lines, true);
state.comparison.as_mut().unwrap().files[0].status = FileStatus::Added;
state.align_file();
assert_eq!(state.rows.len(), 1_000_000);
assert!(state.rows.iter().all(|row| row.changes.is_empty()));
assert_eq!(state.source_ranges[1], [(0..1_000_000, 0)]);
assert_eq!(state.after_display_row(999_999), Some(999_999));
assert_eq!(
state.clamp_source_row(Focus::After, usize::MAX),
Some(999_999)
);
let mut visible = state.source_rows_in(Focus::After, 999_990, 999_999);
assert_eq!(visible.next(), Some(999_990));
assert_eq!(visible.next_back(), Some(999_998));
drop(visible);
let ColumnRow::Text(last) = state
.column_view(Focus::After)
.unwrap()
.row(999_999)
.unwrap()
else {
panic!("the final added source line must remain available");
};
assert_eq!(
last.text,
Some("static const unsigned int parser_state = 1234567890;")
);
assert_eq!(last.source_row, 999_999);
let file = &mut state.comparison.as_mut().unwrap().files[0];
std::mem::swap(&mut file.before, &mut file.after);
file.status = FileStatus::Removed;
file.hunks = line_hunks(&file.before, true, &file.after, false);
assert_eq!(
(file.hunks[0].before_len, file.hunks[0].after_len),
(1_000_000, 0)
);
state.align_file();
assert_eq!(state.source_ranges[0], [(0..1_000_000, 0)]);
assert_eq!(
state.source_display_row(Focus::Before, 999_999),
Some(999_999)
);
assert_eq!(state.source_display_row(Focus::After, 0), None);
}
#[test]
fn large_localized_modifications_keep_exact_hunks_and_only_diff_visible_characters() {
let before = vec!["unchanged generated parser body".to_owned(); 25_000];
let mut after = before.clone();
after[20_000] = "unchanged generated parser body!".into();
let state = live_state(before, after, true);
let file = &state.comparison.as_ref().unwrap().files[0];
assert_eq!(file.hunks.len(), 1);
let hunk = &file.hunks[0];
assert_eq!(
(
hunk.before_start,
hunk.before_len,
hunk.after_start,
hunk.after_len
),
(20_000, 1, 20_000, 1)
);
assert!(state.rows.iter().all(|row| row.changes.is_empty()));
let display = state.after_display_row(20_000).unwrap();
let ColumnRow::Text(line) = state
.column_view(Focus::After)
.unwrap()
.row(display)
.unwrap()
else {
panic!("the modified source row must remain visible");
};
assert_eq!(line.changes().cloned().collect::<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})"
);
}
}
}
}
}
+160 -18
View File
@@ -135,10 +135,21 @@ fn resolver_shares_native_excerpt_search_motions_and_explicit_writable_side() {
type_text(editor, "/needle");
editor.handle(Input::Enter);
assert_eq!(editor.cursor().row, 70);
let rows = editor.diff().unwrap().source_rows(Focus::Before).count();
let rows = editor
.diff()
.unwrap()
.source_rows_in(Focus::Before, 0, usize::MAX)
.count();
let cursor = editor.cursor();
editor.handle(Input::Char(']'));
assert!(editor.diff().unwrap().source_rows(Focus::Before).count() > rows);
assert!(
editor
.diff()
.unwrap()
.source_rows_in(Focus::Before, 0, usize::MAX)
.count()
> rows
);
assert_eq!(editor.cursor(), cursor);
type_text(editor, "21g");
assert_eq!(editor.cursor().row, 20);
@@ -323,6 +334,7 @@ fn resolver_explorer_retains_last_jj_conflict_state_until_confirmation() {
ResolverStatus::Unconfirmed
);
editor.active.saved_lines.clone_from(&editor.active.lines);
editor.active.dirty_cache.set(None);
editor.refresh_resolver_status();
assert_eq!(
editor.resolver().unwrap().files[0].status,
@@ -3245,7 +3257,10 @@ fn review_identity_changes_retire_drafts_before_reusing_comment_ids() {
let restored = root.join("restored.json");
editor.execute(&format!("diff.review.save {}", restored.display()));
assert_eq!(
crate::review::Review::load(&restored, &root).unwrap().comments[0].body,
crate::review::Review::load(&restored, &root)
.unwrap()
.comments[0]
.body,
"Original feedback"
);
@@ -4640,7 +4655,7 @@ fn editable_diff_brackets_expand_caret_excerpt_without_moving_source_anchor() {
editor
.diff()
.unwrap()
.source_rows(Focus::After)
.source_rows_in(Focus::After, 0, usize::MAX)
.collect::<Vec<_>>()
};
assert_eq!(
@@ -6977,7 +6992,7 @@ mod system_clipboard {
let revision = editor.text_revision();
fixture.fail("fail-write", true);
for keys in [
"yy", "2y3y", "2dd", "cc", "C", "D", "Y", "s", "d2w", "d2b", "c2w", "c2b",
"x", "yy", "2y3y", "2dd", "cc", "C", "D", "Y", "s", "d2w", "d2b", "c2w", "c2b",
] {
type_text(&mut editor, keys);
assert_eq!(editor.context().text, "Xone\ntwo\nthree");
@@ -6992,6 +7007,29 @@ mod system_clipboard {
assert_eq!(editor.context().text, "XXone\ntXwo\nthree");
}
#[test]
fn normal_x_publishes_graphemes_and_repeat_failure_is_atomic() {
let fixture = Fixture::new();
let mut editor = fixture.editor();
editor
.apply(vec![Effect::Replace("e\u{301}👩‍🚀z".into())])
.unwrap();
type_text(&mut editor, "x");
assert_eq!(editor.context().text, "👩‍🚀z");
assert_eq!(fixture.bytes("state"), "e\u{301}".as_bytes());
fixture.fail("fail-write", true);
let before = state(&editor);
type_text(&mut editor, ".");
assert_eq!(state(&editor), before);
assert_eq!(fixture.bytes("state"), "e\u{301}".as_bytes());
fixture.fail("fail-write", false);
type_text(&mut editor, ".");
assert_eq!(editor.context().text, "z");
assert_eq!(fixture.bytes("state"), "👩‍🚀".as_bytes());
type_text(&mut editor, "uUP");
assert_eq!(editor.context().text, "👩‍🚀z");
}
#[test]
fn copy_line_publication_failure_keeps_owned_shape_register_and_full_state() {
let fixture = Fixture::new();
@@ -7145,19 +7183,25 @@ mod system_clipboard {
editor
.apply(vec![Effect::Replace("aa\nbb\ncc".into())])
.unwrap();
editor.active.saved_lines.clone_from(&editor.active.lines);
editor.active.dirty_cache.set(Some(false));
assert!(!editor.dirty());
type_text(&mut editor, "Vj");
fixture.fail("fail-write", true);
let before = state(&editor);
editor.handle(Input::Char('.'));
assert_eq!(state(&editor), before);
assert!(!editor.dirty());
assert_eq!(fixture.bytes("state"), b"a\n");
assert!(editor.visual_linewise());
fixture.fail("fail-write", false);
editor.handle(Input::Char('.'));
assert_eq!(editor.context().text, "cc");
assert_eq!(fixture.bytes("state"), b"aa\nbb\n");
assert!(editor.dirty());
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "aa\nbb\ncc");
assert!(!editor.dirty());
assert_eq!(editor.mode(), Mode::Normal);
}
@@ -7686,7 +7730,6 @@ mod system_clipboard {
editor.set_clipboard_mode("system").unwrap();
editor.apply(vec![Effect::Replace("plain".into())]).unwrap();
set_carets(&mut editor, &[(0, 0)], 0);
type_text(&mut editor, "x.");
type_text(&mut editor, "iX");
editor.handle(Input::Backspace);
editor.handle(Input::Delete);
@@ -7795,11 +7838,12 @@ fn filter_escape_resume_and_clear_do_not_edit_underlying_buffers_or_history() {
editor.count = Some(7);
let snapshot =
|state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary);
let history = |entry: &HistoryEntry| (entry.start, entry.end, snapshot(&entry.before));
let lines = editor.lines().to_owned();
let targets = editor.active.view.targets.clone();
let primary = editor.active.view.primary;
let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect();
let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect();
let undo: Vec<_> = editor.active.undo.iter().map(&history).collect();
let redo: Vec<_> = editor.active.redo.iter().map(&history).collect();
let insert = editor.active.view.insert_start.as_ref().map(snapshot);
let buffer = editor.buffer_id();
let buffers = editor.buffer_count();
@@ -7845,11 +7889,11 @@ fn filter_escape_resume_and_clear_do_not_edit_underlying_buffers_or_history() {
assert_eq!(editor.buffer_id(), buffer);
assert_eq!(editor.buffer_count(), buffers);
assert_eq!(
editor.active.undo.iter().map(&snapshot).collect::<Vec<_>>(),
editor.active.undo.iter().map(&history).collect::<Vec<_>>(),
undo
);
assert_eq!(
editor.active.redo.iter().map(&snapshot).collect::<Vec<_>>(),
editor.active.redo.iter().map(&history).collect::<Vec<_>>(),
redo
);
assert_eq!(
@@ -8078,11 +8122,12 @@ fn selection_navigation_wraps_without_touching_any_underlying_mode() {
assert!(len > 1);
let snapshot =
|state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary);
let history = |entry: &HistoryEntry| (entry.start, entry.end, snapshot(&entry.before));
let text = editor.context().text;
let targets = editor.active.view.targets.clone();
let primary = editor.active.view.primary;
let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect();
let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect();
let undo: Vec<_> = editor.active.undo.iter().map(&history).collect();
let redo: Vec<_> = editor.active.redo.iter().map(&history).collect();
let insert = editor.active.view.insert_start.as_ref().map(snapshot);
editor.count = Some(7);
for (input, selected) in [
@@ -8104,11 +8149,11 @@ fn selection_navigation_wraps_without_touching_any_underlying_mode() {
assert_eq!(editor.active.view.command_line, "original prompt");
assert_eq!(editor.count(), Some(7));
assert_eq!(
editor.active.undo.iter().map(&snapshot).collect::<Vec<_>>(),
editor.active.undo.iter().map(&history).collect::<Vec<_>>(),
undo
);
assert_eq!(
editor.active.redo.iter().map(&snapshot).collect::<Vec<_>>(),
editor.active.redo.iter().map(&history).collect::<Vec<_>>(),
redo
);
assert_eq!(
@@ -12182,6 +12227,7 @@ fn palette_cancellation_preserves_every_underlying_mode_and_insert_group() {
}
let snapshot =
|state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary);
let history = |entry: &HistoryEntry| (entry.start, entry.end, snapshot(&entry.before));
let lines = editor.active.lines.clone();
let targets = editor.active.view.targets.clone();
let primary = editor.active.view.primary;
@@ -12190,8 +12236,8 @@ fn palette_cancellation_preserves_every_underlying_mode_and_insert_group() {
let search = editor.active.view.search_query.clone();
let found = editor.active.view.search_match;
let message = editor.message.clone();
let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect();
let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect();
let undo: Vec<_> = editor.active.undo.iter().map(&history).collect();
let redo: Vec<_> = editor.active.redo.iter().map(&history).collect();
let insert = editor.active.view.insert_start.as_ref().map(snapshot);
editor.handle(Input::Ctrl('p'));
@@ -12218,11 +12264,11 @@ fn palette_cancellation_preserves_every_underlying_mode_and_insert_group() {
assert_eq!(editor.active.view.search_match, found);
assert_eq!(editor.message, message);
assert_eq!(
editor.active.undo.iter().map(&snapshot).collect::<Vec<_>>(),
editor.active.undo.iter().map(&history).collect::<Vec<_>>(),
undo
);
assert_eq!(
editor.active.redo.iter().map(&snapshot).collect::<Vec<_>>(),
editor.active.redo.iter().map(&history).collect::<Vec<_>>(),
redo
);
assert_eq!(
@@ -13489,6 +13535,56 @@ fn insert_sessions_and_open_lines_are_single_undo_actions() {
assert_eq!(editor.context().text, "hello\n世界");
}
#[test]
fn large_buffer_history_retains_changed_rows_and_round_trips_all_groups() {
let directory = Directory::new();
let path = directory.path("large.txt");
let source = format!("head\n{}tail\n", "unchanged contents\n".repeat(10_000));
fs::write(&path, &source).unwrap();
let mut editor = Editor::open(Some(path), vec![]).unwrap();
for _ in 0..12 {
editor.handle(Input::Char('i'));
editor.handle(Input::Char('界'));
editor.handle(Input::Escape);
}
assert_eq!(editor.lines()[0], format!("{}head", "界".repeat(12)));
let retained_bytes: usize = editor
.active
.undo
.iter()
.flat_map(|entry| &entry.before.lines)
.map(String::len)
.sum();
assert!(
retained_bytes < 4096,
"small edits retained {retained_bytes} bytes of history"
);
for _ in 0..12 {
editor.handle(Input::Char('u'));
}
assert_eq!(editor.lines().join("\n"), source);
assert!(!editor.dirty());
for _ in 0..12 {
editor.handle(Input::Char('U'));
}
assert_eq!(editor.lines()[0], format!("{}head", "界".repeat(12)));
assert_eq!(&editor.lines()[1..], &split_lines(&source)[1..]);
assert!(editor.dirty());
editor.handle(Input::Char('u'));
editor.handle(Input::Char('i'));
editor.handle(Input::Enter);
editor.handle(Input::Escape);
assert!(editor.active.redo.is_empty());
editor.handle(Input::Char('u'));
assert_eq!(editor.lines()[0], format!("{}head", "界".repeat(11)));
editor.execute("w");
assert!(!editor.dirty());
assert_eq!(
fs::read_to_string(editor.path().unwrap()).unwrap(),
format!("{}{source}", "界".repeat(11))
);
}
#[test]
fn grapheme_navigation_and_deletion_do_not_split_unicode() {
let mut editor = editor();
@@ -14076,6 +14172,52 @@ fn saves_preserve_empty_files_lf_trailing_newlines_and_dirty_history() {
assert!(editor.should_quit());
}
#[test]
fn dirty_cache_tracks_saved_text_across_edits_undo_restore_and_reload() {
let directory = Directory::new();
let path = directory.path("file.txt");
fs::write(&path, "saved\n").unwrap();
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
editor.set_clipboard_mode("separate").unwrap();
assert!(!editor.dirty());
editor
.apply(vec![Effect::Insert("changed".into())])
.unwrap();
assert!(editor.dirty());
for _ in 0..3 {
assert!(editor.dirty());
assert!(editor.active.info(true).dirty);
assert!(editor.active.document().dirty);
assert!(!editor.active.refresh_file().unwrap());
}
editor.handle(Input::Char('u'));
assert!(!editor.dirty());
editor.handle(Input::Char('U'));
assert!(editor.dirty());
editor
.apply(vec![Effect::Replace("saved\n".into())])
.unwrap();
assert!(!editor.dirty());
editor.apply(vec![Effect::Insert("new ".into())]).unwrap();
assert!(editor.dirty());
let revision = editor.text_revision();
editor.execute("w");
assert_eq!(editor.text_revision(), revision);
assert!(!editor.dirty());
editor.handle(Input::Char('u'));
assert!(editor.dirty());
editor.handle(Input::Char('U'));
assert!(!editor.dirty());
fs::write(&path, "reloaded content\n").unwrap();
assert!(editor.active.refresh_file().unwrap());
assert_eq!(editor.lines(), ["reloaded content", ""]);
assert!(!editor.dirty());
assert!(!editor.active.refresh_file().unwrap());
}
#[test]
fn save_as_and_failed_wq_keep_path_dirty_state_and_quit_correct() {
let directory = Directory::new();
+41
View File
@@ -4,6 +4,47 @@ use std::{
sync::atomic::{AtomicU64, Ordering},
};
#[test]
fn oversized_repository_text_skips_snapshots_and_recovers_after_shrinking() {
let fixture = Fixture::new();
let path = fixture.file("buffer.txt", "small\n");
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
let buffer = session.editor.buffer_id();
session.editor.set_gutter_changes(
buffer,
session.editor.text_revision(),
&path,
vec![Some(crate::gutter::Change::Added)],
);
assert_eq!(
session.editor.gutter_change(0),
Some(crate::gutter::Change::Added)
);
fs::write(&path, "x".repeat(crate::gutter::FILE_LIMIT + 1)).unwrap();
assert!(session.editor.refresh_files());
session.poll_gutter_at(Instant::now());
session.synchronize_documents();
let source = session
.gutter_sources
.iter()
.find(|source| source.buffer == buffer)
.unwrap();
assert!(source.contents.is_none());
assert!(session.blame_worker.is_none());
assert_eq!(session.editor.gutter_change(0), None);
fs::write(&path, "small again\n").unwrap();
assert!(session.editor.refresh_files());
session.poll_gutter_at(Instant::now());
session.synchronize_documents();
let source = session
.gutter_sources
.iter()
.find(|source| source.buffer == buffer)
.unwrap();
assert_eq!(source.contents.as_deref(), Some("small again\n"));
assert!(session.blame_worker.is_some());
}
#[test]
fn resolver_load_errors_return_through_real_session_without_touching_the_draft() {
let fixture = Fixture::new();
+146
View File
@@ -729,6 +729,152 @@ fn unchanged_revision_and_same_source_do_not_allocate_or_parse() {
assert_eq!(cache.incremental_parses, 0);
}
fn assert_plain_text_storage(cache: &SyntaxCache) {
assert!(cache.tree.is_none());
assert!(cache.context.is_none());
assert!(cache.scopes.get().is_none());
assert_eq!(cache.source.capacity(), 0);
assert_eq!(cache.next_source.capacity(), 0);
assert_eq!(cache.line_starts.capacity(), 0);
assert_eq!(cache.lines.capacity(), 0);
assert_eq!(cache.string_spans.capacity(), 0);
assert_eq!(cache.highlighted.capacity(), 0);
assert_eq!(cache.injections.capacity(), 0);
assert_eq!(cache.captures.capacity(), 0);
assert_eq!(cache.highlights.capacity(), 0);
assert_eq!(cache.events.capacity(), 0);
assert!(cache.line(0).is_empty());
assert!(cache.string_spans(0).is_empty());
assert!(cache.scope_at(0).is_none());
}
#[test]
fn syntax_budget_crossing_releases_state_and_reenables_after_shrinking() {
let small = "fn main() { let value = \"ok\"; }";
let mut input = lines(small);
let mut remaining = MAX_SYNTAX_BYTES - small.len();
while remaining > 0 {
let length = (remaining - 1).min(16 * 1024);
input.push(" ".repeat(length));
remaining -= length + 1;
}
let path = Some(Path::new("budget.rs"));
let mut cache = SyntaxCache::new();
cache.update_visible(&input, path, 0, std::slice::from_ref(&(0..1)));
assert_eq!(cache.source.len(), MAX_SYNTAX_BYTES);
assert_eq!(cache.error(), None);
assert_eq!(cache.parses, 1);
assert_token(&cache, small, 0, "fn", SyntaxKind::Keyword);
assert!(cache.scope_at(0).is_some());
input.last_mut().unwrap().push(' ');
cache.update_visible(&input, path, 1, std::slice::from_ref(&(0..1)));
assert_eq!(cache.language(), Some("Rust"));
assert!(cache.error().unwrap().contains("plain text"));
assert_eq!(cache.parses, 1);
assert_plain_text_storage(&cache);
let notice = cache.error.as_ref().unwrap().as_ptr();
cache.update(&input, path, 1);
assert_eq!(cache.error.as_ref().unwrap().as_ptr(), notice);
assert_eq!(cache.parses, 1);
assert_plain_text_storage(&cache);
cache.update(&input, Some(Path::new("budget.py")), 1);
assert_eq!(cache.language(), Some("Python"));
assert!(cache.error().unwrap().starts_with("Python"));
assert_plain_text_storage(&cache);
cache.update(&input, Some(Path::new("budget.txt")), 1);
assert_eq!(cache.language(), None);
assert_eq!(cache.error(), None);
assert_plain_text_storage(&cache);
cache.update(&lines(small), path, 2);
assert_eq!(cache.parses, 2);
assert_eq!(cache.error(), None);
assert_token(&cache, small, 0, "fn", SyntaxKind::Keyword);
assert!(cache.scope_at(0).is_some());
}
#[test]
fn diff_syntax_budget_counts_the_separate_final_newline() {
let mut input = vec!["//".to_owned() + &" ".repeat(16 * 1024 - 2); 63];
input.push("//".to_owned() + &" ".repeat(16 * 1024 - 65));
let path = Some(Path::new("budget.rs"));
let mut cache = SyntaxCache::new();
cache.update_diff(&input, path, 0, false, None);
assert_eq!(cache.source.len(), MAX_SYNTAX_BYTES);
assert_eq!(cache.parses, 1);
assert_eq!(kind_at(&cache, 0, 0), Some(SyntaxKind::Comment));
cache.update_diff(&input, path, 0, true, None);
assert_eq!(cache.parses, 1);
assert_plain_text_storage(&cache);
cache.update_diff(&input, path, 0, false, Some(std::slice::from_ref(&(0..1))));
assert_eq!(cache.parses, 2);
assert_eq!(cache.error(), None);
assert_eq!(kind_at(&cache, 0, 0), Some(SyntaxKind::Comment));
}
#[test]
fn long_lines_and_large_text_never_copy_or_parse_and_clear_injections() {
let markdown = "# Title\n```rust\nfn main() { \"ok\"; }\n```";
let mut cache = self::cache(markdown, "hover.md");
assert!(!cache.injections.is_empty());
assert!(cache.scope_at(2).is_some());
let parses = cache.parse_counts();
let long_line = "//".to_owned() + &" ".repeat(MAX_SYNTAX_LINE_BYTES - 1);
cache.update_text(&long_line, Path::new("large.rs"));
assert_eq!(cache.language(), Some("Rust"));
assert_eq!(cache.parse_counts(), parses);
assert!(cache.error().unwrap().contains("line"));
assert_plain_text_storage(&cache);
let large = " ".repeat(52 * 1024 * 1024);
cache.update_text(&large, Path::new("parser.c"));
assert_eq!(cache.language(), Some("C"));
assert_eq!(cache.parse_counts(), parses);
assert_plain_text_storage(&cache);
let notice = cache.error.as_ref().unwrap().as_ptr();
cache.update_text(&large, Path::new("parser.c"));
assert_eq!(cache.error.as_ref().unwrap().as_ptr(), notice);
assert_plain_text_storage(&cache);
let large_lines = vec![large];
cache.update(&large_lines, Some(Path::new("parser.c")), 10);
assert_eq!(cache.language(), Some("C"));
assert_eq!(cache.parse_counts(), parses);
assert_plain_text_storage(&cache);
cache.update_diff(&large_lines, Some(Path::new("parser.c")), 10, true, None);
assert_eq!(cache.parse_counts(), parses);
assert_plain_text_storage(&cache);
cache.update_text(markdown, Path::new("hover.md"));
assert!(cache.parse_counts().0 > parses.0);
assert_eq!(cache.error(), None);
assert!(!cache.injections.is_empty());
assert!(cache.scope_at(2).is_some());
let boundary = "//".to_owned() + &" ".repeat(MAX_SYNTAX_LINE_BYTES - 2);
cache.update(&lines(&boundary), Some(Path::new("line.rs")), 50);
assert_eq!(cache.error(), None);
assert_eq!(kind_at(&cache, 0, 0), Some(SyntaxKind::Comment));
cache.update(&lines(&long_line), Some(Path::new("line.rs")), 51);
assert_plain_text_storage(&cache);
cache.update_text("", Path::new("line.rs"));
assert_eq!(cache.language(), Some("Rust"));
assert_eq!(cache.error(), None);
assert!(cache.budget_exceeded.is_none());
assert!(cache.line(0).is_empty());
assert!(cache.scope_at(0).is_none());
let mut script = vec!["#!/usr/bin/env python3".to_owned(), long_line.clone()];
cache.update(&script, None, 60);
assert_eq!(cache.language(), Some("Python"));
assert_plain_text_storage(&cache);
script[0] = "#!/usr/bin/env ruby".to_owned();
cache.update(&script, None, 61);
assert_eq!(cache.language(), Some("Ruby"));
assert!(cache.error().unwrap().starts_with("Ruby"));
assert_plain_text_storage(&cache);
script.pop();
cache.update(&script, None, 62);
assert_eq!(cache.language(), Some("Ruby"));
assert_eq!(cache.error(), None);
assert!(cache.tree.is_some());
}
#[test]
fn unicode_minimal_edits_use_byte_points_and_never_split_codepoints() {
let old = "x\n\t\"é 👩‍💻\"\n";
+62
View File
@@ -4612,6 +4612,68 @@ fn hiding_diff_explorer_reclaims_workspace_and_restores_its_geometry() {
assert_eq!(panels.files.unwrap().0, original[0].0);
}
#[test]
fn unavailable_file_documents_show_the_actual_error_in_managed_workspaces() {
let mut file = deletion_file();
file.loaded = false;
let mut diff = diff_comparison(vec![file]);
diff.loading = false;
diff.error = Some("parser.c exceeds 128 MiB file limit".into());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
let mut panels = WheelPanels {
workspace_managed: true,
..WheelPanels::default()
};
terminal
.draw(|frame| {
panels.draw_documents(frame, &diff, frame.area(), None, None);
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("parser.c exceeds 128 MiB file limit"));
assert!(!text.contains("select the file to retry"));
}
#[test]
fn large_generated_additions_render_first_scroll_and_final_rows_without_reparsing() {
let after = (0..25_000)
.map(|row| format!("state_row_{row:05} = 1234567890123456789012345678901234567890;"))
.collect::<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]
fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();