perf: avoid eager diff loading and full-file typing work
This commit is contained in:
+580
-31
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user