perf: avoid eager diff loading and full-file typing work
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
- [x] navigation control/actions: seek occurrence of character ('f' forwards, 'F' backwards by default; allow e.g. `2f` for seeking second occurrence instead of first).
|
||||
- [x] buffer ux improvement: enable mouse scrolling sideways so longer lines are easier to follow and read (e.g. in case they extend beyond the width of the buffer view).
|
||||
- [ ] buffer ux improvement: add toggleable word wrap (no keybinding by default).
|
||||
- [ ] add tabs: tab support, editor action to open a buffer in a new tab, default tab navigation is `ctrl+option+h/l`, close tab action.
|
||||
- [ ] diff view: add character-wise change highlight (enabled by default).
|
||||
- [ ] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view.
|
||||
- this will require tab support.
|
||||
- [ ] diff view: change diff view modes; choose between split and unified diff view (split by default).
|
||||
- [ ] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
|
||||
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||
|
||||
+5
-1
@@ -233,7 +233,9 @@ The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits /
|
||||
| Enter | Choose the entry |
|
||||
| Esc | Cancel loading; After → return to Before; Before → clear query without closing |
|
||||
|
||||
Remote references such as `some-bookmark@origin` appear from remote advertisements, including refs never fetched locally. Selecting one retrieves missing content into a temporary comparison repository. **Load history from origin** (or another configured remote) in Revisions/Commits imports searchable remote history without advancing the selection stage. These operations may require network access and the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
|
||||
The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
|
||||
|
||||
Comparison discovery builds the changed-path/status catalog first. File text, notices, and hunks load asynchronously when a file is selected and remain cached for that comparison. Failed file loads can be retried by selecting the file again; cancelled or superseded results cannot replace the current selection. Working-checkout comparisons still capture working bytes and the historical content needed to identify changes, preserving the frozen Before snapshot and unsaved After buffers.
|
||||
|
||||
The changed-file explorer is **docked on the right**. Modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain the split view. Before and historical/remote After snapshots are read-only. Selecting **Working checkout** as After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
|
||||
|
||||
@@ -247,6 +249,8 @@ Text panes show change excerpts with **10 unchanged lines above and below** each
|
||||
|
||||
In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below** the excerpt containing the caret, revealing up to **15 lines** per keypress in either pane. In editable After's Insert mode these keys insert literal text. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored. Opening a file resets its excerpts to the configured initial context. Live edits recompute changes while retaining expanded context; the caret stays visible even when the last text change is reverted.
|
||||
|
||||
Live typing highlights only the displayed source rows while retaining each snapshot's original syntax tree for multiline and injected-language context. Line-width caches and unchanged hunk layouts avoid repeated full-file work; edits that change line alignment or revert changes recompute the diff. Already-queued printable Insert keys share a redraw, but every key still passes through normal bindings, snippet handling, and undo history. Mode changes and other terminal events retain their order.
|
||||
|
||||
Text-pane navigation stays within the shown source lines. At an excerpt boundary, Up/Down or `j`/`k` jumps directly to the next/previous excerpt, and counts count visible lines rather than omitted lines. This also applies to remapped movement keys, Visual-mode movement, and editable After's Insert-mode arrows. Word motions, `gt`/`gb`, search, and occurrence selection use the visible excerpts; `gt`/`gb` target their first/last source line. Hidden short lines do not clamp the caret column. The shared vertical viewport and each pane's horizontal offset stay fixed when movement reverses, scrolling only when the caret crosses a visible edge, as in a normal buffer. Moving or restoring a caret never reveals omitted context: only Normal-mode `[`/`]` expands existing excerpts. Edits still recompute changed regions and retain previously expanded context.
|
||||
|
||||
Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive).
|
||||
|
||||
+578
-29
@@ -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.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();
|
||||
}
|
||||
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,
|
||||
|
||||
+266
-91
@@ -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,27 +572,43 @@ impl Repository {
|
||||
self.revisions(root, store, &mut entries, &mut notices)?;
|
||||
}
|
||||
for remote in &self.remotes {
|
||||
for &view in self.kind.categories() {
|
||||
let history = view == category;
|
||||
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,
|
||||
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,
|
||||
});
|
||||
match self.advertised(&remote.url) {
|
||||
Ok(refs) => {
|
||||
self.remote_refs.retain(|(name, _), _| name != &remote.name);
|
||||
for (reference, id) in refs {
|
||||
}
|
||||
for ((name, reference), id) in &self.remote_refs {
|
||||
if name != &remote.name {
|
||||
continue;
|
||||
}
|
||||
let tag = reference.starts_with("refs/tags/");
|
||||
let name = reference
|
||||
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: remote.name.clone(),
|
||||
remote: name.clone(),
|
||||
reference: reference.clone(),
|
||||
},
|
||||
label: format!("{name}@{}", remote.name),
|
||||
label: format!("{label}@{name}"),
|
||||
description: id.clone(),
|
||||
category: if tag {
|
||||
Category::Tags
|
||||
@@ -593,11 +619,6 @@ impl Repository {
|
||||
},
|
||||
});
|
||||
}
|
||||
self.remote_refs
|
||||
.insert((remote.name.clone(), reference), id);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
],
|
||||
)?;
|
||||
}
|
||||
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
|
||||
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()
|
||||
}
|
||||
.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,
|
||||
},
|
||||
);
|
||||
}
|
||||
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.arg("cat-file").arg("--batch");
|
||||
let mut cmd = git_command(&store);
|
||||
cmd.args(["cat-file", "--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 {
|
||||
for (path, file) in snapshot.iter_mut() {
|
||||
if file
|
||||
.object
|
||||
.as_ref()
|
||||
.is_none_or(|(source, _)| source != &store)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
self.check_cancelled()?;
|
||||
let bytes = if object.is_some() {
|
||||
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,
|
||||
|
||||
+530
-57
@@ -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) {
|
||||
let path_changed = self.path.as_deref() != path;
|
||||
if self.revision == Some(revision) && !path_changed {
|
||||
return;
|
||||
self.update_rows(
|
||||
lines,
|
||||
path,
|
||||
revision,
|
||||
std::slice::from_ref(&(0..lines.len())),
|
||||
);
|
||||
}
|
||||
let syntax = languages::detect(path, lines.first().map(String::as_str));
|
||||
let language_changed = !same_language(self.syntax, syntax);
|
||||
|
||||
/// 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;
|
||||
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.error = None;
|
||||
self.highlighted.fill(false);
|
||||
self.context = 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,9 +681,19 @@ fn collect_layer(
|
||||
let query = &configuration.query;
|
||||
let names = query.capture_names();
|
||||
captures.clear();
|
||||
let mut injections = Vec::new();
|
||||
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();
|
||||
let mut matches = cursor.matches(query, tree.root_node(), source.as_bytes());
|
||||
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 {
|
||||
@@ -586,24 +758,7 @@ fn collect_layer(
|
||||
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"
|
||||
),
|
||||
});
|
||||
captures.push(Capture::new(capture, pattern, value_end, name));
|
||||
}
|
||||
}
|
||||
if cursor.did_exceed_match_limit() {
|
||||
@@ -615,6 +770,39 @@ fn collect_layer(
|
||||
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(),
|
||||
});
|
||||
}
|
||||
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]
|
||||
|
||||
+168
-16
@@ -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,6 +1879,22 @@ impl DiffSyntaxCache {
|
||||
.checked_add(1)
|
||||
.expect("diff syntax revisions exhausted");
|
||||
}
|
||||
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)),
|
||||
@@ -1831,6 +1903,41 @@ impl DiffSyntaxCache {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -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();
|
||||
|
||||
+6
-3
@@ -115,7 +115,7 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close()
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View)
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
let comparison = session
|
||||
.editor()
|
||||
@@ -222,7 +222,7 @@ fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View)
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
session.handle(Input::BackTab);
|
||||
let shown = |session: &EditorSession| {
|
||||
@@ -350,7 +350,7 @@ fn readonly_jump_history_restores_the_comparison_file_and_source_cursor() {
|
||||
session.handle(input);
|
||||
}
|
||||
wait_until(&mut session, |session| {
|
||||
session.editor().diff().unwrap().phase == Phase::View
|
||||
session.editor().diff().unwrap().selected_file_loaded()
|
||||
});
|
||||
session.handle(Input::Char('\t'));
|
||||
let first_origin = session.editor().cursor();
|
||||
@@ -365,6 +365,9 @@ fn readonly_jump_history_restores_the_comparison_file_and_source_cursor() {
|
||||
] {
|
||||
session.handle(input);
|
||||
}
|
||||
wait_until(&mut session, |session| {
|
||||
session.editor().diff().unwrap().selected_file_loaded()
|
||||
});
|
||||
let second_origin = session.editor().cursor();
|
||||
type_query(&mut session, "/second_before");
|
||||
session.handle(Input::Enter);
|
||||
|
||||
+1
-1
@@ -306,7 +306,7 @@ fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_exce
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View)
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
let excerpts = session.editor().diff().unwrap().display_rows.clone();
|
||||
assert!(
|
||||
|
||||
+157
-36
@@ -127,6 +127,26 @@ fn working(kind: RepoKind) -> Revision {
|
||||
r.target = RevisionTarget::Working;
|
||||
r
|
||||
}
|
||||
|
||||
fn compare_loaded(
|
||||
repository: &mut Repository,
|
||||
before: &Revision,
|
||||
after: &Revision,
|
||||
buffers: &[(PathBuf, String)],
|
||||
) -> Result<Comparison, String> {
|
||||
let mut comparison = repository.compare(1, before, after, buffers)?;
|
||||
for (index, file) in comparison.files.iter_mut().enumerate() {
|
||||
*file = repository.load_file(1, index)?;
|
||||
if file
|
||||
.notice
|
||||
.as_deref()
|
||||
.is_some_and(|notice| notice.contains("Binary or non-UTF-8"))
|
||||
{
|
||||
comparison.editable_paths.remove(&file.path);
|
||||
}
|
||||
}
|
||||
Ok(comparison)
|
||||
}
|
||||
fn changed<'a>(comparison: &'a Comparison, path: &Path) -> &'a vamp::diff::ChangedFile {
|
||||
comparison
|
||||
.files
|
||||
@@ -236,8 +256,12 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
|
||||
assert!(catalog.entries.iter().any(|r| r.label == "nested"
|
||||
&& r.category == Category::Tags
|
||||
&& r.target == RevisionTarget::Commit(id.clone())));
|
||||
let comparison = repository
|
||||
.compare(&revision(&id, RepoKind::Git), &working(RepoKind::Git), &[])
|
||||
let comparison = compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&id, RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let text = changed(&comparison, Path::new("text"));
|
||||
assert_eq!(comparison.root, fs::canonicalize(&root).unwrap());
|
||||
@@ -343,8 +367,12 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
|
||||
assert_eq!(moved.old_path.as_deref(), Some(Path::new("rename-old")));
|
||||
assert_eq!(moved.before, moved.after);
|
||||
assert!(moved.hunks.is_empty());
|
||||
let reverse = repository
|
||||
.compare(&working(RepoKind::Git), &revision(&id, RepoKind::Git), &[])
|
||||
let reverse = compare_loaded(
|
||||
&mut repository,
|
||||
&working(RepoKind::Git),
|
||||
&revision(&id, RepoKind::Git),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reverse.editable_paths.is_empty());
|
||||
assert_eq!(
|
||||
@@ -353,7 +381,7 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
|
||||
);
|
||||
assert!(
|
||||
repository
|
||||
.compare(&working(RepoKind::Git), &working(RepoKind::Git), &[])
|
||||
.compare(1, &working(RepoKind::Git), &working(RepoKind::Git), &[])
|
||||
.unwrap()
|
||||
.files
|
||||
.is_empty()
|
||||
@@ -397,7 +425,7 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
||||
);
|
||||
let state = disk_state(&active);
|
||||
let mut repository = open(&active);
|
||||
let catalog = repository.catalog().unwrap();
|
||||
let catalog = repository.list_remote("origin").unwrap();
|
||||
let remote = catalog
|
||||
.entries
|
||||
.iter()
|
||||
@@ -439,8 +467,8 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
let comparison = repository
|
||||
.compare(
|
||||
let comparison = compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&local, RepoKind::Git),
|
||||
&remote,
|
||||
&[(active.join("file"), "unsaved local text\n".into())],
|
||||
@@ -452,11 +480,10 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
||||
changed(&comparison, Path::new("file")).after,
|
||||
["remote tip"]
|
||||
);
|
||||
let comparison = repository.compare(&tag, &remote, &[]).unwrap();
|
||||
let comparison = compare_loaded(&mut repository, &tag, &remote, &[]).unwrap();
|
||||
assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]);
|
||||
let comparison = repository
|
||||
.compare(&remote, &working(RepoKind::Git), &[])
|
||||
.unwrap();
|
||||
let comparison =
|
||||
compare_loaded(&mut repository, &remote, &working(RepoKind::Git), &[]).unwrap();
|
||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]);
|
||||
let loaded = repository.fetch_remote("origin").unwrap();
|
||||
let historic = loaded
|
||||
@@ -468,8 +495,12 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
||||
.unwrap();
|
||||
assert_eq!(historic.label, ancestor);
|
||||
assert_eq!(historic.description, "remote ancestor needle");
|
||||
let comparison = repository
|
||||
.compare(historic, &revision(&local, RepoKind::Git), &[])
|
||||
let comparison = compare_loaded(
|
||||
&mut repository,
|
||||
historic,
|
||||
&revision(&local, RepoKind::Git),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]);
|
||||
assert!(
|
||||
@@ -541,9 +572,8 @@ fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_oper
|
||||
.iter()
|
||||
.any(|r| r.label == "baseline" && r.category == Category::Bookmarks)
|
||||
);
|
||||
let comparison = repository
|
||||
.compare(baseline, &working(RepoKind::Jujutsu), &[])
|
||||
.unwrap();
|
||||
let comparison =
|
||||
compare_loaded(&mut repository, baseline, &working(RepoKind::Jujutsu), &[]).unwrap();
|
||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["disk"]);
|
||||
assert_eq!(
|
||||
changed(&comparison, Path::new("deleted")).status,
|
||||
@@ -644,7 +674,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
fs::write(active.join("unsnapshotted"), "keep disk\n").unwrap();
|
||||
let state = disk_state(&active);
|
||||
let mut repository = open(&active);
|
||||
let catalog = repository.catalog().unwrap();
|
||||
let catalog = repository.list_remote("origin").unwrap();
|
||||
let remote = catalog
|
||||
.entries
|
||||
.iter()
|
||||
@@ -663,9 +693,8 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
.iter()
|
||||
.any(|r| r.target == RevisionTarget::Commit(ancestor.clone()))
|
||||
);
|
||||
let comparison = repository
|
||||
.compare(&remote, &working(RepoKind::Jujutsu), &[])
|
||||
.unwrap();
|
||||
let comparison =
|
||||
compare_loaded(&mut repository, &remote, &working(RepoKind::Jujutsu), &[]).unwrap();
|
||||
assert_eq!(
|
||||
changed(&comparison, Path::new("file")).before,
|
||||
["remote tip"]
|
||||
@@ -682,7 +711,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
.unwrap();
|
||||
assert!(historic.label.contains(&ancestor) && historic.label.contains(&change));
|
||||
assert_eq!(historic.description, "remote historic needle");
|
||||
let comparison = repository.compare(historic, &remote, &[]).unwrap();
|
||||
let comparison = compare_loaded(&mut repository, historic, &remote, &[]).unwrap();
|
||||
assert_eq!(
|
||||
changed(&comparison, Path::new("file")).before,
|
||||
["remote ancestor"]
|
||||
@@ -735,7 +764,14 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
||||
let state = disk_state(&root);
|
||||
let mut repository = open(&root);
|
||||
let catalog = repository.catalog().unwrap();
|
||||
assert!(catalog.notice.unwrap().contains("broken"));
|
||||
assert!(catalog.notice.is_none());
|
||||
assert!(
|
||||
catalog
|
||||
.entries
|
||||
.iter()
|
||||
.any(|revision| revision.target == RevisionTarget::ListRemote("broken".into()))
|
||||
);
|
||||
assert!(repository.list_remote("broken").is_err());
|
||||
assert!(repository.fetch_remote("broken").is_err());
|
||||
assert!(
|
||||
repository
|
||||
@@ -746,6 +782,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
||||
assert!(
|
||||
repository
|
||||
.compare(
|
||||
1,
|
||||
&revision("--bad", RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
&[]
|
||||
@@ -756,6 +793,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
||||
assert!(
|
||||
repository
|
||||
.compare(
|
||||
1,
|
||||
&revision(&"f".repeat(40), RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
&[],
|
||||
@@ -769,7 +807,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
||||
};
|
||||
assert!(
|
||||
repository
|
||||
.compare(&loader, &revision(&id, RepoKind::Git), &[])
|
||||
.compare(1, &loader, &revision(&id, RepoKind::Git), &[])
|
||||
.unwrap_err()
|
||||
.contains("history loader")
|
||||
);
|
||||
@@ -810,6 +848,7 @@ fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit(
|
||||
assert!(
|
||||
repository
|
||||
.compare(
|
||||
1,
|
||||
&revision(&baseline, RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
&[]
|
||||
@@ -824,8 +863,8 @@ fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit(
|
||||
git(&root.join("nested"), &["fetch", "origin"]);
|
||||
git(&root.join("nested"), &["checkout", "--detach", &second]);
|
||||
let state = disk_state(&root);
|
||||
let comparison = repository
|
||||
.compare(
|
||||
let comparison = compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&baseline, RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
&[],
|
||||
@@ -897,8 +936,8 @@ fn multiple_remotes_retain_tag_only_history_even_when_tag_names_collide() {
|
||||
&& r.description == format!("{name} tag-only needle"))
|
||||
);
|
||||
}
|
||||
let comparison = repository
|
||||
.compare(
|
||||
let comparison = compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&ids[0], RepoKind::Git),
|
||||
&revision(&ids[1], RepoKind::Git),
|
||||
&[],
|
||||
@@ -1011,8 +1050,8 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
|
||||
.iter()
|
||||
.any(|r| r.target == RevisionTarget::Commit(merged.clone()))
|
||||
);
|
||||
let comparison = repository
|
||||
.compare(
|
||||
let comparison = compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&base, RepoKind::Jujutsu),
|
||||
&revision(&merged, RepoKind::Jujutsu),
|
||||
&[],
|
||||
@@ -1032,8 +1071,8 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
|
||||
assert_eq!(ordinary.before, ["old"]);
|
||||
assert_eq!(ordinary.after, ["left ordinary"]);
|
||||
assert!(ordinary.notice.is_none());
|
||||
let working_comparison = repository
|
||||
.compare(
|
||||
let working_comparison = compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&merged, RepoKind::Jujutsu),
|
||||
&working(RepoKind::Jujutsu),
|
||||
&[],
|
||||
@@ -1079,7 +1118,7 @@ fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths()
|
||||
let mut repository = open(&root.join("sub"));
|
||||
let base = revision(&id, RepoKind::Git);
|
||||
let checkout = working(RepoKind::Git);
|
||||
let comparison = repository.compare(&base, &checkout, &buffers).unwrap();
|
||||
let comparison = compare_loaded(&mut repository, &base, &checkout, &buffers).unwrap();
|
||||
assert_eq!(comparison.root, fs::canonicalize(&root).unwrap());
|
||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["unsaved"]);
|
||||
assert!(comparison.editable_paths.contains(Path::new("file")));
|
||||
@@ -1101,7 +1140,7 @@ fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths()
|
||||
|| file.path == Path::new("missing")
|
||||
|| file.path == Path::new("linked/file"))
|
||||
);
|
||||
let immutable = repository.compare(&checkout, &base, &buffers).unwrap();
|
||||
let immutable = compare_loaded(&mut repository, &checkout, &base, &buffers).unwrap();
|
||||
assert!(immutable.editable_paths.is_empty());
|
||||
assert!(
|
||||
!immutable
|
||||
@@ -1111,12 +1150,12 @@ fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths()
|
||||
);
|
||||
assert!(
|
||||
repository
|
||||
.compare(&base, &base, &buffers)
|
||||
.compare(1, &base, &base, &buffers)
|
||||
.unwrap()
|
||||
.files
|
||||
.is_empty()
|
||||
);
|
||||
let same_checkout = repository.compare(&checkout, &checkout, &buffers).unwrap();
|
||||
let same_checkout = compare_loaded(&mut repository, &checkout, &checkout, &buffers).unwrap();
|
||||
assert_eq!(changed(&same_checkout, Path::new("file")).before, ["base"]);
|
||||
assert_eq!(
|
||||
changed(&same_checkout, Path::new("file")).after,
|
||||
@@ -1128,3 +1167,85 @@ fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths()
|
||||
"outside\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immutable_catalog_skips_unchanged_blobs_and_prepares_only_requested_details() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("lazy");
|
||||
init_git(&root);
|
||||
fs::write(root.join("unchanged-large"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
|
||||
fs::write(root.join("selected"), "before\n").unwrap();
|
||||
fs::write(root.join("unselected"), "old\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "before"]);
|
||||
let before = git(&root, &["rev-parse", "HEAD"]);
|
||||
fs::write(root.join("selected"), "after\n").unwrap();
|
||||
fs::write(root.join("unselected"), vec![b'y'; 17 * 1024 * 1024]).unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "after"]);
|
||||
let after = git(&root, &["rev-parse", "HEAD"]);
|
||||
let mut repository = open(&root);
|
||||
let comparison = repository
|
||||
.compare(
|
||||
100,
|
||||
&revision(&before, RepoKind::Git),
|
||||
&revision(&after, RepoKind::Git),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(comparison.files.len(), 2);
|
||||
assert!(comparison.files.iter().all(|file| !file.loaded
|
||||
&& file.before.is_empty()
|
||||
&& file.after.is_empty()
|
||||
&& file.hunks.is_empty()));
|
||||
let index = comparison
|
||||
.files
|
||||
.iter()
|
||||
.position(|file| file.path == Path::new("selected"))
|
||||
.unwrap();
|
||||
let selected = repository.load_file(100, index).unwrap();
|
||||
assert!(selected.loaded);
|
||||
assert_eq!(selected.before, ["before"]);
|
||||
assert_eq!(selected.after, ["after"]);
|
||||
assert_eq!(selected.hunks.len(), 1);
|
||||
assert!(repository.load_file(99, index).is_err());
|
||||
let empty = repository
|
||||
.compare(
|
||||
101,
|
||||
&revision(&after, RepoKind::Git),
|
||||
&revision(&after, RepoKind::Git),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(empty.files.is_empty());
|
||||
assert!(repository.load_file(100, index).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("frozen").canonicalize().unwrap();
|
||||
init_git(&root);
|
||||
fs::write(root.join("file"), "committed\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "before"]);
|
||||
fs::write(root.join("file"), "disk before\n").unwrap();
|
||||
let mut repository = open(&root);
|
||||
let comparison = repository
|
||||
.compare(
|
||||
50,
|
||||
&working(RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
&[(root.join("file"), "unsaved after\n".into())],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(comparison.files.len(), 1);
|
||||
assert!(!comparison.files[0].loaded);
|
||||
fs::write(root.join("file"), "later disk\n").unwrap();
|
||||
let details = repository.load_file(50, 0).unwrap();
|
||||
assert_eq!(details.before, ["disk before"]);
|
||||
assert_eq!(details.after, ["unsaved after"]);
|
||||
let again = repository.load_file(50, 0).unwrap();
|
||||
assert_eq!(again.before, details.before);
|
||||
assert_eq!(again.after, details.after);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user