feat: toggle diff explorer and show file change indicators

This commit is contained in:
2026-10-11 00:54:35 +02:00
parent 83c37f5a19
commit 2f595ca4ca
9 changed files with 454 additions and 38 deletions
+162 -3
View File
@@ -148,6 +148,7 @@ pub struct DiffState {
pub error: Option<String>,
pub comparison: Option<Comparison>,
pub files: Option<FilePicker>,
pub files_visible: bool,
pub file: usize,
pub focus: Focus,
pub top: usize,
@@ -177,6 +178,7 @@ impl DiffState {
error: None,
comparison: None,
files: None,
files_visible: true,
file: 0,
focus: Focus::Files,
top: 0,
@@ -434,6 +436,18 @@ impl DiffState {
}
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 {
@@ -491,14 +505,16 @@ impl DiffState {
self.focus = match self.focus {
Focus::Files => Focus::Before,
Focus::Before => Focus::After,
Focus::After => Focus::Files,
Focus::After if self.files_visible => Focus::Files,
Focus::After => Focus::Before,
}
}
Input::BackTab => {
self.focus = match self.focus {
Focus::Files => Focus::After,
Focus::After => Focus::Before,
Focus::Before => Focus::Files,
Focus::Before if self.files_visible => Focus::Files,
Focus::Before => Focus::After,
}
}
Input::Up | Input::Ctrl('k') => self.navigate(false),
@@ -527,7 +543,7 @@ impl DiffState {
_ => {}
}
if let Some(files) = &mut self.files {
files.focused = self.focus == Focus::Files;
files.focused = self.files_visible && self.focus == Focus::Files;
}
}
@@ -1008,6 +1024,149 @@ mod tests {
assert!(editor.diff().is_none());
}
#[test]
fn explorer_toggle_preserves_filtered_tree_review_and_source_state() {
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
.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_eq!(
(state.file, state.top, state.left_before, state.left_after),
(0, 1, 1, 1)
);
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'));
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);
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_none());
}
#[test]
fn explorer_toggle_is_not_a_revision_selector_control() {
let mut editor = editor();
open(&mut editor);
editor.handle(Input::Ctrl('e'));
assert_eq!(editor.diff().unwrap().selected, 0);
editor
.bind("selection", "ctrl+e", Some("core.down".into()))
.unwrap();
for phase in [Phase::Before, Phase::After] {
assert_eq!(editor.diff().unwrap().phase, phase);
editor.handle(Input::Ctrl('e'));
let state = editor.diff().unwrap();
assert!(state.files_visible);
assert_eq!(state.selected, 1);
assert_eq!(state.phase, phase);
editor.handle(Input::Enter);
}
}
#[test]
fn aligned_hunks_navigation_shared_top_independent_horizontal_and_file_reset() {
let mut editor = editor();