fix: keep large-file editing responsive

Bound synchronous full-source syntax parsing, cache dirty comparisons, edit ordinary inline changes in place, retain changed-row undo history, and buffer atomic saves. Skip repository snapshots beyond existing worker limits before copying their text.
This commit is contained in:
2026-10-16 21:14:16 +02:00
parent 02d1422a04
commit adbd2c3f81
13 changed files with 639 additions and 55 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ use std::{
time::{Duration, Instant},
};
const LIMIT: usize = 16 * 1024 * 1024;
pub(crate) const LIMIT: usize = 16 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(5);
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>;
+167 -23
View File
@@ -295,6 +295,40 @@ struct Snapshot {
primary: usize,
}
/// Retain only changed rows between undo groups, not another copy of the file.
#[cfg_attr(test, derive(Debug))]
struct HistoryEntry {
start: usize,
end: usize,
before: Snapshot,
}
impl HistoryEntry {
fn new(mut before: Snapshot, after: &[String]) -> Self {
let start = before
.lines
.iter()
.zip(after)
.take_while(|(a, b)| a == b)
.count();
let suffix = before.lines[start..]
.iter()
.rev()
.zip(after[start..].iter().rev())
.take_while(|(a, b)| a == b)
.count();
before.lines = before
.lines
.drain(start..before.lines.len() - suffix)
.collect();
Self {
start,
end: after.len() - suffix,
before,
}
}
}
struct Edit<'a> {
target: Option<usize>,
start: usize,
@@ -1868,6 +1902,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 +1914,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 +1961,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 +1982,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 +2038,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 +2074,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,15 +2651,18 @@ impl Editor {
let seed = split_lines(projection.seed());
if buffer.lines != seed {
let previous = buffer.replace_lines(seed);
buffer.undo.push(Snapshot {
lines: previous,
targets: if buffer.view.targets.is_empty() {
vec![Target::caret(Cursor::default())]
} else {
buffer.view.targets.clone()
buffer.undo.push(HistoryEntry::new(
Snapshot {
lines: previous,
targets: if buffer.view.targets.is_empty() {
vec![Target::caret(Cursor::default())]
} else {
buffer.view.targets.clone()
},
primary: buffer.view.primary,
},
primary: buffer.view.primary,
});
&buffer.lines,
));
buffer.redo.clear();
buffer.view.clamp(&buffer.lines);
for pane in self.panes.values_mut() {
@@ -2675,12 +2728,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 +3038,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 +5080,7 @@ impl Editor {
buffer.snapshot_document_path = Some(document_path);
buffer.snapshot_root = Some(root);
buffer.lines = lines;
buffer.dirty_cache.set(None);
buffer
}
@@ -10028,6 +10085,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 +10121,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();
}
}
@@ -11369,6 +11429,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 +11441,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 +11451,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 +11475,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;
@@ -11677,13 +11741,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 +11780,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");
}
@@ -11951,6 +12037,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 +13256,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 +13270,15 @@ fn atomic_save(
}
};
let result = (|| {
let mut writer = io::BufWriter::new(&file);
for (index, line) in lines.iter().enumerate() {
if index > 0 {
file.write_all(b"\n")?;
writer.write_all(b"\n")?;
}
file.write_all(line.as_bytes())?;
writer.write_all(line.as_bytes())?;
}
writer.flush()?;
drop(writer);
if let Some(metadata) = metadata {
file.set_permissions(metadata.permissions())?;
}
+3
View File
@@ -109,6 +109,7 @@ impl Editor {
.is_some_and(|binding| binding.owner == self.tab_id)
{
buffer.saved_lines.clone_from(&buffer.lines);
buffer.dirty_cache.set(Some(false));
}
}
}
@@ -268,6 +269,7 @@ impl Editor {
}) {
buffer.review_buffer = None;
buffer.saved_lines.clone_from(&buffer.lines);
buffer.dirty_cache.set(Some(false));
}
}
self.autosave_local_review()?;
@@ -849,6 +851,7 @@ impl Editor {
self.active.lines = split_lines(text);
self.active.saved_lines.clone_from(&self.active.lines);
self.active.text_revision = self.active.text_revision.wrapping_add(1);
self.active.dirty_cache.set(Some(false));
self.normalize_targets();
}
} else {
+1 -1
View File
@@ -19,7 +19,7 @@ use std::{
thread::{self, JoinHandle},
};
const FILE_LIMIT: usize = 16 * 1024 * 1024;
pub(crate) const FILE_LIMIT: usize = 16 * 1024 * 1024;
const BATCH_LIMIT: usize = 64 * 1024 * 1024;
const FILE_COUNT_LIMIT: usize = 1024;
type Files = Vec<(usize, PathBuf, Arc<str>)>;
+37 -9
View File
@@ -425,7 +425,18 @@ struct GutterSource {
save_revision: u64,
path: PathBuf,
dirty: bool,
contents: Arc<str>,
contents: Option<Arc<str>>,
}
fn text_fits(lines: &[String], limit: usize) -> bool {
lines
.iter()
.try_fold(lines.len().saturating_sub(1), |bytes, line| {
bytes
.checked_add(line.len())
.filter(|bytes| *bytes <= limit)
})
.is_some()
}
/// Owns command eligibility and execution hosts together. Frontends can inspect
@@ -1479,8 +1490,11 @@ impl EditorSession {
source.revision == document.revision && source.path == path
})
.map_or_else(
|| Arc::from(document.lines.join("\n")),
|source| Arc::clone(&source.contents),
|| {
text_fits(document.lines, crate::gutter::FILE_LIMIT)
.then(|| Arc::from(document.lines.join("\n")))
},
|source| source.contents.clone(),
);
Some(GutterSource {
buffer: document.id,
@@ -1492,6 +1506,16 @@ impl EditorSession {
})
})
.collect();
for source in &self.gutter_sources {
if source.contents.is_none() {
self.editor.set_gutter_changes(
source.buffer,
source.revision,
&source.path,
Vec::new(),
);
}
}
}
self.gutter_request_id = self.gutter_request_id.wrapping_add(1);
self.last_gutter_refresh = Some(now);
@@ -1508,12 +1532,12 @@ impl EditorSession {
.gutter_sources
.iter()
.filter(|_| line_gutter)
.map(|source| {
(
.filter_map(|source| {
Some((
source.buffer,
source.path.clone(),
Arc::clone(&source.contents),
)
Arc::clone(source.contents.as_ref()?),
))
})
.collect();
let picker = self.picker_source.as_ref().map(|root| {
@@ -1521,7 +1545,9 @@ impl EditorSession {
.gutter_sources
.iter()
.filter(|source| source.dirty)
.map(|source| (source.path.clone(), Arc::clone(&source.contents)))
.filter_map(|source| {
Some((source.path.clone(), Arc::clone(source.contents.as_ref()?)))
})
.collect();
(root.clone(), buffers)
});
@@ -1624,7 +1650,9 @@ impl EditorSession {
self.blame_request_id = self.blame_request_id.wrapping_add(1);
self.blame_lines = None;
changed |= self.editor.clear_line_blame();
if let Some(source) = &self.blame_source {
if let Some(source) = &self.blame_source
&& text_fits(self.editor.lines(), crate::blame::LIMIT)
{
if self.blame_worker.is_none() {
self.blame_worker = BlameWorker::start().ok();
}
+109 -6
View File
@@ -14,6 +14,12 @@ pub(crate) const RUST_ID: &str = "rust";
pub(crate) const RUBY_ID: &str = "ruby";
pub(crate) const PYTHON_ID: &str = "python";
// Full trees and their indexes are optional; editing remains available beyond these budgets.
const MAX_SYNTAX_BYTES: usize = 1024 * 1024;
const MAX_SYNTAX_LINE_BYTES: usize = 64 * 1024;
const SOURCE_BUDGET_NOTICE: &str = "source exceeds 1 MiB syntax budget";
const LINE_BUDGET_NOTICE: &str = "line exceeds 64 KiB syntax budget";
pub(crate) fn is_markdown_path(path: &Path) -> bool {
languages::detect(Some(path), None).is_some_and(|syntax| syntax.name == "Markdown")
}
@@ -69,6 +75,7 @@ pub struct SyntaxCache {
path: Option<PathBuf>,
final_newline: bool,
syntax: Option<&'static LanguageSpec>,
budget_exceeded: Option<&'static str>,
source: String,
next_source: String,
line_starts: Vec<usize>,
@@ -100,7 +107,34 @@ impl SyntaxCache {
}
pub(crate) fn update_text(&mut self, text: &str, path: &Path) {
if self.source == text
// Check lengths before equality, splitting into owned lines, or copying the source.
let budget_exceeded = if text.len() > MAX_SYNTAX_BYTES {
Some(SOURCE_BUDGET_NOTICE)
} else if text
.split('\n')
.any(|line| line.len() > MAX_SYNTAX_LINE_BYTES)
{
Some(LINE_BUDGET_NOTICE)
} else {
None
};
if let Some(reason) = budget_exceeded {
let syntax = languages::detect(Some(path), None)
.or_else(|| languages::detect(None, Some(syntax_prefix(text))));
let language_changed = !same_language(self.syntax, syntax);
self.revision = None;
self.final_newline = false;
if self.path.as_deref() != Some(path) {
self.path = Some(path.to_path_buf());
}
self.syntax = syntax;
if self.budget_exceeded != Some(reason) || language_changed {
self.use_plain_text(reason);
}
return;
}
if self.budget_exceeded.is_none()
&& self.source == text
&& self.path.as_deref() == Some(path)
&& self.highlighted.iter().all(|highlighted| *highlighted)
{
@@ -168,8 +202,11 @@ impl SyntaxCache {
let path_changed = self.path.as_deref() != path;
let revision_changed =
self.revision != Some(revision) || self.final_newline != final_newline;
if self.budget_exceeded.is_some() && !path_changed && !revision_changed {
return;
}
let syntax = if path_changed || revision_changed {
languages::detect(path, lines.first().map(String::as_str))
languages::detect(path, lines.first().map(|line| syntax_prefix(line)))
} else {
self.syntax
};
@@ -181,12 +218,29 @@ impl SyntaxCache {
}
self.syntax = syntax;
let length = if revision_changed {
syntax_source_length(lines, final_newline)
} else if let Some(reason) = self.budget_exceeded {
Err(reason)
} else {
Ok(self.source.len())
};
let length = match length {
Ok(length) => length,
Err(reason) => {
if self.budget_exceeded != Some(reason) || language_changed {
self.use_plain_text(reason);
}
return;
}
};
if self.budget_exceeded.take().is_some() {
self.error = None;
}
let mut edit = None;
if revision_changed {
self.next_source.clear();
let length = lines.iter().map(String::len).sum::<usize>()
+ lines.len().saturating_sub(1)
+ usize::from(final_newline);
self.next_source.reserve(length);
for (row, line) in lines.iter().enumerate() {
if row > 0 {
@@ -289,6 +343,27 @@ impl SyntaxCache {
}
}
fn use_plain_text(&mut self, reason: &'static str) {
// Drop parser arenas as well as source/span capacities when crossing the budget.
*self = Self {
revision: self.revision,
path: self.path.take(),
final_newline: self.final_newline,
syntax: self.syntax,
budget_exceeded: Some(reason),
error: self
.syntax
.map(|syntax| format!("{}: plain text ({reason})", syntax.name)),
#[cfg(test)]
parses: self.parses,
#[cfg(test)]
incremental_parses: self.incremental_parses,
#[cfg(test)]
injection_parses: self.injection_parses,
..Self::default()
};
}
/// Sorted, non-overlapping, end-exclusive ranges. Unclassified text uses the normal foreground.
pub fn line(&self, row: usize) -> &[SyntaxSpan] {
self.lines.get(row).map_or(&[], Vec::as_slice)
@@ -352,7 +427,7 @@ impl SyntaxCache {
self.syntax.map(|syntax| syntax.name)
}
/// Genuine grammar/query failures disable highlighting; ERROR nodes in user text do not.
/// Why highlighting is unavailable: a deliberate plain-text fallback or grammar/query failure.
pub fn error(&self) -> Option<&str> {
self.error.as_deref()
}
@@ -546,6 +621,34 @@ impl SyntaxCache {
}
}
fn syntax_prefix(text: &str) -> &str {
let mut end = text.len().min(MAX_SYNTAX_LINE_BYTES);
while !text.is_char_boundary(end) {
end -= 1;
}
text[..end].split('\n').next().unwrap_or_default()
}
fn syntax_source_length(lines: &[String], final_newline: bool) -> Result<usize, &'static str> {
let mut length = lines
.len()
.saturating_sub(1)
.saturating_add(usize::from(final_newline));
if length > MAX_SYNTAX_BYTES {
return Err(SOURCE_BUDGET_NOTICE);
}
for line in lines {
if line.len() > MAX_SYNTAX_LINE_BYTES {
return Err(LINE_BUDGET_NOTICE);
}
length = length.saturating_add(line.len());
if length > MAX_SYNTAX_BYTES {
return Err(SOURCE_BUDGET_NOTICE);
}
}
Ok(length)
}
fn has_scope_language(language: &str) -> bool {
matches!(
language,