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] 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).
|
- [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).
|
- [ ] 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: 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: 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: 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.
|
- [ ] 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 |
|
| Enter | Choose the entry |
|
||||||
| Esc | Cancel loading; After → return to Before; Before → clear query without closing |
|
| 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.
|
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.
|
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.
|
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).
|
Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive).
|
||||||
|
|||||||
+580
-31
@@ -44,6 +44,7 @@ pub enum RevisionTarget {
|
|||||||
Working,
|
Working,
|
||||||
Commit(String),
|
Commit(String),
|
||||||
Remote { remote: String, reference: String },
|
Remote { remote: String, reference: String },
|
||||||
|
ListRemote(String),
|
||||||
FetchRemote(String),
|
FetchRemote(String),
|
||||||
}
|
}
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -78,6 +79,8 @@ pub struct ChangedFile {
|
|||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub old_path: Option<PathBuf>,
|
pub old_path: Option<PathBuf>,
|
||||||
pub status: FileStatus,
|
pub status: FileStatus,
|
||||||
|
/// False means text, EOF flags, hunks and notices have not been prepared.
|
||||||
|
pub loaded: bool,
|
||||||
pub before: Vec<String>,
|
pub before: Vec<String>,
|
||||||
pub after: Vec<String>,
|
pub after: Vec<String>,
|
||||||
pub before_eof: bool,
|
pub before_eof: bool,
|
||||||
@@ -91,22 +94,33 @@ pub struct Comparison {
|
|||||||
pub after: String,
|
pub after: String,
|
||||||
pub files: Vec<ChangedFile>,
|
pub files: Vec<ChangedFile>,
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
|
/// Regular working-After candidates; loaded binary details are excluded.
|
||||||
pub editable_paths: BTreeSet<PathBuf>,
|
pub editable_paths: BTreeSet<PathBuf>,
|
||||||
}
|
}
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Operation {
|
pub enum Operation {
|
||||||
Discover,
|
Discover,
|
||||||
|
ListRemote(String),
|
||||||
FetchRemote(String),
|
FetchRemote(String),
|
||||||
Compare {
|
Compare {
|
||||||
before: Revision,
|
before: Revision,
|
||||||
after: Revision,
|
after: Revision,
|
||||||
buffers: Box<[(PathBuf, String)]>,
|
buffers: Box<[(PathBuf, String)]>,
|
||||||
},
|
},
|
||||||
|
LoadFile {
|
||||||
|
comparison_id: u64,
|
||||||
|
file_index: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Outcome {
|
pub enum Outcome {
|
||||||
Catalog(Catalog),
|
Catalog(Catalog),
|
||||||
Comparison(Comparison),
|
Comparison(Comparison),
|
||||||
|
File {
|
||||||
|
comparison_id: u64,
|
||||||
|
file_index: usize,
|
||||||
|
file: ChangedFile,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum Phase {
|
pub enum Phase {
|
||||||
@@ -158,6 +172,10 @@ struct SelectorPosition {
|
|||||||
enum RequestKind {
|
enum RequestKind {
|
||||||
Catalog,
|
Catalog,
|
||||||
Comparison,
|
Comparison,
|
||||||
|
File {
|
||||||
|
comparison_id: u64,
|
||||||
|
file_index: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -196,6 +214,7 @@ pub struct DiffState {
|
|||||||
before_position: Option<SelectorPosition>,
|
before_position: Option<SelectorPosition>,
|
||||||
max_before: usize,
|
max_before: usize,
|
||||||
max_after: usize,
|
max_after: usize,
|
||||||
|
after_widths: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DiffState {
|
impl DiffState {
|
||||||
@@ -235,6 +254,7 @@ impl DiffState {
|
|||||||
before_position: None,
|
before_position: None,
|
||||||
max_before: 0,
|
max_before: 0,
|
||||||
max_after: 0,
|
max_after: 0,
|
||||||
|
after_widths: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +271,13 @@ impl DiffState {
|
|||||||
fn queue(&mut self, operation: Operation) {
|
fn queue(&mut self, operation: Operation) {
|
||||||
self.request_kind = Some(match operation {
|
self.request_kind = Some(match operation {
|
||||||
Operation::Compare { .. } => RequestKind::Comparison,
|
Operation::Compare { .. } => RequestKind::Comparison,
|
||||||
|
Operation::LoadFile {
|
||||||
|
comparison_id,
|
||||||
|
file_index,
|
||||||
|
} => RequestKind::File {
|
||||||
|
comparison_id,
|
||||||
|
file_index,
|
||||||
|
},
|
||||||
_ => RequestKind::Catalog,
|
_ => RequestKind::Catalog,
|
||||||
});
|
});
|
||||||
self.pending = Some(operation);
|
self.pending = Some(operation);
|
||||||
@@ -259,6 +286,24 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn apply_result(&mut self, id: u64, result: Result<Outcome, String>) {
|
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();
|
let expected = self.request_kind.take();
|
||||||
self.loading = false;
|
self.loading = false;
|
||||||
self.pending = None;
|
self.pending = None;
|
||||||
@@ -313,8 +358,37 @@ impl DiffState {
|
|||||||
} else {
|
} else {
|
||||||
self.first_text_focus()
|
self.first_text_focus()
|
||||||
};
|
};
|
||||||
self.align_file();
|
self.prepare_selected_file();
|
||||||
self.error = None;
|
}
|
||||||
|
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()),
|
Ok(_) => self.error = Some("Unexpected repository response".into()),
|
||||||
Err(error) => self.error = Some(error),
|
Err(error) => self.error = Some(error),
|
||||||
@@ -481,6 +555,8 @@ impl DiffState {
|
|||||||
};
|
};
|
||||||
if let RevisionTarget::FetchRemote(remote) = &revision.target {
|
if let RevisionTarget::FetchRemote(remote) = &revision.target {
|
||||||
self.queue(Operation::FetchRemote(remote.clone()));
|
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 {
|
} else if self.phase == Phase::Before {
|
||||||
self.before_position = Some(SelectorPosition {
|
self.before_position = Some(SelectorPosition {
|
||||||
category: self.category,
|
category: self.category,
|
||||||
@@ -597,6 +673,9 @@ impl DiffState {
|
|||||||
else {
|
else {
|
||||||
return PaneLayout::Empty;
|
return PaneLayout::Empty;
|
||||||
};
|
};
|
||||||
|
if !file.loaded {
|
||||||
|
return PaneLayout::Empty;
|
||||||
|
}
|
||||||
match file.status {
|
match file.status {
|
||||||
FileStatus::Added => PaneLayout::After,
|
FileStatus::Added => PaneLayout::After,
|
||||||
FileStatus::Removed => PaneLayout::Before,
|
FileStatus::Removed => PaneLayout::Before,
|
||||||
@@ -697,16 +776,20 @@ impl DiffState {
|
|||||||
|
|
||||||
pub fn after_editable(&self) -> bool {
|
pub fn after_editable(&self) -> bool {
|
||||||
self.phase == Phase::View
|
self.phase == Phase::View
|
||||||
|
&& self.selected_file_loaded()
|
||||||
&& matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
|
&& matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
|
||||||
&& self
|
&& self
|
||||||
.after
|
.after
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|revision| revision.target == RevisionTarget::Working)
|
.is_some_and(|revision| revision.target == RevisionTarget::Working)
|
||||||
&& self.comparison.as_ref().is_some_and(|comparison| {
|
&& self.comparison.as_ref().is_some_and(|comparison| {
|
||||||
comparison
|
comparison.files.get(self.file).is_some_and(|file| {
|
||||||
.files
|
comparison.editable_paths.contains(&file.path)
|
||||||
.get(self.file)
|
&& file
|
||||||
.is_some_and(|file| comparison.editable_paths.contains(&file.path))
|
.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_buffer = None;
|
||||||
self.live_revision = None;
|
self.live_revision = None;
|
||||||
self.follow_caret = false;
|
self.follow_caret = false;
|
||||||
self.align_file();
|
self.prepare_selected_file();
|
||||||
true
|
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> {
|
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
|
||||||
self.display_rows.iter().position(|display| {
|
self.display_rows.iter().position(|display| {
|
||||||
matches!(*display, DisplayRow::Line(index) if self.source_row(focus, index) == Some(source_row))
|
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 file = &self.comparison.as_ref().unwrap().files[self.file];
|
||||||
let was_live = self.live_files.contains(&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<_>>();
|
let mut old = file.after.iter().map(String::as_str).collect::<Vec<_>>();
|
||||||
if !was_live && (file.after_eof || old.is_empty()) {
|
if !was_live && (file.after_eof || old.is_empty()) {
|
||||||
old.push("");
|
old.push("");
|
||||||
@@ -870,10 +1001,13 @@ impl DiffState {
|
|||||||
.map(map_after);
|
.map(map_after);
|
||||||
let was_changed = file_is_changed(file);
|
let was_changed = file_is_changed(file);
|
||||||
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
|
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
|
||||||
file.after.resize_with(lines.len(), String::new);
|
replace_after_lines(
|
||||||
for (target, line) in file.after.iter_mut().zip(lines) {
|
&mut file.after,
|
||||||
target.clone_from(line);
|
&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.after_eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
|
||||||
file.hunks = line_hunks(
|
file.hunks = line_hunks(
|
||||||
&file.before,
|
&file.before,
|
||||||
@@ -946,35 +1080,31 @@ impl DiffState {
|
|||||||
self.left_before = 0;
|
self.left_before = 0;
|
||||||
self.left_after = 0;
|
self.left_after = 0;
|
||||||
self.max_before = 0;
|
self.max_before = 0;
|
||||||
|
self.max_after = 0;
|
||||||
|
self.after_widths.clear();
|
||||||
}
|
}
|
||||||
self.max_after = 0;
|
|
||||||
let Some(file) = self
|
let Some(file) = self
|
||||||
.comparison
|
.comparison
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|comparison| comparison.files.get(self.file))
|
.and_then(|comparison| comparison.files.get(self.file))
|
||||||
|
.filter(|file| file.loaded)
|
||||||
else {
|
else {
|
||||||
|
self.after_widths.clear();
|
||||||
|
self.max_before = 0;
|
||||||
|
self.max_after = 0;
|
||||||
return;
|
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 {
|
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) {
|
let after_len = if self.live_files.contains(&self.file) {
|
||||||
live_content(&file.after, file.after_eof).len()
|
live_content(&file.after, file.after_eof).len()
|
||||||
} else {
|
} 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] {
|
fn live_content(lines: &[String], eof: bool) -> &[String] {
|
||||||
if eof || (lines.len() == 1 && lines[0].is_empty()) {
|
if eof || (lines.len() == 1 && lines[0].is_empty()) {
|
||||||
&lines[..lines.len().saturating_sub(1)]
|
&lines[..lines.len().saturating_sub(1)]
|
||||||
@@ -1220,7 +1433,8 @@ pub(crate) fn line_hunks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn file_is_changed(file: &ChangedFile) -> bool {
|
fn file_is_changed(file: &ChangedFile) -> bool {
|
||||||
file.status != FileStatus::Modified
|
!file.loaded
|
||||||
|
|| file.status != FileStatus::Modified
|
||||||
|| !file.hunks.is_empty()
|
|| !file.hunks.is_empty()
|
||||||
|| file.notice.as_ref().is_some_and(|notice| {
|
|| file.notice.as_ref().is_some_and(|notice| {
|
||||||
notice
|
notice
|
||||||
@@ -1269,6 +1483,9 @@ fn update_eof_notice(file: &mut ChangedFile) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::editor::{Editor, Effect, Mode};
|
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 {
|
fn excerpt_state(len: usize, changed: &[usize]) -> DiffState {
|
||||||
let mut state = DiffState::new();
|
let mut state = DiffState::new();
|
||||||
@@ -1583,6 +1800,7 @@ mod tests {
|
|||||||
path: "renamed.txt".into(),
|
path: "renamed.txt".into(),
|
||||||
old_path: Some("old.txt".into()),
|
old_path: Some("old.txt".into()),
|
||||||
status: FileStatus::Renamed,
|
status: FileStatus::Renamed,
|
||||||
|
loaded: true,
|
||||||
before: vec![
|
before: vec![
|
||||||
"same".into(),
|
"same".into(),
|
||||||
"old".into(),
|
"old".into(),
|
||||||
@@ -1619,6 +1837,7 @@ mod tests {
|
|||||||
path: "added.txt".into(),
|
path: "added.txt".into(),
|
||||||
old_path: None,
|
old_path: None,
|
||||||
status: FileStatus::Added,
|
status: FileStatus::Added,
|
||||||
|
loaded: true,
|
||||||
before: vec![],
|
before: vec![],
|
||||||
after: vec!["a".into(), "b".into()],
|
after: vec!["a".into(), "b".into()],
|
||||||
before_eof: false,
|
before_eof: false,
|
||||||
@@ -1635,6 +1854,7 @@ mod tests {
|
|||||||
path: "removed.bin".into(),
|
path: "removed.bin".into(),
|
||||||
old_path: None,
|
old_path: None,
|
||||||
status: FileStatus::Removed,
|
status: FileStatus::Removed,
|
||||||
|
loaded: true,
|
||||||
before: vec![],
|
before: vec![],
|
||||||
after: vec![],
|
after: vec![],
|
||||||
before_eof: false,
|
before_eof: false,
|
||||||
@@ -2134,6 +2354,95 @@ mod tests {
|
|||||||
assert_eq!(editor.diff().unwrap().comparison_id, Some(second));
|
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]
|
#[test]
|
||||||
fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() {
|
fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() {
|
||||||
let mut editor = editor();
|
let mut editor = editor();
|
||||||
@@ -2309,6 +2618,7 @@ mod tests {
|
|||||||
before_eof: eof,
|
before_eof: eof,
|
||||||
after_eof: eof,
|
after_eof: eof,
|
||||||
notice: None,
|
notice: None,
|
||||||
|
loaded: true,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
state.files = Some(FilePicker::from_paths(
|
state.files = Some(FilePicker::from_paths(
|
||||||
@@ -2319,6 +2629,147 @@ mod tests {
|
|||||||
state
|
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]
|
#[test]
|
||||||
fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() {
|
fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() {
|
||||||
let base = (0..100)
|
let base = (0..100)
|
||||||
@@ -2398,4 +2849,102 @@ mod tests {
|
|||||||
empty.handle(Input::Escape);
|
empty.handle(Input::Escape);
|
||||||
assert_eq!(empty.phase, Phase::View);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let Some(diff) = &self.diff else { return };
|
let Some(diff) = &self.diff else { return };
|
||||||
if diff.phase != Phase::View {
|
if diff.phase != Phase::View || !diff.selected_file_loaded() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(comparison_id) = diff.comparison_id else {
|
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 text = fs::read_to_string(root.join(path)).unwrap();
|
||||||
let lines: Vec<String> = text.lines().map(str::to_owned).collect();
|
let lines: Vec<String> = text.lines().map(str::to_owned).collect();
|
||||||
ChangedFile {
|
ChangedFile {
|
||||||
|
loaded: true,
|
||||||
path: PathBuf::from(*path),
|
path: PathBuf::from(*path),
|
||||||
old_path: None,
|
old_path: None,
|
||||||
status: FileStatus::Modified,
|
status: FileStatus::Modified,
|
||||||
@@ -7494,6 +7495,7 @@ mod tests {
|
|||||||
after: "head".into(),
|
after: "head".into(),
|
||||||
editable_paths: Default::default(),
|
editable_paths: Default::default(),
|
||||||
files: vec![ChangedFile {
|
files: vec![ChangedFile {
|
||||||
|
loaded: true,
|
||||||
path: "file.txt".into(),
|
path: "file.txt".into(),
|
||||||
old_path: None,
|
old_path: None,
|
||||||
status: FileStatus::Modified,
|
status: FileStatus::Modified,
|
||||||
|
|||||||
+297
-122
@@ -297,10 +297,18 @@ struct Remote {
|
|||||||
struct FileData {
|
struct FileData {
|
||||||
mode: String,
|
mode: String,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
|
object: Option<(PathBuf, String)>,
|
||||||
notice: Option<String>,
|
notice: Option<String>,
|
||||||
}
|
}
|
||||||
type Snapshot = BTreeMap<PathBuf, FileData>;
|
type Snapshot = BTreeMap<PathBuf, FileData>;
|
||||||
|
|
||||||
|
struct PreparedComparison {
|
||||||
|
id: u64,
|
||||||
|
left: Snapshot,
|
||||||
|
right: Snapshot,
|
||||||
|
files: Vec<(PathBuf, Option<PathBuf>, FileStatus)>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Repository {
|
pub struct Repository {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
kind: RepoKind,
|
kind: RepoKind,
|
||||||
@@ -311,6 +319,7 @@ pub struct Repository {
|
|||||||
remote_refs: BTreeMap<(String, String), String>,
|
remote_refs: BTreeMap<(String, String), String>,
|
||||||
loaded: BTreeSet<String>,
|
loaded: BTreeSet<String>,
|
||||||
registered: BTreeSet<String>,
|
registered: BTreeSet<String>,
|
||||||
|
comparison: Option<PreparedComparison>,
|
||||||
cancelled: Arc<AtomicBool>,
|
cancelled: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
impl Repository {
|
impl Repository {
|
||||||
@@ -348,6 +357,7 @@ impl Repository {
|
|||||||
remote_refs: BTreeMap::new(),
|
remote_refs: BTreeMap::new(),
|
||||||
loaded: BTreeSet::new(),
|
loaded: BTreeSet::new(),
|
||||||
registered: BTreeSet::new(),
|
registered: BTreeSet::new(),
|
||||||
|
comparison: None,
|
||||||
cancelled,
|
cancelled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -562,42 +572,53 @@ impl Repository {
|
|||||||
self.revisions(root, store, &mut entries, &mut notices)?;
|
self.revisions(root, store, &mut entries, &mut notices)?;
|
||||||
}
|
}
|
||||||
for remote in &self.remotes {
|
for remote in &self.remotes {
|
||||||
entries.push(Revision {
|
for &view in self.kind.categories() {
|
||||||
target: RevisionTarget::FetchRemote(remote.name.clone()),
|
let history = view == category;
|
||||||
label: format!("Load history from {}", remote.name),
|
entries.push(Revision {
|
||||||
description: "Fetch reachable history into a private comparison repository".into(),
|
target: if history {
|
||||||
category,
|
RevisionTarget::FetchRemote(remote.name.clone())
|
||||||
});
|
} else {
|
||||||
match self.advertised(&remote.url) {
|
RevisionTarget::ListRemote(remote.name.clone())
|
||||||
Ok(refs) => {
|
},
|
||||||
self.remote_refs.retain(|(name, _), _| name != &remote.name);
|
label: format!(
|
||||||
for (reference, id) in refs {
|
"Load {} from {}",
|
||||||
let tag = reference.starts_with("refs/tags/");
|
if history { "history" } else { "refs" },
|
||||||
let name = reference
|
remote.name
|
||||||
.strip_prefix(if tag { "refs/tags/" } else { "refs/heads/" })
|
),
|
||||||
.unwrap();
|
description: if history {
|
||||||
if !tag || self.kind == RepoKind::Git {
|
"Fetch reachable history into a private comparison repository"
|
||||||
entries.push(Revision {
|
} else {
|
||||||
target: RevisionTarget::Remote {
|
"List remote references without fetching history"
|
||||||
remote: remote.name.clone(),
|
|
||||||
reference: reference.clone(),
|
|
||||||
},
|
|
||||||
label: format!("{name}@{}", remote.name),
|
|
||||||
description: id.clone(),
|
|
||||||
category: if tag {
|
|
||||||
Category::Tags
|
|
||||||
} else if self.kind == RepoKind::Jujutsu {
|
|
||||||
Category::Bookmarks
|
|
||||||
} else {
|
|
||||||
Category::Branches
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.remote_refs
|
|
||||||
.insert((remote.name.clone(), reference), id);
|
|
||||||
}
|
}
|
||||||
|
.into(),
|
||||||
|
category: view,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for ((name, reference), id) in &self.remote_refs {
|
||||||
|
if name != &remote.name {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let tag = reference.starts_with("refs/tags/");
|
||||||
|
let label = reference
|
||||||
|
.strip_prefix(if tag { "refs/tags/" } else { "refs/heads/" })
|
||||||
|
.unwrap();
|
||||||
|
if !tag || self.kind == RepoKind::Git {
|
||||||
|
entries.push(Revision {
|
||||||
|
target: RevisionTarget::Remote {
|
||||||
|
remote: name.clone(),
|
||||||
|
reference: reference.clone(),
|
||||||
|
},
|
||||||
|
label: format!("{label}@{name}"),
|
||||||
|
description: id.clone(),
|
||||||
|
category: if tag {
|
||||||
|
Category::Tags
|
||||||
|
} else if self.kind == RepoKind::Jujutsu {
|
||||||
|
Category::Bookmarks
|
||||||
|
} else {
|
||||||
|
Category::Branches
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Err(e) => notices.push(format!("{}: {e}", remote.name)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.check_cancelled()?;
|
self.check_cancelled()?;
|
||||||
@@ -645,7 +666,7 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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() {
|
if self.remotes.is_empty() {
|
||||||
self.discover_remotes()?;
|
self.discover_remotes()?;
|
||||||
}
|
}
|
||||||
@@ -674,7 +695,27 @@ impl Repository {
|
|||||||
"--remote",
|
"--remote",
|
||||||
&format!("exact:{name}"),
|
&format!("exact:{name}"),
|
||||||
"--branch",
|
"--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 {
|
} else {
|
||||||
@@ -690,16 +731,33 @@ impl Repository {
|
|||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
self.loaded.insert(name.into());
|
if reference.is_none() {
|
||||||
let refs = self.advertised(&url)?;
|
self.loaded.insert(name.into());
|
||||||
self.remote_refs.retain(|(remote, _), _| remote != name);
|
let refs = self.advertised(&url)?;
|
||||||
for (reference, id) in refs {
|
self.remote_refs.retain(|(remote, _), _| remote != name);
|
||||||
self.remote_refs.insert((name.into(), reference), id);
|
for (reference, id) in refs {
|
||||||
|
self.remote_refs.insert((name.into(), reference), id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
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> {
|
pub fn fetch_remote(&mut self, remote: &str) -> Result<Catalog, String> {
|
||||||
self.load_remote(remote)?;
|
self.load_remote(remote, None)?;
|
||||||
self.catalog()
|
self.catalog()
|
||||||
}
|
}
|
||||||
fn resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> {
|
fn resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> {
|
||||||
@@ -719,22 +777,33 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
id.clone()
|
id.clone()
|
||||||
}
|
}
|
||||||
RevisionTarget::Remote { remote, reference } => {
|
RevisionTarget::Remote { remote, reference } => self
|
||||||
if !self.loaded.contains(remote) {
|
.remote_refs
|
||||||
self.load_remote(remote)?;
|
.get(&(remote.clone(), reference.clone()))
|
||||||
}
|
.ok_or_else(|| {
|
||||||
self.remote_refs
|
format!("Remote reference is no longer advertised: {reference}@{remote}")
|
||||||
.get(&(remote.clone(), reference.clone()))
|
})?
|
||||||
.ok_or_else(|| {
|
.clone(),
|
||||||
format!("Remote reference is no longer advertised: {reference}@{remote}")
|
|
||||||
})?
|
|
||||||
.clone()
|
|
||||||
}
|
|
||||||
RevisionTarget::Working => return Err("Working checkout is not a commit".into()),
|
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());
|
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
|
for store in
|
||||||
std::iter::once(&self.store).chain(self.private.as_ref().map(|(_, store)| store))
|
std::iter::once(&self.store).chain(self.private.as_ref().map(|(_, store)| store))
|
||||||
{
|
{
|
||||||
@@ -743,15 +812,13 @@ impl Repository {
|
|||||||
.current_dir(&self.root);
|
.current_dir(&self.root);
|
||||||
let output = run(cmd, vec![], &self.cancelled)?;
|
let output = run(cmd, vec![], &self.cancelled)?;
|
||||||
if output.code == Some(0) {
|
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)) {
|
if !matches!(output.code, Some(1 | 128)) {
|
||||||
checked(output)?;
|
checked(output)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(format!(
|
Ok(None)
|
||||||
"Commit {id} is unavailable locally or in loaded remote history"
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
fn conflicted_snapshot(&self, root: &Path, id: &str) -> Result<Snapshot, String> {
|
fn conflicted_snapshot(&self, root: &Path, id: &str) -> Result<Snapshot, String> {
|
||||||
let revset = format!("commit_id({})", serde_json::to_string(id).unwrap());
|
let revset = format!("commit_id({})", serde_json::to_string(id).unwrap());
|
||||||
@@ -820,6 +887,7 @@ impl Repository {
|
|||||||
FileData {
|
FileData {
|
||||||
mode: mode.into(),
|
mode: mode.into(),
|
||||||
bytes,
|
bytes,
|
||||||
|
object: None,
|
||||||
notice,
|
notice,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -853,8 +921,7 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let output = self.git(store, &["ls-tree", "-r", "-z", "--full-tree", id])?;
|
let output = self.git(store, &["ls-tree", "-r", "-z", "--full-tree", id])?;
|
||||||
let mut entries = vec![];
|
let mut snapshot = BTreeMap::new();
|
||||||
let mut request = Vec::new();
|
|
||||||
for record in output.split(|b| *b == 0).filter(|r| !r.is_empty()) {
|
for record in output.split(|b| *b == 0).filter(|r| !r.is_empty()) {
|
||||||
let tab = record
|
let tab = record
|
||||||
.iter()
|
.iter()
|
||||||
@@ -868,32 +935,60 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
let path = path_bytes(&record[tab + 1..])?;
|
let path = path_bytes(&record[tab + 1..])?;
|
||||||
safe_path(&path)?;
|
safe_path(&path)?;
|
||||||
if fields[1] == "commit" {
|
let (object, notice) = if fields[1] == "commit" {
|
||||||
entries.push((
|
(
|
||||||
path,
|
|
||||||
fields[0].to_owned(),
|
|
||||||
None,
|
None,
|
||||||
Some(format!(
|
Some(format!(
|
||||||
"Submodule commit {} (contents are not traversed)",
|
"Submodule commit {} (contents are not traversed)",
|
||||||
fields[2]
|
fields[2]
|
||||||
)),
|
)),
|
||||||
));
|
)
|
||||||
} else if fields[1] == "blob" {
|
} else if fields[1] == "blob" {
|
||||||
request.extend_from_slice(fields[2].as_bytes());
|
(Some((store.to_path_buf(), fields[2].to_owned())), None)
|
||||||
request.push(b'\n');
|
|
||||||
entries.push((path, fields[0].to_owned(), Some(fields[2].to_owned()), None));
|
|
||||||
} else {
|
} else {
|
||||||
return Err(format!("Unsupported tree entry type: {}", fields[1]));
|
return Err(format!("Unsupported tree entry type: {}", fields[1]));
|
||||||
}
|
};
|
||||||
|
snapshot.insert(
|
||||||
|
path,
|
||||||
|
FileData {
|
||||||
|
mode: fields[0].into(),
|
||||||
|
bytes: vec![],
|
||||||
|
object,
|
||||||
|
notice,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let mut cmd = git_command(store);
|
Ok(snapshot)
|
||||||
cmd.arg("cat-file").arg("--batch");
|
}
|
||||||
let data = checked(run(cmd, request, &self.cancelled)?)?;
|
|
||||||
let mut offset = 0;
|
fn materialize(&self, snapshot: &mut Snapshot) -> Result<(), String> {
|
||||||
let mut snapshot = BTreeMap::new();
|
let stores: BTreeSet<_> = snapshot
|
||||||
for (path, mode, object, notice) in entries {
|
.values()
|
||||||
self.check_cancelled()?;
|
.filter_map(|file| file.object.as_ref().map(|(store, _)| store.clone()))
|
||||||
let bytes = if object.is_some() {
|
.collect();
|
||||||
|
for store in stores {
|
||||||
|
let mut request = Vec::new();
|
||||||
|
for file in snapshot.values() {
|
||||||
|
if let Some((source, id)) = &file.object
|
||||||
|
&& source == &store
|
||||||
|
{
|
||||||
|
request.extend_from_slice(id.as_bytes());
|
||||||
|
request.push(b'\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut cmd = git_command(&store);
|
||||||
|
cmd.args(["cat-file", "--batch"]);
|
||||||
|
let data = checked(run(cmd, request, &self.cancelled)?)?;
|
||||||
|
let mut offset = 0;
|
||||||
|
for (path, file) in snapshot.iter_mut() {
|
||||||
|
if file
|
||||||
|
.object
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|(source, _)| source != &store)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.check_cancelled()?;
|
||||||
let end = offset
|
let end = offset
|
||||||
+ data[offset..]
|
+ data[offset..]
|
||||||
.iter()
|
.iter()
|
||||||
@@ -917,22 +1012,12 @@ impl Repository {
|
|||||||
if size >= data.len().saturating_sub(offset) {
|
if size >= data.len().saturating_sub(offset) {
|
||||||
return Err("Truncated Git object content".into());
|
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;
|
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> {
|
fn working_snapshot(&self, buffers: &[(PathBuf, String)]) -> Result<Snapshot, String> {
|
||||||
let mut tracked = BTreeSet::new();
|
let mut tracked = BTreeSet::new();
|
||||||
@@ -1100,6 +1185,7 @@ impl Repository {
|
|||||||
FileData {
|
FileData {
|
||||||
mode: "160000".into(),
|
mode: "160000".into(),
|
||||||
bytes: vec![],
|
bytes: vec![],
|
||||||
|
object: None,
|
||||||
notice: Some(notice),
|
notice: Some(notice),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1178,6 +1264,7 @@ impl Repository {
|
|||||||
FileData {
|
FileData {
|
||||||
mode,
|
mode,
|
||||||
bytes,
|
bytes,
|
||||||
|
object: None,
|
||||||
notice,
|
notice,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1192,38 +1279,72 @@ impl Repository {
|
|||||||
self.commit_snapshot(&store, &id)
|
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(
|
pub fn compare(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
comparison_id: u64,
|
||||||
before: &Revision,
|
before: &Revision,
|
||||||
after: &Revision,
|
after: &Revision,
|
||||||
buffers: &[(PathBuf, String)],
|
buffers: &[(PathBuf, String)],
|
||||||
) -> Result<Comparison, 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 working_after = after.target == RevisionTarget::Working;
|
||||||
let right = if before.target == after.target && (!working_after || buffers.is_empty()) {
|
let mut right = if before.target == after.target && (!working_after || buffers.is_empty()) {
|
||||||
None
|
BTreeMap::new()
|
||||||
} else if working_after {
|
} else if working_after {
|
||||||
Some(self.working_snapshot(buffers)?)
|
self.working_snapshot(buffers)?
|
||||||
} else {
|
} 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 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 {
|
for path in paths {
|
||||||
self.check_cancelled()?;
|
self.check_cancelled()?;
|
||||||
let a = left.get(path);
|
let a = left.get(path);
|
||||||
let b = right.get(path);
|
let b = right_view.get(path);
|
||||||
if a == b {
|
if a.map(file_identity) == b.map(file_identity) {
|
||||||
continue;
|
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.
|
// 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() {
|
for (i, file) in files.iter().enumerate() {
|
||||||
if file.status == FileStatus::Removed {
|
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();
|
let mut removed = BTreeSet::new();
|
||||||
@@ -1232,18 +1353,11 @@ impl Repository {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(j) = candidates
|
if let Some(j) = candidates
|
||||||
.get_mut(&right[&files[i].path])
|
.get_mut(&file_identity(&right_view[&files[i].path]))
|
||||||
.and_then(Vec::pop)
|
.and_then(Vec::pop)
|
||||||
{
|
{
|
||||||
let old_path = files[j].path.clone();
|
files[i].status = FileStatus::Renamed;
|
||||||
let mut renamed = self.changed_file(
|
files[i].old_path = Some(files[j].path.clone());
|
||||||
&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;
|
|
||||||
removed.insert(j);
|
removed.insert(j);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1256,21 +1370,24 @@ impl Repository {
|
|||||||
files
|
files
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|file| {
|
.filter(|file| {
|
||||||
right.get(&file.path).is_some_and(|data| {
|
right_view
|
||||||
matches!(data.mode.as_str(), "100644" | "100755")
|
.get(&file.path)
|
||||||
&& !data.bytes.contains(&0)
|
.is_some_and(|data| matches!(data.mode.as_str(), "100644" | "100755"))
|
||||||
&& std::str::from_utf8(&data.bytes).is_ok()
|
|
||||||
&& file
|
|
||||||
.notice
|
|
||||||
.as_deref()
|
|
||||||
.is_none_or(|notice| !notice.contains("Binary or non-UTF-8"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.map(|file| file.path.clone())
|
.map(|file| file.path.clone())
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
BTreeSet::new()
|
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 {
|
Ok(Comparison {
|
||||||
before: before.label.clone(),
|
before: before.label.clone(),
|
||||||
after: after.label.clone(),
|
after: after.label.clone(),
|
||||||
@@ -1279,6 +1396,45 @@ impl Repository {
|
|||||||
editable_paths,
|
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(
|
fn changed_file(
|
||||||
&self,
|
&self,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
@@ -1337,6 +1493,7 @@ impl Repository {
|
|||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
old_path: None,
|
old_path: None,
|
||||||
status,
|
status,
|
||||||
|
loaded: true,
|
||||||
before: before_lines,
|
before: before_lines,
|
||||||
after: after_lines,
|
after: after_lines,
|
||||||
before_eof: a.ends_with(b"\n"),
|
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> {
|
fn lines(bytes: &[u8]) -> Vec<String> {
|
||||||
if bytes.is_empty() {
|
if bytes.is_empty() {
|
||||||
return vec![];
|
return vec![];
|
||||||
|
|||||||
+25
-12
@@ -5,7 +5,7 @@ use std::{
|
|||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc,
|
||||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
mpsc::{self, Receiver, SyncSender},
|
mpsc::{self, Receiver, Sender},
|
||||||
},
|
},
|
||||||
thread::{self, JoinHandle},
|
thread::{self, JoinHandle},
|
||||||
};
|
};
|
||||||
@@ -25,7 +25,7 @@ use crate::{
|
|||||||
type DiffResult = (u64, Result<DiffOutcome, String>);
|
type DiffResult = (u64, Result<DiffOutcome, String>);
|
||||||
|
|
||||||
struct DiffWorker {
|
struct DiffWorker {
|
||||||
requests: Option<SyncSender<(u64, DiffOperation)>>,
|
requests: Option<Sender<(u64, DiffOperation)>>,
|
||||||
results: Option<Receiver<DiffResult>>,
|
results: Option<Receiver<DiffResult>>,
|
||||||
cancelled: Arc<AtomicBool>,
|
cancelled: Arc<AtomicBool>,
|
||||||
stopped: Arc<AtomicBool>,
|
stopped: Arc<AtomicBool>,
|
||||||
@@ -36,7 +36,7 @@ struct DiffWorker {
|
|||||||
|
|
||||||
impl DiffWorker {
|
impl DiffWorker {
|
||||||
fn start(root: PathBuf) -> Result<Self, String> {
|
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 (outgoing, results) = mpsc::sync_channel(1);
|
||||||
let cancelled = Arc::new(AtomicBool::new(false));
|
let cancelled = Arc::new(AtomicBool::new(false));
|
||||||
let stopped = Arc::new(AtomicBool::new(false));
|
let stopped = Arc::new(AtomicBool::new(false));
|
||||||
@@ -56,7 +56,7 @@ impl DiffWorker {
|
|||||||
if stop.load(Ordering::Acquire) {
|
if stop.load(Ordering::Acquire) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if cancelled_id.load(Ordering::Acquire) == id {
|
if cancelled_id.load(Ordering::Acquire) >= id {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let result = (|| {
|
let result = (|| {
|
||||||
@@ -68,6 +68,9 @@ impl DiffWorker {
|
|||||||
DiffOperation::Discover => {
|
DiffOperation::Discover => {
|
||||||
repository.catalog().map(DiffOutcome::Catalog)
|
repository.catalog().map(DiffOutcome::Catalog)
|
||||||
}
|
}
|
||||||
|
DiffOperation::ListRemote(remote) => {
|
||||||
|
repository.list_remote(&remote).map(DiffOutcome::Catalog)
|
||||||
|
}
|
||||||
DiffOperation::FetchRemote(remote) => {
|
DiffOperation::FetchRemote(remote) => {
|
||||||
repository.fetch_remote(&remote).map(DiffOutcome::Catalog)
|
repository.fetch_remote(&remote).map(DiffOutcome::Catalog)
|
||||||
}
|
}
|
||||||
@@ -76,8 +79,18 @@ impl DiffWorker {
|
|||||||
after,
|
after,
|
||||||
buffers,
|
buffers,
|
||||||
} => repository
|
} => repository
|
||||||
.compare(&before, &after, &buffers)
|
.compare(id, &before, &after, &buffers)
|
||||||
.map(DiffOutcome::Comparison),
|
.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() {
|
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> {
|
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
|
self.requests
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("live diff worker has a sender")
|
.expect("live diff worker has a sender")
|
||||||
.try_send((id, operation))
|
.send((id, operation))
|
||||||
.map_err(|error| match error {
|
.map_err(|_| "Diff worker stopped unexpectedly".to_owned())?;
|
||||||
mpsc::TrySendError::Full(_) => "Diff worker is already busy".to_owned(),
|
|
||||||
mpsc::TrySendError::Disconnected(_) => {
|
|
||||||
"Diff worker stopped unexpectedly".to_owned()
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
self.pending = Some(id);
|
self.pending = Some(id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -3387,6 +3399,7 @@ end
|
|||||||
path: PathBuf::from("buffer.txt"),
|
path: PathBuf::from("buffer.txt"),
|
||||||
old_path: None,
|
old_path: None,
|
||||||
status: FileStatus::Modified,
|
status: FileStatus::Modified,
|
||||||
|
loaded: true,
|
||||||
before: vec!["base".into()],
|
before: vec!["base".into()],
|
||||||
after: vec!["base".into()],
|
after: vec!["base".into()],
|
||||||
before_eof: false,
|
before_eof: false,
|
||||||
|
|||||||
+601
-128
@@ -42,6 +42,8 @@ pub struct SyntaxCache {
|
|||||||
next_source: String,
|
next_source: String,
|
||||||
line_starts: Vec<usize>,
|
line_starts: Vec<usize>,
|
||||||
lines: Vec<Vec<SyntaxSpan>>,
|
lines: Vec<Vec<SyntaxSpan>>,
|
||||||
|
highlighted: Vec<bool>,
|
||||||
|
context: Option<LayerContext>,
|
||||||
parser: Parser,
|
parser: Parser,
|
||||||
tree: Option<Tree>,
|
tree: Option<Tree>,
|
||||||
injection_parser: Parser,
|
injection_parser: Parser,
|
||||||
@@ -65,7 +67,10 @@ impl SyntaxCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn update_text(&mut self, text: &str, path: &Path) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
|
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.
|
/// `revision` must change whenever line content changes; path changes are detected separately.
|
||||||
pub fn update(&mut self, lines: &[String], path: Option<&Path>, revision: u64) {
|
pub fn update(&mut self, lines: &[String], path: Option<&Path>, revision: u64) {
|
||||||
|
self.update_rows(
|
||||||
|
lines,
|
||||||
|
path,
|
||||||
|
revision,
|
||||||
|
std::slice::from_ref(&(0..lines.len())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the original full source, but collect colors only for these end-exclusive source rows.
|
||||||
|
/// Unrequested rows may be empty; requesting more rows at the same revision reuses parsed trees.
|
||||||
|
pub(crate) fn update_visible(
|
||||||
|
&mut self,
|
||||||
|
lines: &[String],
|
||||||
|
path: Option<&Path>,
|
||||||
|
revision: u64,
|
||||||
|
visible: &[std::ops::Range<usize>],
|
||||||
|
) {
|
||||||
|
self.update_rows(lines, path, revision, visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_rows(
|
||||||
|
&mut self,
|
||||||
|
lines: &[String],
|
||||||
|
path: Option<&Path>,
|
||||||
|
revision: u64,
|
||||||
|
visible: &[std::ops::Range<usize>],
|
||||||
|
) {
|
||||||
let path_changed = self.path.as_deref() != path;
|
let path_changed = self.path.as_deref() != path;
|
||||||
if self.revision == Some(revision) && !path_changed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let syntax = languages::detect(path, lines.first().map(String::as_str));
|
|
||||||
let language_changed = !same_language(self.syntax, syntax);
|
|
||||||
let revision_changed = self.revision != Some(revision);
|
let 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);
|
self.revision = Some(revision);
|
||||||
if path_changed {
|
if path_changed {
|
||||||
self.path = path.map(Path::to_path_buf);
|
self.path = path.map(Path::to_path_buf);
|
||||||
@@ -114,30 +147,76 @@ impl SyntaxCache {
|
|||||||
start += line.len() + 1;
|
start += line.len() + 1;
|
||||||
}
|
}
|
||||||
self.lines.resize_with(lines.len(), Vec::new);
|
self.lines.resize_with(lines.len(), Vec::new);
|
||||||
|
self.highlighted.resize(lines.len(), false);
|
||||||
}
|
}
|
||||||
if edit.is_none() && !language_changed && self.error.is_none() {
|
if edit.is_some() || language_changed {
|
||||||
return;
|
for spans in &mut self.lines {
|
||||||
|
spans.clear();
|
||||||
|
}
|
||||||
|
self.highlighted.fill(false);
|
||||||
|
self.context = None;
|
||||||
}
|
}
|
||||||
for spans in &mut self.lines {
|
|
||||||
spans.clear();
|
|
||||||
}
|
|
||||||
self.error = None;
|
|
||||||
if language_changed {
|
if language_changed {
|
||||||
self.tree = None;
|
self.tree = None;
|
||||||
self.injections.clear();
|
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 {
|
let Some(syntax) = syntax else {
|
||||||
self.tree = None;
|
self.tree = None;
|
||||||
self.injections.clear();
|
self.injections.clear();
|
||||||
|
self.highlighted.fill(true);
|
||||||
return;
|
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.error = Some(format!("{} syntax: {error}", syntax.name));
|
||||||
self.tree = None;
|
self.tree = None;
|
||||||
self.injections.clear();
|
self.injections.clear();
|
||||||
|
self.context = None;
|
||||||
|
self.highlighted.fill(false);
|
||||||
for spans in &mut self.lines {
|
for spans in &mut self.lines {
|
||||||
spans.clear();
|
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,
|
syntax: &'static LanguageSpec,
|
||||||
edit: Option<&InputEdit>,
|
edit: Option<&InputEdit>,
|
||||||
language_changed: bool,
|
language_changed: bool,
|
||||||
|
visible: &[Range],
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let primary_configuration = configuration(syntax)?;
|
let primary_configuration = configuration(syntax)?;
|
||||||
if language_changed || self.tree.is_none() {
|
if language_changed || self.tree.is_none() {
|
||||||
@@ -194,15 +274,19 @@ impl SyntaxCache {
|
|||||||
start_byte: 0,
|
start_byte: 0,
|
||||||
end_byte: self.source.len(),
|
end_byte: self.source.len(),
|
||||||
start_point: Point::new(0, 0),
|
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();
|
self.highlights.clear();
|
||||||
// ponytail: query the whole incremental tree; restrict changed ranges if query time dominates.
|
|
||||||
let mut queue: VecDeque<_> = collect_layer(
|
let mut queue: VecDeque<_> = collect_layer(
|
||||||
primary_configuration,
|
primary_configuration,
|
||||||
self.tree.as_ref().unwrap(),
|
self.tree.as_ref().unwrap(),
|
||||||
&self.source,
|
&self.source,
|
||||||
&[full_range],
|
&[full_range],
|
||||||
|
visible,
|
||||||
|
&mut self.context,
|
||||||
0,
|
0,
|
||||||
None,
|
None,
|
||||||
&mut self.cursor,
|
&mut self.cursor,
|
||||||
@@ -220,6 +304,7 @@ impl SyntaxCache {
|
|||||||
if let Some(edit) = edit {
|
if let Some(edit) = edit {
|
||||||
layer.tree.edit(edit);
|
layer.tree.edit(edit);
|
||||||
layer.ranges = layer.tree.included_ranges();
|
layer.ranges = layer.tree.included_ranges();
|
||||||
|
layer.context = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while let Some(request) = queue.pop_front() {
|
while let Some(request) = queue.pop_front() {
|
||||||
@@ -250,8 +335,9 @@ impl SyntaxCache {
|
|||||||
let unchanged = old
|
let unchanged = old
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|layer| !layer.dirty && layer.ranges == request.ranges);
|
.is_some_and(|layer| !layer.dirty && layer.ranges == request.ranges);
|
||||||
let tree = if unchanged {
|
let (tree, mut context) = if unchanged {
|
||||||
old.unwrap().tree
|
let old = old.unwrap();
|
||||||
|
(old.tree, old.context)
|
||||||
} else {
|
} else {
|
||||||
self.injection_parser
|
self.injection_parser
|
||||||
.set_language(&request.syntax.language)
|
.set_language(&request.syntax.language)
|
||||||
@@ -263,7 +349,8 @@ impl SyntaxCache {
|
|||||||
{
|
{
|
||||||
self.injection_parses += 1;
|
self.injection_parses += 1;
|
||||||
}
|
}
|
||||||
self.injection_parser
|
let tree = self
|
||||||
|
.injection_parser
|
||||||
.parse(
|
.parse(
|
||||||
self.source.as_bytes(),
|
self.source.as_bytes(),
|
||||||
old.as_ref().map(|layer| &layer.tree),
|
old.as_ref().map(|layer| &layer.tree),
|
||||||
@@ -273,14 +360,19 @@ impl SyntaxCache {
|
|||||||
"{} injection parser stopped without a tree",
|
"{} injection parser stopped without a tree",
|
||||||
request.syntax.name
|
request.syntax.name
|
||||||
)
|
)
|
||||||
})?
|
})?;
|
||||||
|
(tree, None)
|
||||||
};
|
};
|
||||||
// Markdown's inline grammar refines the block grammar, so plain inline text keeps
|
// Markdown's inline grammar refines the block grammar, so plain inline text keeps
|
||||||
// its enclosing heading/link foreground. Other languages replace host strings/markup.
|
// its enclosing heading/link foreground. Other languages replace host strings/markup.
|
||||||
let refines_host = languages::by_name("markdown_inline")
|
let refines_host = languages::by_name("markdown_inline")
|
||||||
.is_some_and(|inline| std::ptr::eq(inline, request.syntax));
|
.is_some_and(|inline| std::ptr::eq(inline, request.syntax));
|
||||||
if !refines_host {
|
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 {
|
self.highlights.push(Highlight {
|
||||||
start: range.start_byte,
|
start: range.start_byte,
|
||||||
end: range.end_byte,
|
end: range.end_byte,
|
||||||
@@ -296,6 +388,8 @@ impl SyntaxCache {
|
|||||||
&tree,
|
&tree,
|
||||||
&self.source,
|
&self.source,
|
||||||
&request.ranges,
|
&request.ranges,
|
||||||
|
visible,
|
||||||
|
&mut context,
|
||||||
request.depth,
|
request.depth,
|
||||||
Some(request.parent),
|
Some(request.parent),
|
||||||
&mut self.cursor,
|
&mut self.cursor,
|
||||||
@@ -314,6 +408,7 @@ impl SyntaxCache {
|
|||||||
ranges: request.ranges,
|
ranges: request.ranges,
|
||||||
depth: request.depth,
|
depth: request.depth,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
|
context,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
render_spans(
|
render_spans(
|
||||||
@@ -322,6 +417,7 @@ impl SyntaxCache {
|
|||||||
&mut self.lines,
|
&mut self.lines,
|
||||||
&self.highlights,
|
&self.highlights,
|
||||||
&mut self.events,
|
&mut self.events,
|
||||||
|
visible,
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -380,10 +476,39 @@ fn point_at(source: &str, byte: usize) -> Point {
|
|||||||
struct Configuration {
|
struct Configuration {
|
||||||
syntax: &'static LanguageSpec,
|
syntax: &'static LanguageSpec,
|
||||||
query: Query,
|
query: Query,
|
||||||
|
context_query: Query,
|
||||||
|
highlights_query: Query,
|
||||||
locals_start: usize,
|
locals_start: usize,
|
||||||
highlights_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> {
|
fn configuration(syntax: &'static LanguageSpec) -> Result<&'static Configuration, String> {
|
||||||
static CONFIGURATIONS: LazyLock<Vec<(&'static LanguageSpec, Result<Configuration, String>)>> =
|
static CONFIGURATIONS: LazyLock<Vec<(&'static LanguageSpec, Result<Configuration, String>)>> =
|
||||||
LazyLock::new(|| {
|
LazyLock::new(|| {
|
||||||
@@ -429,12 +554,12 @@ fn compile_configuration(syntax: &'static LanguageSpec) -> Result<Configuration,
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Configuration {
|
Ok(Configuration::new(
|
||||||
syntax,
|
syntax,
|
||||||
query,
|
query,
|
||||||
locals_start,
|
locals_start,
|
||||||
highlights_start,
|
highlights_start,
|
||||||
})
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify(capture: &str, numeric_literal: bool) -> Option<SyntaxKind> {
|
fn classify(capture: &str, numeric_literal: bool) -> Option<SyntaxKind> {
|
||||||
@@ -463,8 +588,10 @@ struct Layer {
|
|||||||
ranges: Vec<Range>,
|
ranges: Vec<Range>,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
|
context: Option<LayerContext>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
struct Injection {
|
struct Injection {
|
||||||
syntax: &'static LanguageSpec,
|
syntax: &'static LanguageSpec,
|
||||||
parent: &'static LanguageSpec,
|
parent: &'static LanguageSpec,
|
||||||
@@ -473,7 +600,12 @@ struct Injection {
|
|||||||
ancestors: Vec<(&'static LanguageSpec, Vec<Range>)>,
|
ancestors: Vec<(&'static LanguageSpec, Vec<Range>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
struct LayerContext {
|
||||||
|
captures: Vec<Capture>,
|
||||||
|
injections: Vec<Injection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||||
struct Capture {
|
struct Capture {
|
||||||
node: usize,
|
node: usize,
|
||||||
start: usize,
|
start: usize,
|
||||||
@@ -484,6 +616,34 @@ struct Capture {
|
|||||||
numeric_literal: bool,
|
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 {
|
struct Highlight {
|
||||||
start: usize,
|
start: usize,
|
||||||
end: usize,
|
end: usize,
|
||||||
@@ -510,6 +670,8 @@ fn collect_layer(
|
|||||||
tree: &Tree,
|
tree: &Tree,
|
||||||
source: &str,
|
source: &str,
|
||||||
ranges: &[Range],
|
ranges: &[Range],
|
||||||
|
visible: &[Range],
|
||||||
|
context: &mut Option<LayerContext>,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
parent: Option<&'static LanguageSpec>,
|
parent: Option<&'static LanguageSpec>,
|
||||||
cursor: &mut QueryCursor,
|
cursor: &mut QueryCursor,
|
||||||
@@ -519,102 +681,128 @@ fn collect_layer(
|
|||||||
let query = &configuration.query;
|
let query = &configuration.query;
|
||||||
let names = query.capture_names();
|
let names = query.capture_names();
|
||||||
captures.clear();
|
captures.clear();
|
||||||
let mut injections = Vec::new();
|
let mut injections;
|
||||||
let mut combined: HashMap<(usize, usize), usize> = HashMap::new();
|
if let Some(context) = context {
|
||||||
let mut matches = cursor.matches(query, tree.root_node(), source.as_bytes());
|
captures.extend_from_slice(&context.captures);
|
||||||
while let Some(found) = matches.next() {
|
injections = context.injections.clone();
|
||||||
let pattern = found.pattern_index;
|
} else {
|
||||||
if pattern < configuration.locals_start {
|
injections = Vec::new();
|
||||||
let mut language = None;
|
let mut combined: HashMap<(usize, usize), usize> = HashMap::new();
|
||||||
let mut nodes = Vec::new();
|
cursor.set_byte_range(0..source.len());
|
||||||
for capture in found.captures() {
|
let mut matches = cursor.matches(
|
||||||
match names[capture.index as usize] {
|
&configuration.context_query,
|
||||||
"injection.language" => {
|
tree.root_node(),
|
||||||
language = source[capture.node.byte_range()]
|
source.as_bytes(),
|
||||||
.split_whitespace()
|
);
|
||||||
.next()
|
while let Some(found) = matches.next() {
|
||||||
.and_then(languages::by_name);
|
let pattern = found.pattern_index;
|
||||||
}
|
if pattern < configuration.locals_start {
|
||||||
"injection.content" => nodes.push(capture.node),
|
let mut language = None;
|
||||||
_ => {}
|
let mut nodes = Vec::new();
|
||||||
}
|
for capture in found.captures() {
|
||||||
}
|
match names[capture.index as usize] {
|
||||||
let mut include_children = false;
|
"injection.language" => {
|
||||||
let mut is_combined = false;
|
language = source[capture.node.byte_range()]
|
||||||
for property in query.property_settings(pattern) {
|
.split_whitespace()
|
||||||
match property.key.as_ref() {
|
.next()
|
||||||
"injection.language" => {
|
.and_then(languages::by_name);
|
||||||
language = property.value.as_deref().and_then(languages::by_name);
|
}
|
||||||
}
|
"injection.content" => nodes.push(capture.node),
|
||||||
"injection.self" => language = Some(configuration.syntax),
|
_ => {}
|
||||||
"injection.parent" => language = parent,
|
}
|
||||||
"injection.include-children" => include_children = true,
|
}
|
||||||
"injection.combined" => is_combined = true,
|
let mut include_children = false;
|
||||||
_ => {}
|
let mut is_combined = false;
|
||||||
}
|
for property in query.property_settings(pattern) {
|
||||||
}
|
match property.key.as_ref() {
|
||||||
if let Some(language) = language {
|
"injection.language" => {
|
||||||
let mut content = injection_ranges(&nodes, ranges, include_children);
|
language = property.value.as_deref().and_then(languages::by_name);
|
||||||
if !content.is_empty() {
|
}
|
||||||
let key = (pattern, language as *const LanguageSpec as usize);
|
"injection.self" => language = Some(configuration.syntax),
|
||||||
if is_combined && let Some(index) = combined.get(&key) {
|
"injection.parent" => language = parent,
|
||||||
let injection: &mut Injection = &mut injections[*index];
|
"injection.include-children" => include_children = true,
|
||||||
injection.ranges.append(&mut content);
|
"injection.combined" => is_combined = true,
|
||||||
} else {
|
_ => {}
|
||||||
if is_combined {
|
}
|
||||||
combined.insert(key, injections.len());
|
}
|
||||||
|
if let Some(language) = language {
|
||||||
|
let mut content = injection_ranges(&nodes, ranges, include_children);
|
||||||
|
if !content.is_empty() {
|
||||||
|
let key = (pattern, language as *const LanguageSpec as usize);
|
||||||
|
if is_combined && let Some(index) = combined.get(&key) {
|
||||||
|
let injection: &mut Injection = &mut injections[*index];
|
||||||
|
injection.ranges.append(&mut content);
|
||||||
|
} else {
|
||||||
|
if is_combined {
|
||||||
|
combined.insert(key, injections.len());
|
||||||
|
}
|
||||||
|
injections.push(Injection {
|
||||||
|
syntax: language,
|
||||||
|
parent: configuration.syntax,
|
||||||
|
ranges: content,
|
||||||
|
depth: depth + 1,
|
||||||
|
ancestors: vec![(configuration.syntax, ranges.to_vec())],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
injections.push(Injection {
|
|
||||||
syntax: language,
|
|
||||||
parent: configuration.syntax,
|
|
||||||
ranges: content,
|
|
||||||
depth: depth + 1,
|
|
||||||
ancestors: vec![(configuration.syntax, ranges.to_vec())],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let value_end = found
|
|
||||||
.captures()
|
|
||||||
.iter()
|
|
||||||
.find(|capture| names[capture.index as usize] == "local.definition-value")
|
|
||||||
.map_or(0, |capture| capture.node.end_byte());
|
|
||||||
for capture in found.captures() {
|
|
||||||
let name = names[capture.index as usize];
|
|
||||||
if name.starts_with('_') || name == "local.definition-value" {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
captures.push(Capture {
|
let value_end = found
|
||||||
node: capture.node.id(),
|
.captures()
|
||||||
start: capture.node.start_byte(),
|
.iter()
|
||||||
end: capture.node.end_byte(),
|
.find(|capture| names[capture.index as usize] == "local.definition-value")
|
||||||
capture: capture.index,
|
.map_or(0, |capture| capture.node.end_byte());
|
||||||
pattern,
|
for capture in found.captures() {
|
||||||
value_end,
|
let name = names[capture.index as usize];
|
||||||
numeric_literal: name.starts_with("constant")
|
if name.starts_with('_') || name == "local.definition-value" {
|
||||||
&& matches!(
|
continue;
|
||||||
capture.node.kind(),
|
}
|
||||||
"integer_literal"
|
captures.push(Capture::new(capture, pattern, value_end, name));
|
||||||
| "float_literal"
|
}
|
||||||
| "number_literal"
|
|
||||||
| "integer"
|
|
||||||
| "float"
|
|
||||||
| "number"
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
if cursor.did_exceed_match_limit() {
|
||||||
|
return Err(format!(
|
||||||
|
"{} query exceeded its match limit",
|
||||||
|
configuration.syntax.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for injection in &mut injections {
|
||||||
|
normalize_ranges(&mut injection.ranges);
|
||||||
|
}
|
||||||
|
// Locals must be resolved in source order, including definitions above the viewport.
|
||||||
|
// Query only their nodes for the foreground that references inherit.
|
||||||
|
let definitions: BTreeSet<_> = captures
|
||||||
|
.iter()
|
||||||
|
.filter(|capture| names[capture.capture as usize] == "local.definition")
|
||||||
|
.map(|capture| capture.node)
|
||||||
|
.collect();
|
||||||
|
let mut definition_ranges: Vec<_> = captures
|
||||||
|
.iter()
|
||||||
|
.filter(|capture| definitions.contains(&capture.node))
|
||||||
|
.map(|capture| Range {
|
||||||
|
start_byte: capture.start,
|
||||||
|
end_byte: capture.end,
|
||||||
|
start_point: Point::new(0, 0),
|
||||||
|
end_point: Point::new(0, 0),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
normalize_ranges(&mut definition_ranges);
|
||||||
|
collect_highlight_captures(
|
||||||
|
configuration,
|
||||||
|
tree,
|
||||||
|
source,
|
||||||
|
&definition_ranges,
|
||||||
|
Some(&definitions),
|
||||||
|
cursor,
|
||||||
|
captures,
|
||||||
|
)?;
|
||||||
|
*context = Some(LayerContext {
|
||||||
|
captures: captures.clone(),
|
||||||
|
injections: injections.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if cursor.did_exceed_match_limit() {
|
collect_highlight_captures(configuration, tree, source, visible, None, cursor, captures)?;
|
||||||
return Err(format!(
|
|
||||||
"{} query exceeded its match limit",
|
|
||||||
configuration.syntax.name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for injection in &mut injections {
|
|
||||||
normalize_ranges(&mut injection.ranges);
|
|
||||||
}
|
|
||||||
captures.sort_unstable_by_key(|capture| {
|
captures.sort_unstable_by_key(|capture| {
|
||||||
(
|
(
|
||||||
capture.start,
|
capture.start,
|
||||||
@@ -624,6 +812,7 @@ fn collect_layer(
|
|||||||
capture.capture,
|
capture.capture,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
captures.dedup();
|
||||||
let mut scopes = vec![Scope {
|
let mut scopes = vec![Scope {
|
||||||
end: usize::MAX,
|
end: usize::MAX,
|
||||||
inherits: false,
|
inherits: false,
|
||||||
@@ -737,7 +926,7 @@ fn collect_layer(
|
|||||||
{
|
{
|
||||||
let low = first.start.max(range.start_byte);
|
let low = first.start.max(range.start_byte);
|
||||||
let high = first.end.min(range.end_byte);
|
let high = first.end.min(range.end_byte);
|
||||||
if low < high {
|
if low < high && intersects(visible, low, high) {
|
||||||
highlights.push(Highlight {
|
highlights.push(Highlight {
|
||||||
start: low,
|
start: low,
|
||||||
end: high,
|
end: high,
|
||||||
@@ -754,6 +943,57 @@ fn collect_layer(
|
|||||||
Ok(injections)
|
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 {
|
fn capture_priority(query: &Query, capture: &Capture) -> i32 {
|
||||||
query
|
query
|
||||||
.property_settings(capture.pattern)
|
.property_settings(capture.pattern)
|
||||||
@@ -851,6 +1091,7 @@ fn render_spans(
|
|||||||
lines: &mut [Vec<SyntaxSpan>],
|
lines: &mut [Vec<SyntaxSpan>],
|
||||||
highlights: &[Highlight],
|
highlights: &[Highlight],
|
||||||
events: &mut Vec<(usize, bool, usize)>,
|
events: &mut Vec<(usize, bool, usize)>,
|
||||||
|
visible: &[Range],
|
||||||
) {
|
) {
|
||||||
events.clear();
|
events.clear();
|
||||||
for (index, highlight) in highlights.iter().enumerate() {
|
for (index, highlight) in highlights.iter().enumerate() {
|
||||||
@@ -875,7 +1116,20 @@ fn render_spans(
|
|||||||
&& let Some((_, current)) = active.last()
|
&& let Some((_, current)) = active.last()
|
||||||
&& let Some(kind) = highlights[*current].kind
|
&& 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 {
|
if entering {
|
||||||
active.insert((rank(index), index));
|
active.insert((rank(index), index));
|
||||||
@@ -933,6 +1187,179 @@ mod tests {
|
|||||||
source.split('\n').map(str::to_owned).collect()
|
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 {
|
fn cache(source: &str, path: &str) -> SyntaxCache {
|
||||||
let mut cache = SyntaxCache::new();
|
let mut cache = SyntaxCache::new();
|
||||||
cache.update(&lines(source), Some(Path::new(path)), 0);
|
cache.update(&lines(source), Some(Path::new(path)), 0);
|
||||||
@@ -1433,7 +1860,14 @@ mod tests {
|
|||||||
pattern: 0,
|
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!(
|
assert_eq!(
|
||||||
spans[0],
|
spans[0],
|
||||||
vec![
|
vec![
|
||||||
@@ -1472,12 +1906,7 @@ mod tests {
|
|||||||
let highlights_start = (0..query.pattern_count())
|
let highlights_start = (0..query.pattern_count())
|
||||||
.filter(|pattern| query.start_byte_for_pattern(*pattern) < locals.len() + 1)
|
.filter(|pattern| query.start_byte_for_pattern(*pattern) < locals.len() + 1)
|
||||||
.count();
|
.count();
|
||||||
let configuration = Configuration {
|
let configuration = Configuration::new(syntax, query, 0, highlights_start);
|
||||||
syntax,
|
|
||||||
query,
|
|
||||||
locals_start: 0,
|
|
||||||
highlights_start,
|
|
||||||
};
|
|
||||||
let source = "const value = value;\nvalue;\nfunction inner() { value; }";
|
let source = "const value = value;\nvalue;\nfunction inner() { value; }";
|
||||||
let cache = self::cache(source, "a.js");
|
let cache = self::cache(source, "a.js");
|
||||||
let mut highlights = Vec::new();
|
let mut highlights = Vec::new();
|
||||||
@@ -1485,12 +1914,9 @@ mod tests {
|
|||||||
&configuration,
|
&configuration,
|
||||||
cache.tree.as_ref().unwrap(),
|
cache.tree.as_ref().unwrap(),
|
||||||
source,
|
source,
|
||||||
&[Range {
|
&[source_range(source)],
|
||||||
start_byte: 0,
|
&[source_range(source)],
|
||||||
end_byte: source.len(),
|
&mut None,
|
||||||
start_point: Point::new(0, 0),
|
|
||||||
end_point: point_at(source, source.len()),
|
|
||||||
}],
|
|
||||||
0,
|
0,
|
||||||
None,
|
None,
|
||||||
&mut QueryCursor::new(),
|
&mut QueryCursor::new(),
|
||||||
@@ -1504,7 +1930,14 @@ mod tests {
|
|||||||
source.find("\nvalue;").unwrap() + 1,
|
source.find("\nvalue;").unwrap() + 1,
|
||||||
source.find("\nfunction").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| {
|
let kind = |row: usize, byte: usize| {
|
||||||
spans[row].iter().find_map(|span| {
|
spans[row].iter().find_map(|span| {
|
||||||
(span.start_byte <= byte && byte < span.end_byte).then_some(span.kind)
|
(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(0, 14), Some(SyntaxKind::Function));
|
||||||
assert_eq!(kind(1, 0), Some(SyntaxKind::Type));
|
assert_eq!(kind(1, 0), Some(SyntaxKind::Type));
|
||||||
assert_eq!(kind(2, 19), Some(SyntaxKind::Function));
|
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]
|
#[test]
|
||||||
|
|||||||
+175
-23
@@ -347,7 +347,17 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
|
|||||||
};
|
};
|
||||||
match input {
|
match input {
|
||||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
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();
|
viewports.panels.follow_files();
|
||||||
session.handle(input);
|
session.handle(input);
|
||||||
redraw = true;
|
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> {
|
fn translate(key: KeyEvent) -> Option<Input> {
|
||||||
if key
|
if key
|
||||||
.modifiers
|
.modifiers
|
||||||
@@ -488,7 +528,6 @@ fn draw(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
panels.diff_syntax = None;
|
panels.diff_syntax = None;
|
||||||
syntax.update(editor.lines(), editor.path(), editor.text_revision());
|
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -568,6 +607,12 @@ fn draw(
|
|||||||
*left = column + 1 - width;
|
*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 {
|
if gutter.width > 0 {
|
||||||
let numbers: Vec<Line> = (*top..top.saturating_add(height).min(editor.lines().len()))
|
let numbers: Vec<Line> = (*top..top.saturating_add(height).min(editor.lines().len()))
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
@@ -1087,15 +1132,7 @@ fn draw_diff(
|
|||||||
) {
|
) {
|
||||||
let comparison = diff.comparison.as_ref();
|
let comparison = diff.comparison.as_ref();
|
||||||
let selected = comparison.and_then(|comparison| comparison.files.get(diff.file));
|
let selected = comparison.and_then(|comparison| comparison.files.get(diff.file));
|
||||||
if diff.phase == Phase::View
|
if diff.phase != Phase::View || !diff.selected_file_loaded() {
|
||||||
&& 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 {
|
|
||||||
panels.diff_syntax = None;
|
panels.diff_syntax = None;
|
||||||
}
|
}
|
||||||
if area.width == 0 || area.height == 0 {
|
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()));
|
.and_then(|syntax| syntax.before.error().or_else(|| syntax.after.error()));
|
||||||
let notice = diff.error.as_deref().or_else(|| {
|
let notice = diff.error.as_deref().or_else(|| {
|
||||||
if diff.loading {
|
if diff.loading {
|
||||||
Some("Loading comparison…")
|
Some("Loading file…")
|
||||||
} else {
|
} else {
|
||||||
syntax_error.or_else(|| selected.and_then(|file| file.notice.as_deref()))
|
syntax_error.or_else(|| selected.and_then(|file| file.notice.as_deref()))
|
||||||
}
|
}
|
||||||
@@ -1413,6 +1450,20 @@ fn draw_diff(
|
|||||||
*left = column + 1 - width;
|
*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
|
if let Some(picker) = &diff.files
|
||||||
&& let Some(navigator) = navigator
|
&& let Some(navigator) = navigator
|
||||||
{
|
{
|
||||||
@@ -1435,7 +1486,7 @@ fn draw_diff(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map(|geometry| geometry.text_widths);
|
.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
|
&& let Some(syntax) = &panels.diff_syntax
|
||||||
{
|
{
|
||||||
if pane_layout == PaneLayout::Empty {
|
if pane_layout == PaneLayout::Empty {
|
||||||
@@ -1505,7 +1556,9 @@ fn draw_diff(
|
|||||||
} else {
|
} else {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(if diff.loading {
|
Paragraph::new(if diff.loading {
|
||||||
"Loading comparison…"
|
"Loading file…"
|
||||||
|
} else if selected.is_some() {
|
||||||
|
"File details unavailable · select the file to retry"
|
||||||
} else {
|
} else {
|
||||||
"No changed files"
|
"No changed files"
|
||||||
}),
|
}),
|
||||||
@@ -1799,6 +1852,8 @@ struct DiffSyntaxCache {
|
|||||||
before_revision: u64,
|
before_revision: u64,
|
||||||
before: SyntaxCache,
|
before: SyntaxCache,
|
||||||
after: SyntaxCache,
|
after: SyntaxCache,
|
||||||
|
before_rows: Vec<std::ops::Range<usize>>,
|
||||||
|
after_rows: Vec<std::ops::Range<usize>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DiffSyntaxCache {
|
impl DiffSyntaxCache {
|
||||||
@@ -1808,6 +1863,7 @@ impl DiffSyntaxCache {
|
|||||||
file_index: usize,
|
file_index: usize,
|
||||||
file: &ChangedFile,
|
file: &ChangedFile,
|
||||||
live_revision: Option<u64>,
|
live_revision: Option<u64>,
|
||||||
|
visible: Option<(&DiffState, usize, [Rect; 2])>,
|
||||||
) {
|
) {
|
||||||
let key = Some((comparison_id, file_index, live_revision));
|
let key = Some((comparison_id, file_index, live_revision));
|
||||||
if self.key.map(|(id, index, _)| (id, index)) != Some((comparison_id, file_index)) {
|
if self.key.map(|(id, index, _)| (id, index)) != Some((comparison_id, file_index)) {
|
||||||
@@ -1823,13 +1879,64 @@ impl DiffSyntaxCache {
|
|||||||
.checked_add(1)
|
.checked_add(1)
|
||||||
.expect("diff syntax revisions exhausted");
|
.expect("diff syntax revisions exhausted");
|
||||||
}
|
}
|
||||||
self.before.update(
|
if let Some((diff, top, areas)) = visible {
|
||||||
&file.before,
|
diff_visible_rows(diff, top, areas[0], true, &mut self.before_rows);
|
||||||
Some(file.old_path.as_deref().unwrap_or(&file.path)),
|
diff_visible_rows(diff, top, areas[1], false, &mut self.after_rows);
|
||||||
self.before_revision,
|
self.before.update_visible(
|
||||||
);
|
&file.before,
|
||||||
self.after
|
Some(file.old_path.as_deref().unwrap_or(&file.path)),
|
||||||
.update(&file.after, Some(&file.path), self.revision);
|
self.before_revision,
|
||||||
|
&self.before_rows,
|
||||||
|
);
|
||||||
|
self.after.update_visible(
|
||||||
|
&file.after,
|
||||||
|
Some(&file.path),
|
||||||
|
self.revision,
|
||||||
|
&self.after_rows,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self.before.update(
|
||||||
|
&file.before,
|
||||||
|
Some(file.old_path.as_deref().unwrap_or(&file.path)),
|
||||||
|
self.before_revision,
|
||||||
|
);
|
||||||
|
self.after
|
||||||
|
.update(&file.after, Some(&file.path), self.revision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_visible_rows(
|
||||||
|
diff: &DiffState,
|
||||||
|
top: usize,
|
||||||
|
pane: Rect,
|
||||||
|
before: bool,
|
||||||
|
ranges: &mut Vec<std::ops::Range<usize>>,
|
||||||
|
) {
|
||||||
|
ranges.clear();
|
||||||
|
let height = if pane.width >= 4 && pane.height >= 4 {
|
||||||
|
pane.height - 2
|
||||||
|
} else {
|
||||||
|
pane.height
|
||||||
|
};
|
||||||
|
for row in diff.display_rows.iter().skip(top).take(usize::from(height)) {
|
||||||
|
let DisplayRow::Line(index) = *row else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let source = if before {
|
||||||
|
diff.rows[index].before
|
||||||
|
} else {
|
||||||
|
diff.rows[index].after
|
||||||
|
};
|
||||||
|
if let Some(source) = source {
|
||||||
|
if let Some(last) = ranges.last_mut()
|
||||||
|
&& last.end == source
|
||||||
|
{
|
||||||
|
last.end += 1;
|
||||||
|
} else {
|
||||||
|
ranges.push(source..source + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2713,6 +2820,7 @@ mod tests {
|
|||||||
let before: Vec<String> = before.split('\n').map(str::to_owned).collect();
|
let before: Vec<String> = before.split('\n').map(str::to_owned).collect();
|
||||||
let after: Vec<String> = after.split('\n').map(str::to_owned).collect();
|
let after: Vec<String> = after.split('\n').map(str::to_owned).collect();
|
||||||
ChangedFile {
|
ChangedFile {
|
||||||
|
loaded: true,
|
||||||
before_eof: false,
|
before_eof: false,
|
||||||
after_eof: false,
|
after_eof: false,
|
||||||
path: path.into(),
|
path: path.into(),
|
||||||
@@ -2759,13 +2867,13 @@ mod tests {
|
|||||||
let mut file = syntax_diff_file("new.rs", "return 42", "fn main() {}");
|
let mut file = syntax_diff_file("new.rs", "return 42", "fn main() {}");
|
||||||
file.old_path = Some("old.py".into());
|
file.old_path = Some("old.py".into());
|
||||||
let mut syntax = DiffSyntaxCache::default();
|
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.before.language(), Some("Python"));
|
||||||
assert_eq!(syntax.after.language(), Some("Rust"));
|
assert_eq!(syntax.after.language(), Some("Rust"));
|
||||||
assert_eq!(syntax.before.line(0)[0].kind, SyntaxKind::Keyword);
|
assert_eq!(syntax.before.line(0)[0].kind, SyntaxKind::Keyword);
|
||||||
assert_eq!(syntax.after.line(0)[0].kind, SyntaxKind::Keyword);
|
assert_eq!(syntax.after.line(0)[0].kind, SyntaxKind::Keyword);
|
||||||
file.path = "unknown.extension".into();
|
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_eq!(syntax.after.language(), None);
|
||||||
assert!(syntax.after.line(0).is_empty());
|
assert!(syntax.after.line(0).is_empty());
|
||||||
|
|
||||||
@@ -2877,6 +2985,7 @@ mod tests {
|
|||||||
|
|
||||||
fn deletion_file() -> ChangedFile {
|
fn deletion_file() -> ChangedFile {
|
||||||
ChangedFile {
|
ChangedFile {
|
||||||
|
loaded: true,
|
||||||
before_eof: false,
|
before_eof: false,
|
||||||
after_eof: false,
|
after_eof: false,
|
||||||
path: "deleted-lines.txt".into(),
|
path: "deleted-lines.txt".into(),
|
||||||
@@ -3075,6 +3184,7 @@ mod tests {
|
|||||||
after[30] = "let first_change = 900;".into();
|
after[30] = "let first_change = 900;".into();
|
||||||
after[60] = "let second_change = 901;".into();
|
after[60] = "let second_change = 901;".into();
|
||||||
let mut diff = diff_comparison(vec![ChangedFile {
|
let mut diff = diff_comparison(vec![ChangedFile {
|
||||||
|
loaded: true,
|
||||||
before_eof: false,
|
before_eof: false,
|
||||||
after_eof: false,
|
after_eof: false,
|
||||||
path: "excerpts.rs".into(),
|
path: "excerpts.rs".into(),
|
||||||
@@ -3182,6 +3292,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() {
|
fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() {
|
||||||
let mut diff = diff_comparison(vec![ChangedFile {
|
let mut diff = diff_comparison(vec![ChangedFile {
|
||||||
|
loaded: true,
|
||||||
before_eof: false,
|
before_eof: false,
|
||||||
after_eof: false,
|
after_eof: false,
|
||||||
path: "added.txt".into(),
|
path: "added.txt".into(),
|
||||||
@@ -3237,6 +3348,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() {
|
fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() {
|
||||||
let mut diff = diff_comparison(vec![ChangedFile {
|
let mut diff = diff_comparison(vec![ChangedFile {
|
||||||
|
loaded: true,
|
||||||
before_eof: false,
|
before_eof: false,
|
||||||
after_eof: false,
|
after_eof: false,
|
||||||
path: "changed.rs".into(),
|
path: "changed.rs".into(),
|
||||||
@@ -3344,6 +3456,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() {
|
fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() {
|
||||||
let added = ChangedFile {
|
let added = ChangedFile {
|
||||||
|
loaded: true,
|
||||||
before_eof: false,
|
before_eof: false,
|
||||||
after_eof: false,
|
after_eof: false,
|
||||||
path: "new-name.txt".into(),
|
path: "new-name.txt".into(),
|
||||||
@@ -3624,6 +3737,7 @@ mod tests {
|
|||||||
assert!(screen_text(&terminal).contains("No changed files"));
|
assert!(screen_text(&terminal).contains("No changed files"));
|
||||||
assert!(screen_text(&terminal).contains("Explorer · Tree"));
|
assert!(screen_text(&terminal).contains("Explorer · Tree"));
|
||||||
let binary = diff_comparison(vec![ChangedFile {
|
let binary = diff_comparison(vec![ChangedFile {
|
||||||
|
loaded: true,
|
||||||
before_eof: false,
|
before_eof: false,
|
||||||
after_eof: false,
|
after_eof: false,
|
||||||
path: "image.bin".into(),
|
path: "image.bin".into(),
|
||||||
@@ -3637,6 +3751,7 @@ mod tests {
|
|||||||
render_diff(&mut terminal, &binary, &mut panels);
|
render_diff(&mut terminal, &binary, &mut panels);
|
||||||
assert!(screen_text(&terminal).contains("Binary file"));
|
assert!(screen_text(&terminal).contains("Binary file"));
|
||||||
let blank = diff_comparison(vec![ChangedFile {
|
let blank = diff_comparison(vec![ChangedFile {
|
||||||
|
loaded: true,
|
||||||
before_eof: false,
|
before_eof: false,
|
||||||
after_eof: false,
|
after_eof: false,
|
||||||
path: "empty.txt".into(),
|
path: "empty.txt".into(),
|
||||||
@@ -4340,6 +4455,10 @@ mod tests {
|
|||||||
assert_eq!(diff.file, 0);
|
assert_eq!(diff.file, 0);
|
||||||
session.handle(Input::Enter);
|
session.handle(Input::Enter);
|
||||||
assert_eq!(session.editor().diff().unwrap().file, 3);
|
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(
|
assert!(viewports.scroll(
|
||||||
&mut session,
|
&mut session,
|
||||||
document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1),
|
document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1),
|
||||||
@@ -7510,6 +7629,39 @@ end
|
|||||||
assert!(terminal.backend().cursor_visible());
|
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]
|
#[test]
|
||||||
fn queued_wheel_input_renders_latest_view_without_crossing_key_or_resize_barriers() {
|
fn queued_wheel_input_renders_latest_view_without_crossing_key_or_resize_barriers() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
|
|||||||
+6
-3
@@ -115,7 +115,7 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close()
|
|||||||
session
|
session
|
||||||
.editor()
|
.editor()
|
||||||
.diff()
|
.diff()
|
||||||
.is_some_and(|diff| diff.phase == Phase::View)
|
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||||
});
|
});
|
||||||
let comparison = session
|
let comparison = session
|
||||||
.editor()
|
.editor()
|
||||||
@@ -222,7 +222,7 @@ fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() {
|
|||||||
session
|
session
|
||||||
.editor()
|
.editor()
|
||||||
.diff()
|
.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);
|
session.handle(Input::BackTab);
|
||||||
let shown = |session: &EditorSession| {
|
let shown = |session: &EditorSession| {
|
||||||
@@ -350,7 +350,7 @@ fn readonly_jump_history_restores_the_comparison_file_and_source_cursor() {
|
|||||||
session.handle(input);
|
session.handle(input);
|
||||||
}
|
}
|
||||||
wait_until(&mut session, |session| {
|
wait_until(&mut session, |session| {
|
||||||
session.editor().diff().unwrap().phase == Phase::View
|
session.editor().diff().unwrap().selected_file_loaded()
|
||||||
});
|
});
|
||||||
session.handle(Input::Char('\t'));
|
session.handle(Input::Char('\t'));
|
||||||
let first_origin = session.editor().cursor();
|
let first_origin = session.editor().cursor();
|
||||||
@@ -365,6 +365,9 @@ fn readonly_jump_history_restores_the_comparison_file_and_source_cursor() {
|
|||||||
] {
|
] {
|
||||||
session.handle(input);
|
session.handle(input);
|
||||||
}
|
}
|
||||||
|
wait_until(&mut session, |session| {
|
||||||
|
session.editor().diff().unwrap().selected_file_loaded()
|
||||||
|
});
|
||||||
let second_origin = session.editor().cursor();
|
let second_origin = session.editor().cursor();
|
||||||
type_query(&mut session, "/second_before");
|
type_query(&mut session, "/second_before");
|
||||||
session.handle(Input::Enter);
|
session.handle(Input::Enter);
|
||||||
|
|||||||
+1
-1
@@ -306,7 +306,7 @@ fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_exce
|
|||||||
session
|
session
|
||||||
.editor()
|
.editor()
|
||||||
.diff()
|
.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();
|
let excerpts = session.editor().diff().unwrap().display_rows.clone();
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+185
-64
@@ -127,6 +127,26 @@ fn working(kind: RepoKind) -> Revision {
|
|||||||
r.target = RevisionTarget::Working;
|
r.target = RevisionTarget::Working;
|
||||||
r
|
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 {
|
fn changed<'a>(comparison: &'a Comparison, path: &Path) -> &'a vamp::diff::ChangedFile {
|
||||||
comparison
|
comparison
|
||||||
.files
|
.files
|
||||||
@@ -236,9 +256,13 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
|
|||||||
assert!(catalog.entries.iter().any(|r| r.label == "nested"
|
assert!(catalog.entries.iter().any(|r| r.label == "nested"
|
||||||
&& r.category == Category::Tags
|
&& r.category == Category::Tags
|
||||||
&& r.target == RevisionTarget::Commit(id.clone())));
|
&& r.target == RevisionTarget::Commit(id.clone())));
|
||||||
let comparison = repository
|
let comparison = compare_loaded(
|
||||||
.compare(&revision(&id, RepoKind::Git), &working(RepoKind::Git), &[])
|
&mut repository,
|
||||||
.unwrap();
|
&revision(&id, RepoKind::Git),
|
||||||
|
&working(RepoKind::Git),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let text = changed(&comparison, Path::new("text"));
|
let text = changed(&comparison, Path::new("text"));
|
||||||
assert_eq!(comparison.root, fs::canonicalize(&root).unwrap());
|
assert_eq!(comparison.root, fs::canonicalize(&root).unwrap());
|
||||||
assert!(comparison.editable_paths.contains(Path::new("text")));
|
assert!(comparison.editable_paths.contains(Path::new("text")));
|
||||||
@@ -343,9 +367,13 @@ 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.old_path.as_deref(), Some(Path::new("rename-old")));
|
||||||
assert_eq!(moved.before, moved.after);
|
assert_eq!(moved.before, moved.after);
|
||||||
assert!(moved.hunks.is_empty());
|
assert!(moved.hunks.is_empty());
|
||||||
let reverse = repository
|
let reverse = compare_loaded(
|
||||||
.compare(&working(RepoKind::Git), &revision(&id, RepoKind::Git), &[])
|
&mut repository,
|
||||||
.unwrap();
|
&working(RepoKind::Git),
|
||||||
|
&revision(&id, RepoKind::Git),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(reverse.editable_paths.is_empty());
|
assert!(reverse.editable_paths.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
changed(&reverse, Path::new("added")).status,
|
changed(&reverse, Path::new("added")).status,
|
||||||
@@ -353,7 +381,7 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
repository
|
repository
|
||||||
.compare(&working(RepoKind::Git), &working(RepoKind::Git), &[])
|
.compare(1, &working(RepoKind::Git), &working(RepoKind::Git), &[])
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.files
|
.files
|
||||||
.is_empty()
|
.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 state = disk_state(&active);
|
||||||
let mut repository = open(&active);
|
let mut repository = open(&active);
|
||||||
let catalog = repository.catalog().unwrap();
|
let catalog = repository.list_remote("origin").unwrap();
|
||||||
let remote = catalog
|
let remote = catalog
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -439,24 +467,23 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
|||||||
.status
|
.status
|
||||||
.success()
|
.success()
|
||||||
);
|
);
|
||||||
let comparison = repository
|
let comparison = compare_loaded(
|
||||||
.compare(
|
&mut repository,
|
||||||
&revision(&local, RepoKind::Git),
|
&revision(&local, RepoKind::Git),
|
||||||
&remote,
|
&remote,
|
||||||
&[(active.join("file"), "unsaved local text\n".into())],
|
&[(active.join("file"), "unsaved local text\n".into())],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(comparison.editable_paths.is_empty());
|
assert!(comparison.editable_paths.is_empty());
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).before, ["local"]);
|
assert_eq!(changed(&comparison, Path::new("file")).before, ["local"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
changed(&comparison, Path::new("file")).after,
|
changed(&comparison, Path::new("file")).after,
|
||||||
["remote tip"]
|
["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"]);
|
assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]);
|
||||||
let comparison = repository
|
let comparison =
|
||||||
.compare(&remote, &working(RepoKind::Git), &[])
|
compare_loaded(&mut repository, &remote, &working(RepoKind::Git), &[]).unwrap();
|
||||||
.unwrap();
|
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]);
|
assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]);
|
||||||
let loaded = repository.fetch_remote("origin").unwrap();
|
let loaded = repository.fetch_remote("origin").unwrap();
|
||||||
let historic = loaded
|
let historic = loaded
|
||||||
@@ -468,9 +495,13 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(historic.label, ancestor);
|
assert_eq!(historic.label, ancestor);
|
||||||
assert_eq!(historic.description, "remote ancestor needle");
|
assert_eq!(historic.description, "remote ancestor needle");
|
||||||
let comparison = repository
|
let comparison = compare_loaded(
|
||||||
.compare(historic, &revision(&local, RepoKind::Git), &[])
|
&mut repository,
|
||||||
.unwrap();
|
historic,
|
||||||
|
&revision(&local, RepoKind::Git),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]);
|
assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]);
|
||||||
assert!(
|
assert!(
|
||||||
!command("git", &active, &["cat-file", "-e", &tip])
|
!command("git", &active, &["cat-file", "-e", &tip])
|
||||||
@@ -541,9 +572,8 @@ fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_oper
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|r| r.label == "baseline" && r.category == Category::Bookmarks)
|
.any(|r| r.label == "baseline" && r.category == Category::Bookmarks)
|
||||||
);
|
);
|
||||||
let comparison = repository
|
let comparison =
|
||||||
.compare(baseline, &working(RepoKind::Jujutsu), &[])
|
compare_loaded(&mut repository, baseline, &working(RepoKind::Jujutsu), &[]).unwrap();
|
||||||
.unwrap();
|
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["disk"]);
|
assert_eq!(changed(&comparison, Path::new("file")).after, ["disk"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
changed(&comparison, Path::new("deleted")).status,
|
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();
|
fs::write(active.join("unsnapshotted"), "keep disk\n").unwrap();
|
||||||
let state = disk_state(&active);
|
let state = disk_state(&active);
|
||||||
let mut repository = open(&active);
|
let mut repository = open(&active);
|
||||||
let catalog = repository.catalog().unwrap();
|
let catalog = repository.list_remote("origin").unwrap();
|
||||||
let remote = catalog
|
let remote = catalog
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -663,9 +693,8 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|r| r.target == RevisionTarget::Commit(ancestor.clone()))
|
.any(|r| r.target == RevisionTarget::Commit(ancestor.clone()))
|
||||||
);
|
);
|
||||||
let comparison = repository
|
let comparison =
|
||||||
.compare(&remote, &working(RepoKind::Jujutsu), &[])
|
compare_loaded(&mut repository, &remote, &working(RepoKind::Jujutsu), &[]).unwrap();
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
changed(&comparison, Path::new("file")).before,
|
changed(&comparison, Path::new("file")).before,
|
||||||
["remote tip"]
|
["remote tip"]
|
||||||
@@ -682,7 +711,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(historic.label.contains(&ancestor) && historic.label.contains(&change));
|
assert!(historic.label.contains(&ancestor) && historic.label.contains(&change));
|
||||||
assert_eq!(historic.description, "remote historic needle");
|
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!(
|
assert_eq!(
|
||||||
changed(&comparison, Path::new("file")).before,
|
changed(&comparison, Path::new("file")).before,
|
||||||
["remote ancestor"]
|
["remote ancestor"]
|
||||||
@@ -735,7 +764,14 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
|||||||
let state = disk_state(&root);
|
let state = disk_state(&root);
|
||||||
let mut repository = open(&root);
|
let mut repository = open(&root);
|
||||||
let catalog = repository.catalog().unwrap();
|
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.fetch_remote("broken").is_err());
|
||||||
assert!(
|
assert!(
|
||||||
repository
|
repository
|
||||||
@@ -746,6 +782,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
|||||||
assert!(
|
assert!(
|
||||||
repository
|
repository
|
||||||
.compare(
|
.compare(
|
||||||
|
1,
|
||||||
&revision("--bad", RepoKind::Git),
|
&revision("--bad", RepoKind::Git),
|
||||||
&working(RepoKind::Git),
|
&working(RepoKind::Git),
|
||||||
&[]
|
&[]
|
||||||
@@ -756,6 +793,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
|||||||
assert!(
|
assert!(
|
||||||
repository
|
repository
|
||||||
.compare(
|
.compare(
|
||||||
|
1,
|
||||||
&revision(&"f".repeat(40), RepoKind::Git),
|
&revision(&"f".repeat(40), RepoKind::Git),
|
||||||
&working(RepoKind::Git),
|
&working(RepoKind::Git),
|
||||||
&[],
|
&[],
|
||||||
@@ -769,7 +807,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
|||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
repository
|
repository
|
||||||
.compare(&loader, &revision(&id, RepoKind::Git), &[])
|
.compare(1, &loader, &revision(&id, RepoKind::Git), &[])
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.contains("history loader")
|
.contains("history loader")
|
||||||
);
|
);
|
||||||
@@ -810,6 +848,7 @@ fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit(
|
|||||||
assert!(
|
assert!(
|
||||||
repository
|
repository
|
||||||
.compare(
|
.compare(
|
||||||
|
1,
|
||||||
&revision(&baseline, RepoKind::Git),
|
&revision(&baseline, RepoKind::Git),
|
||||||
&working(RepoKind::Git),
|
&working(RepoKind::Git),
|
||||||
&[]
|
&[]
|
||||||
@@ -824,13 +863,13 @@ fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit(
|
|||||||
git(&root.join("nested"), &["fetch", "origin"]);
|
git(&root.join("nested"), &["fetch", "origin"]);
|
||||||
git(&root.join("nested"), &["checkout", "--detach", &second]);
|
git(&root.join("nested"), &["checkout", "--detach", &second]);
|
||||||
let state = disk_state(&root);
|
let state = disk_state(&root);
|
||||||
let comparison = repository
|
let comparison = compare_loaded(
|
||||||
.compare(
|
&mut repository,
|
||||||
&revision(&baseline, RepoKind::Git),
|
&revision(&baseline, RepoKind::Git),
|
||||||
&working(RepoKind::Git),
|
&working(RepoKind::Git),
|
||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let submodule = changed(&comparison, Path::new("nested"));
|
let submodule = changed(&comparison, Path::new("nested"));
|
||||||
assert_eq!(submodule.status, FileStatus::Modified);
|
assert_eq!(submodule.status, FileStatus::Modified);
|
||||||
let notice = submodule.notice.as_ref().unwrap();
|
let notice = submodule.notice.as_ref().unwrap();
|
||||||
@@ -897,13 +936,13 @@ fn multiple_remotes_retain_tag_only_history_even_when_tag_names_collide() {
|
|||||||
&& r.description == format!("{name} tag-only needle"))
|
&& r.description == format!("{name} tag-only needle"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let comparison = repository
|
let comparison = compare_loaded(
|
||||||
.compare(
|
&mut repository,
|
||||||
&revision(&ids[0], RepoKind::Git),
|
&revision(&ids[0], RepoKind::Git),
|
||||||
&revision(&ids[1], RepoKind::Git),
|
&revision(&ids[1], RepoKind::Git),
|
||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).before, ["one"]);
|
assert_eq!(changed(&comparison, Path::new("file")).before, ["one"]);
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["two"]);
|
assert_eq!(changed(&comparison, Path::new("file")).after, ["two"]);
|
||||||
assert_eq!(disk_state(&active), state);
|
assert_eq!(disk_state(&active), state);
|
||||||
@@ -1011,13 +1050,13 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|r| r.target == RevisionTarget::Commit(merged.clone()))
|
.any(|r| r.target == RevisionTarget::Commit(merged.clone()))
|
||||||
);
|
);
|
||||||
let comparison = repository
|
let comparison = compare_loaded(
|
||||||
.compare(
|
&mut repository,
|
||||||
&revision(&base, RepoKind::Jujutsu),
|
&revision(&base, RepoKind::Jujutsu),
|
||||||
&revision(&merged, RepoKind::Jujutsu),
|
&revision(&merged, RepoKind::Jujutsu),
|
||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let conflict = changed(&comparison, Path::new("conflict"));
|
let conflict = changed(&comparison, Path::new("conflict"));
|
||||||
assert_eq!(conflict.after, expected);
|
assert_eq!(conflict.after, expected);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1032,13 +1071,13 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
|
|||||||
assert_eq!(ordinary.before, ["old"]);
|
assert_eq!(ordinary.before, ["old"]);
|
||||||
assert_eq!(ordinary.after, ["left ordinary"]);
|
assert_eq!(ordinary.after, ["left ordinary"]);
|
||||||
assert!(ordinary.notice.is_none());
|
assert!(ordinary.notice.is_none());
|
||||||
let working_comparison = repository
|
let working_comparison = compare_loaded(
|
||||||
.compare(
|
&mut repository,
|
||||||
&revision(&merged, RepoKind::Jujutsu),
|
&revision(&merged, RepoKind::Jujutsu),
|
||||||
&working(RepoKind::Jujutsu),
|
&working(RepoKind::Jujutsu),
|
||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
changed(&working_comparison, Path::new("conflict"))
|
changed(&working_comparison, Path::new("conflict"))
|
||||||
.notice
|
.notice
|
||||||
@@ -1079,7 +1118,7 @@ fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths()
|
|||||||
let mut repository = open(&root.join("sub"));
|
let mut repository = open(&root.join("sub"));
|
||||||
let base = revision(&id, RepoKind::Git);
|
let base = revision(&id, RepoKind::Git);
|
||||||
let checkout = working(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!(comparison.root, fs::canonicalize(&root).unwrap());
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["unsaved"]);
|
assert_eq!(changed(&comparison, Path::new("file")).after, ["unsaved"]);
|
||||||
assert!(comparison.editable_paths.contains(Path::new("file")));
|
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("missing")
|
||||||
|| file.path == Path::new("linked/file"))
|
|| 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.editable_paths.is_empty());
|
||||||
assert!(
|
assert!(
|
||||||
!immutable
|
!immutable
|
||||||
@@ -1111,12 +1150,12 @@ fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths()
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
repository
|
repository
|
||||||
.compare(&base, &base, &buffers)
|
.compare(1, &base, &base, &buffers)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.files
|
.files
|
||||||
.is_empty()
|
.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")).before, ["base"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
changed(&same_checkout, Path::new("file")).after,
|
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"
|
"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