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
+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,