perf: avoid eager diff loading and full-file typing work

This commit is contained in:
2026-10-11 15:06:05 +02:00
parent 7b4bc80538
commit 2e05725a38
11 changed files with 1881 additions and 386 deletions
+580 -31
View File
@@ -44,6 +44,7 @@ pub enum RevisionTarget {
Working,
Commit(String),
Remote { remote: String, reference: String },
ListRemote(String),
FetchRemote(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -78,6 +79,8 @@ pub struct ChangedFile {
pub path: PathBuf,
pub old_path: Option<PathBuf>,
pub status: FileStatus,
/// False means text, EOF flags, hunks and notices have not been prepared.
pub loaded: bool,
pub before: Vec<String>,
pub after: Vec<String>,
pub before_eof: bool,
@@ -91,22 +94,33 @@ pub struct Comparison {
pub after: String,
pub files: Vec<ChangedFile>,
pub root: PathBuf,
/// Regular working-After candidates; loaded binary details are excluded.
pub editable_paths: BTreeSet<PathBuf>,
}
#[derive(Debug)]
pub enum Operation {
Discover,
ListRemote(String),
FetchRemote(String),
Compare {
before: Revision,
after: Revision,
buffers: Box<[(PathBuf, String)]>,
},
LoadFile {
comparison_id: u64,
file_index: usize,
},
}
#[derive(Debug)]
pub enum Outcome {
Catalog(Catalog),
Comparison(Comparison),
File {
comparison_id: u64,
file_index: usize,
file: ChangedFile,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Phase {
@@ -158,6 +172,10 @@ struct SelectorPosition {
enum RequestKind {
Catalog,
Comparison,
File {
comparison_id: u64,
file_index: usize,
},
}
#[derive(Debug)]
@@ -196,6 +214,7 @@ pub struct DiffState {
before_position: Option<SelectorPosition>,
max_before: usize,
max_after: usize,
after_widths: Vec<usize>,
}
impl DiffState {
@@ -235,6 +254,7 @@ impl DiffState {
before_position: None,
max_before: 0,
max_after: 0,
after_widths: Vec::new(),
}
}
@@ -251,6 +271,13 @@ impl DiffState {
fn queue(&mut self, operation: Operation) {
self.request_kind = Some(match operation {
Operation::Compare { .. } => RequestKind::Comparison,
Operation::LoadFile {
comparison_id,
file_index,
} => RequestKind::File {
comparison_id,
file_index,
},
_ => RequestKind::Catalog,
});
self.pending = Some(operation);
@@ -259,6 +286,24 @@ impl DiffState {
}
pub(crate) fn apply_result(&mut self, id: u64, result: Result<Outcome, String>) {
if self.request_kind.is_none() {
return;
}
if let Ok(Outcome::File {
comparison_id,
file_index,
..
}) = &result
&& (self.comparison_id != Some(*comparison_id)
|| self.file != *file_index
|| self.request_kind
!= Some(RequestKind::File {
comparison_id: *comparison_id,
file_index: *file_index,
}))
{
return;
}
let expected = self.request_kind.take();
self.loading = false;
self.pending = None;
@@ -313,8 +358,37 @@ impl DiffState {
} else {
self.first_text_focus()
};
self.align_file();
self.error = None;
self.prepare_selected_file();
}
Ok(Outcome::File {
comparison_id,
file_index,
file,
}) if expected
== Some(RequestKind::File {
comparison_id,
file_index,
}) =>
{
if let Some(comparison) = &mut self.comparison
&& let Some(selected) = comparison.files.get_mut(file_index)
&& selected.path == file.path
&& file.loaded
{
if file
.notice
.as_deref()
.is_some_and(|notice| notice.contains("Binary or non-UTF-8"))
{
comparison.editable_paths.remove(&file.path);
}
*selected = file;
self.align_file();
self.normalize_focus();
self.error = None;
} else {
self.error = Some("Unexpected changed-file response".into());
}
}
Ok(_) => self.error = Some("Unexpected repository response".into()),
Err(error) => self.error = Some(error),
@@ -481,6 +555,8 @@ impl DiffState {
};
if let RevisionTarget::FetchRemote(remote) = &revision.target {
self.queue(Operation::FetchRemote(remote.clone()));
} else if let RevisionTarget::ListRemote(remote) = &revision.target {
self.queue(Operation::ListRemote(remote.clone()));
} else if self.phase == Phase::Before {
self.before_position = Some(SelectorPosition {
category: self.category,
@@ -597,6 +673,9 @@ impl DiffState {
else {
return PaneLayout::Empty;
};
if !file.loaded {
return PaneLayout::Empty;
}
match file.status {
FileStatus::Added => PaneLayout::After,
FileStatus::Removed => PaneLayout::Before,
@@ -697,16 +776,20 @@ impl DiffState {
pub fn after_editable(&self) -> bool {
self.phase == Phase::View
&& self.selected_file_loaded()
&& matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
&& self
.after
.as_ref()
.is_some_and(|revision| revision.target == RevisionTarget::Working)
&& self.comparison.as_ref().is_some_and(|comparison| {
comparison
.files
.get(self.file)
.is_some_and(|file| comparison.editable_paths.contains(&file.path))
comparison.files.get(self.file).is_some_and(|file| {
comparison.editable_paths.contains(&file.path)
&& file
.notice
.as_deref()
.is_none_or(|notice| !notice.contains("Binary or non-UTF-8"))
})
})
}
@@ -725,10 +808,40 @@ impl DiffState {
self.live_buffer = None;
self.live_revision = None;
self.follow_caret = false;
self.align_file();
self.prepare_selected_file();
true
}
pub fn selected_file_loaded(&self) -> bool {
self.comparison
.as_ref()
.and_then(|comparison| comparison.files.get(self.file))
.is_some_and(|file| file.loaded)
}
fn prepare_selected_file(&mut self) {
self.pending = None;
self.request_kind = None;
self.loading = false;
self.error = None;
if self.selected_file_loaded() {
self.align_file();
} else {
self.align_file();
if let Some(comparison_id) = self.comparison_id
&& self
.comparison
.as_ref()
.is_some_and(|comparison| !comparison.files.is_empty())
{
self.queue(Operation::LoadFile {
comparison_id,
file_index: self.file,
});
}
}
}
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
self.display_rows.iter().position(|display| {
matches!(*display, DisplayRow::Line(index) if self.source_row(focus, index) == Some(source_row))
@@ -829,6 +942,24 @@ impl DiffState {
}
let file = &self.comparison.as_ref().unwrap().files[self.file];
let was_live = self.live_files.contains(&self.file);
let changed_lines = changed_line_range(&file.after, lines);
if was_live && changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2 {
self.live_revision = Some(revision);
return;
}
if was_live && stable_after_edit(file, lines, changed_lines) {
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
replace_after_lines(
&mut file.after,
&mut self.after_widths,
&mut self.max_after,
lines,
changed_lines,
);
self.left_after = self.left_after.min(self.max_after);
self.live_revision = Some(revision);
return;
}
let mut old = file.after.iter().map(String::as_str).collect::<Vec<_>>();
if !was_live && (file.after_eof || old.is_empty()) {
old.push("");
@@ -870,10 +1001,13 @@ impl DiffState {
.map(map_after);
let was_changed = file_is_changed(file);
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
file.after.resize_with(lines.len(), String::new);
for (target, line) in file.after.iter_mut().zip(lines) {
target.clone_from(line);
}
replace_after_lines(
&mut file.after,
&mut self.after_widths,
&mut self.max_after,
lines,
changed_lines,
);
file.after_eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
file.hunks = line_hunks(
&file.before,
@@ -946,35 +1080,31 @@ impl DiffState {
self.left_before = 0;
self.left_after = 0;
self.max_before = 0;
self.max_after = 0;
self.after_widths.clear();
}
self.max_after = 0;
let Some(file) = self
.comparison
.as_ref()
.and_then(|comparison| comparison.files.get(self.file))
.filter(|file| file.loaded)
else {
self.after_widths.clear();
self.max_before = 0;
self.max_after = 0;
return;
};
let width = |lines: &[String]| {
lines
.iter()
.map(|line| {
line.graphemes(true).fold(0, |column, grapheme| {
column
+ if grapheme == "\t" {
4 - column % 4
} else {
UnicodeWidthStr::width(grapheme)
}
})
})
.max()
.unwrap_or(0)
};
if reset {
self.max_before = width(&file.before);
self.max_before = file
.before
.iter()
.map(|line| line_width(line))
.max()
.unwrap_or(0);
self.after_widths
.extend(file.after.iter().map(|line| line_width(line)));
self.max_after = self.after_widths.iter().copied().max().unwrap_or(0);
}
self.max_after = width(&file.after);
let after_len = if self.live_files.contains(&self.file) {
live_content(&file.after, file.after_eof).len()
} else {
@@ -1182,6 +1312,89 @@ fn append_rows(
}
}
fn line_width(line: &str) -> usize {
#[cfg(test)]
tests::WIDTH_SCANS.with(|scans| scans.set(scans.get() + 1));
line.graphemes(true).fold(0, |column, grapheme| {
column
+ if grapheme == "\t" {
4 - column % 4
} else {
UnicodeWidthStr::width(grapheme)
}
})
}
fn changed_line_range(old: &[String], new: &[String]) -> (usize, usize, usize) {
let start = old
.iter()
.zip(new)
.take_while(|(old, new)| old == new)
.count();
let suffix = old[start..]
.iter()
.rev()
.zip(new[start..].iter().rev())
.take_while(|(old, new)| old == new)
.count();
(start, old.len() - suffix, new.len() - suffix)
}
fn stable_after_edit(
file: &ChangedFile,
lines: &[String],
(start, old_end, new_end): (usize, usize, usize),
) -> bool {
if old_end != start + 1
|| new_end != start + 1
|| file.after_eof != (lines.len() > 1 && lines.last().is_some_and(String::is_empty))
|| live_content(&file.after, file.after_eof).len()
!= live_content(lines, file.after_eof).len()
{
return false;
}
let old = &file.after[start];
let new = &lines[start];
// With neither token matching another line, the equality graph seen by Myers
// is unchanged. Hunks, alignment, excerpts and scroll anchors remain exact.
!file.before.iter().any(|line| line == old || line == new)
&& !file
.after
.iter()
.enumerate()
.any(|(index, line)| index != start && (line == old || line == new))
}
fn replace_after_lines(
after: &mut Vec<String>,
widths: &mut Vec<usize>,
max_width: &mut usize,
lines: &[String],
(start, old_end, new_end): (usize, usize, usize),
) {
let removed_max = widths[start..old_end].iter().copied().max().unwrap_or(0);
if old_end == new_end {
for index in start..new_end {
if after[index] != lines[index] {
after[index].clone_from(&lines[index]);
widths[index] = line_width(&lines[index]);
}
}
} else {
after.splice(start..old_end, lines[start..new_end].iter().cloned());
widths.splice(
start..old_end,
lines[start..new_end].iter().map(|line| line_width(line)),
);
}
let added_max = widths[start..new_end].iter().copied().max().unwrap_or(0);
*max_width = if removed_max == *max_width && added_max < removed_max {
widths.iter().copied().max().unwrap_or(0)
} else {
(*max_width).max(added_max)
};
}
fn live_content(lines: &[String], eof: bool) -> &[String] {
if eof || (lines.len() == 1 && lines[0].is_empty()) {
&lines[..lines.len().saturating_sub(1)]
@@ -1220,7 +1433,8 @@ pub(crate) fn line_hunks(
}
fn file_is_changed(file: &ChangedFile) -> bool {
file.status != FileStatus::Modified
!file.loaded
|| file.status != FileStatus::Modified
|| !file.hunks.is_empty()
|| file.notice.as_ref().is_some_and(|notice| {
notice
@@ -1269,6 +1483,9 @@ fn update_eof_notice(file: &mut ChangedFile) {
mod tests {
use super::*;
use crate::editor::{Editor, Effect, Mode};
std::thread_local! {
pub(super) static WIDTH_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn excerpt_state(len: usize, changed: &[usize]) -> DiffState {
let mut state = DiffState::new();
@@ -1583,6 +1800,7 @@ mod tests {
path: "renamed.txt".into(),
old_path: Some("old.txt".into()),
status: FileStatus::Renamed,
loaded: true,
before: vec![
"same".into(),
"old".into(),
@@ -1619,6 +1837,7 @@ mod tests {
path: "added.txt".into(),
old_path: None,
status: FileStatus::Added,
loaded: true,
before: vec![],
after: vec!["a".into(), "b".into()],
before_eof: false,
@@ -1635,6 +1854,7 @@ mod tests {
path: "removed.bin".into(),
old_path: None,
status: FileStatus::Removed,
loaded: true,
before: vec![],
after: vec![],
before_eof: false,
@@ -2134,6 +2354,95 @@ mod tests {
assert_eq!(editor.diff().unwrap().comparison_id, Some(second));
}
#[test]
fn closing_diff_rejects_pending_file_details_even_after_reopening() {
let mut editor = editor();
open(&mut editor);
let comparison_id = choose_pair(&mut editor);
let mut catalog = comparison();
let detail = catalog.files.remove(0);
catalog.files.clear();
catalog.files.push(ChangedFile {
path: detail.path.clone(),
old_path: detail.old_path.clone(),
status: detail.status,
loaded: false,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: None,
});
editor.apply_diff_result(comparison_id, Ok(Outcome::Comparison(catalog)));
let (file_request, _) = editor.take_diff_request().unwrap();
editor.execute("diff.close");
assert!(!editor.apply_diff_result(
file_request,
Ok(Outcome::File {
comparison_id,
file_index: 0,
file: detail,
})
));
assert!(editor.diff().is_none());
open(&mut editor);
assert!(editor.diff().unwrap().comparison.is_none());
}
#[test]
fn explicit_remote_ref_loader_preserves_selector_position_without_choosing_revision() {
let mut state = DiffState::new();
let mut initial = catalog();
let loader = initial
.entries
.iter_mut()
.find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_)))
.unwrap();
loader.target = RevisionTarget::ListRemote("origin".into());
loader.label = "Load refs from origin".into();
state.apply_result(1, Ok(Outcome::Catalog(initial)));
state.phase = Phase::After;
state.query = "origin".into();
state.refilter();
state.selected = 1;
state.choose();
assert!(
matches!(state.take_request(), Some(Operation::ListRemote(remote)) if remote == "origin")
);
let mut loaded = catalog();
let loader = loaded
.entries
.iter_mut()
.find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_)))
.unwrap();
loader.target = RevisionTarget::ListRemote("origin".into());
loader.label = "Load refs from origin".into();
loaded.entries.push(revision(
RevisionTarget::Remote {
remote: "origin".into(),
reference: "refs/heads/new".into(),
},
"new@origin",
"",
Category::Branches,
));
state.apply_result(2, Ok(Outcome::Catalog(loaded)));
assert_eq!(state.phase, Phase::After);
assert_eq!(state.query, "origin");
assert_eq!(state.category, 0);
assert_eq!(state.selected, 1);
assert!(state.after.is_none());
assert!(!state.loading);
state.category = 1;
state.refilter();
assert!(
state
.entries()
.any(|entry| matches!(entry.target, RevisionTarget::Remote { .. }))
);
}
#[test]
fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() {
let mut editor = editor();
@@ -2309,6 +2618,7 @@ mod tests {
before_eof: eof,
after_eof: eof,
notice: None,
loaded: true,
}],
});
state.files = Some(FilePicker::from_paths(
@@ -2319,6 +2629,147 @@ mod tests {
state
}
#[test]
fn reverting_loaded_file_keeps_unloaded_changes_selectable() {
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
state.comparison_id = Some(17);
state.pending = None;
state.comparison.as_mut().unwrap().files.push(ChangedFile {
path: "unloaded.txt".into(),
old_path: None,
status: FileStatus::Modified,
loaded: false,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: None,
});
state.refresh_after(&["base".into()], 1);
assert_eq!(
state
.files
.as_ref()
.unwrap()
.entries()
.map(|entry| entry.path)
.collect::<Vec<_>>(),
[Path::new("unloaded.txt")],
);
assert!(state.select_file(Path::new("unloaded.txt")));
assert!(matches!(
state.take_request(),
Some(Operation::LoadFile {
comparison_id: 17,
file_index: 1
})
));
}
#[test]
fn live_width_cache_scans_only_changed_lines_and_shrinks_after_deletion() {
let base = vec!["original".into(), "medium".into(), "short".into()];
let mut lines = vec!["界\tz".into(), "medium".into(), "short".into()];
let mut state = live_state(base, lines.clone(), false);
state.refresh_after(&lines, 1);
assert_eq!(state.after_widths, [5, 6, 5]);
lines[0] = "界界\t🙂".into();
WIDTH_SCANS.set(0);
state.refresh_after(&lines, 2);
assert_eq!(WIDTH_SCANS.get(), 1);
assert_eq!(state.max_after, 10);
state.left_after = 9;
lines[0] = "界".into();
WIDTH_SCANS.set(0);
state.refresh_after(&lines, 3);
assert_eq!(WIDTH_SCANS.get(), 1);
assert_eq!(state.max_after, 6);
assert_eq!(state.left_after, 6);
lines.remove(1);
WIDTH_SCANS.set(0);
state.refresh_after(&lines, 4);
assert_eq!(WIDTH_SCANS.get(), 0);
assert_eq!(state.after_widths, [2, 5]);
assert_eq!(state.max_after, 5);
lines.insert(1, "巨大\t🙂".into());
WIDTH_SCANS.set(0);
state.refresh_after(&lines, 5);
assert_eq!(WIDTH_SCANS.get(), 1);
assert_eq!(state.after_widths, [2, 10, 5]);
lines = vec!["".into()];
state.refresh_after(&lines, 6);
assert_eq!(state.after_widths, [0]);
assert_eq!(state.max_after, 0);
assert_eq!(state.left_after, 0);
}
#[test]
fn stable_line_edits_keep_fresh_hunks_expansion_and_scroll_anchor_until_revert() {
let base = (0..100)
.map(|row| format!("line {row}"))
.collect::<Vec<_>>();
let mut lines = base.clone();
lines[30] = "first change".into();
let mut state = live_state(base.clone(), lines.clone(), true);
state.set_context_options(2, 5);
lines.push(String::new());
state.refresh_after(&lines, 1);
state.top = state.after_display_row(30).unwrap();
state.handle(Input::Char('['));
let display = state.display_rows.clone();
let excerpts = state.excerpt_ranges.clone();
let top = state.top;
lines[30].push('!');
let file = &state.comparison.as_ref().unwrap().files[0];
assert!(stable_after_edit(
file,
&lines,
changed_line_range(&file.after, &lines)
));
WIDTH_SCANS.set(0);
state.refresh_after(&lines, 2);
assert_eq!(WIDTH_SCANS.get(), 1);
assert_eq!(state.display_rows, display);
assert_eq!(state.excerpt_ranges, excerpts);
assert_eq!(state.top, top);
let file = &state.comparison.as_ref().unwrap().files[0];
let hunk_ranges = |hunks: &[Hunk]| {
hunks
.iter()
.map(|hunk| {
(
hunk.before_start,
hunk.before_len,
hunk.after_start,
hunk.after_len,
)
})
.collect::<Vec<_>>()
};
assert_eq!(
hunk_ranges(&file.hunks),
hunk_ranges(&line_hunks(&base, true, live_content(&lines, true), true))
);
lines[30].clone_from(&base[30]);
assert!(!stable_after_edit(
file,
&lines,
changed_line_range(&file.after, &lines)
));
state.refresh_after(&lines, 3);
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal));
assert!(state.after_display_row(23).is_some());
assert_eq!(state.top, state.after_display_row(30).unwrap());
assert_eq!(state.files.as_ref().unwrap().entries().len(), 0);
lines[50] = "changed again".into();
state.refresh_after(&lines, 4);
assert_eq!(state.files.as_ref().unwrap().entries().len(), 1);
assert!(state.after_display_row(23).is_some());
assert!(state.after_display_row(50).is_some());
}
#[test]
fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() {
let base = (0..100)
@@ -2398,4 +2849,102 @@ mod tests {
empty.handle(Input::Escape);
assert_eq!(empty.phase, Phase::View);
}
#[test]
fn lazy_selection_waits_for_details_rejects_stale_files_and_reuses_loaded_cache() {
let mut state = DiffState::new();
state.pending = None;
state.request_kind = Some(RequestKind::Comparison);
let mut catalog = comparison();
catalog.files.truncate(2);
let details: Vec<_> = catalog.files.drain(..).collect();
for detail in &details {
catalog.files.push(ChangedFile {
path: detail.path.clone(),
old_path: detail.old_path.clone(),
status: detail.status,
loaded: false,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: None,
});
}
state.apply_result(41, Ok(Outcome::Comparison(catalog)));
assert!(!state.selected_file_loaded());
assert!(state.rows.is_empty() && state.display_rows.is_empty());
assert_eq!(state.pane_layout(), PaneLayout::Empty);
assert!(!state.after_editable());
assert!(matches!(
state.take_request(),
Some(Operation::LoadFile {
comparison_id: 41,
file_index: 0,
})
));
assert!(state.select_file(Path::new("added.txt")));
assert!(matches!(
state.take_request(),
Some(Operation::LoadFile {
comparison_id: 41,
file_index: 1,
})
));
let mut details = details.into_iter();
let first = details.next().unwrap();
let second = details.next().unwrap();
state.apply_result(
42,
Ok(Outcome::File {
comparison_id: 40,
file_index: 1,
file: first,
}),
);
assert!(state.loading && !state.selected_file_loaded());
state.apply_result(
43,
Ok(Outcome::File {
comparison_id: 41,
file_index: 1,
file: second,
}),
);
assert!(state.selected_file_loaded());
assert_eq!(state.pane_layout(), PaneLayout::After);
assert!(!state.rows.is_empty());
assert!(state.select_file(Path::new("renamed.txt")));
assert!(state.loading);
assert!(state.select_file(Path::new("added.txt")));
assert!(!state.loading && state.selected_file_loaded());
assert!(state.take_request().is_none());
state.apply_result(44, Err("cancelled old load".into()));
assert!(state.error.is_none() && state.selected_file_loaded());
}
#[test]
fn failed_lazy_file_load_can_be_retried_without_reopening_comparison() {
let mut state = DiffState::new();
state.pending = None;
state.request_kind = Some(RequestKind::Comparison);
let mut catalog = comparison();
catalog.files.truncate(1);
catalog.files[0].loaded = false;
state.apply_result(7, Ok(Outcome::Comparison(catalog)));
state.take_request();
state.apply_result(8, Err("cannot read blob".into()));
assert_eq!(state.error.as_deref(), Some("cannot read blob"));
assert!(!state.selected_file_loaded());
assert!(state.select_file(Path::new("renamed.txt")));
assert!(state.loading && state.error.is_none());
assert!(matches!(
state.take_request(),
Some(Operation::LoadFile {
comparison_id: 7,
file_index: 0,
})
));
}
}
+3 -1
View File
@@ -1551,7 +1551,7 @@ impl Editor {
return;
}
let Some(diff) = &self.diff else { return };
if diff.phase != Phase::View {
if diff.phase != Phase::View || !diff.selected_file_loaded() {
return;
}
let Some(comparison_id) = diff.comparison_id else {
@@ -7436,6 +7436,7 @@ mod tests {
let text = fs::read_to_string(root.join(path)).unwrap();
let lines: Vec<String> = text.lines().map(str::to_owned).collect();
ChangedFile {
loaded: true,
path: PathBuf::from(*path),
old_path: None,
status: FileStatus::Modified,
@@ -7494,6 +7495,7 @@ mod tests {
after: "head".into(),
editable_paths: Default::default(),
files: vec![ChangedFile {
loaded: true,
path: "file.txt".into(),
old_path: None,
status: FileStatus::Modified,
+297 -122
View File
@@ -297,10 +297,18 @@ struct Remote {
struct FileData {
mode: String,
bytes: Vec<u8>,
object: Option<(PathBuf, String)>,
notice: Option<String>,
}
type Snapshot = BTreeMap<PathBuf, FileData>;
struct PreparedComparison {
id: u64,
left: Snapshot,
right: Snapshot,
files: Vec<(PathBuf, Option<PathBuf>, FileStatus)>,
}
pub struct Repository {
root: PathBuf,
kind: RepoKind,
@@ -311,6 +319,7 @@ pub struct Repository {
remote_refs: BTreeMap<(String, String), String>,
loaded: BTreeSet<String>,
registered: BTreeSet<String>,
comparison: Option<PreparedComparison>,
cancelled: Arc<AtomicBool>,
}
impl Repository {
@@ -348,6 +357,7 @@ impl Repository {
remote_refs: BTreeMap::new(),
loaded: BTreeSet::new(),
registered: BTreeSet::new(),
comparison: None,
cancelled,
})
}
@@ -562,42 +572,53 @@ impl Repository {
self.revisions(root, store, &mut entries, &mut notices)?;
}
for remote in &self.remotes {
entries.push(Revision {
target: RevisionTarget::FetchRemote(remote.name.clone()),
label: format!("Load history from {}", remote.name),
description: "Fetch reachable history into a private comparison repository".into(),
category,
});
match self.advertised(&remote.url) {
Ok(refs) => {
self.remote_refs.retain(|(name, _), _| name != &remote.name);
for (reference, id) in refs {
let tag = reference.starts_with("refs/tags/");
let name = reference
.strip_prefix(if tag { "refs/tags/" } else { "refs/heads/" })
.unwrap();
if !tag || self.kind == RepoKind::Git {
entries.push(Revision {
target: RevisionTarget::Remote {
remote: remote.name.clone(),
reference: reference.clone(),
},
label: format!("{name}@{}", remote.name),
description: id.clone(),
category: if tag {
Category::Tags
} else if self.kind == RepoKind::Jujutsu {
Category::Bookmarks
} else {
Category::Branches
},
});
}
self.remote_refs
.insert((remote.name.clone(), reference), id);
for &view in self.kind.categories() {
let history = view == category;
entries.push(Revision {
target: if history {
RevisionTarget::FetchRemote(remote.name.clone())
} else {
RevisionTarget::ListRemote(remote.name.clone())
},
label: format!(
"Load {} from {}",
if history { "history" } else { "refs" },
remote.name
),
description: if history {
"Fetch reachable history into a private comparison repository"
} else {
"List remote references without fetching history"
}
.into(),
category: view,
});
}
for ((name, reference), id) in &self.remote_refs {
if name != &remote.name {
continue;
}
let tag = reference.starts_with("refs/tags/");
let label = reference
.strip_prefix(if tag { "refs/tags/" } else { "refs/heads/" })
.unwrap();
if !tag || self.kind == RepoKind::Git {
entries.push(Revision {
target: RevisionTarget::Remote {
remote: name.clone(),
reference: reference.clone(),
},
label: format!("{label}@{name}"),
description: id.clone(),
category: if tag {
Category::Tags
} else if self.kind == RepoKind::Jujutsu {
Category::Bookmarks
} else {
Category::Branches
},
});
}
Err(e) => notices.push(format!("{}: {e}", remote.name)),
}
}
self.check_cancelled()?;
@@ -645,7 +666,7 @@ impl Repository {
}
Ok(())
}
fn load_remote(&mut self, name: &str) -> Result<(), String> {
fn load_remote(&mut self, name: &str, reference: Option<&str>) -> Result<(), String> {
if self.remotes.is_empty() {
self.discover_remotes()?;
}
@@ -674,7 +695,27 @@ impl Repository {
"--remote",
&format!("exact:{name}"),
"--branch",
"glob:*",
&reference.map_or_else(
|| Ok("glob:*".to_owned()),
|reference| {
reference
.strip_prefix("refs/heads/")
.map(|branch| format!("exact:{branch}"))
.ok_or("Jujutsu remote selection requires a bookmark")
},
)?,
],
)?;
} else if let Some(reference) = reference {
self.git(
store,
&[
"fetch",
"--no-tags",
"--no-write-fetch-head",
"--",
&url,
&format!("+{reference}:refs/vamp-selected/{name}/{reference}"),
],
)?;
} else {
@@ -690,16 +731,33 @@ impl Repository {
],
)?;
}
self.loaded.insert(name.into());
let refs = self.advertised(&url)?;
self.remote_refs.retain(|(remote, _), _| remote != name);
for (reference, id) in refs {
self.remote_refs.insert((name.into(), reference), id);
if reference.is_none() {
self.loaded.insert(name.into());
let refs = self.advertised(&url)?;
self.remote_refs.retain(|(remote, _), _| remote != name);
for (reference, id) in refs {
self.remote_refs.insert((name.into(), reference), id);
}
}
Ok(())
}
pub fn list_remote(&mut self, name: &str) -> Result<Catalog, String> {
self.discover_remotes()?;
let remote = self
.remotes
.iter()
.find(|remote| remote.name == name)
.ok_or_else(|| format!("Unknown configured remote: {name}"))?;
let refs = self.advertised(&remote.url)?;
self.remote_refs.retain(|(remote, _), _| remote != name);
self.remote_refs.extend(
refs.into_iter()
.map(|(reference, id)| ((name.into(), reference), id)),
);
self.catalog()
}
pub fn fetch_remote(&mut self, remote: &str) -> Result<Catalog, String> {
self.load_remote(remote)?;
self.load_remote(remote, None)?;
self.catalog()
}
fn resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> {
@@ -719,22 +777,33 @@ impl Repository {
}
id.clone()
}
RevisionTarget::Remote { remote, reference } => {
if !self.loaded.contains(remote) {
self.load_remote(remote)?;
}
self.remote_refs
.get(&(remote.clone(), reference.clone()))
.ok_or_else(|| {
format!("Remote reference is no longer advertised: {reference}@{remote}")
})?
.clone()
}
RevisionTarget::Remote { remote, reference } => self
.remote_refs
.get(&(remote.clone(), reference.clone()))
.ok_or_else(|| {
format!("Remote reference is no longer advertised: {reference}@{remote}")
})?
.clone(),
RevisionTarget::Working => return Err("Working checkout is not a commit".into()),
RevisionTarget::FetchRemote(_) => {
RevisionTarget::FetchRemote(_) | RevisionTarget::ListRemote(_) => {
return Err("Select a revision, not a history loader".into());
}
};
if let Some(resolved) = self.resolve_stored(&id)? {
return Ok(resolved);
}
if let RevisionTarget::Remote { remote, reference } = target {
self.load_remote(remote, Some(reference))?;
if let Some(resolved) = self.resolve_stored(&id)? {
return Ok(resolved);
}
}
Err(format!(
"Commit {id} is unavailable locally or in loaded remote history"
))
}
fn resolve_stored(&self, id: &str) -> Result<Option<(PathBuf, String)>, String> {
for store in
std::iter::once(&self.store).chain(self.private.as_ref().map(|(_, store)| store))
{
@@ -743,15 +812,13 @@ impl Repository {
.current_dir(&self.root);
let output = run(cmd, vec![], &self.cancelled)?;
if output.code == Some(0) {
return Ok((store.clone(), text(output.stdout)?.trim().into()));
return Ok(Some((store.clone(), text(output.stdout)?.trim().into())));
}
if !matches!(output.code, Some(1 | 128)) {
checked(output)?;
}
}
Err(format!(
"Commit {id} is unavailable locally or in loaded remote history"
))
Ok(None)
}
fn conflicted_snapshot(&self, root: &Path, id: &str) -> Result<Snapshot, String> {
let revset = format!("commit_id({})", serde_json::to_string(id).unwrap());
@@ -820,6 +887,7 @@ impl Repository {
FileData {
mode: mode.into(),
bytes,
object: None,
notice,
},
);
@@ -853,8 +921,7 @@ impl Repository {
}
}
let output = self.git(store, &["ls-tree", "-r", "-z", "--full-tree", id])?;
let mut entries = vec![];
let mut request = Vec::new();
let mut snapshot = BTreeMap::new();
for record in output.split(|b| *b == 0).filter(|r| !r.is_empty()) {
let tab = record
.iter()
@@ -868,32 +935,60 @@ impl Repository {
}
let path = path_bytes(&record[tab + 1..])?;
safe_path(&path)?;
if fields[1] == "commit" {
entries.push((
path,
fields[0].to_owned(),
let (object, notice) = if fields[1] == "commit" {
(
None,
Some(format!(
"Submodule commit {} (contents are not traversed)",
fields[2]
)),
));
)
} else if fields[1] == "blob" {
request.extend_from_slice(fields[2].as_bytes());
request.push(b'\n');
entries.push((path, fields[0].to_owned(), Some(fields[2].to_owned()), None));
(Some((store.to_path_buf(), fields[2].to_owned())), None)
} else {
return Err(format!("Unsupported tree entry type: {}", fields[1]));
}
};
snapshot.insert(
path,
FileData {
mode: fields[0].into(),
bytes: vec![],
object,
notice,
},
);
}
let mut cmd = git_command(store);
cmd.arg("cat-file").arg("--batch");
let data = checked(run(cmd, request, &self.cancelled)?)?;
let mut offset = 0;
let mut snapshot = BTreeMap::new();
for (path, mode, object, notice) in entries {
self.check_cancelled()?;
let bytes = if object.is_some() {
Ok(snapshot)
}
fn materialize(&self, snapshot: &mut Snapshot) -> Result<(), String> {
let stores: BTreeSet<_> = snapshot
.values()
.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
{
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
.object
.as_ref()
.is_none_or(|(source, _)| source != &store)
{
continue;
}
self.check_cancelled()?;
let end = offset
+ data[offset..]
.iter()
@@ -917,22 +1012,12 @@ impl Repository {
if size >= data.len().saturating_sub(offset) {
return Err("Truncated Git object content".into());
}
let bytes = data[offset..offset + size].to_vec();
file.bytes = data[offset..offset + size].to_vec();
file.object = None;
offset += size + 1;
bytes
} else {
vec![]
};
snapshot.insert(
path,
FileData {
mode,
bytes,
notice,
},
);
}
}
Ok(snapshot)
Ok(())
}
fn working_snapshot(&self, buffers: &[(PathBuf, String)]) -> Result<Snapshot, String> {
let mut tracked = BTreeSet::new();
@@ -1100,6 +1185,7 @@ impl Repository {
FileData {
mode: "160000".into(),
bytes: vec![],
object: None,
notice: Some(notice),
},
);
@@ -1178,6 +1264,7 @@ impl Repository {
FileData {
mode,
bytes,
object: None,
notice,
},
);
@@ -1192,38 +1279,72 @@ impl Repository {
self.commit_snapshot(&store, &id)
}
}
/// Freeze comparison sources and return path/status metadata only.
/// A new comparison replaces sources retained for the previous request ID.
pub fn compare(
&mut self,
comparison_id: u64,
before: &Revision,
after: &Revision,
buffers: &[(PathBuf, String)],
) -> Result<Comparison, String> {
let left = self.snapshot(&before.target)?;
self.comparison = None;
let mut left = self.snapshot(&before.target)?;
let working_after = after.target == RevisionTarget::Working;
let right = if before.target == after.target && (!working_after || buffers.is_empty()) {
None
let mut right = if before.target == after.target && (!working_after || buffers.is_empty()) {
BTreeMap::new()
} else if working_after {
Some(self.working_snapshot(buffers)?)
self.working_snapshot(buffers)?
} else {
Some(self.snapshot(&after.target)?)
self.snapshot(&after.target)?
};
let right = right.as_ref().unwrap_or(&left);
let same = before.target == after.target && (!working_after || buffers.is_empty());
// Frozen checkout/conflict bytes have no object identity. Compare them in a
// single batch, while immutable tree/tree comparisons need only object 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(&mut left)?;
self.materialize(&mut right)?;
}
let right_view = if same { &left } else { &right };
let mut files = vec![];
let paths: BTreeSet<_> = left.keys().chain(right.keys()).collect();
let paths: BTreeSet<_> = left.keys().chain(right_view.keys()).collect();
for path in paths {
self.check_cancelled()?;
let a = left.get(path);
let b = right.get(path);
if a == b {
let b = right_view.get(path);
if a.map(file_identity) == b.map(file_identity) {
continue;
}
files.push(self.changed_file(path, a, b)?);
files.push(ChangedFile {
path: path.clone(),
old_path: None,
status: if a.is_none() {
FileStatus::Added
} else if b.is_none() {
FileStatus::Removed
} else {
FileStatus::Modified
},
loaded: false,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: None,
});
}
// Match only identical data; modified-content moves remain add/remove pairs.
let mut candidates: BTreeMap<&FileData, Vec<usize>> = BTreeMap::new();
let mut candidates = BTreeMap::new();
for (i, file) in files.iter().enumerate() {
if file.status == FileStatus::Removed {
candidates.entry(&left[&file.path]).or_default().push(i);
candidates
.entry(file_identity(&left[&file.path]))
.or_insert_with(Vec::new)
.push(i);
}
}
let mut removed = BTreeSet::new();
@@ -1232,18 +1353,11 @@ impl Repository {
continue;
}
if let Some(j) = candidates
.get_mut(&right[&files[i].path])
.get_mut(&file_identity(&right_view[&files[i].path]))
.and_then(Vec::pop)
{
let old_path = files[j].path.clone();
let mut renamed = self.changed_file(
&files[i].path,
left.get(&old_path),
right.get(&files[i].path),
)?;
renamed.status = FileStatus::Renamed;
renamed.old_path = Some(old_path);
files[i] = renamed;
files[i].status = FileStatus::Renamed;
files[i].old_path = Some(files[j].path.clone());
removed.insert(j);
}
}
@@ -1256,21 +1370,24 @@ impl Repository {
files
.iter()
.filter(|file| {
right.get(&file.path).is_some_and(|data| {
matches!(data.mode.as_str(), "100644" | "100755")
&& !data.bytes.contains(&0)
&& std::str::from_utf8(&data.bytes).is_ok()
&& file
.notice
.as_deref()
.is_none_or(|notice| !notice.contains("Binary or non-UTF-8"))
})
right_view
.get(&file.path)
.is_some_and(|data| matches!(data.mode.as_str(), "100644" | "100755"))
})
.map(|file| file.path.clone())
.collect()
} else {
BTreeSet::new()
};
self.comparison = Some(PreparedComparison {
id: comparison_id,
left,
right,
files: files
.iter()
.map(|file| (file.path.clone(), file.old_path.clone(), file.status))
.collect(),
});
Ok(Comparison {
before: before.label.clone(),
after: after.label.clone(),
@@ -1279,6 +1396,45 @@ impl Repository {
editable_paths,
})
}
/// Prepare one catalog entry using its original comparison's frozen sources.
pub fn load_file(
&mut self,
comparison_id: u64,
file_index: usize,
) -> Result<ChangedFile, String> {
self.check_cancelled()?;
let prepared = self
.comparison
.as_ref()
.filter(|comparison| comparison.id == comparison_id)
.ok_or("Comparison is no longer available")?;
let (path, old_path, status) = prepared
.files
.get(file_index)
.ok_or("Changed file is no longer available")?;
let before_path = old_path.as_ref().unwrap_or(path);
let mut left = BTreeMap::new();
let mut right = BTreeMap::new();
let before = prepared.left.get(before_path);
let after = prepared.right.get(path);
if let Some(data) = before.filter(|data| data.object.is_some()) {
left.insert(before_path.clone(), clone_file_data(data));
}
if let Some(data) = after.filter(|data| data.object.is_some()) {
right.insert(path.clone(), clone_file_data(data));
}
self.materialize(&mut left)?;
self.materialize(&mut right)?;
let mut file = self.changed_file(
path,
left.get(before_path).or(before),
right.get(path).or(after),
)?;
file.status = *status;
file.old_path = old_path.clone();
Ok(file)
}
fn changed_file(
&self,
path: &Path,
@@ -1337,6 +1493,7 @@ impl Repository {
path: path.to_path_buf(),
old_path: None,
status,
loaded: true,
before: before_lines,
after: after_lines,
before_eof: a.ends_with(b"\n"),
@@ -1350,6 +1507,24 @@ impl Repository {
})
}
}
fn file_identity(file: &FileData) -> (&str, Option<&str>, &[u8], Option<&str>) {
(
&file.mode,
file.object.as_ref().map(|(_, id)| id.as_str()),
&file.bytes,
file.notice.as_deref(),
)
}
fn clone_file_data(file: &FileData) -> FileData {
FileData {
mode: file.mode.clone(),
bytes: file.bytes.clone(),
object: file.object.clone(),
notice: file.notice.clone(),
}
}
fn lines(bytes: &[u8]) -> Vec<String> {
if bytes.is_empty() {
return vec![];
+25 -12
View File
@@ -5,7 +5,7 @@ use std::{
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc::{self, Receiver, SyncSender},
mpsc::{self, Receiver, Sender},
},
thread::{self, JoinHandle},
};
@@ -25,7 +25,7 @@ use crate::{
type DiffResult = (u64, Result<DiffOutcome, String>);
struct DiffWorker {
requests: Option<SyncSender<(u64, DiffOperation)>>,
requests: Option<Sender<(u64, DiffOperation)>>,
results: Option<Receiver<DiffResult>>,
cancelled: Arc<AtomicBool>,
stopped: Arc<AtomicBool>,
@@ -36,7 +36,7 @@ struct DiffWorker {
impl DiffWorker {
fn start(root: PathBuf) -> Result<Self, String> {
let (requests, incoming) = mpsc::sync_channel::<(u64, DiffOperation)>(1);
let (requests, incoming) = mpsc::channel::<(u64, DiffOperation)>();
let (outgoing, results) = mpsc::sync_channel(1);
let cancelled = Arc::new(AtomicBool::new(false));
let stopped = Arc::new(AtomicBool::new(false));
@@ -56,7 +56,7 @@ impl DiffWorker {
if stop.load(Ordering::Acquire) {
break;
}
if cancelled_id.load(Ordering::Acquire) == id {
if cancelled_id.load(Ordering::Acquire) >= id {
continue;
}
let result = (|| {
@@ -68,6 +68,9 @@ impl DiffWorker {
DiffOperation::Discover => {
repository.catalog().map(DiffOutcome::Catalog)
}
DiffOperation::ListRemote(remote) => {
repository.list_remote(&remote).map(DiffOutcome::Catalog)
}
DiffOperation::FetchRemote(remote) => {
repository.fetch_remote(&remote).map(DiffOutcome::Catalog)
}
@@ -76,8 +79,18 @@ impl DiffWorker {
after,
buffers,
} => repository
.compare(&before, &after, &buffers)
.compare(id, &before, &after, &buffers)
.map(DiffOutcome::Comparison),
DiffOperation::LoadFile {
comparison_id,
file_index,
} => repository.load_file(comparison_id, file_index).map(|file| {
DiffOutcome::File {
comparison_id,
file_index,
file,
}
}),
}
})();
if stop.load(Ordering::Acquire) || outgoing.send((id, result)).is_err() {
@@ -98,16 +111,15 @@ impl DiffWorker {
}
fn request(&mut self, id: u64, operation: DiffOperation) -> Result<(), String> {
if let Some(previous) = self.pending.take() {
self.cancelled_request.store(previous, Ordering::Release);
self.cancelled.store(true, Ordering::Release);
}
self.requests
.as_ref()
.expect("live diff worker has a sender")
.try_send((id, operation))
.map_err(|error| match error {
mpsc::TrySendError::Full(_) => "Diff worker is already busy".to_owned(),
mpsc::TrySendError::Disconnected(_) => {
"Diff worker stopped unexpectedly".to_owned()
}
})?;
.send((id, operation))
.map_err(|_| "Diff worker stopped unexpectedly".to_owned())?;
self.pending = Some(id);
Ok(())
}
@@ -3387,6 +3399,7 @@ end
path: PathBuf::from("buffer.txt"),
old_path: None,
status: FileStatus::Modified,
loaded: true,
before: vec!["base".into()],
after: vec!["base".into()],
before_eof: false,
+601 -128
View File
@@ -42,6 +42,8 @@ pub struct SyntaxCache {
next_source: String,
line_starts: Vec<usize>,
lines: Vec<Vec<SyntaxSpan>>,
highlighted: Vec<bool>,
context: Option<LayerContext>,
parser: Parser,
tree: Option<Tree>,
injection_parser: Parser,
@@ -65,7 +67,10 @@ impl SyntaxCache {
}
pub(crate) fn update_text(&mut self, text: &str, path: &Path) {
if self.source == text && self.path.as_deref() == Some(path) {
if self.source == text
&& self.path.as_deref() == Some(path)
&& self.highlighted.iter().all(|highlighted| *highlighted)
{
return;
}
let lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
@@ -78,13 +83,41 @@ impl SyntaxCache {
/// `revision` must change whenever line content changes; path changes are detected separately.
pub fn update(&mut self, lines: &[String], path: Option<&Path>, revision: u64) {
self.update_rows(
lines,
path,
revision,
std::slice::from_ref(&(0..lines.len())),
);
}
/// Parse the original full source, but collect colors only for these end-exclusive source rows.
/// Unrequested rows may be empty; requesting more rows at the same revision reuses parsed trees.
pub(crate) fn update_visible(
&mut self,
lines: &[String],
path: Option<&Path>,
revision: u64,
visible: &[std::ops::Range<usize>],
) {
self.update_rows(lines, path, revision, visible);
}
fn update_rows(
&mut self,
lines: &[String],
path: Option<&Path>,
revision: u64,
visible: &[std::ops::Range<usize>],
) {
let path_changed = self.path.as_deref() != path;
if self.revision == Some(revision) && !path_changed {
return;
}
let syntax = languages::detect(path, lines.first().map(String::as_str));
let language_changed = !same_language(self.syntax, syntax);
let revision_changed = self.revision != Some(revision);
let syntax = if path_changed || revision_changed {
languages::detect(path, lines.first().map(String::as_str))
} else {
self.syntax
};
let language_changed = !same_language(self.syntax, syntax);
self.revision = Some(revision);
if path_changed {
self.path = path.map(Path::to_path_buf);
@@ -114,30 +147,76 @@ impl SyntaxCache {
start += line.len() + 1;
}
self.lines.resize_with(lines.len(), Vec::new);
self.highlighted.resize(lines.len(), false);
}
if edit.is_none() && !language_changed && self.error.is_none() {
return;
if edit.is_some() || language_changed {
for spans in &mut self.lines {
spans.clear();
}
self.highlighted.fill(false);
self.context = None;
}
for spans in &mut self.lines {
spans.clear();
}
self.error = None;
if language_changed {
self.tree = None;
self.injections.clear();
}
let mut requested = Vec::new();
for rows in visible {
let end = rows.end.min(lines.len());
let mut row = rows.start.min(end);
while row < end {
if self.highlighted[row] {
row += 1;
continue;
}
let start = row;
while row < end && !self.highlighted[row] {
row += 1;
}
requested.push(Range {
start_byte: self.line_starts[start],
end_byte: self
.line_starts
.get(row)
.copied()
.unwrap_or(self.source.len()),
start_point: Point::new(start, 0),
end_point: if row < lines.len() {
Point::new(row, 0)
} else {
Point::new(
lines.len().saturating_sub(1),
lines.last().map_or(0, String::len),
)
},
});
}
}
normalize_ranges(&mut requested);
if requested.is_empty() && edit.is_none() && !language_changed {
return;
}
self.error = None;
let Some(syntax) = syntax else {
self.tree = None;
self.injections.clear();
self.highlighted.fill(true);
return;
};
if let Err(error) = self.highlight(syntax, edit.as_ref(), language_changed) {
if let Err(error) = self.highlight(syntax, edit.as_ref(), language_changed, &requested) {
self.error = Some(format!("{} syntax: {error}", syntax.name));
self.tree = None;
self.injections.clear();
self.context = None;
self.highlighted.fill(false);
for spans in &mut self.lines {
spans.clear();
}
} else {
for rows in visible {
let end = rows.end.min(lines.len());
self.highlighted[rows.start.min(end)..end].fill(true);
}
}
}
@@ -166,6 +245,7 @@ impl SyntaxCache {
syntax: &'static LanguageSpec,
edit: Option<&InputEdit>,
language_changed: bool,
visible: &[Range],
) -> Result<(), String> {
let primary_configuration = configuration(syntax)?;
if language_changed || self.tree.is_none() {
@@ -194,15 +274,19 @@ impl SyntaxCache {
start_byte: 0,
end_byte: self.source.len(),
start_point: Point::new(0, 0),
end_point: point_at(&self.source, self.source.len()),
end_point: Point::new(
self.line_starts.len().saturating_sub(1),
self.source.len() - self.line_starts.last().copied().unwrap_or(0),
),
};
self.highlights.clear();
// ponytail: query the whole incremental tree; restrict changed ranges if query time dominates.
let mut queue: VecDeque<_> = collect_layer(
primary_configuration,
self.tree.as_ref().unwrap(),
&self.source,
&[full_range],
visible,
&mut self.context,
0,
None,
&mut self.cursor,
@@ -220,6 +304,7 @@ impl SyntaxCache {
if let Some(edit) = edit {
layer.tree.edit(edit);
layer.ranges = layer.tree.included_ranges();
layer.context = None;
}
}
while let Some(request) = queue.pop_front() {
@@ -250,8 +335,9 @@ impl SyntaxCache {
let unchanged = old
.as_ref()
.is_some_and(|layer| !layer.dirty && layer.ranges == request.ranges);
let tree = if unchanged {
old.unwrap().tree
let (tree, mut context) = if unchanged {
let old = old.unwrap();
(old.tree, old.context)
} else {
self.injection_parser
.set_language(&request.syntax.language)
@@ -263,7 +349,8 @@ impl SyntaxCache {
{
self.injection_parses += 1;
}
self.injection_parser
let tree = self
.injection_parser
.parse(
self.source.as_bytes(),
old.as_ref().map(|layer| &layer.tree),
@@ -273,14 +360,19 @@ impl SyntaxCache {
"{} injection parser stopped without a tree",
request.syntax.name
)
})?
})?;
(tree, None)
};
// Markdown's inline grammar refines the block grammar, so plain inline text keeps
// its enclosing heading/link foreground. Other languages replace host strings/markup.
let refines_host = languages::by_name("markdown_inline")
.is_some_and(|inline| std::ptr::eq(inline, request.syntax));
if !refines_host {
for range in &request.ranges {
for range in request
.ranges
.iter()
.filter(|range| intersects(visible, range.start_byte, range.end_byte))
{
self.highlights.push(Highlight {
start: range.start_byte,
end: range.end_byte,
@@ -296,6 +388,8 @@ impl SyntaxCache {
&tree,
&self.source,
&request.ranges,
visible,
&mut context,
request.depth,
Some(request.parent),
&mut self.cursor,
@@ -314,6 +408,7 @@ impl SyntaxCache {
ranges: request.ranges,
depth: request.depth,
dirty: false,
context,
});
}
render_spans(
@@ -322,6 +417,7 @@ impl SyntaxCache {
&mut self.lines,
&self.highlights,
&mut self.events,
visible,
);
Ok(())
}
@@ -380,10 +476,39 @@ fn point_at(source: &str, byte: usize) -> Point {
struct Configuration {
syntax: &'static LanguageSpec,
query: Query,
context_query: Query,
highlights_query: Query,
locals_start: usize,
highlights_start: usize,
}
impl Configuration {
fn new(
syntax: &'static LanguageSpec,
query: Query,
locals_start: usize,
highlights_start: usize,
) -> Self {
let mut context_query = query.deep_clone();
let mut highlights_query = query.deep_clone();
for pattern in 0..query.pattern_count() {
if pattern < highlights_start {
highlights_query.disable_pattern(pattern);
} else {
context_query.disable_pattern(pattern);
}
}
Self {
syntax,
query,
context_query,
highlights_query,
locals_start,
highlights_start,
}
}
}
fn configuration(syntax: &'static LanguageSpec) -> Result<&'static Configuration, String> {
static CONFIGURATIONS: LazyLock<Vec<(&'static LanguageSpec, Result<Configuration, String>)>> =
LazyLock::new(|| {
@@ -429,12 +554,12 @@ fn compile_configuration(syntax: &'static LanguageSpec) -> Result<Configuration,
));
}
}
Ok(Configuration {
Ok(Configuration::new(
syntax,
query,
locals_start,
highlights_start,
})
))
}
fn classify(capture: &str, numeric_literal: bool) -> Option<SyntaxKind> {
@@ -463,8 +588,10 @@ struct Layer {
ranges: Vec<Range>,
depth: usize,
dirty: bool,
context: Option<LayerContext>,
}
#[derive(Clone)]
struct Injection {
syntax: &'static LanguageSpec,
parent: &'static LanguageSpec,
@@ -473,7 +600,12 @@ struct Injection {
ancestors: Vec<(&'static LanguageSpec, Vec<Range>)>,
}
#[derive(Clone, Copy)]
struct LayerContext {
captures: Vec<Capture>,
injections: Vec<Injection>,
}
#[derive(Clone, Copy, Eq, PartialEq)]
struct Capture {
node: usize,
start: usize,
@@ -484,6 +616,34 @@ struct Capture {
numeric_literal: bool,
}
impl Capture {
fn new(
capture: &tree_sitter::QueryCapture<'_>,
pattern: usize,
value_end: usize,
name: &str,
) -> Self {
Self {
node: capture.node.id(),
start: capture.node.start_byte(),
end: capture.node.end_byte(),
capture: capture.index,
pattern,
value_end,
numeric_literal: name.starts_with("constant")
&& matches!(
capture.node.kind(),
"integer_literal"
| "float_literal"
| "number_literal"
| "integer"
| "float"
| "number"
),
}
}
}
struct Highlight {
start: usize,
end: usize,
@@ -510,6 +670,8 @@ fn collect_layer(
tree: &Tree,
source: &str,
ranges: &[Range],
visible: &[Range],
context: &mut Option<LayerContext>,
depth: usize,
parent: Option<&'static LanguageSpec>,
cursor: &mut QueryCursor,
@@ -519,102 +681,128 @@ fn collect_layer(
let query = &configuration.query;
let names = query.capture_names();
captures.clear();
let mut injections = Vec::new();
let mut combined: HashMap<(usize, usize), usize> = HashMap::new();
let mut matches = cursor.matches(query, tree.root_node(), source.as_bytes());
while let Some(found) = matches.next() {
let pattern = found.pattern_index;
if pattern < configuration.locals_start {
let mut language = None;
let mut nodes = Vec::new();
for capture in found.captures() {
match names[capture.index as usize] {
"injection.language" => {
language = source[capture.node.byte_range()]
.split_whitespace()
.next()
.and_then(languages::by_name);
}
"injection.content" => nodes.push(capture.node),
_ => {}
}
}
let mut include_children = false;
let mut is_combined = false;
for property in query.property_settings(pattern) {
match property.key.as_ref() {
"injection.language" => {
language = property.value.as_deref().and_then(languages::by_name);
}
"injection.self" => language = Some(configuration.syntax),
"injection.parent" => language = parent,
"injection.include-children" => include_children = true,
"injection.combined" => is_combined = true,
_ => {}
}
}
if let Some(language) = language {
let mut content = injection_ranges(&nodes, ranges, include_children);
if !content.is_empty() {
let key = (pattern, language as *const LanguageSpec as usize);
if is_combined && let Some(index) = combined.get(&key) {
let injection: &mut Injection = &mut injections[*index];
injection.ranges.append(&mut content);
} else {
if is_combined {
combined.insert(key, injections.len());
let mut injections;
if let Some(context) = context {
captures.extend_from_slice(&context.captures);
injections = context.injections.clone();
} else {
injections = Vec::new();
let mut combined: HashMap<(usize, usize), usize> = HashMap::new();
cursor.set_byte_range(0..source.len());
let mut matches = cursor.matches(
&configuration.context_query,
tree.root_node(),
source.as_bytes(),
);
while let Some(found) = matches.next() {
let pattern = found.pattern_index;
if pattern < configuration.locals_start {
let mut language = None;
let mut nodes = Vec::new();
for capture in found.captures() {
match names[capture.index as usize] {
"injection.language" => {
language = source[capture.node.byte_range()]
.split_whitespace()
.next()
.and_then(languages::by_name);
}
"injection.content" => nodes.push(capture.node),
_ => {}
}
}
let mut include_children = false;
let mut is_combined = false;
for property in query.property_settings(pattern) {
match property.key.as_ref() {
"injection.language" => {
language = property.value.as_deref().and_then(languages::by_name);
}
"injection.self" => language = Some(configuration.syntax),
"injection.parent" => language = parent,
"injection.include-children" => include_children = true,
"injection.combined" => is_combined = true,
_ => {}
}
}
if let Some(language) = language {
let mut content = injection_ranges(&nodes, ranges, include_children);
if !content.is_empty() {
let key = (pattern, language as *const LanguageSpec as usize);
if is_combined && let Some(index) = combined.get(&key) {
let injection: &mut Injection = &mut injections[*index];
injection.ranges.append(&mut content);
} else {
if is_combined {
combined.insert(key, injections.len());
}
injections.push(Injection {
syntax: language,
parent: configuration.syntax,
ranges: content,
depth: depth + 1,
ancestors: vec![(configuration.syntax, ranges.to_vec())],
});
}
injections.push(Injection {
syntax: language,
parent: configuration.syntax,
ranges: content,
depth: depth + 1,
ancestors: vec![(configuration.syntax, ranges.to_vec())],
});
}
}
}
continue;
}
let value_end = found
.captures()
.iter()
.find(|capture| names[capture.index as usize] == "local.definition-value")
.map_or(0, |capture| capture.node.end_byte());
for capture in found.captures() {
let name = names[capture.index as usize];
if name.starts_with('_') || name == "local.definition-value" {
continue;
}
captures.push(Capture {
node: capture.node.id(),
start: capture.node.start_byte(),
end: capture.node.end_byte(),
capture: capture.index,
pattern,
value_end,
numeric_literal: name.starts_with("constant")
&& matches!(
capture.node.kind(),
"integer_literal"
| "float_literal"
| "number_literal"
| "integer"
| "float"
| "number"
),
});
let value_end = found
.captures()
.iter()
.find(|capture| names[capture.index as usize] == "local.definition-value")
.map_or(0, |capture| capture.node.end_byte());
for capture in found.captures() {
let name = names[capture.index as usize];
if name.starts_with('_') || name == "local.definition-value" {
continue;
}
captures.push(Capture::new(capture, pattern, value_end, name));
}
}
if cursor.did_exceed_match_limit() {
return Err(format!(
"{} query exceeded its match limit",
configuration.syntax.name
));
}
for injection in &mut injections {
normalize_ranges(&mut injection.ranges);
}
// Locals must be resolved in source order, including definitions above the viewport.
// Query only their nodes for the foreground that references inherit.
let definitions: BTreeSet<_> = captures
.iter()
.filter(|capture| names[capture.capture as usize] == "local.definition")
.map(|capture| capture.node)
.collect();
let mut definition_ranges: Vec<_> = captures
.iter()
.filter(|capture| definitions.contains(&capture.node))
.map(|capture| Range {
start_byte: capture.start,
end_byte: capture.end,
start_point: Point::new(0, 0),
end_point: Point::new(0, 0),
})
.collect();
normalize_ranges(&mut definition_ranges);
collect_highlight_captures(
configuration,
tree,
source,
&definition_ranges,
Some(&definitions),
cursor,
captures,
)?;
*context = Some(LayerContext {
captures: captures.clone(),
injections: injections.clone(),
});
}
if cursor.did_exceed_match_limit() {
return Err(format!(
"{} query exceeded its match limit",
configuration.syntax.name
));
}
for injection in &mut injections {
normalize_ranges(&mut injection.ranges);
}
collect_highlight_captures(configuration, tree, source, visible, None, cursor, captures)?;
captures.sort_unstable_by_key(|capture| {
(
capture.start,
@@ -624,6 +812,7 @@ fn collect_layer(
capture.capture,
)
});
captures.dedup();
let mut scopes = vec![Scope {
end: usize::MAX,
inherits: false,
@@ -737,7 +926,7 @@ fn collect_layer(
{
let low = first.start.max(range.start_byte);
let high = first.end.min(range.end_byte);
if low < high {
if low < high && intersects(visible, low, high) {
highlights.push(Highlight {
start: low,
end: high,
@@ -754,6 +943,57 @@ fn collect_layer(
Ok(injections)
}
fn intersects(ranges: &[Range], start: usize, end: usize) -> bool {
ranges
.get(ranges.partition_point(|range| range.end_byte <= start))
.is_some_and(|range| range.start_byte < end)
}
#[allow(clippy::too_many_arguments)]
fn collect_highlight_captures(
configuration: &Configuration,
tree: &Tree,
source: &str,
ranges: &[Range],
definitions: Option<&BTreeSet<usize>>,
cursor: &mut QueryCursor,
captures: &mut Vec<Capture>,
) -> Result<(), String> {
let names = configuration.query.capture_names();
for range in ranges {
if range.start_byte == range.end_byte {
continue;
}
cursor.set_byte_range(range.start_byte..range.end_byte);
let mut matches = cursor.matches(
&configuration.highlights_query,
tree.root_node(),
source.as_bytes(),
);
while let Some(found) = matches.next() {
for capture in found.captures() {
let name = names[capture.index as usize];
if name.starts_with('_')
|| capture.node.end_byte() <= range.start_byte
|| capture.node.start_byte() >= range.end_byte
|| definitions
.is_some_and(|definitions| !definitions.contains(&capture.node.id()))
{
continue;
}
captures.push(Capture::new(capture, found.pattern_index, 0, name));
}
}
if cursor.did_exceed_match_limit() {
return Err(format!(
"{} query exceeded its match limit",
configuration.syntax.name
));
}
}
Ok(())
}
fn capture_priority(query: &Query, capture: &Capture) -> i32 {
query
.property_settings(capture.pattern)
@@ -851,6 +1091,7 @@ fn render_spans(
lines: &mut [Vec<SyntaxSpan>],
highlights: &[Highlight],
events: &mut Vec<(usize, bool, usize)>,
visible: &[Range],
) {
events.clear();
for (index, highlight) in highlights.iter().enumerate() {
@@ -875,7 +1116,20 @@ fn render_spans(
&& let Some((_, current)) = active.last()
&& let Some(kind) = highlights[*current].kind
{
append_global(source, starts, lines, previous, byte, kind);
let start = visible.partition_point(|range| range.end_byte <= previous);
for range in visible[start..]
.iter()
.take_while(|range| range.start_byte < byte)
{
append_global(
source,
starts,
lines,
previous.max(range.start_byte),
byte.min(range.end_byte),
kind,
);
}
}
if entering {
active.insert((rank(index), index));
@@ -933,6 +1187,179 @@ mod tests {
source.split('\n').map(str::to_owned).collect()
}
fn source_range(source: &str) -> Range {
Range {
start_byte: 0,
end_byte: source.len(),
start_point: Point::new(0, 0),
end_point: point_at(source, source.len()),
}
}
#[test]
fn visible_windows_match_full_colors_after_edits_and_scrolling_without_reparsing() {
let cases = [
(
"a.rs",
"fn main() {\n/* start\ninside é 👩‍💻\nend */\nlet n = 42;\nlet text = \"unfinished",
),
("a.py", "text = '''first\ninside é\nlast'''\nanswer = 42"),
(
"a.js",
"const action = () => 42;\nfunction hello(require) {\n require('local');\n action;\n}\nrequire('global');\naction;",
),
(
"a.md",
"# Heading **bold**\n\n```rust\nfn main() {\n let n = 42;\n}\n```\n\n```python\ndef hello():\n return 7\n```\n\n```unknown\nfn plain() {}\n```",
),
(
"a.md",
"> # Heading **bold**\n>\n> ```rust\n> fn main() {\n> let n = 42;\n> }\n> ```",
),
(
"a.md",
"```html\n<script>\nfunction nested() {\n return 42;\n}\n</script>\n```",
),
(
"a.html",
"<div>é</div>\n<script>\nconst action = () => 42;\naction;\n</script>\n<style>\nbody { width: 7px; }\n</style>",
),
(
"a.js",
"const page = html`<div>${value}</div>\n<script>\nfunction inside() { return 42; }\n</script>`;",
),
];
for (path, original) in cases {
let mut visible = SyntaxCache::new();
for (revision, source) in [
original.to_owned(),
original.replace("42", "123"),
format!("\n{original}"),
original.replace("() => 42", "42"),
]
.iter()
.enumerate()
{
let input = lines(source);
let middle = input.len() / 2;
visible.update_visible(
&input,
Some(Path::new(path)),
revision as u64,
std::slice::from_ref(&(middle..middle + 1)),
);
let full = cache(source, path);
assert_eq!(visible.error(), None, "{path}");
assert_eq!(
visible.line(middle),
full.line(middle),
"{path} revision {revision}"
);
assert!(
visible
.lines
.iter()
.enumerate()
.all(|(row, spans)| row == middle || spans.is_empty())
);
let parses = visible.parse_counts();
let context = visible.context.as_ref().unwrap().captures.as_ptr();
// Unsorted, overlapping and out-of-bounds windows use original source rows.
for row in (0..input.len()).rev() {
visible.update_visible(
&input,
Some(Path::new(path)),
revision as u64,
&[
row..row + 1,
middle..middle + 1,
row..row + 1,
input.len()..usize::MAX,
],
);
assert_eq!(
visible.line(row),
full.line(row),
"{path} revision {revision} row {row}"
);
assert_eq!(
visible.parse_counts(),
parses,
"{path}: scrolling reparsed a tree"
);
assert_eq!(visible.context.as_ref().unwrap().captures.as_ptr(), context);
}
}
}
}
#[test]
fn visible_highlight_collection_stays_bounded_by_the_requested_rows() {
let mut input: Vec<_> = (0..1000)
.map(|row| format!("fn function_{row}() {{ let value = 42; }}"))
.collect();
let path = Path::new("large.rs");
let mut visible = SyntaxCache::new();
visible.update_visible(&input, Some(path), 0, std::slice::from_ref(&(500..502)));
assert_eq!(visible.parses, 1);
assert!(visible.captures.len() < 50);
assert!(visible.highlights.len() < 30);
assert!(visible.lines[..500].iter().all(Vec::is_empty));
assert!(visible.lines[502..].iter().all(Vec::is_empty));
input[500] = "fn changed() { let value = \"é 👩‍💻\"; }".to_owned();
visible.update_visible(&input, Some(path), 1, std::slice::from_ref(&(500..502)));
assert_eq!(visible.incremental_parses, 1);
assert!(visible.captures.len() < 50);
let mut full = SyntaxCache::new();
full.update(&input, Some(path), 1);
assert!(full.captures.len() > 1000);
for row in 500..502 {
assert_eq!(visible.line(row), full.line(row));
}
let parses = visible.parse_counts();
visible.update_visible(&input, Some(path), 1, std::slice::from_ref(&(950..952)));
assert_eq!(visible.parse_counts(), parses);
assert!(visible.captures.len() < 50);
for row in 950..952 {
assert_eq!(visible.line(row), full.line(row));
}
}
#[test]
fn visible_cache_supports_full_updates_and_same_revision_path_changes() {
let input = lines("const element = <div title=\"hi\" />;\nconst answer = 42;");
let mut visible = SyntaxCache::new();
visible.update_visible(
&input,
Some(Path::new("a.ts")),
0,
std::slice::from_ref(&(1..2)),
);
let parses = visible.parse_counts();
visible.update(&input, Some(Path::new("a.ts")), 0);
assert_eq!(visible.parse_counts(), parses);
assert_fresh(&visible, &input.join("\n"), "a.ts");
visible.update_visible(
&input,
Some(Path::new("a.tsx")),
0,
std::slice::from_ref(&(0..1)),
);
assert_eq!(visible.parses, parses.0 + 1);
assert_eq!(visible.line(0), cache(&input.join("\n"), "a.tsx").line(0));
assert!(visible.line(1).is_empty());
visible.update_text(&input.join("\n"), Path::new("a.tsx"));
assert_fresh(&visible, &input.join("\n"), "a.tsx");
visible.update_visible(
&input,
Some(Path::new("a.txt")),
0,
std::slice::from_ref(&(0..2)),
);
assert_eq!(visible.language(), None);
assert!(visible.lines.iter().all(Vec::is_empty));
}
fn cache(source: &str, path: &str) -> SyntaxCache {
let mut cache = SyntaxCache::new();
cache.update(&lines(source), Some(Path::new(path)), 0);
@@ -1433,7 +1860,14 @@ mod tests {
pattern: 0,
},
];
render_spans(source, &[0], &mut spans, &highlights, &mut events);
render_spans(
source,
&[0],
&mut spans,
&highlights,
&mut events,
&[source_range(source)],
);
assert_eq!(
spans[0],
vec![
@@ -1472,12 +1906,7 @@ mod tests {
let highlights_start = (0..query.pattern_count())
.filter(|pattern| query.start_byte_for_pattern(*pattern) < locals.len() + 1)
.count();
let configuration = Configuration {
syntax,
query,
locals_start: 0,
highlights_start,
};
let configuration = Configuration::new(syntax, query, 0, highlights_start);
let source = "const value = value;\nvalue;\nfunction inner() { value; }";
let cache = self::cache(source, "a.js");
let mut highlights = Vec::new();
@@ -1485,12 +1914,9 @@ mod tests {
&configuration,
cache.tree.as_ref().unwrap(),
source,
&[Range {
start_byte: 0,
end_byte: source.len(),
start_point: Point::new(0, 0),
end_point: point_at(source, source.len()),
}],
&[source_range(source)],
&[source_range(source)],
&mut None,
0,
None,
&mut QueryCursor::new(),
@@ -1504,7 +1930,14 @@ mod tests {
source.find("\nvalue;").unwrap() + 1,
source.find("\nfunction").unwrap() + 1,
];
render_spans(source, &starts, &mut spans, &highlights, &mut Vec::new());
render_spans(
source,
&starts,
&mut spans,
&highlights,
&mut Vec::new(),
&[source_range(source)],
);
let kind = |row: usize, byte: usize| {
spans[row].iter().find_map(|span| {
(span.start_byte <= byte && byte < span.end_byte).then_some(span.kind)
@@ -1514,6 +1947,46 @@ mod tests {
assert_eq!(kind(0, 14), Some(SyntaxKind::Function));
assert_eq!(kind(1, 0), Some(SyntaxKind::Type));
assert_eq!(kind(2, 19), Some(SyntaxKind::Function));
let mut context = None;
for row in [1, 2] {
let window = [Range {
start_byte: starts[row],
end_byte: starts.get(row + 1).copied().unwrap_or(source.len()),
start_point: Point::new(row, 0),
end_point: point_at(source, starts.get(row + 1).copied().unwrap_or(source.len())),
}];
let mut visible_highlights = Vec::new();
collect_layer(
&configuration,
cache.tree.as_ref().unwrap(),
source,
&[source_range(source)],
&window,
&mut context,
0,
None,
&mut QueryCursor::new(),
&mut Vec::new(),
&mut visible_highlights,
)
.unwrap();
let mut visible_spans = vec![Vec::new(); 3];
render_spans(
source,
&starts,
&mut visible_spans,
&visible_highlights,
&mut Vec::new(),
&window,
);
assert_eq!(visible_spans[row], spans[row]);
assert!(
visible_spans
.iter()
.enumerate()
.all(|(other, spans)| other == row || spans.is_empty())
);
}
}
#[test]
+175 -23
View File
@@ -347,7 +347,17 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
};
match input {
Event::Key(key) if key.kind != KeyEventKind::Release => {
if let Some(input) = translate(key) {
if session.editor().mode() == Mode::Insert && typing_key(&key) {
viewports.panels.follow_files();
pending = typing_ready(session, key, || {
if event::poll(Duration::ZERO)? {
event::read().map(Some)
} else {
Ok(None)
}
})?;
redraw = true;
} else if let Some(input) = translate(key) {
viewports.panels.follow_files();
session.handle(input);
redraw = true;
@@ -377,6 +387,36 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
})
}
fn typing_key(key: &KeyEvent) -> bool {
key.kind != KeyEventKind::Release
&& matches!(key.code, KeyCode::Char(ch) if !ch.is_control())
&& !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
}
// Coalesce frames, not edits: bindings, snippets and history still see each key.
fn typing_ready(
session: &mut EditorSession,
mut key: KeyEvent,
mut next: impl FnMut() -> io::Result<Option<Event>>,
) -> io::Result<Option<Event>> {
for handled in 1..=64 {
session.handle(translate(key).expect("plain typing key"));
if handled == 64
|| session.editor().mode() != Mode::Insert
|| session.editor().should_quit()
{
return Ok(None);
}
match next()? {
Some(Event::Key(next)) if typing_key(&next) => key = next,
pending => return Ok(pending),
}
}
unreachable!()
}
fn translate(key: KeyEvent) -> Option<Input> {
if key
.modifiers
@@ -488,7 +528,6 @@ fn draw(
return;
}
panels.diff_syntax = None;
syntax.update(editor.lines(), editor.path(), editor.text_revision());
if area.width == 0 || area.height == 0 {
return;
}
@@ -568,6 +607,12 @@ fn draw(
*left = column + 1 - width;
}
}
syntax.update_visible(
editor.lines(),
editor.path(),
editor.text_revision(),
std::slice::from_ref(&(*top..top.saturating_add(height).min(editor.lines().len()))),
);
if gutter.width > 0 {
let numbers: Vec<Line> = (*top..top.saturating_add(height).min(editor.lines().len()))
.map(|row| {
@@ -1087,15 +1132,7 @@ fn draw_diff(
) {
let comparison = diff.comparison.as_ref();
let selected = comparison.and_then(|comparison| comparison.files.get(diff.file));
if diff.phase == Phase::View
&& let Some(id) = diff.comparison_id
&& let Some(file) = selected
{
panels
.diff_syntax
.get_or_insert_with(DiffSyntaxCache::default)
.update(id, diff.file, file, diff.live_revision);
} else {
if diff.phase != Phase::View || !diff.selected_file_loaded() {
panels.diff_syntax = None;
}
if area.width == 0 || area.height == 0 {
@@ -1297,7 +1334,7 @@ fn draw_diff(
.and_then(|syntax| syntax.before.error().or_else(|| syntax.after.error()));
let notice = diff.error.as_deref().or_else(|| {
if diff.loading {
Some("Loading comparison…")
Some("Loading file…")
} else {
syntax_error.or_else(|| selected.and_then(|file| file.notice.as_deref()))
}
@@ -1413,6 +1450,20 @@ fn draw_diff(
*left = column + 1 - width;
}
}
if let Some(id) = diff.comparison_id
&& let Some(file) = selected.filter(|file| file.loaded)
{
panels
.diff_syntax
.get_or_insert_with(DiffSyntaxCache::default)
.update(
id,
diff.file,
file,
diff.live_revision,
Some((diff, top, [before_area, after_area])),
);
}
if let Some(picker) = &diff.files
&& let Some(navigator) = navigator
{
@@ -1435,7 +1486,7 @@ fn draw_diff(
})
})
.map(|geometry| geometry.text_widths);
if let Some(file) = selected
if let Some(file) = selected.filter(|file| file.loaded)
&& let Some(syntax) = &panels.diff_syntax
{
if pane_layout == PaneLayout::Empty {
@@ -1505,7 +1556,9 @@ fn draw_diff(
} else {
frame.render_widget(
Paragraph::new(if diff.loading {
"Loading comparison…"
"Loading file…"
} else if selected.is_some() {
"File details unavailable · select the file to retry"
} else {
"No changed files"
}),
@@ -1799,6 +1852,8 @@ struct DiffSyntaxCache {
before_revision: u64,
before: SyntaxCache,
after: SyntaxCache,
before_rows: Vec<std::ops::Range<usize>>,
after_rows: Vec<std::ops::Range<usize>>,
}
impl DiffSyntaxCache {
@@ -1808,6 +1863,7 @@ impl DiffSyntaxCache {
file_index: usize,
file: &ChangedFile,
live_revision: Option<u64>,
visible: Option<(&DiffState, usize, [Rect; 2])>,
) {
let key = Some((comparison_id, file_index, live_revision));
if self.key.map(|(id, index, _)| (id, index)) != Some((comparison_id, file_index)) {
@@ -1823,13 +1879,64 @@ impl DiffSyntaxCache {
.checked_add(1)
.expect("diff syntax revisions exhausted");
}
self.before.update(
&file.before,
Some(file.old_path.as_deref().unwrap_or(&file.path)),
self.before_revision,
);
self.after
.update(&file.after, Some(&file.path), self.revision);
if let Some((diff, top, areas)) = visible {
diff_visible_rows(diff, top, areas[0], true, &mut self.before_rows);
diff_visible_rows(diff, top, areas[1], false, &mut self.after_rows);
self.before.update_visible(
&file.before,
Some(file.old_path.as_deref().unwrap_or(&file.path)),
self.before_revision,
&self.before_rows,
);
self.after.update_visible(
&file.after,
Some(&file.path),
self.revision,
&self.after_rows,
);
} else {
self.before.update(
&file.before,
Some(file.old_path.as_deref().unwrap_or(&file.path)),
self.before_revision,
);
self.after
.update(&file.after, Some(&file.path), self.revision);
}
}
}
fn diff_visible_rows(
diff: &DiffState,
top: usize,
pane: Rect,
before: bool,
ranges: &mut Vec<std::ops::Range<usize>>,
) {
ranges.clear();
let height = if pane.width >= 4 && pane.height >= 4 {
pane.height - 2
} else {
pane.height
};
for row in diff.display_rows.iter().skip(top).take(usize::from(height)) {
let DisplayRow::Line(index) = *row else {
continue;
};
let source = if before {
diff.rows[index].before
} else {
diff.rows[index].after
};
if let Some(source) = source {
if let Some(last) = ranges.last_mut()
&& last.end == source
{
last.end += 1;
} else {
ranges.push(source..source + 1);
}
}
}
}
@@ -2713,6 +2820,7 @@ mod tests {
let before: Vec<String> = before.split('\n').map(str::to_owned).collect();
let after: Vec<String> = after.split('\n').map(str::to_owned).collect();
ChangedFile {
loaded: true,
before_eof: false,
after_eof: false,
path: path.into(),
@@ -2759,13 +2867,13 @@ mod tests {
let mut file = syntax_diff_file("new.rs", "return 42", "fn main() {}");
file.old_path = Some("old.py".into());
let mut syntax = DiffSyntaxCache::default();
syntax.update(1, 0, &file, None);
syntax.update(1, 0, &file, None, None);
assert_eq!(syntax.before.language(), Some("Python"));
assert_eq!(syntax.after.language(), Some("Rust"));
assert_eq!(syntax.before.line(0)[0].kind, SyntaxKind::Keyword);
assert_eq!(syntax.after.line(0)[0].kind, SyntaxKind::Keyword);
file.path = "unknown.extension".into();
syntax.update(1, 0, &file, None);
syntax.update(1, 0, &file, None, None);
assert_eq!(syntax.after.language(), None);
assert!(syntax.after.line(0).is_empty());
@@ -2877,6 +2985,7 @@ mod tests {
fn deletion_file() -> ChangedFile {
ChangedFile {
loaded: true,
before_eof: false,
after_eof: false,
path: "deleted-lines.txt".into(),
@@ -3075,6 +3184,7 @@ mod tests {
after[30] = "let first_change = 900;".into();
after[60] = "let second_change = 901;".into();
let mut diff = diff_comparison(vec![ChangedFile {
loaded: true,
before_eof: false,
after_eof: false,
path: "excerpts.rs".into(),
@@ -3182,6 +3292,7 @@ mod tests {
#[test]
fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() {
let mut diff = diff_comparison(vec![ChangedFile {
loaded: true,
before_eof: false,
after_eof: false,
path: "added.txt".into(),
@@ -3237,6 +3348,7 @@ mod tests {
#[test]
fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() {
let mut diff = diff_comparison(vec![ChangedFile {
loaded: true,
before_eof: false,
after_eof: false,
path: "changed.rs".into(),
@@ -3344,6 +3456,7 @@ mod tests {
#[test]
fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() {
let added = ChangedFile {
loaded: true,
before_eof: false,
after_eof: false,
path: "new-name.txt".into(),
@@ -3624,6 +3737,7 @@ mod tests {
assert!(screen_text(&terminal).contains("No changed files"));
assert!(screen_text(&terminal).contains("Explorer · Tree"));
let binary = diff_comparison(vec![ChangedFile {
loaded: true,
before_eof: false,
after_eof: false,
path: "image.bin".into(),
@@ -3637,6 +3751,7 @@ mod tests {
render_diff(&mut terminal, &binary, &mut panels);
assert!(screen_text(&terminal).contains("Binary file"));
let blank = diff_comparison(vec![ChangedFile {
loaded: true,
before_eof: false,
after_eof: false,
path: "empty.txt".into(),
@@ -4340,6 +4455,10 @@ mod tests {
assert_eq!(diff.file, 0);
session.handle(Input::Enter);
assert_eq!(session.editor().diff().unwrap().file, 3);
ready(&mut session, Phase::View);
terminal
.draw(|frame| viewports.draw_session(frame, &mut session))
.unwrap();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1),
@@ -7510,6 +7629,39 @@ end
assert!(terminal.backend().cursor_visible());
}
#[test]
fn queued_typing_preserves_graphemes_history_and_mode_barriers() {
let directory = Directory::new();
let path = directory.file("input.txt", "seed");
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
session.handle(Input::Char('i'));
let mut events = [
Event::Key(KeyEvent::new(KeyCode::Char('界'), KeyModifiers::NONE)),
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
]
.into_iter();
let pending = typing_ready(
&mut session,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
|| Ok(events.next()),
)
.unwrap();
assert_eq!(session.editor().lines(), ["a界seed"]);
assert_eq!(session.editor().mode(), Mode::Insert);
assert!(matches!(pending, Some(Event::Key(key)) if key.code == KeyCode::Esc));
session.handle(Input::Escape);
let Some(Event::Key(key)) = events.next() else {
panic!("remaining command")
};
session.handle(translate(key).unwrap());
assert_eq!(session.editor().lines(), ["aseed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), ["a界seed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), ["seed"]);
}
#[test]
fn queued_wheel_input_renders_latest_view_without_crossing_key_or_resize_barriers() {
let directory = Directory::new();