feat: edit working-checkout comparisons with normal editor actions
This commit is contained in:
+529
-150
@@ -1,6 +1,9 @@
|
||||
use crate::editor::Input;
|
||||
use crate::files::{FilePicker, FileView};
|
||||
use std::path::PathBuf;
|
||||
use crate::files::{FilePicker, FilePresentation, FileView};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -77,6 +80,8 @@ pub struct ChangedFile {
|
||||
pub status: FileStatus,
|
||||
pub before: Vec<String>,
|
||||
pub after: Vec<String>,
|
||||
pub before_eof: bool,
|
||||
pub after_eof: bool,
|
||||
pub hunks: Vec<Hunk>,
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
@@ -85,12 +90,18 @@ pub struct Comparison {
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
pub files: Vec<ChangedFile>,
|
||||
pub root: PathBuf,
|
||||
pub editable_paths: BTreeSet<PathBuf>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub enum Operation {
|
||||
Discover,
|
||||
FetchRemote(String),
|
||||
Compare { before: Revision, after: Revision },
|
||||
Compare {
|
||||
before: Revision,
|
||||
after: Revision,
|
||||
buffers: Box<[(PathBuf, String)]>,
|
||||
},
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub enum Outcome {
|
||||
@@ -164,6 +175,10 @@ pub struct DiffState {
|
||||
pub rows: Vec<AlignedRow>,
|
||||
pub display_rows: Vec<DisplayRow>,
|
||||
excerpt_ranges: Vec<(usize, usize)>,
|
||||
pub live_buffer: Option<usize>,
|
||||
pub live_revision: Option<u64>,
|
||||
pub follow_caret: bool,
|
||||
live_files: BTreeSet<usize>,
|
||||
context_lines: usize,
|
||||
expand_lines: usize,
|
||||
pending: Option<Operation>,
|
||||
@@ -199,6 +214,10 @@ impl DiffState {
|
||||
rows: Vec::new(),
|
||||
display_rows: Vec::new(),
|
||||
excerpt_ranges: Vec::new(),
|
||||
live_buffer: None,
|
||||
live_revision: None,
|
||||
follow_caret: false,
|
||||
live_files: BTreeSet::new(),
|
||||
context_lines: 10,
|
||||
expand_lines: 15,
|
||||
pending: Some(Operation::Discover),
|
||||
@@ -261,9 +280,17 @@ impl DiffState {
|
||||
self.error = None;
|
||||
}
|
||||
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
|
||||
self.live_files.clear();
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
self.follow_caret = false;
|
||||
let mut files = FilePicker::from_paths(
|
||||
PathBuf::new(),
|
||||
comparison.files.iter().map(|file| file.path.clone()),
|
||||
comparison.root.clone(),
|
||||
comparison
|
||||
.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, file)| (index, file.path.clone())),
|
||||
);
|
||||
if let Some(file) = comparison.files.first() {
|
||||
files.reveal_path(&file.path);
|
||||
@@ -351,8 +378,8 @@ impl DiffState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true only when the modal should close. All other inputs are consumed.
|
||||
pub(crate) fn handle(&mut self, input: Input) -> bool {
|
||||
/// Handles modal input; closing is an explicit editor action.
|
||||
pub(crate) fn handle(&mut self, input: Input) {
|
||||
if input == Input::Escape {
|
||||
if self.phase == Phase::View
|
||||
&& self.focus == Focus::Files
|
||||
@@ -361,28 +388,40 @@ impl DiffState {
|
||||
&& files.view == FileView::Tree
|
||||
{
|
||||
files.finish_search();
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
if self.phase != Phase::After {
|
||||
return true;
|
||||
if self.phase == Phase::View {
|
||||
if self.focus == Focus::Files
|
||||
&& let Some(files) = &mut self.files
|
||||
{
|
||||
files.focused = false;
|
||||
self.files_visible = files.presentation == FilePresentation::Docked;
|
||||
self.focus = Focus::Before;
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.pending = None;
|
||||
self.request_kind = None;
|
||||
self.loading = false;
|
||||
self.error = None;
|
||||
if self.phase == Phase::Before {
|
||||
self.query.clear();
|
||||
self.refilter();
|
||||
return;
|
||||
}
|
||||
self.phase = Phase::Before;
|
||||
self.after = None;
|
||||
self.error = None;
|
||||
if let Some(position) = self.before_position.take() {
|
||||
self.category = position.category;
|
||||
self.query = position.query;
|
||||
self.refilter();
|
||||
self.select_target(&position.selected);
|
||||
}
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
if self.phase == Phase::View {
|
||||
self.handle_view(input);
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
match input {
|
||||
Input::Char('\t') | Input::BackTab => {
|
||||
@@ -417,7 +456,6 @@ impl DiffState {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn choose(&mut self) {
|
||||
@@ -448,23 +486,12 @@ impl DiffState {
|
||||
self.queue(Operation::Compare {
|
||||
before: before.clone(),
|
||||
after: revision,
|
||||
buffers: Box::new([]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_view(&mut self, input: Input) {
|
||||
if input == Input::Ctrl('e') {
|
||||
self.files_visible = !self.files_visible;
|
||||
if self.files_visible {
|
||||
self.focus = Focus::Files;
|
||||
} else if self.focus == Focus::Files {
|
||||
self.focus = Focus::Before;
|
||||
}
|
||||
if let Some(files) = &mut self.files {
|
||||
files.focused = self.files_visible && self.focus == Focus::Files;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.focus == Focus::Files {
|
||||
let files = self.files.as_mut().expect("comparison file picker");
|
||||
match input {
|
||||
@@ -500,14 +527,12 @@ impl DiffState {
|
||||
}
|
||||
Input::Enter => {
|
||||
if let Some(path) = files.accept()
|
||||
&& let Some(index) = self.comparison.as_ref().and_then(|comparison| {
|
||||
comparison.files.iter().position(|file| file.path == path)
|
||||
})
|
||||
&& self.select_file(&path)
|
||||
{
|
||||
self.file = index;
|
||||
self.focus = Focus::Before;
|
||||
self.files.as_mut().expect("comparison picker").focused = false;
|
||||
self.align_file();
|
||||
let files = self.files.as_mut().expect("comparison picker");
|
||||
files.focused = false;
|
||||
self.files_visible = files.presentation == FilePresentation::Docked;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -566,14 +591,213 @@ impl DiffState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn after_editable(&self) -> bool {
|
||||
self.phase == Phase::View
|
||||
&& self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|revision| revision.target == RevisionTarget::Working)
|
||||
&& self.comparison.as_ref().is_some_and(|comparison| {
|
||||
comparison
|
||||
.files
|
||||
.get(self.file)
|
||||
.is_some_and(|file| comparison.editable_paths.contains(&file.path))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn select_file(&mut self, path: &Path) -> bool {
|
||||
let Some(index) = self.comparison.as_ref().and_then(|comparison| {
|
||||
let relative = path.strip_prefix(&comparison.root).unwrap_or(path);
|
||||
comparison
|
||||
.files
|
||||
.iter()
|
||||
.position(|file| file.path == relative)
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
self.file = index;
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
self.follow_caret = false;
|
||||
self.align_file();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn after_display_row(&self, source_row: usize) -> Option<usize> {
|
||||
self.display_rows.iter().position(|display| {
|
||||
matches!(*display, DisplayRow::Line(index) if self.rows[index].after == Some(source_row))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reveal_after(&mut self, caret_row: usize) {
|
||||
let Some(index) = self
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| row.after == Some(caret_row))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.after_display_row(caret_row).is_some() {
|
||||
return;
|
||||
}
|
||||
let anchor = self.display_rows.get(self.top).copied();
|
||||
self.excerpt_ranges.push((
|
||||
index.saturating_sub(self.context_lines),
|
||||
index
|
||||
.saturating_add(self.context_lines)
|
||||
.saturating_add(1)
|
||||
.min(self.rows.len()),
|
||||
));
|
||||
self.excerpt_ranges.sort_unstable();
|
||||
self.rebuild_display_rows();
|
||||
self.restore_top(anchor);
|
||||
}
|
||||
|
||||
fn restore_top(&mut self, anchor: Option<DisplayRow>) {
|
||||
if let Some(anchor) = anchor {
|
||||
let index = match anchor {
|
||||
DisplayRow::Line(index) => index,
|
||||
DisplayRow::Fold { start, .. } => start,
|
||||
};
|
||||
self.top = self
|
||||
.display_rows
|
||||
.iter()
|
||||
.position(|row| match *row {
|
||||
DisplayRow::Line(row) => row == index,
|
||||
DisplayRow::Fold { start, end } => start <= index && index < end,
|
||||
})
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_after(&mut self, lines: &[String], revision: u64, caret_row: usize) {
|
||||
if !self.after_editable() {
|
||||
return;
|
||||
}
|
||||
if self.live_revision == Some(revision) {
|
||||
self.reveal_after(caret_row);
|
||||
return;
|
||||
}
|
||||
let file = &self.comparison.as_ref().unwrap().files[self.file];
|
||||
let was_live = self.live_files.contains(&self.file);
|
||||
let mut old = file.after.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
if !was_live && (file.after_eof || old.is_empty()) {
|
||||
old.push("");
|
||||
}
|
||||
let new = lines.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let ops = similar::capture_diff_slices(similar::Algorithm::Myers, &old, &new);
|
||||
let mut mapping = vec![new.len().saturating_sub(1); old.len()];
|
||||
for op in &ops {
|
||||
let (_, old_range, new_range) = op.as_tag_tuple();
|
||||
for offset in 0..old_range.len() {
|
||||
mapping[old_range.start + offset] = (new_range.start
|
||||
+ offset.min(new_range.len().saturating_sub(1)))
|
||||
.min(new.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
let map_after = |index: usize| mapping.get(index).copied().unwrap_or(0);
|
||||
let mut before_visible = BTreeSet::new();
|
||||
let mut after_visible = BTreeSet::new();
|
||||
for &(start, end) in &self.excerpt_ranges {
|
||||
for row in &self.rows[start..end] {
|
||||
before_visible.extend(row.before);
|
||||
after_visible.extend(row.after.map(map_after));
|
||||
}
|
||||
}
|
||||
let top_before = self
|
||||
.display_rows
|
||||
.get(self.top)
|
||||
.and_then(|display| match *display {
|
||||
DisplayRow::Line(index) => self.rows[index].before,
|
||||
DisplayRow::Fold { start, .. } => self.rows[start].before,
|
||||
});
|
||||
let top_after = self
|
||||
.display_rows
|
||||
.get(self.top)
|
||||
.and_then(|display| match *display {
|
||||
DisplayRow::Line(index) => self.rows[index].after,
|
||||
DisplayRow::Fold { start, .. } => self.rows[start].after,
|
||||
})
|
||||
.map(map_after);
|
||||
let was_changed = file_is_changed(file);
|
||||
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
|
||||
file.after.resize_with(lines.len(), String::new);
|
||||
for (target, line) in file.after.iter_mut().zip(lines) {
|
||||
target.clone_from(line);
|
||||
}
|
||||
file.after_eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
|
||||
file.hunks = line_hunks(
|
||||
&file.before,
|
||||
file.before_eof,
|
||||
live_content(lines, file.after_eof),
|
||||
file.after_eof,
|
||||
);
|
||||
update_eof_notice(file);
|
||||
let changed = file_is_changed(file);
|
||||
self.live_files.insert(self.file);
|
||||
self.live_revision = Some(revision);
|
||||
let left_before = self.left_before;
|
||||
let left_after = self.left_after;
|
||||
self.rebuild_file(false);
|
||||
self.left_before = left_before.min(self.max_before);
|
||||
self.left_after = left_after.min(self.max_after);
|
||||
for (index, row) in self.rows.iter().enumerate() {
|
||||
if row
|
||||
.before
|
||||
.is_some_and(|index| before_visible.contains(&index))
|
||||
|| row
|
||||
.after
|
||||
.is_some_and(|index| after_visible.contains(&index))
|
||||
{
|
||||
self.excerpt_ranges.push((index, index + 1));
|
||||
}
|
||||
}
|
||||
self.excerpt_ranges.sort_unstable();
|
||||
self.rebuild_display_rows();
|
||||
if let Some(row) = top_after {
|
||||
self.top = self.after_display_row(row).unwrap_or(0);
|
||||
} else if let Some(source) = top_before
|
||||
&& let Some(index) = self.rows.iter().position(|row| row.before == Some(source))
|
||||
{
|
||||
self.restore_top(Some(DisplayRow::Line(index)));
|
||||
}
|
||||
self.reveal_after(caret_row);
|
||||
if was_changed != changed {
|
||||
self.refresh_files();
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_files(&mut self) {
|
||||
let Some(comparison) = &self.comparison else {
|
||||
return;
|
||||
};
|
||||
let paths = comparison
|
||||
.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, file)| file_is_changed(file))
|
||||
.map(|(index, file)| (index, file.path.clone()));
|
||||
if let Some(files) = &mut self.files {
|
||||
files.replace_paths(paths);
|
||||
} else {
|
||||
self.files = Some(FilePicker::from_paths(comparison.root.clone(), paths));
|
||||
}
|
||||
}
|
||||
|
||||
fn align_file(&mut self) {
|
||||
self.rebuild_file(true);
|
||||
}
|
||||
|
||||
fn rebuild_file(&mut self, reset: bool) {
|
||||
self.rows.clear();
|
||||
self.display_rows.clear();
|
||||
self.excerpt_ranges.clear();
|
||||
self.top = 0;
|
||||
self.left_before = 0;
|
||||
self.left_after = 0;
|
||||
self.max_before = 0;
|
||||
if reset {
|
||||
self.top = 0;
|
||||
self.left_before = 0;
|
||||
self.left_after = 0;
|
||||
self.max_before = 0;
|
||||
}
|
||||
self.max_after = 0;
|
||||
let Some(file) = self
|
||||
.comparison
|
||||
@@ -598,15 +822,22 @@ impl DiffState {
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
self.max_before = width(&file.before);
|
||||
if reset {
|
||||
self.max_before = width(&file.before);
|
||||
}
|
||||
self.max_after = width(&file.after);
|
||||
let after_len = if self.live_files.contains(&self.file) {
|
||||
live_content(&file.after, file.after_eof).len()
|
||||
} else {
|
||||
file.after.len()
|
||||
};
|
||||
self.rows.reserve(file.before.len().max(file.after.len()));
|
||||
let mut before = 0;
|
||||
let mut after = 0;
|
||||
for hunk in &file.hunks {
|
||||
// Clamp invalid ranges rather than indexing historical text unchecked.
|
||||
let start_before = hunk.before_start.min(file.before.len()).max(before);
|
||||
let start_after = hunk.after_start.min(file.after.len()).max(after);
|
||||
let start_after = hunk.after_start.min(after_len).max(after);
|
||||
append_rows(
|
||||
&mut self.rows,
|
||||
before,
|
||||
@@ -620,7 +851,7 @@ impl DiffState {
|
||||
let end_before = before
|
||||
.saturating_add(hunk.before_len)
|
||||
.min(file.before.len());
|
||||
let end_after = after.saturating_add(hunk.after_len).min(file.after.len());
|
||||
let end_after = after.saturating_add(hunk.after_len).min(after_len);
|
||||
append_rows(&mut self.rows, before, end_before, after, end_after, true);
|
||||
before = end_before;
|
||||
after = end_after;
|
||||
@@ -630,9 +861,16 @@ impl DiffState {
|
||||
before,
|
||||
file.before.len(),
|
||||
after,
|
||||
file.after.len(),
|
||||
after_len,
|
||||
false,
|
||||
);
|
||||
if after_len < file.after.len() {
|
||||
self.rows.push(AlignedRow {
|
||||
before: None,
|
||||
after: Some(after_len),
|
||||
kind: RowKind::Equal,
|
||||
});
|
||||
}
|
||||
self.reset_excerpts();
|
||||
}
|
||||
|
||||
@@ -781,6 +1019,89 @@ fn append_rows(
|
||||
}
|
||||
}
|
||||
|
||||
fn live_content(lines: &[String], eof: bool) -> &[String] {
|
||||
if eof || (lines.len() == 1 && lines[0].is_empty()) {
|
||||
&lines[..lines.len().saturating_sub(1)]
|
||||
} else {
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn line_hunks(
|
||||
before: &[String],
|
||||
before_eof: bool,
|
||||
after: &[String],
|
||||
after_eof: bool,
|
||||
) -> Vec<Hunk> {
|
||||
fn tokens(lines: &[String], eof: bool) -> Vec<(&str, bool)> {
|
||||
lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, line)| (line.as_str(), index + 1 < lines.len() || eof))
|
||||
.collect()
|
||||
}
|
||||
let before = tokens(before, before_eof);
|
||||
let after = tokens(after, after_eof);
|
||||
similar::capture_diff_slices(similar::Algorithm::Myers, &before, &after)
|
||||
.into_iter()
|
||||
.filter_map(|op| {
|
||||
let (tag, before, after) = op.as_tag_tuple();
|
||||
(tag != similar::DiffTag::Equal).then_some(Hunk {
|
||||
before_start: before.start,
|
||||
before_len: before.len(),
|
||||
after_start: after.start,
|
||||
after_len: after.len(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn file_is_changed(file: &ChangedFile) -> bool {
|
||||
file.status != FileStatus::Modified
|
||||
|| !file.hunks.is_empty()
|
||||
|| file.notice.as_ref().is_some_and(|notice| {
|
||||
notice
|
||||
.split("; ")
|
||||
.any(|part| !part.starts_with("EOF newline:"))
|
||||
})
|
||||
}
|
||||
|
||||
fn update_eof_notice(file: &mut ChangedFile) {
|
||||
let mut notices = file
|
||||
.notice
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.split("; ")
|
||||
.filter(|part| !part.is_empty() && !part.starts_with("EOF newline:"))
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
let before_empty = file.before.is_empty();
|
||||
let after_empty = live_content(&file.after, file.after_eof).is_empty();
|
||||
if (!before_empty && !file.before_eof) || (!after_empty && !file.after_eof) {
|
||||
let label = |absent, empty, eof| {
|
||||
if absent {
|
||||
"absent"
|
||||
} else if empty {
|
||||
"empty file"
|
||||
} else if eof {
|
||||
"present"
|
||||
} else {
|
||||
"missing"
|
||||
}
|
||||
};
|
||||
notices.push(format!(
|
||||
"EOF newline: Before {}, After {}",
|
||||
label(
|
||||
file.status == FileStatus::Added,
|
||||
before_empty,
|
||||
file.before_eof
|
||||
),
|
||||
label(false, after_empty, file.after_eof)
|
||||
));
|
||||
}
|
||||
file.notice = (!notices.is_empty()).then(|| notices.join("; "));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1079,7 +1400,7 @@ mod tests {
|
||||
editor.handle(Input::Enter);
|
||||
let (id, operation) = editor.take_diff_request().unwrap();
|
||||
match operation {
|
||||
Operation::Compare { before, after } => {
|
||||
Operation::Compare { before, after, .. } => {
|
||||
assert_eq!(before.target, RevisionTarget::Commit("abcdef123".into()));
|
||||
assert_eq!(after.target, RevisionTarget::Working);
|
||||
}
|
||||
@@ -1092,6 +1413,8 @@ mod tests {
|
||||
Comparison {
|
||||
before: "abcdef123".into(),
|
||||
after: "Working checkout".into(),
|
||||
root: PathBuf::new(),
|
||||
editable_paths: BTreeSet::new(),
|
||||
files: vec![
|
||||
ChangedFile {
|
||||
path: "renamed.txt".into(),
|
||||
@@ -1111,6 +1434,8 @@ mod tests {
|
||||
"tail".into(),
|
||||
"end".into(),
|
||||
],
|
||||
before_eof: false,
|
||||
after_eof: false,
|
||||
hunks: vec![
|
||||
Hunk {
|
||||
before_start: 1,
|
||||
@@ -1133,6 +1458,8 @@ mod tests {
|
||||
status: FileStatus::Added,
|
||||
before: vec![],
|
||||
after: vec!["a".into(), "b".into()],
|
||||
before_eof: false,
|
||||
after_eof: false,
|
||||
hunks: vec![Hunk {
|
||||
before_start: 0,
|
||||
before_len: 0,
|
||||
@@ -1147,6 +1474,8 @@ mod tests {
|
||||
status: FileStatus::Removed,
|
||||
before: vec![],
|
||||
after: vec![],
|
||||
before_eof: false,
|
||||
after_eof: false,
|
||||
hunks: vec![],
|
||||
notice: Some("Binary file".into()),
|
||||
},
|
||||
@@ -1261,6 +1590,9 @@ mod tests {
|
||||
assert_eq!(editor.diff().unwrap().phase, Phase::After);
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.diff().unwrap().phase, Phase::Before);
|
||||
assert!(editor.diff().unwrap().query.is_empty());
|
||||
editor.execute("diff.close");
|
||||
assert!(editor.diff().is_none());
|
||||
}
|
||||
|
||||
@@ -1362,129 +1694,55 @@ mod tests {
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.diff().is_some());
|
||||
editor.execute("diff.close");
|
||||
assert!(editor.diff().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explorer_toggle_preserves_filtered_tree_review_and_source_state() {
|
||||
fn explorer_actions_preserve_review_and_ctrl_e_uses_normal_bindings() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("unsaved source".into())])
|
||||
.unwrap();
|
||||
let original = editor.context().text;
|
||||
let carets = editor.carets();
|
||||
let revision = editor.text_revision();
|
||||
open(&mut editor);
|
||||
let id = choose_pair(&mut editor);
|
||||
let mut changes = comparison();
|
||||
changes.files[0].path = "src/deep/renamed.txt".into();
|
||||
changes.files[1].path = "src/deep/added.txt".into();
|
||||
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Right);
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.handle(Input::Right);
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.handle(Input::Char('i'));
|
||||
text(&mut editor, "src");
|
||||
editor.handle(Input::Down);
|
||||
let snapshot = |state: &DiffState| {
|
||||
let files = state.files.as_ref().unwrap();
|
||||
(
|
||||
files.query.clone(),
|
||||
files.selected,
|
||||
files.view,
|
||||
files.searching,
|
||||
files
|
||||
.entries()
|
||||
.map(|row| (row.path.to_owned(), row.expanded))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
let tree = snapshot(editor.diff().unwrap());
|
||||
let rows = editor
|
||||
.diff()
|
||||
.unwrap()
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| (row.before, row.after, row.kind))
|
||||
.collect::<Vec<_>>();
|
||||
editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())));
|
||||
editor
|
||||
.bind("tree", "ctrl+e", Some("core.down".into()))
|
||||
.unwrap();
|
||||
editor
|
||||
.bind("selection", "ctrl+e", Some("core.up".into()))
|
||||
.unwrap();
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let state = editor.diff().unwrap();
|
||||
assert!(!state.files_visible);
|
||||
assert_eq!(state.focus, Focus::Before);
|
||||
assert!(!state.files.as_ref().unwrap().focused);
|
||||
assert!(editor.diff().unwrap().files_visible);
|
||||
assert_eq!(
|
||||
(state.file, state.top, state.left_before, state.left_after),
|
||||
(0, 1, 1, 1)
|
||||
editor
|
||||
.diff()
|
||||
.unwrap()
|
||||
.files
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.entries()
|
||||
.nth(editor.diff().unwrap().files.as_ref().unwrap().selected)
|
||||
.unwrap()
|
||||
.path,
|
||||
Path::new("added.txt")
|
||||
);
|
||||
assert_eq!(snapshot(state), tree);
|
||||
for (input, focus) in [
|
||||
(Input::Char('\t'), Focus::After),
|
||||
(Input::Char('\t'), Focus::Before),
|
||||
(Input::BackTab, Focus::After),
|
||||
(Input::BackTab, Focus::Before),
|
||||
] {
|
||||
editor.handle(input);
|
||||
let state = editor.diff().unwrap();
|
||||
assert_eq!(state.focus, focus);
|
||||
assert!(!state.files.as_ref().unwrap().focused);
|
||||
}
|
||||
for input in [
|
||||
Input::Char('z'),
|
||||
Input::Ctrl('t'),
|
||||
Input::Backspace,
|
||||
Input::Delete,
|
||||
Input::Paste("not source".into()),
|
||||
Input::Enter,
|
||||
] {
|
||||
editor.handle(input);
|
||||
}
|
||||
assert_eq!(snapshot(editor.diff().unwrap()), tree);
|
||||
editor.handle(Input::Down);
|
||||
assert_eq!(editor.diff().unwrap().top, 2);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let state = editor.diff().unwrap();
|
||||
assert!(state.files_visible && state.files.as_ref().unwrap().focused);
|
||||
assert_eq!(state.focus, Focus::Files);
|
||||
assert_eq!(snapshot(state), tree);
|
||||
assert_eq!(
|
||||
(state.file, state.top, state.left_before, state.left_after),
|
||||
(0, 2, 1, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| (row.before, row.after, row.kind))
|
||||
.collect::<Vec<_>>(),
|
||||
rows
|
||||
);
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
editor.handle(Input::BackTab);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::After);
|
||||
assert!(!editor.diff().unwrap().files.as_ref().unwrap().focused);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
assert!(editor.diff().is_some());
|
||||
assert!(!editor.diff().unwrap().files_visible);
|
||||
text(&mut editor, " e");
|
||||
assert!(editor.diff().unwrap().files_visible);
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::Files);
|
||||
assert_eq!(snapshot(editor.diff().unwrap()), tree);
|
||||
assert_eq!(editor.context().text, original);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert_eq!(
|
||||
editor.diff().unwrap().files.as_ref().unwrap().presentation,
|
||||
crate::files::FilePresentation::Docked
|
||||
);
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
text(&mut editor, " o");
|
||||
assert_eq!(
|
||||
editor.diff().unwrap().files.as_ref().unwrap().presentation,
|
||||
crate::files::FilePresentation::Floating
|
||||
);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(!editor.diff().unwrap().files.as_ref().unwrap().searching);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.diff().is_some());
|
||||
editor.execute("diff.close");
|
||||
assert!(editor.diff().is_none());
|
||||
}
|
||||
|
||||
@@ -1584,7 +1842,7 @@ mod tests {
|
||||
let first = choose_pair(&mut editor);
|
||||
assert!(editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison()))));
|
||||
assert_eq!(editor.diff().unwrap().comparison_id, Some(first));
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.close");
|
||||
open(&mut editor);
|
||||
assert_eq!(editor.diff().unwrap().comparison_id, None);
|
||||
let second = choose_pair(&mut editor);
|
||||
@@ -1628,7 +1886,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_and_ex_diff_preserve_dirty_buffers_carets_and_undo_and_gate_all_edits() {
|
||||
fn immutable_diff_and_selectors_preserve_dirty_buffers_carets_and_undo() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("original\\nsecond".into())])
|
||||
@@ -1671,6 +1929,8 @@ mod tests {
|
||||
buffers
|
||||
);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.diff().is_some());
|
||||
editor.execute("diff.close");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
open(&mut editor);
|
||||
let id = choose_pair(&mut editor);
|
||||
@@ -1690,6 +1950,8 @@ mod tests {
|
||||
assert_eq!(editor.carets(), carets);
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.diff().is_some());
|
||||
editor.execute("diff.close");
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "original\\nsecond");
|
||||
}
|
||||
@@ -1724,6 +1986,8 @@ mod tests {
|
||||
before: "Working".into(),
|
||||
after: "Working".into(),
|
||||
files: vec![],
|
||||
root: PathBuf::new(),
|
||||
editable_paths: BTreeSet::new(),
|
||||
}))
|
||||
));
|
||||
assert_eq!(editor.diff().unwrap().phase, Phase::View);
|
||||
@@ -1734,6 +1998,121 @@ mod tests {
|
||||
assert_eq!(editor.diff().unwrap().top, 0);
|
||||
assert_eq!(editor.diff().unwrap().file, 0);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.diff().is_some());
|
||||
editor.execute("diff.close");
|
||||
assert!(editor.diff().is_none());
|
||||
}
|
||||
|
||||
fn live_state(before: Vec<String>, after: Vec<String>, eof: bool) -> DiffState {
|
||||
let mut state = DiffState::new();
|
||||
state.phase = Phase::View;
|
||||
state.focus = Focus::After;
|
||||
state.after = Some(revision(
|
||||
RevisionTarget::Working,
|
||||
"Working",
|
||||
"",
|
||||
Category::Commits,
|
||||
));
|
||||
state.comparison = Some(Comparison {
|
||||
root: PathBuf::new(),
|
||||
before: "base".into(),
|
||||
after: "Working".into(),
|
||||
editable_paths: BTreeSet::from([PathBuf::from("file")]),
|
||||
files: vec![ChangedFile {
|
||||
path: "file".into(),
|
||||
old_path: None,
|
||||
status: FileStatus::Modified,
|
||||
hunks: line_hunks(&before, eof, &after, eof),
|
||||
before,
|
||||
after,
|
||||
before_eof: eof,
|
||||
after_eof: eof,
|
||||
notice: None,
|
||||
}],
|
||||
});
|
||||
state.files = Some(FilePicker::from_paths(
|
||||
PathBuf::new(),
|
||||
[(0, PathBuf::from("file"))],
|
||||
));
|
||||
state.align_file();
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_reflow_keeps_separate_hunks_expansion_and_caret_after_revert() {
|
||||
let base = (0..100)
|
||||
.map(|row| format!("line {row}"))
|
||||
.collect::<Vec<_>>();
|
||||
let mut changed = base.clone();
|
||||
changed[30] = "first change".into();
|
||||
changed[70] = "second change".into();
|
||||
let mut state = live_state(base.clone(), changed.clone(), true);
|
||||
state.set_context_options(2, 5);
|
||||
changed.push(String::new());
|
||||
state.refresh_after(&changed, 1, 30);
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 2);
|
||||
assert!(
|
||||
state
|
||||
.rows
|
||||
.last()
|
||||
.is_some_and(|row| row.after == Some(100) && row.kind == RowKind::Equal)
|
||||
);
|
||||
state.top = state.after_display_row(30).unwrap();
|
||||
state.handle(Input::Char('['));
|
||||
assert!(state.after_display_row(23).is_some());
|
||||
changed.insert(10, "inserted outside excerpts".into());
|
||||
state.refresh_after(&changed, 2, 31);
|
||||
assert!(state.after_display_row(24).is_some());
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 3);
|
||||
assert!(
|
||||
state.display_rows.iter().any(
|
||||
|row| matches!(row, DisplayRow::Fold { start, end } if *start < 60 && *end > 40)
|
||||
)
|
||||
);
|
||||
let mut reverted = base;
|
||||
reverted.push(String::new());
|
||||
state.refresh_after(&reverted, 3, 90);
|
||||
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(90).is_some());
|
||||
assert_eq!(state.files.as_ref().unwrap().entries().len(), 0);
|
||||
assert_eq!(state.file, 0);
|
||||
reverted[50] = "changed again".into();
|
||||
state.refresh_after(&reverted, 4, 50);
|
||||
assert_eq!(state.files.as_ref().unwrap().entries().len(), 1);
|
||||
assert!(state.after_display_row(50).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_empty_and_eof_rows_match_buffer_without_false_eof_highlights() {
|
||||
let mut state = live_state(vec!["same".into()], vec!["changed".into()], true);
|
||||
state.refresh_after(&["same".into(), "".into()], 1, 1);
|
||||
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
|
||||
assert_eq!(state.rows.len(), 2);
|
||||
assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal));
|
||||
assert!(state.after_display_row(1).is_some());
|
||||
state.refresh_after(&["same".into()], 2, 0);
|
||||
assert_eq!(state.rows[0].kind, RowKind::Changed);
|
||||
assert!(
|
||||
state.comparison.as_ref().unwrap().files[0]
|
||||
.notice
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("After missing")
|
||||
);
|
||||
state.refresh_after(&["".into()], 3, 0);
|
||||
assert!(state.after_display_row(0).is_some());
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[0].after, [""]);
|
||||
let mut empty = live_state(vec![], vec!["text".into()], false);
|
||||
empty.refresh_after(&["".into()], 1, 0);
|
||||
assert!(empty.comparison.as_ref().unwrap().files[0].hunks.is_empty());
|
||||
assert_eq!(empty.rows.len(), 1);
|
||||
assert_eq!(empty.rows[0].kind, RowKind::Equal);
|
||||
assert_eq!(empty.after_display_row(0), Some(0));
|
||||
empty.after.as_mut().unwrap().target = RevisionTarget::Commit("historic".into());
|
||||
empty.refresh_after(&["must not apply".into()], 2, 0);
|
||||
assert_eq!(empty.comparison.as_ref().unwrap().files[0].after, [""]);
|
||||
empty.handle(Input::Escape);
|
||||
assert_eq!(empty.phase, Phase::View);
|
||||
}
|
||||
}
|
||||
|
||||
+719
-55
File diff suppressed because it is too large
Load Diff
+67
-58
@@ -165,10 +165,10 @@ impl FilePicker {
|
||||
Ok(Self::from_nodes(root, nodes, presentation, view, warning))
|
||||
}
|
||||
|
||||
/// Builds the changed-path catalog without consulting the working tree.
|
||||
pub fn from_paths(root: PathBuf, paths: impl IntoIterator<Item = PathBuf>) -> Self {
|
||||
/// Builds a changed-path catalog, retaining each path's comparison index.
|
||||
pub fn from_paths(root: PathBuf, paths: impl IntoIterator<Item = (usize, PathBuf)>) -> Self {
|
||||
let mut catalog = HashMap::new();
|
||||
for (index, path) in paths.into_iter().enumerate() {
|
||||
for (index, path) in paths {
|
||||
for parent in path
|
||||
.ancestors()
|
||||
.skip(1)
|
||||
@@ -719,63 +719,69 @@ impl FilePicker {
|
||||
|
||||
pub(crate) fn refresh(&mut self) {
|
||||
match Self::scan(self.root.clone(), self.presentation, self.view) {
|
||||
Ok(mut replacement) => {
|
||||
let by_path: HashMap<_, _> = replacement
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, node)| (node.path.clone(), index))
|
||||
.collect();
|
||||
let surviving = |node: usize| {
|
||||
let mut path = self.nodes[node].path.as_path();
|
||||
loop {
|
||||
if let Some(&node) = by_path.get(path) {
|
||||
return Some(node);
|
||||
}
|
||||
path = path.parent()?;
|
||||
}
|
||||
};
|
||||
replacement.expanded = self
|
||||
.expanded
|
||||
.iter()
|
||||
.filter_map(|&node| {
|
||||
by_path
|
||||
.get(&self.nodes[node].path)
|
||||
.copied()
|
||||
.filter(|&node| replacement.nodes[node].is_dir)
|
||||
})
|
||||
.collect();
|
||||
replacement.filter_collapsed = self
|
||||
.filter_collapsed
|
||||
.iter()
|
||||
.filter_map(|&node| {
|
||||
by_path
|
||||
.get(&self.nodes[node].path)
|
||||
.copied()
|
||||
.filter(|&node| replacement.nodes[node].is_dir)
|
||||
})
|
||||
.collect();
|
||||
replacement.saved_selection =
|
||||
self.saved_selection.map(|node| node.and_then(surviving));
|
||||
replacement.filtered_selection =
|
||||
self.filtered_selection.map(|node| node.and_then(surviving));
|
||||
replacement.query = self.query.clone();
|
||||
replacement.focused = self.focused;
|
||||
replacement.view = self.view;
|
||||
replacement.searching = self.searching;
|
||||
replacement.tree_searching = self.tree_searching;
|
||||
let selected = self
|
||||
.selected_node()
|
||||
.or(self.filtered_selection[self.view.index()])
|
||||
.or(self.saved_selection[self.view.index()])
|
||||
.and_then(surviving);
|
||||
replacement.project(selected, false);
|
||||
replacement.remember_selection(false);
|
||||
*self = replacement;
|
||||
}
|
||||
Ok(replacement) => self.replace_catalog(replacement),
|
||||
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn replace_paths(&mut self, paths: impl IntoIterator<Item = (usize, PathBuf)>) {
|
||||
self.replace_catalog(Self::from_paths(self.root.clone(), paths));
|
||||
}
|
||||
|
||||
fn replace_catalog(&mut self, mut replacement: Self) {
|
||||
replacement.presentation = self.presentation;
|
||||
let by_path: HashMap<_, _> = replacement
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, node)| (node.path.clone(), index))
|
||||
.collect();
|
||||
let surviving = |node: usize| {
|
||||
let mut path = self.nodes[node].path.as_path();
|
||||
loop {
|
||||
if let Some(&node) = by_path.get(path) {
|
||||
return Some(node);
|
||||
}
|
||||
path = path.parent()?;
|
||||
}
|
||||
};
|
||||
replacement.expanded = self
|
||||
.expanded
|
||||
.iter()
|
||||
.filter_map(|&node| {
|
||||
by_path
|
||||
.get(&self.nodes[node].path)
|
||||
.copied()
|
||||
.filter(|&node| replacement.nodes[node].is_dir)
|
||||
})
|
||||
.collect();
|
||||
replacement.filter_collapsed = self
|
||||
.filter_collapsed
|
||||
.iter()
|
||||
.filter_map(|&node| {
|
||||
by_path
|
||||
.get(&self.nodes[node].path)
|
||||
.copied()
|
||||
.filter(|&node| replacement.nodes[node].is_dir)
|
||||
})
|
||||
.collect();
|
||||
replacement.saved_selection = self.saved_selection.map(|node| node.and_then(surviving));
|
||||
replacement.filtered_selection =
|
||||
self.filtered_selection.map(|node| node.and_then(surviving));
|
||||
replacement.query = self.query.clone();
|
||||
replacement.focused = self.focused;
|
||||
replacement.view = self.view;
|
||||
replacement.searching = self.searching;
|
||||
replacement.tree_searching = self.tree_searching;
|
||||
let selected = self
|
||||
.selected_node()
|
||||
.or(self.filtered_selection[self.view.index()])
|
||||
.or(self.saved_selection[self.view.index()])
|
||||
.and_then(surviving);
|
||||
replacement.project(selected, false);
|
||||
replacement.remember_selection(false);
|
||||
*self = replacement;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -786,7 +792,10 @@ mod tests {
|
||||
fn virtual_catalog_compacts_and_retains_file_directory_collisions() {
|
||||
let mut picker = FilePicker::from_paths(
|
||||
PathBuf::new(),
|
||||
["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"].map(PathBuf::from),
|
||||
["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"]
|
||||
.map(PathBuf::from)
|
||||
.into_iter()
|
||||
.enumerate(),
|
||||
);
|
||||
assert_eq!(
|
||||
picker
|
||||
|
||||
+5
-3
@@ -40,10 +40,12 @@ Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
|
||||
Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
|
||||
:q refuses any dirty buffer; :wq saves active and quits only if all are clean
|
||||
Markdown links: :follow_link (primary caret; local files only)
|
||||
Diff: :diff (Normal); choose Before then After; Working checkout means disk, not unsaved buffers
|
||||
Tab/Shift+Tab categories/focus; type to search IDs/refs; arrows navigate/scroll; Esc back/close
|
||||
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
|
||||
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
|
||||
Type to search IDs/refs; Normal [/] expands excerpts; Insert brackets remain literal text
|
||||
:diff.close exits explicitly; Esc cancels modes/prompts/filtering without closing the diff
|
||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||
Changed-file tree: Ctrl+e hides/shows; A added, M modified, D removed, R renamed (old path shown)
|
||||
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||
|
||||
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
||||
Set VAMP_RUBY to choose the Ruby executable.
|
||||
|
||||
+87
-68
@@ -1,7 +1,7 @@
|
||||
//! Read-only active-repository discovery and isolated revision comparison.
|
||||
use crate::diff::{
|
||||
Catalog, Category, ChangedFile, Comparison, FileStatus, Hunk, RepoKind, Revision,
|
||||
RevisionTarget,
|
||||
Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, RevisionTarget,
|
||||
line_hunks,
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -934,7 +934,7 @@ impl Repository {
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
fn working_snapshot(&self) -> Result<Snapshot, String> {
|
||||
fn working_snapshot(&self, buffers: &[(PathBuf, String)]) -> Result<Snapshot, String> {
|
||||
let mut tracked = BTreeSet::new();
|
||||
let mut submodules = BTreeSet::new();
|
||||
if self.kind == RepoKind::Git {
|
||||
@@ -1017,6 +1017,32 @@ impl Repository {
|
||||
}
|
||||
}
|
||||
}
|
||||
let overlays: BTreeMap<_, _> = buffers
|
||||
.iter()
|
||||
.filter_map(|(path, text)| {
|
||||
let relative = path.strip_prefix(&self.root).ok()?;
|
||||
if safe_path(relative).is_err()
|
||||
|| !path.is_absolute()
|
||||
|| text.as_bytes().contains(&0)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Reject symlinks in every component, not just the final file.
|
||||
let mut full = self.root.clone();
|
||||
for component in relative.components() {
|
||||
full.push(component);
|
||||
let metadata = fs::symlink_metadata(&full).ok()?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
fs::symlink_metadata(&full)
|
||||
.ok()?
|
||||
.is_file()
|
||||
.then_some((relative.to_path_buf(), text))
|
||||
})
|
||||
.collect();
|
||||
paths.extend(overlays.keys().cloned());
|
||||
let mut total = 0;
|
||||
let mut snapshot = BTreeMap::new();
|
||||
for path in paths {
|
||||
@@ -1110,6 +1136,20 @@ impl Repository {
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let bytes = if !bytes.contains(&0)
|
||||
&& std::str::from_utf8(&bytes).is_ok()
|
||||
&& let Some(text) = overlays.get(&path)
|
||||
{
|
||||
if text.len() > FILE_LIMIT {
|
||||
return Err(format!(
|
||||
"{} exceeds the 16 MiB comparison file limit",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
text.as_bytes().to_vec()
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let executable = {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -1146,16 +1186,24 @@ impl Repository {
|
||||
}
|
||||
fn snapshot(&mut self, target: &RevisionTarget) -> Result<Snapshot, String> {
|
||||
if *target == RevisionTarget::Working {
|
||||
self.working_snapshot()
|
||||
self.working_snapshot(&[])
|
||||
} else {
|
||||
let (store, id) = self.resolve(target)?;
|
||||
self.commit_snapshot(&store, &id)
|
||||
}
|
||||
}
|
||||
pub fn compare(&mut self, before: &Revision, after: &Revision) -> Result<Comparison, String> {
|
||||
pub fn compare(
|
||||
&mut self,
|
||||
before: &Revision,
|
||||
after: &Revision,
|
||||
buffers: &[(PathBuf, String)],
|
||||
) -> Result<Comparison, String> {
|
||||
let left = self.snapshot(&before.target)?;
|
||||
let right = if before.target == after.target {
|
||||
let working_after = after.target == RevisionTarget::Working;
|
||||
let right = if before.target == after.target && (!working_after || buffers.is_empty()) {
|
||||
None
|
||||
} else if working_after {
|
||||
Some(self.working_snapshot(buffers)?)
|
||||
} else {
|
||||
Some(self.snapshot(&after.target)?)
|
||||
};
|
||||
@@ -1199,15 +1247,36 @@ impl Repository {
|
||||
removed.insert(j);
|
||||
}
|
||||
}
|
||||
let files = files
|
||||
let files: Vec<_> = files
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, f)| (!removed.contains(&i)).then_some(f))
|
||||
.collect();
|
||||
let editable_paths = if working_after {
|
||||
files
|
||||
.iter()
|
||||
.filter(|file| {
|
||||
right.get(&file.path).is_some_and(|data| {
|
||||
matches!(data.mode.as_str(), "100644" | "100755")
|
||||
&& !data.bytes.contains(&0)
|
||||
&& std::str::from_utf8(&data.bytes).is_ok()
|
||||
&& file
|
||||
.notice
|
||||
.as_deref()
|
||||
.is_none_or(|notice| !notice.contains("Binary or non-UTF-8"))
|
||||
})
|
||||
})
|
||||
.map(|file| file.path.clone())
|
||||
.collect()
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
Ok(Comparison {
|
||||
before: before.label.clone(),
|
||||
after: after.label.clone(),
|
||||
files,
|
||||
root: self.root.clone(),
|
||||
editable_paths,
|
||||
})
|
||||
}
|
||||
fn changed_file(
|
||||
@@ -1253,38 +1322,15 @@ impl Repository {
|
||||
eof_label(after)
|
||||
));
|
||||
}
|
||||
let before_path = self.temporary.0.join("before");
|
||||
let after_path = self.temporary.0.join("after");
|
||||
fs::write(&before_path, a).map_err(|e| e.to_string())?;
|
||||
fs::write(&after_path, b).map_err(|e| e.to_string())?;
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args([
|
||||
"--no-pager",
|
||||
"diff",
|
||||
"--no-index",
|
||||
"--no-color",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--text",
|
||||
"--unified=0",
|
||||
"--",
|
||||
])
|
||||
.arg(&before_path)
|
||||
.arg(&after_path)
|
||||
.current_dir(&self.temporary.0);
|
||||
let output = run(cmd, vec![], &self.cancelled)?;
|
||||
if !matches!(output.code, Some(0 | 1)) {
|
||||
return Err(format!(
|
||||
"Cannot calculate diff hunks: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
let patch = text(output.stdout)?;
|
||||
let mut hunks = vec![];
|
||||
for line in patch.lines().filter(|l| l.starts_with("@@ ")) {
|
||||
hunks.push(parse_hunk(line)?);
|
||||
}
|
||||
(lines(a), lines(b), hunks)
|
||||
let before_lines = lines(a);
|
||||
let after_lines = lines(b);
|
||||
let hunks = line_hunks(
|
||||
&before_lines,
|
||||
a.ends_with(b"\n"),
|
||||
&after_lines,
|
||||
b.ends_with(b"\n"),
|
||||
);
|
||||
(before_lines, after_lines, hunks)
|
||||
};
|
||||
notices.dedup();
|
||||
Ok(ChangedFile {
|
||||
@@ -1293,6 +1339,8 @@ impl Repository {
|
||||
status,
|
||||
before: before_lines,
|
||||
after: after_lines,
|
||||
before_eof: a.ends_with(b"\n"),
|
||||
after_eof: b.ends_with(b"\n"),
|
||||
hunks,
|
||||
notice: if notices.is_empty() {
|
||||
None
|
||||
@@ -1318,35 +1366,6 @@ fn eof_label(file: Option<&FileData>) -> &'static str {
|
||||
Some(_) => "missing",
|
||||
}
|
||||
}
|
||||
fn parse_hunk(line: &str) -> Result<Hunk, String> {
|
||||
fn range(value: &str) -> Result<(usize, usize), String> {
|
||||
let (start, len) = value[1..].split_once(',').unwrap_or((&value[1..], "1"));
|
||||
let start: usize = start.parse().map_err(|_| "Invalid diff hunk start")?;
|
||||
let len: usize = len.parse().map_err(|_| "Invalid diff hunk length")?;
|
||||
Ok((
|
||||
if len == 0 {
|
||||
start
|
||||
} else {
|
||||
start
|
||||
.checked_sub(1)
|
||||
.ok_or("Invalid nonempty diff hunk start")?
|
||||
},
|
||||
len,
|
||||
))
|
||||
}
|
||||
let mut fields = line.split_whitespace();
|
||||
if fields.next() != Some("@@") {
|
||||
return Err("Invalid diff hunk header".into());
|
||||
}
|
||||
let (before_start, before_len) = range(fields.next().ok_or("Missing Before hunk range")?)?;
|
||||
let (after_start, after_len) = range(fields.next().ok_or("Missing After hunk range")?)?;
|
||||
Ok(Hunk {
|
||||
before_start,
|
||||
before_len,
|
||||
after_start,
|
||||
after_len,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
|
||||
+14
@@ -933,8 +933,19 @@ end
|
||||
config.bind "V", config.core.buffer_previous
|
||||
config.bind "D", config.core.buffer_delete
|
||||
config.bind "Z", config.core.buffer_force_delete
|
||||
config.bind "R", config.core.diff
|
||||
config.bind "T", config.core.diff.close
|
||||
raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? &&
|
||||
config.core.diff.close.frozen? && config.core.diff.name.frozen? &&
|
||||
config.core.diff.close.name.frozen?
|
||||
end"#,
|
||||
);
|
||||
let mut review = EditorSession::open(None, vec!["text".into()], Some(&config)).unwrap();
|
||||
review.handle(Input::Char('R'));
|
||||
review.handle(Input::Escape);
|
||||
assert!(review.editor().diff().is_some());
|
||||
review.handle(Input::Char('T'));
|
||||
assert!(review.editor().diff().is_none());
|
||||
for (key, dirty, quit, saved) in [
|
||||
('Q', false, true, false),
|
||||
('Q', true, false, false),
|
||||
@@ -1047,6 +1058,9 @@ end
|
||||
"config.core.lsp",
|
||||
"config.core.lsp.hover.tap { |action| action.name = 'core.delete' }",
|
||||
"config.core.lsp.tap { |namespace| namespace.define_singleton_method(:bogus) { namespace.hover } }.bogus",
|
||||
"config.core.diff.tap { |action| action.name = 'core.delete' }",
|
||||
"config.core.diff.tap { |action| action.define_singleton_method(:bogus) { action.close } }.bogus",
|
||||
"config.core.diff.close.tap { |action| action.name = 'core.delete' }",
|
||||
] {
|
||||
let invalid = fixture.file(
|
||||
"invalid_action.rb",
|
||||
|
||||
+12
-11
@@ -59,13 +59,13 @@ module Vamp
|
||||
Core = Module.new
|
||||
|
||||
# @param entries [Array<Hash{String => String, nil}>] action name and optional Ex alias descriptors
|
||||
# @return [Array<Module>] frozen action namespaces
|
||||
# @return [Array<Module, Vamp::Action>] frozen action namespaces and leaves
|
||||
def self.install_actions(entries)
|
||||
raise TypeError, "core action catalog must be an Array" unless entries.is_a?(Array)
|
||||
|
||||
namespaces = [Core]
|
||||
aliases = {}
|
||||
entries.each do |entry|
|
||||
entries.sort_by { |entry| entry.fetch("name").count(".") }.each do |entry|
|
||||
raise TypeError, "core action descriptor must be a Hash" unless entry.is_a?(Hash)
|
||||
|
||||
install_action(entry.fetch("name"), namespaces)
|
||||
@@ -139,7 +139,7 @@ module Vamp
|
||||
end
|
||||
|
||||
# @param name [String]
|
||||
# @param namespaces [Array<Module>]
|
||||
# @param namespaces [Array<Module, Vamp::Action>]
|
||||
# @return [Symbol] the installed action method name
|
||||
def self.install_action(name, namespaces)
|
||||
name = action_name(name)
|
||||
@@ -147,7 +147,8 @@ module Vamp
|
||||
namespace = parts[0...-1].reduce(Core) { |parent, part| action_namespace(parent, part, namespaces, name) }
|
||||
leaf = parts.last
|
||||
available_action!(namespace, leaf, name)
|
||||
action = Action.send(:new, name.freeze).freeze
|
||||
action = Action.send(:new, name.freeze)
|
||||
namespaces << action
|
||||
namespace.define_singleton_method(leaf) { action }
|
||||
end
|
||||
|
||||
@@ -160,23 +161,23 @@ module Vamp
|
||||
name
|
||||
end
|
||||
|
||||
# @param parent [Module]
|
||||
# @param parent [Module, Vamp::Action]
|
||||
# @param part [String]
|
||||
# @param namespaces [Array<Module>]
|
||||
# @param namespaces [Array<Module, Vamp::Action>]
|
||||
# @param name [String]
|
||||
# @return [Module]
|
||||
# @return [Module, Vamp::Action]
|
||||
def self.action_namespace(parent, part, namespaces, name)
|
||||
return new_action_namespace(parent, part, namespaces) unless parent.singleton_methods(false).include?(part.to_sym)
|
||||
|
||||
child = parent.public_send(part)
|
||||
raise ArgumentError, "conflicting core action namespace: #{name}" unless child.is_a?(Module)
|
||||
raise ArgumentError, "conflicting core action namespace: #{name}" unless child.is_a?(Module) || child.is_a?(Action)
|
||||
|
||||
child
|
||||
end
|
||||
|
||||
# @param parent [Module]
|
||||
# @param parent [Module, Vamp::Action]
|
||||
# @param part [String]
|
||||
# @param namespaces [Array<Module>]
|
||||
# @param namespaces [Array<Module, Vamp::Action>]
|
||||
# @return [Module]
|
||||
def self.new_action_namespace(parent, part, namespaces)
|
||||
child = Module.new
|
||||
@@ -185,7 +186,7 @@ module Vamp
|
||||
child
|
||||
end
|
||||
|
||||
# @param namespace [Module]
|
||||
# @param namespace [Module, Vamp::Action]
|
||||
# @param leaf [String]
|
||||
# @param name [String]
|
||||
# @return [nil]
|
||||
|
||||
+151
-10
@@ -11,7 +11,7 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
diff::{Operation as DiffOperation, Outcome as DiffOutcome},
|
||||
diff::{Focus, Operation as DiffOperation, Outcome as DiffOutcome},
|
||||
editor::{Cursor, Editor, Effect, Input, Invocation, Mode},
|
||||
lsp::Lsp,
|
||||
plugin::{
|
||||
@@ -71,8 +71,12 @@ impl DiffWorker {
|
||||
DiffOperation::FetchRemote(remote) => {
|
||||
repository.fetch_remote(&remote).map(DiffOutcome::Catalog)
|
||||
}
|
||||
DiffOperation::Compare { before, after } => repository
|
||||
.compare(&before, &after)
|
||||
DiffOperation::Compare {
|
||||
before,
|
||||
after,
|
||||
buffers,
|
||||
} => repository
|
||||
.compare(&before, &after, &buffers)
|
||||
.map(DiffOutcome::Comparison),
|
||||
}
|
||||
})();
|
||||
@@ -254,6 +258,10 @@ impl EditorSession {
|
||||
&self.editor
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) {
|
||||
self.editor.scroll_diff(focus, down);
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, input: Input) {
|
||||
self.cancel_pending_popup_request();
|
||||
let invocation = self.editor.handle(input);
|
||||
@@ -386,6 +394,7 @@ impl EditorSession {
|
||||
return;
|
||||
}
|
||||
let context = self.editor.context();
|
||||
let read_only = self.editor.diff().is_some() && !self.editor.diff_editing();
|
||||
let result = match self.plugins.get_mut(&invocation.plugin) {
|
||||
Some(plugin) if plugin.is_async() => {
|
||||
let requests = self.requests.entry(invocation.plugin.clone()).or_default();
|
||||
@@ -402,7 +411,18 @@ impl EditorSession {
|
||||
}
|
||||
Some(plugin) => plugin
|
||||
.invoke(&invocation.command, &invocation.args, &context)
|
||||
.and_then(|effects| self.editor.apply(effects)),
|
||||
.and_then(|effects| {
|
||||
if read_only
|
||||
&& effects
|
||||
.iter()
|
||||
.any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_)))
|
||||
{
|
||||
self.editor.set_message("Diff pane is read-only");
|
||||
Ok(())
|
||||
} else {
|
||||
self.editor.apply(effects)
|
||||
}
|
||||
}),
|
||||
None => Err("plugin host is unavailable".into()),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
@@ -555,7 +575,7 @@ impl EditorSession {
|
||||
request_id: Option<u64>,
|
||||
source: InvocationSource,
|
||||
) -> bool {
|
||||
if self.editor.diff().is_some() {
|
||||
if self.editor.diff().is_some() && !self.editor.diff_editing() {
|
||||
return false;
|
||||
}
|
||||
self.editor.buffer_id() == source.buffer_id
|
||||
@@ -791,6 +811,7 @@ impl EditorSession {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.editor.refresh_live_diff();
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
@@ -807,10 +828,7 @@ impl EditorSession {
|
||||
let Some(source) = source else {
|
||||
return Err("plugin response has an unknown invocation ID".into());
|
||||
};
|
||||
if !self.source_is_current(source)
|
||||
|| self.editor.buffer_id() != source.buffer_id
|
||||
|| self.editor.diff().is_some()
|
||||
{
|
||||
if !self.source_is_current(source) || self.editor.buffer_id() != source.buffer_id {
|
||||
return Ok(false);
|
||||
}
|
||||
if effects
|
||||
@@ -826,6 +844,9 @@ impl EditorSession {
|
||||
}
|
||||
return Ok(changed);
|
||||
}
|
||||
if self.editor.diff().is_some() && !self.editor.diff_editing() {
|
||||
return Ok(false);
|
||||
}
|
||||
let changed = !effects.is_empty();
|
||||
self.editor.apply(effects)?;
|
||||
Ok(changed)
|
||||
@@ -3314,11 +3335,131 @@ end
|
||||
});
|
||||
assert_eq!(session.editor().context().text, "original");
|
||||
assert_eq!(fs::read_to_string(&source).unwrap(), "original");
|
||||
session.handle(Input::Escape);
|
||||
session.execute("diff.close");
|
||||
session.execute("ping");
|
||||
drain_until(&mut session, |session| {
|
||||
session.editor().message() == "still alive"
|
||||
});
|
||||
assert_eq!(session.editor().context().text, "original");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_responses_edit_only_current_bound_working_after_and_refresh_projection() {
|
||||
use crate::diff::{
|
||||
Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision,
|
||||
RevisionTarget,
|
||||
};
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "base");
|
||||
let mut session = fixture.open("");
|
||||
session.editor.execute("diff");
|
||||
let (id, _) = session.editor.take_diff_request().unwrap();
|
||||
let revision = |target, label: &str| Revision {
|
||||
target,
|
||||
label: label.into(),
|
||||
description: String::new(),
|
||||
category: Category::Commits,
|
||||
};
|
||||
session.editor.apply_diff_result(
|
||||
id,
|
||||
Ok(DiffOutcome::Catalog(Catalog {
|
||||
kind: RepoKind::Git,
|
||||
notice: None,
|
||||
entries: vec![
|
||||
revision(RevisionTarget::Commit("base".into()), "base"),
|
||||
revision(RevisionTarget::Working, "Working"),
|
||||
],
|
||||
})),
|
||||
);
|
||||
for input in [Input::Down, Input::Enter, Input::Enter] {
|
||||
session.editor.handle(input);
|
||||
}
|
||||
let (id, _) = session.editor.take_diff_request().unwrap();
|
||||
session.editor.apply_diff_result(
|
||||
id,
|
||||
Ok(DiffOutcome::Comparison(Comparison {
|
||||
root: fixture.0.canonicalize().unwrap(),
|
||||
before: "base".into(),
|
||||
after: "Working".into(),
|
||||
editable_paths: [PathBuf::from("buffer.txt")].into_iter().collect(),
|
||||
files: vec![ChangedFile {
|
||||
path: PathBuf::from("buffer.txt"),
|
||||
old_path: None,
|
||||
status: FileStatus::Modified,
|
||||
before: vec!["base".into()],
|
||||
after: vec!["base".into()],
|
||||
before_eof: false,
|
||||
after_eof: false,
|
||||
hunks: Vec::new(),
|
||||
notice: None,
|
||||
}],
|
||||
})),
|
||||
);
|
||||
session.editor.handle(Input::BackTab);
|
||||
let source = InvocationSource {
|
||||
buffer_id: session.editor.buffer_id(),
|
||||
revision: session.editor.text_revision(),
|
||||
};
|
||||
session
|
||||
.requests
|
||||
.entry("fixture".into())
|
||||
.or_default()
|
||||
.pending
|
||||
.insert(1, source);
|
||||
assert!(
|
||||
session
|
||||
.apply_response("fixture", 1, vec![Effect::Insert("accepted".into())])
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(session.editor.lines(), ["acceptedbase"]);
|
||||
let diff = session.editor.diff().unwrap();
|
||||
assert_eq!(diff.live_revision, Some(session.editor.text_revision()));
|
||||
assert_eq!(
|
||||
diff.comparison.as_ref().unwrap().files[0].after,
|
||||
session.editor.lines()
|
||||
);
|
||||
|
||||
session
|
||||
.requests
|
||||
.get_mut("fixture")
|
||||
.unwrap()
|
||||
.pending
|
||||
.insert(2, source);
|
||||
assert!(
|
||||
!session
|
||||
.apply_response("fixture", 2, vec![Effect::Insert("stale".into())])
|
||||
.unwrap()
|
||||
);
|
||||
let current = InvocationSource {
|
||||
buffer_id: session.editor.buffer_id(),
|
||||
revision: session.editor.text_revision(),
|
||||
};
|
||||
session.editor.scroll_diff(Focus::Before, false);
|
||||
session
|
||||
.requests
|
||||
.get_mut("fixture")
|
||||
.unwrap()
|
||||
.pending
|
||||
.insert(3, current);
|
||||
assert!(
|
||||
!session
|
||||
.apply_response("fixture", 3, vec![Effect::Insert("readonly".into())])
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(session.editor.lines(), ["acceptedbase"]);
|
||||
session
|
||||
.requests
|
||||
.get_mut("fixture")
|
||||
.unwrap()
|
||||
.pending
|
||||
.insert(4, current);
|
||||
assert!(
|
||||
session
|
||||
.apply_response("fixture", 4, vec![Effect::Message("allowed".into())])
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(session.editor.message(), "allowed");
|
||||
session.editor.execute("diff.close");
|
||||
assert!(session.editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
+512
-251
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user