From fc960ea0bf05f3058077624f83db39923d3ff588 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 11:17:38 +0200 Subject: [PATCH] feat: show single-pane diffs for added deleted and renamed files --- docs/editing.md | 8 +- src/diff.rs | 222 +++++++++++++++++++++++++++++++++++++++++++----- src/editor.rs | 7 +- src/tui.rs | 211 ++++++++++++++++++++++++++++++++++++--------- 4 files changed, 383 insertions(+), 65 deletions(-) diff --git a/docs/editing.md b/docs/editing.md index 9d2d8ab..789d0ee 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -222,11 +222,11 @@ The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / 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 comparison has Before on the left, After on the right, and the changed-file explorer **docked on the right**. 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. Focus After to use native editing, selections, search, completion, undo/redo, and `:w`. Removed 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. Focus After to use native editing, selections, search, 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. -Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses Before. While the explorer is hidden, Tab/Shift+Tab cycles only between the two text panes. +Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses its first visible text pane: After for added files, otherwise Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus. -File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show the new name followed by `← old/path`; directories have no file-status marker. Ordinary file exploration remains unannotated. +File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show `R old/path -> new/path`; directories have no file-status marker. Ordinary file exploration remains unannotated. Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source. @@ -240,7 +240,7 @@ Changed text rows use muted red backgrounds in Before and muted green background Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched. -In Normal mode, Tab/Shift+Tab cycles focus between Files, Before, and After. Read-only text panes use Up/Down or Ctrl+j/k for shared vertical scrolling, Left/Right or Ctrl+h/l for independent horizontal scrolling, and Home/End for the first/last displayed row; editable After uses normal buffer motions. Wheel input over text panes scrolls both panes without moving the editable caret; over Files it navigates visible tree rows without opening them. Escape cancels modes, prompts, or filtering, or returns picker focus to Before; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias. +In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Read-only text panes use Up/Down or Ctrl+j/k for shared vertical scrolling, Left/Right or Ctrl+h/l for independent horizontal scrolling, and Home/End for the first/last displayed row; editable After uses normal buffer motions. Wheel input over text panes scrolls the visible panes without moving the editable caret; over Files it navigates visible tree rows without opening them. Escape cancels modes, prompts, or filtering, or returns picker focus to the first visible text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias. Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file. diff --git a/src/diff.rs b/src/diff.rs index 4b8d689..9ee776c 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -120,6 +120,14 @@ pub enum Focus { Before, After, } + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PaneLayout { + Split, + Before, + After, + Empty, +} #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RowKind { Equal, @@ -299,8 +307,12 @@ impl DiffState { self.comparison = Some(comparison); self.comparison_id = Some(id); self.phase = Phase::View; - self.focus = Focus::Files; self.file = 0; + self.focus = if self.files_visible { + Focus::Files + } else { + self.first_text_focus() + }; self.align_file(); self.error = None; } @@ -396,7 +408,7 @@ impl DiffState { { files.focused = false; self.files_visible = files.presentation == FilePresentation::Docked; - self.focus = Focus::Before; + self.focus = self.first_text_focus(); } return; } @@ -529,7 +541,7 @@ impl DiffState { if let Some(path) = files.accept() && self.select_file(&path) { - self.focus = Focus::Before; + self.focus = self.first_text_focus(); let files = self.files.as_mut().expect("comparison picker"); files.focused = false; self.files_visible = files.presentation == FilePresentation::Docked; @@ -543,22 +555,8 @@ impl DiffState { } } match input { - Input::Char('\t') => { - self.focus = match self.focus { - Focus::Files => Focus::Before, - Focus::Before => Focus::After, - 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 if self.files_visible => Focus::Files, - Focus::Before => Focus::After, - } - } + Input::Char('\t') => self.cycle_focus(true), + Input::BackTab => self.cycle_focus(false), Input::Up | Input::Ctrl('k') => self.navigate(false), Input::Down | Input::Ctrl('j') => self.navigate(true), Input::Left | Input::Ctrl('h') => match self.focus { @@ -591,8 +589,76 @@ impl DiffState { } } + pub(crate) fn pane_layout(&self) -> PaneLayout { + let Some(file) = self + .comparison + .as_ref() + .and_then(|comparison| comparison.files.get(self.file)) + else { + return PaneLayout::Empty; + }; + match file.status { + FileStatus::Added => PaneLayout::After, + FileStatus::Removed => PaneLayout::Before, + FileStatus::Renamed + if file.before == file.after && file.before_eof == file.after_eof => + { + PaneLayout::Empty + } + FileStatus::Modified | FileStatus::Renamed => PaneLayout::Split, + } + } + + pub(crate) fn first_text_focus(&self) -> Focus { + if self.pane_layout() == PaneLayout::After { + Focus::After + } else { + Focus::Before + } + } + + pub(crate) fn normalize_focus(&mut self) { + self.focus = match (self.focus, self.pane_layout()) { + (Focus::Files, _) if self.files_visible => Focus::Files, + (Focus::Before | Focus::After, PaneLayout::Split) => self.focus, + _ => self.first_text_focus(), + }; + if let Some(files) = &mut self.files { + files.focused = self.files_visible && self.focus == Focus::Files; + } + } + + fn cycle_focus(&mut self, forward: bool) { + let first = self.first_text_focus(); + let last = if self.pane_layout() == PaneLayout::Split { + Focus::After + } else { + first + }; + self.focus = match self.focus { + Focus::Files => { + if forward { + first + } else { + last + } + } + focus if forward && focus != last => last, + focus if !forward && focus != first => first, + _ if self.files_visible => Focus::Files, + _ => { + if forward { + first + } else { + last + } + } + }; + } + pub fn after_editable(&self) -> bool { self.phase == Phase::View + && matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After) && self .after .as_ref() @@ -616,6 +682,7 @@ impl DiffState { return false; }; self.file = index; + self.normalize_focus(); self.live_buffer = None; self.live_revision = None; self.follow_caret = false; @@ -783,6 +850,7 @@ impl DiffState { } fn rebuild_file(&mut self, reset: bool) { + self.normalize_focus(); self.rows.clear(); self.display_rows.clear(); self.excerpt_ranges.clear(); @@ -1491,6 +1559,120 @@ mod tests { } } + #[test] + fn status_layout_cycles_only_present_panes_and_normalizes_selection() { + let mut state = live_state(vec!["old".into()], vec!["new".into()], true); + state.comparison = Some(comparison()); + state.files = Some(FilePicker::from_paths( + PathBuf::new(), + [ + (0, "renamed.txt".into()), + (1, "added.txt".into()), + (2, "removed.bin".into()), + ], + )); + for (path, layout, first, last) in [ + ( + "renamed.txt", + PaneLayout::Split, + Focus::Before, + Focus::After, + ), + ("added.txt", PaneLayout::After, Focus::After, Focus::After), + ( + "removed.bin", + PaneLayout::Before, + Focus::Before, + Focus::Before, + ), + ] { + state.files_visible = true; + state.focus = Focus::Files; + assert!(state.select_file(Path::new(path))); + assert_eq!(state.pane_layout(), layout); + state.handle(Input::Char('\t')); + assert_eq!(state.focus, first); + state.handle(Input::BackTab); + assert_eq!(state.focus, Focus::Files); + state.handle(Input::BackTab); + assert_eq!(state.focus, last); + state.handle(Input::Char('\t')); + assert_eq!(state.focus, Focus::Files); + state.files_visible = false; + state.normalize_focus(); + assert_eq!(state.focus, first); + state.handle(Input::BackTab); + assert_eq!(state.focus, last); + state.handle(Input::Char('\t')); + assert_eq!(state.focus, first); + } + state.focus = Focus::Before; + assert!(state.select_file(Path::new("added.txt"))); + assert_eq!(state.focus, Focus::After); + assert!(state.select_file(Path::new("removed.bin"))); + assert_eq!(state.focus, Focus::Before); + assert!(state.select_file(Path::new("renamed.txt"))); + assert_eq!(state.focus, Focus::Before); + } + + #[test] + fn rename_layout_and_editability_include_eof_changes() { + let mut state = live_state(vec!["same".into()], vec!["same".into()], true); + state.comparison.as_mut().unwrap().files[0].status = FileStatus::Renamed; + assert_eq!(state.pane_layout(), PaneLayout::Empty); + assert!(!state.after_editable()); + state.normalize_focus(); + assert_eq!(state.focus, Focus::Before); + state.files_visible = false; + for input in [Input::Char('\t'), Input::BackTab] { + state.handle(input); + assert_eq!(state.focus, Focus::Before); + } + state.comparison.as_mut().unwrap().files[0].after_eof = false; + assert_eq!(state.pane_layout(), PaneLayout::Split); + assert!(state.after_editable()); + state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into(); + assert!(state.after_editable()); + state.comparison.as_mut().unwrap().files[0].status = FileStatus::Removed; + assert!(!state.after_editable()); + state.comparison.as_mut().unwrap().files[0].status = FileStatus::Added; + assert!(state.after_editable()); + let file = &mut state.comparison.as_mut().unwrap().files[0]; + file.before.clear(); + file.after.clear(); + file.notice = Some("empty file".into()); + state.normalize_focus(); + assert_eq!(state.pane_layout(), PaneLayout::After); + assert_eq!(state.focus, Focus::After); + assert!(state.after_editable()); + assert_eq!( + state.comparison.as_ref().unwrap().files[0] + .notice + .as_deref(), + Some("empty file") + ); + } + + #[test] + fn added_file_picker_cancel_close_and_mouse_focus_use_after() { + let mut editor = editor(); + open(&mut editor); + let id = choose_pair(&mut editor); + let mut changes = comparison(); + changes.files = vec![changes.files.remove(1)]; + editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); + editor.handle(Input::Escape); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + text(&mut editor, " e"); + editor.handle(Input::Ctrl('w')); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + assert!(!editor.diff().unwrap().files_visible); + editor.scroll_diff(Focus::Before, true); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + editor.handle(Input::BackTab); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + } + #[test] fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() { let mut editor = editor(); @@ -1623,7 +1805,7 @@ mod tests { text(&mut editor, "x/y"); editor.handle(Input::Enter); assert_eq!(editor.diff().unwrap().file, 1); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); + assert_eq!(editor.diff().unwrap().focus, Focus::After); } #[test] diff --git a/src/editor.rs b/src/editor.rs index f8c4123..c37e635 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1454,9 +1454,10 @@ impl Editor { && diff.phase == Phase::View { diff.focus = focus; + diff.normalize_focus(); diff.follow_caret = false; if let Some(files) = &mut diff.files { - files.focused = diff.files_visible && focus == Focus::Files; + files.focused = diff.files_visible && diff.focus == Focus::Files; } diff.navigate(down); } @@ -1503,7 +1504,7 @@ impl Editor { if files_focused && input == Input::Ctrl('w') { let diff = self.diff.as_mut().expect("active diff"); diff.files_visible = false; - diff.focus = Focus::Before; + diff.focus = diff.first_text_focus(); if let Some(files) = &mut diff.files { files.focused = false; } @@ -3229,7 +3230,7 @@ impl Editor { } } if diff.focus == Focus::Files { - diff.focus = Focus::Before; + diff.focus = diff.first_text_focus(); } } match self.file_picker.as_mut() { diff --git a/src/tui.rs b/src/tui.rs index 53e9bec..7f175ea 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -32,7 +32,7 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::{ - diff::{ChangedFile, DiffState, DisplayRow, FileStatus, Focus, Phase, RowKind}, + diff::{ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind}, editor::{ Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic, }, @@ -1279,12 +1279,20 @@ fn draw_diff( } else { (workspace, None) }; - let [before_area, after_area] = - Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(panes); + let pane_layout = diff.pane_layout(); + let [before_area, after_area] = match pane_layout { + PaneLayout::Split => { + Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(panes) + } + PaneLayout::Before | PaneLayout::Empty => [panes, Rect::default()], + PaneLayout::After => [Rect::default(), panes], + }; let mut document_caret = None; let mut top = diff.top; let mut after_left = diff.left_after; - if let Some(editor) = editor.filter(|editor| editor.diff_editing() && diff.follow_caret) { + if let Some(editor) = editor.filter(|editor| { + editor.diff_editing() && diff.follow_caret && after_area != Rect::default() + }) { let height = usize::from(after_area.height.saturating_sub(2)); if let Some(row) = diff.after_display_row(editor.cursor().row) { if row < top { @@ -1317,27 +1325,36 @@ fn draw_diff( if let Some(file) = selected && let Some(syntax) = &panels.diff_syntax { - draw_diff_pane( - frame, - diff, - file, - before_area, - true, - &syntax.before, - (top, diff.left_before), - ); - let text_area = draw_diff_pane( - frame, - diff, - file, - after_area, - false, - &syntax.after, - (top, after_left), - ); - if let Some(editor) = editor.filter(|editor| editor.diff_editable()) { - document_caret = paint_diff_editor(frame, editor, diff, text_area, (top, after_left)) - .map(|position| (text_area, position)); + if pane_layout == PaneLayout::Empty { + diff_panel(frame, before_area, "", diff.focus == Focus::Before); + } else { + if before_area != Rect::default() { + draw_diff_pane( + frame, + diff, + file, + before_area, + true, + &syntax.before, + (top, diff.left_before), + ); + } + if after_area != Rect::default() { + let text_area = draw_diff_pane( + frame, + diff, + file, + after_area, + false, + &syntax.after, + (top, after_left), + ); + if let Some(editor) = editor.filter(|editor| editor.diff_editable()) { + document_caret = + paint_diff_editor(frame, editor, diff, text_area, (top, after_left)) + .map(|position| (text_area, position)); + } + } } } else { frame.render_widget( @@ -2172,16 +2189,18 @@ fn draw_file_list( )); spans.push(Span::raw(" ")); } - spans.push(Span::raw(entry.label)); - if entry.is_dir && picker.view == FileView::Tree { - spans.push(Span::raw("/")); - } if let Some(file) = changed && file.status == FileStatus::Renamed && let Some(old_path) = &file.old_path { - spans.push(Span::raw(" ← ")); spans.push(Span::raw(display_text(&old_path.to_string_lossy()))); + spans.push(Span::raw(" -> ")); + spans.push(Span::raw(display_text(&file.path.to_string_lossy()))); + } else { + spans.push(Span::raw(entry.label)); + if entry.is_dir && picker.view == FileView::Tree { + spans.push(Span::raw("/")); + } } Line::from(spans).style(style) }) @@ -2675,6 +2694,118 @@ mod tests { notice: None, } } + #[test] + fn diff_status_layouts_hide_absent_panes_and_leave_unchanged_renames_blank() { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 12)).unwrap(); + let mut panels = WheelPanels::default(); + for status in [FileStatus::Added, FileStatus::Removed] { + let added = status == FileStatus::Added; + let mut file = deletion_file(); + file.status = status; + file.before = if added { + vec![] + } else { + vec!["removed content".into()] + }; + file.after = if added { + vec!["added content".into()] + } else { + vec![] + }; + file.hunks = vec![crate::diff::Hunk { + before_start: 0, + before_len: usize::from(!added), + after_start: 0, + after_len: usize::from(added), + }]; + let mut diff = diff_comparison(vec![file]); + diff.files_visible = false; + diff.focus = if added { Focus::After } else { Focus::Before }; + diff.left_after = 2; + render_diff(&mut terminal, &diff, &mut panels); + let geometry = panels.diff.as_ref().unwrap(); + let present = geometry.regions[if added { 2 } else { 1 }].0; + assert_eq!( + geometry.regions[if added { 1 } else { 2 }].0, + Rect::default() + ); + assert_eq!(present.x, 0); + assert_eq!(present.width, 160); + assert_eq!(geometry.viewport.unwrap().3, 2); + assert_eq!(geometry.regions[if added { 2 } else { 1 }].1, diff.focus); + assert_eq!( + terminal.backend().buffer()[(present.x, present.y)].fg, + Color::Cyan + ); + let text = screen_text(&terminal); + assert!(text.contains(if added { + "ded content" + } else { + "removed content" + })); + assert!(text.contains(if added { "After · A" } else { "Before · D" })); + assert!(!text.contains(if added { "Before ·" } else { "After ·" })); + assert!(!text.contains('╱')); + } + let mut empty_added = deletion_file(); + empty_added.status = FileStatus::Added; + empty_added.before.clear(); + empty_added.after.clear(); + empty_added.hunks.clear(); + empty_added.notice = Some("metadata-only addition".into()); + let mut diff = diff_comparison(vec![empty_added]); + diff.files_visible = false; + diff.focus = Focus::After; + render_diff(&mut terminal, &diff, &mut panels); + let geometry = panels.diff.as_ref().unwrap(); + assert_eq!(geometry.regions[1].0, Rect::default()); + assert_eq!(geometry.regions[2].0.width, 160); + assert!(screen_text(&terminal).contains("metadata-only addition")); + + let mut renamed = deletion_file(); + renamed.status = FileStatus::Renamed; + renamed.old_path = Some("old/path.txt".into()); + renamed.path = "new/path.txt".into(); + renamed.before = vec!["unchanged rename content".into()]; + renamed.after = renamed.before.clone(); + renamed.hunks.clear(); + let mut diff = diff_comparison(vec![renamed]); + render_diff(&mut terminal, &diff, &mut panels); + assert!(screen_text(&terminal).contains("old/path.txt -> new/path.txt")); + assert_eq!( + diff.files.as_mut().unwrap().accept(), + Some("new/path.txt".into()) + ); + diff.files_visible = false; + render_diff(&mut terminal, &diff, &mut panels); + let geometry = panels.diff.as_ref().unwrap(); + let blank = geometry.regions[1].0; + assert_eq!(blank.width, 160); + assert_eq!(geometry.regions[2].0, Rect::default()); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(blank.x, blank.y)].symbol(), "┌"); + for y in blank.y + 1..blank.bottom() - 1 { + for x in blank.x + 1..blank.right() - 1 { + assert_eq!(buffer[(x, y)].symbol(), " "); + assert_eq!(buffer[(x, y)].bg, Color::Reset); + } + } + let text = screen_text(&terminal); + assert!(!text.contains("Before ·")); + assert!(!text.contains("After ·")); + assert!(!text.contains("unchanged rename content")); + assert!(!text.contains('╱')); + + let mut modified = diff_comparison(vec![deletion_file()]); + modified.files_visible = false; + render_diff(&mut terminal, &modified, &mut panels); + let regions = panels.diff.as_ref().unwrap().regions; + assert_eq!(regions[1].0.width, 80); + assert_eq!(regions[2].0.width, 80); + let text = screen_text(&terminal); + assert!(text.contains("Before · M")); + assert!(text.contains("After · M")); + } fn render_diff( terminal: &mut Terminal, @@ -2855,7 +2986,7 @@ mod tests { after_eof: false, path: "added.txt".into(), old_path: None, - status: FileStatus::Added, + status: FileStatus::Modified, before: vec![], after: vec!["first addition".into(), String::new()], hunks: vec![crate::diff::Hunk { @@ -3189,8 +3320,11 @@ mod tests { } else { file.path.as_path() }; - let expected = - format!("{} {}", diff_status(file.status), path.to_string_lossy()); + let expected = if file.status == FileStatus::Renamed { + format!("R old�name.txt -> {}", file.path.to_string_lossy()) + } else { + format!("{} {}", diff_status(file.status), path.to_string_lossy()) + }; let (x, y) = (0..16) .find_map(|y| { let row = (0..100) @@ -3203,7 +3337,6 @@ mod tests { .unwrap_or_else(|| panic!("missing {expected}")); assert_eq!(buffer[(x, y)].fg, color); } - assert!(screen_text(&terminal).contains("r.txt ← old�name.txt")); assert_eq!(picker.accept(), Some(files[index].path.clone())); assert!(picker.query.is_empty()); } @@ -3326,9 +3459,11 @@ mod tests { assert!(rect.right() <= width && rect.bottom() <= height); } let [navigator, before, after] = geometry.regions.map(|(rect, _)| rect); - assert!(before.right() <= after.x); + if before.width > 0 && after.width > 0 { + assert!(before.right() <= after.x); + } if navigator.width > 0 { - assert!(after.right() <= navigator.x); + assert!(before.right().max(after.right()) <= navigator.x); } } } @@ -3596,7 +3731,7 @@ mod tests { git(&["add", "."]); git(&["commit", "-qm", "base"]); for index in 0..8 { - fs::remove_file(directory.0.join(format!("file-{index}.txt"))).unwrap(); + directory.file(&format!("file-{index}.txt"), "changed\nsecond\nthird\n"); } let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); let original = session.editor().lines().to_vec(); @@ -3616,8 +3751,8 @@ mod tests { }; session.execute("diff"); ready(&mut session, Phase::Before); - session.handle(Input::Down); session.handle(Input::Enter); + session.handle(Input::Down); session.handle(Input::Enter); ready(&mut session, Phase::View); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();