feat: reuse the docked file tree for diff navigation

This commit is contained in:
2026-10-11 00:12:50 +02:00
parent 81a2bf9a02
commit 83c37f5a19
9 changed files with 531 additions and 117 deletions
+191 -35
View File
@@ -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");
}