Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8be35e3bd | |||
| b9f29d258c | |||
| adbd2c3f81 |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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())?;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user