feat: reuse the docked file tree for diff navigation
This commit is contained in:
+191
-35
@@ -1,4 +1,5 @@
|
||||
use crate::editor::Input;
|
||||
use crate::files::{FilePicker, FileView};
|
||||
use std::path::PathBuf;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
@@ -146,6 +147,7 @@ pub struct DiffState {
|
||||
pub loading: bool,
|
||||
pub error: Option<String>,
|
||||
pub comparison: Option<Comparison>,
|
||||
pub files: Option<FilePicker>,
|
||||
pub file: usize,
|
||||
pub focus: Focus,
|
||||
pub top: usize,
|
||||
@@ -174,6 +176,7 @@ impl DiffState {
|
||||
loading: true,
|
||||
error: None,
|
||||
comparison: None,
|
||||
files: None,
|
||||
file: 0,
|
||||
focus: Focus::Files,
|
||||
top: 0,
|
||||
@@ -240,6 +243,14 @@ impl DiffState {
|
||||
self.error = None;
|
||||
}
|
||||
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
|
||||
let mut files = FilePicker::from_paths(
|
||||
PathBuf::new(),
|
||||
comparison.files.iter().map(|file| file.path.clone()),
|
||||
);
|
||||
if let Some(file) = comparison.files.first() {
|
||||
files.reveal_path(&file.path);
|
||||
}
|
||||
self.files = Some(files);
|
||||
self.comparison = Some(comparison);
|
||||
self.phase = Phase::View;
|
||||
self.focus = Focus::Files;
|
||||
@@ -295,18 +306,8 @@ impl DiffState {
|
||||
pub(crate) fn navigate(&mut self, forward: bool) {
|
||||
if self.phase == Phase::View {
|
||||
if self.focus == Focus::Files {
|
||||
let count = self
|
||||
.comparison
|
||||
.as_ref()
|
||||
.map_or(0, |comparison| comparison.files.len());
|
||||
let file = if forward {
|
||||
self.file.saturating_add(1).min(count.saturating_sub(1))
|
||||
} else {
|
||||
self.file.saturating_sub(1)
|
||||
};
|
||||
if file != self.file {
|
||||
self.file = file;
|
||||
self.align_file();
|
||||
if let Some(files) = &mut self.files {
|
||||
files.navigate(forward);
|
||||
}
|
||||
} else {
|
||||
self.top = if forward {
|
||||
@@ -334,6 +335,15 @@ impl DiffState {
|
||||
/// Returns true only when the modal should close. All other inputs are consumed.
|
||||
pub(crate) fn handle(&mut self, input: Input) -> bool {
|
||||
if input == Input::Escape {
|
||||
if self.phase == Phase::View
|
||||
&& self.focus == Focus::Files
|
||||
&& let Some(files) = &mut self.files
|
||||
&& files.searching
|
||||
&& files.view == FileView::Tree
|
||||
{
|
||||
files.finish_search();
|
||||
return false;
|
||||
}
|
||||
if self.phase != Phase::After {
|
||||
return true;
|
||||
}
|
||||
@@ -424,6 +434,58 @@ impl DiffState {
|
||||
}
|
||||
|
||||
fn handle_view(&mut self, input: Input) {
|
||||
if self.focus == Focus::Files {
|
||||
let files = self.files.as_mut().expect("comparison file picker");
|
||||
match input {
|
||||
Input::Char('\t') | Input::BackTab => {}
|
||||
Input::Ctrl('t') => {
|
||||
files.toggle_view();
|
||||
return;
|
||||
}
|
||||
Input::Home => {
|
||||
files.selected = 0;
|
||||
return;
|
||||
}
|
||||
Input::End => {
|
||||
let last = files.entries().len().saturating_sub(1);
|
||||
files.selected = last;
|
||||
return;
|
||||
}
|
||||
Input::Up | Input::Ctrl('k') => {
|
||||
files.navigate(false);
|
||||
return;
|
||||
}
|
||||
Input::Down | Input::Ctrl('j') => {
|
||||
files.navigate(true);
|
||||
return;
|
||||
}
|
||||
Input::Left | Input::Ctrl('h') => {
|
||||
files.left();
|
||||
return;
|
||||
}
|
||||
Input::Right | Input::Ctrl('l') => {
|
||||
files.right();
|
||||
return;
|
||||
}
|
||||
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.file = index;
|
||||
self.focus = Focus::Before;
|
||||
self.files.as_mut().expect("comparison picker").focused = false;
|
||||
self.align_file();
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
files.handle_filter_input(&input);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
match input {
|
||||
Input::Char('\t') => {
|
||||
self.focus = match self.focus {
|
||||
@@ -441,12 +503,6 @@ impl DiffState {
|
||||
}
|
||||
Input::Up | Input::Ctrl('k') => self.navigate(false),
|
||||
Input::Down | Input::Ctrl('j') => self.navigate(true),
|
||||
Input::Right | Input::Ctrl('l') if self.focus == Focus::Files => {
|
||||
self.focus = Focus::Before
|
||||
}
|
||||
Input::Left | Input::Ctrl('h') if self.focus == Focus::Files => {
|
||||
self.focus = Focus::After
|
||||
}
|
||||
Input::Left | Input::Ctrl('h') => match self.focus {
|
||||
Focus::Before => self.left_before = self.left_before.saturating_sub(1),
|
||||
Focus::After => self.left_after = self.left_after.saturating_sub(1),
|
||||
@@ -462,25 +518,17 @@ impl DiffState {
|
||||
Focus::Files => {}
|
||||
},
|
||||
Input::Home | Input::End => {
|
||||
if self.focus == Focus::Files {
|
||||
self.file = if input == Input::Home {
|
||||
0
|
||||
} else {
|
||||
self.comparison
|
||||
.as_ref()
|
||||
.map_or(0, |comparison| comparison.files.len().saturating_sub(1))
|
||||
};
|
||||
self.align_file();
|
||||
self.top = if input == Input::Home {
|
||||
0
|
||||
} else {
|
||||
self.top = if input == Input::Home {
|
||||
0
|
||||
} else {
|
||||
self.rows.len().saturating_sub(1)
|
||||
};
|
||||
}
|
||||
self.rows.len().saturating_sub(1)
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(files) = &mut self.files {
|
||||
files.focused = self.focus == Focus::Files;
|
||||
}
|
||||
}
|
||||
|
||||
fn align_file(&mut self) {
|
||||
@@ -859,6 +907,107 @@ mod tests {
|
||||
assert!(editor.diff().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_to_directory_change_opens_both_historical_paths() {
|
||||
let mut editor = editor();
|
||||
open(&mut editor);
|
||||
let id = choose_pair(&mut editor);
|
||||
let mut changes = comparison();
|
||||
changes.files[0].path = "x".into();
|
||||
changes.files[1].path = "x/y".into();
|
||||
changes.files.truncate(2);
|
||||
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
|
||||
let picker = editor.diff().unwrap().files.as_ref().unwrap();
|
||||
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.diff().unwrap().file, 0);
|
||||
editor.handle(Input::BackTab);
|
||||
editor.handle(Input::Char('i'));
|
||||
text(&mut editor, "x/y");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.diff().unwrap().file, 1);
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_tree_filters_navigates_and_opens_without_touching_buffers() {
|
||||
let mut editor = editor();
|
||||
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();
|
||||
changes.files[2].path = "archive/gone/removed.bin".into();
|
||||
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
|
||||
let picker = editor.diff().unwrap().files.as_ref().unwrap();
|
||||
assert!(picker.focused && !picker.searching);
|
||||
assert_eq!(
|
||||
picker.entries().nth(picker.selected).unwrap().path,
|
||||
std::path::Path::new("src/deep/renamed.txt")
|
||||
);
|
||||
assert!(picker.entries().any(|row| row.label == "src/deep"));
|
||||
editor.bind("tree", "z", Some("core.left".into())).unwrap();
|
||||
editor.handle(Input::Char('z'));
|
||||
let picker = editor.diff().unwrap().files.as_ref().unwrap();
|
||||
assert!(picker.entries().nth(picker.selected).unwrap().is_dir);
|
||||
editor.handle(Input::Enter);
|
||||
let picker = editor.diff().unwrap().files.as_ref().unwrap();
|
||||
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
||||
assert_eq!(editor.diff().unwrap().file, 0);
|
||||
editor.handle(Input::Char('l'));
|
||||
editor.handle(Input::Char('l'));
|
||||
editor
|
||||
.bind("selection", "ctrl+n", Some("core.down".into()))
|
||||
.unwrap();
|
||||
editor.handle(Input::Ctrl('n'));
|
||||
assert_eq!(editor.diff().unwrap().file, 0);
|
||||
editor.handle(Input::Char('i'));
|
||||
text(&mut editor, "removed.bin");
|
||||
editor.handle(Input::Enter);
|
||||
let state = editor.diff().unwrap();
|
||||
assert_eq!(state.file, 2);
|
||||
assert_eq!(state.focus, Focus::Before);
|
||||
assert!(!state.files.as_ref().unwrap().focused);
|
||||
assert!(state.rows.is_empty());
|
||||
editor.handle(Input::BackTab);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(!editor.diff().unwrap().files.as_ref().unwrap().searching);
|
||||
editor.handle(Input::Ctrl('u'));
|
||||
assert!(
|
||||
editor
|
||||
.diff()
|
||||
.unwrap()
|
||||
.files
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.query
|
||||
.is_empty()
|
||||
);
|
||||
editor.handle(Input::Ctrl('t'));
|
||||
assert_eq!(
|
||||
editor.diff().unwrap().files.as_ref().unwrap().view,
|
||||
FileView::List
|
||||
);
|
||||
editor.handle(Input::Ctrl('t'));
|
||||
assert_eq!(
|
||||
editor.diff().unwrap().files.as_ref().unwrap().view,
|
||||
FileView::Tree
|
||||
);
|
||||
editor.handle(Input::Ctrl('r'));
|
||||
editor.handle(Input::Char('i'));
|
||||
text(&mut editor, "removed.bin");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||
assert_eq!(editor.context().text, original);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.diff().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aligned_hunks_navigation_shared_top_independent_horizontal_and_file_reset() {
|
||||
let mut editor = editor();
|
||||
@@ -901,6 +1050,8 @@ mod tests {
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||
editor.handle(Input::BackTab);
|
||||
editor.handle(Input::Down);
|
||||
assert_eq!(editor.diff().unwrap().file, 0);
|
||||
editor.handle(Input::Enter);
|
||||
let state = editor.diff().unwrap();
|
||||
assert_eq!(state.file, 1);
|
||||
assert_eq!((state.top, state.left_before, state.left_after), (0, 0, 0));
|
||||
@@ -910,7 +1061,9 @@ mod tests {
|
||||
.iter()
|
||||
.all(|row| row.before.is_none() && row.kind == RowKind::Added)
|
||||
);
|
||||
editor.handle(Input::End);
|
||||
editor.handle(Input::BackTab);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.diff().unwrap().file, 2);
|
||||
assert!(editor.diff().unwrap().rows.is_empty());
|
||||
assert!(
|
||||
@@ -918,7 +1071,9 @@ mod tests {
|
||||
.notice
|
||||
.is_some()
|
||||
);
|
||||
editor.handle(Input::Home);
|
||||
editor.handle(Input::BackTab);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.diff().unwrap().file, 0);
|
||||
}
|
||||
|
||||
@@ -1016,6 +1171,7 @@ mod tests {
|
||||
assert_eq!(editor.context().text, original);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "original\\nsecond");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user