feat: show single-pane diffs for added deleted and renamed files
This commit is contained in:
+4
-4
@@ -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.
|
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.
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+202
-20
@@ -120,6 +120,14 @@ pub enum Focus {
|
|||||||
Before,
|
Before,
|
||||||
After,
|
After,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum PaneLayout {
|
||||||
|
Split,
|
||||||
|
Before,
|
||||||
|
After,
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum RowKind {
|
pub enum RowKind {
|
||||||
Equal,
|
Equal,
|
||||||
@@ -299,8 +307,12 @@ impl DiffState {
|
|||||||
self.comparison = Some(comparison);
|
self.comparison = Some(comparison);
|
||||||
self.comparison_id = Some(id);
|
self.comparison_id = Some(id);
|
||||||
self.phase = Phase::View;
|
self.phase = Phase::View;
|
||||||
self.focus = Focus::Files;
|
|
||||||
self.file = 0;
|
self.file = 0;
|
||||||
|
self.focus = if self.files_visible {
|
||||||
|
Focus::Files
|
||||||
|
} else {
|
||||||
|
self.first_text_focus()
|
||||||
|
};
|
||||||
self.align_file();
|
self.align_file();
|
||||||
self.error = None;
|
self.error = None;
|
||||||
}
|
}
|
||||||
@@ -396,7 +408,7 @@ impl DiffState {
|
|||||||
{
|
{
|
||||||
files.focused = false;
|
files.focused = false;
|
||||||
self.files_visible = files.presentation == FilePresentation::Docked;
|
self.files_visible = files.presentation == FilePresentation::Docked;
|
||||||
self.focus = Focus::Before;
|
self.focus = self.first_text_focus();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -529,7 +541,7 @@ impl DiffState {
|
|||||||
if let Some(path) = files.accept()
|
if let Some(path) = files.accept()
|
||||||
&& self.select_file(&path)
|
&& self.select_file(&path)
|
||||||
{
|
{
|
||||||
self.focus = Focus::Before;
|
self.focus = self.first_text_focus();
|
||||||
let files = self.files.as_mut().expect("comparison picker");
|
let files = self.files.as_mut().expect("comparison picker");
|
||||||
files.focused = false;
|
files.focused = false;
|
||||||
self.files_visible = files.presentation == FilePresentation::Docked;
|
self.files_visible = files.presentation == FilePresentation::Docked;
|
||||||
@@ -543,22 +555,8 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
match input {
|
match input {
|
||||||
Input::Char('\t') => {
|
Input::Char('\t') => self.cycle_focus(true),
|
||||||
self.focus = match self.focus {
|
Input::BackTab => self.cycle_focus(false),
|
||||||
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::Up | Input::Ctrl('k') => self.navigate(false),
|
Input::Up | Input::Ctrl('k') => self.navigate(false),
|
||||||
Input::Down | Input::Ctrl('j') => self.navigate(true),
|
Input::Down | Input::Ctrl('j') => self.navigate(true),
|
||||||
Input::Left | Input::Ctrl('h') => match self.focus {
|
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 {
|
pub fn after_editable(&self) -> bool {
|
||||||
self.phase == Phase::View
|
self.phase == Phase::View
|
||||||
|
&& matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
|
||||||
&& self
|
&& self
|
||||||
.after
|
.after
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -616,6 +682,7 @@ impl DiffState {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
self.file = index;
|
self.file = index;
|
||||||
|
self.normalize_focus();
|
||||||
self.live_buffer = None;
|
self.live_buffer = None;
|
||||||
self.live_revision = None;
|
self.live_revision = None;
|
||||||
self.follow_caret = false;
|
self.follow_caret = false;
|
||||||
@@ -783,6 +850,7 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild_file(&mut self, reset: bool) {
|
fn rebuild_file(&mut self, reset: bool) {
|
||||||
|
self.normalize_focus();
|
||||||
self.rows.clear();
|
self.rows.clear();
|
||||||
self.display_rows.clear();
|
self.display_rows.clear();
|
||||||
self.excerpt_ranges.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]
|
#[test]
|
||||||
fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() {
|
fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() {
|
||||||
let mut editor = editor();
|
let mut editor = editor();
|
||||||
@@ -1623,7 +1805,7 @@ mod tests {
|
|||||||
text(&mut editor, "x/y");
|
text(&mut editor, "x/y");
|
||||||
editor.handle(Input::Enter);
|
editor.handle(Input::Enter);
|
||||||
assert_eq!(editor.diff().unwrap().file, 1);
|
assert_eq!(editor.diff().unwrap().file, 1);
|
||||||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
assert_eq!(editor.diff().unwrap().focus, Focus::After);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+4
-3
@@ -1454,9 +1454,10 @@ impl Editor {
|
|||||||
&& diff.phase == Phase::View
|
&& diff.phase == Phase::View
|
||||||
{
|
{
|
||||||
diff.focus = focus;
|
diff.focus = focus;
|
||||||
|
diff.normalize_focus();
|
||||||
diff.follow_caret = false;
|
diff.follow_caret = false;
|
||||||
if let Some(files) = &mut diff.files {
|
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);
|
diff.navigate(down);
|
||||||
}
|
}
|
||||||
@@ -1503,7 +1504,7 @@ impl Editor {
|
|||||||
if files_focused && input == Input::Ctrl('w') {
|
if files_focused && input == Input::Ctrl('w') {
|
||||||
let diff = self.diff.as_mut().expect("active diff");
|
let diff = self.diff.as_mut().expect("active diff");
|
||||||
diff.files_visible = false;
|
diff.files_visible = false;
|
||||||
diff.focus = Focus::Before;
|
diff.focus = diff.first_text_focus();
|
||||||
if let Some(files) = &mut diff.files {
|
if let Some(files) = &mut diff.files {
|
||||||
files.focused = false;
|
files.focused = false;
|
||||||
}
|
}
|
||||||
@@ -3229,7 +3230,7 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if diff.focus == Focus::Files {
|
if diff.focus == Focus::Files {
|
||||||
diff.focus = Focus::Before;
|
diff.focus = diff.first_text_focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match self.file_picker.as_mut() {
|
match self.file_picker.as_mut() {
|
||||||
|
|||||||
+173
-38
@@ -32,7 +32,7 @@ use unicode_segmentation::UnicodeSegmentation;
|
|||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
diff::{ChangedFile, DiffState, DisplayRow, FileStatus, Focus, Phase, RowKind},
|
diff::{ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind},
|
||||||
editor::{
|
editor::{
|
||||||
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
|
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
|
||||||
},
|
},
|
||||||
@@ -1279,12 +1279,20 @@ fn draw_diff(
|
|||||||
} else {
|
} else {
|
||||||
(workspace, None)
|
(workspace, None)
|
||||||
};
|
};
|
||||||
let [before_area, after_area] =
|
let pane_layout = diff.pane_layout();
|
||||||
Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(panes);
|
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 document_caret = None;
|
||||||
let mut top = diff.top;
|
let mut top = diff.top;
|
||||||
let mut after_left = diff.left_after;
|
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));
|
let height = usize::from(after_area.height.saturating_sub(2));
|
||||||
if let Some(row) = diff.after_display_row(editor.cursor().row) {
|
if let Some(row) = diff.after_display_row(editor.cursor().row) {
|
||||||
if row < top {
|
if row < top {
|
||||||
@@ -1317,27 +1325,36 @@ fn draw_diff(
|
|||||||
if let Some(file) = selected
|
if let Some(file) = selected
|
||||||
&& let Some(syntax) = &panels.diff_syntax
|
&& let Some(syntax) = &panels.diff_syntax
|
||||||
{
|
{
|
||||||
draw_diff_pane(
|
if pane_layout == PaneLayout::Empty {
|
||||||
frame,
|
diff_panel(frame, before_area, "", diff.focus == Focus::Before);
|
||||||
diff,
|
} else {
|
||||||
file,
|
if before_area != Rect::default() {
|
||||||
before_area,
|
draw_diff_pane(
|
||||||
true,
|
frame,
|
||||||
&syntax.before,
|
diff,
|
||||||
(top, diff.left_before),
|
file,
|
||||||
);
|
before_area,
|
||||||
let text_area = draw_diff_pane(
|
true,
|
||||||
frame,
|
&syntax.before,
|
||||||
diff,
|
(top, diff.left_before),
|
||||||
file,
|
);
|
||||||
after_area,
|
}
|
||||||
false,
|
if after_area != Rect::default() {
|
||||||
&syntax.after,
|
let text_area = draw_diff_pane(
|
||||||
(top, after_left),
|
frame,
|
||||||
);
|
diff,
|
||||||
if let Some(editor) = editor.filter(|editor| editor.diff_editable()) {
|
file,
|
||||||
document_caret = paint_diff_editor(frame, editor, diff, text_area, (top, after_left))
|
after_area,
|
||||||
.map(|position| (text_area, position));
|
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 {
|
} else {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
@@ -2172,16 +2189,18 @@ fn draw_file_list(
|
|||||||
));
|
));
|
||||||
spans.push(Span::raw(" "));
|
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
|
if let Some(file) = changed
|
||||||
&& file.status == FileStatus::Renamed
|
&& file.status == FileStatus::Renamed
|
||||||
&& let Some(old_path) = &file.old_path
|
&& 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(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)
|
Line::from(spans).style(style)
|
||||||
})
|
})
|
||||||
@@ -2675,6 +2694,118 @@ mod tests {
|
|||||||
notice: None,
|
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(
|
fn render_diff(
|
||||||
terminal: &mut Terminal<ratatui::backend::TestBackend>,
|
terminal: &mut Terminal<ratatui::backend::TestBackend>,
|
||||||
@@ -2855,7 +2986,7 @@ mod tests {
|
|||||||
after_eof: false,
|
after_eof: false,
|
||||||
path: "added.txt".into(),
|
path: "added.txt".into(),
|
||||||
old_path: None,
|
old_path: None,
|
||||||
status: FileStatus::Added,
|
status: FileStatus::Modified,
|
||||||
before: vec![],
|
before: vec![],
|
||||||
after: vec!["first addition".into(), String::new()],
|
after: vec!["first addition".into(), String::new()],
|
||||||
hunks: vec![crate::diff::Hunk {
|
hunks: vec![crate::diff::Hunk {
|
||||||
@@ -3189,8 +3320,11 @@ mod tests {
|
|||||||
} else {
|
} else {
|
||||||
file.path.as_path()
|
file.path.as_path()
|
||||||
};
|
};
|
||||||
let expected =
|
let expected = if file.status == FileStatus::Renamed {
|
||||||
format!("{} {}", diff_status(file.status), path.to_string_lossy());
|
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)
|
let (x, y) = (0..16)
|
||||||
.find_map(|y| {
|
.find_map(|y| {
|
||||||
let row = (0..100)
|
let row = (0..100)
|
||||||
@@ -3203,7 +3337,6 @@ mod tests {
|
|||||||
.unwrap_or_else(|| panic!("missing {expected}"));
|
.unwrap_or_else(|| panic!("missing {expected}"));
|
||||||
assert_eq!(buffer[(x, y)].fg, color);
|
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_eq!(picker.accept(), Some(files[index].path.clone()));
|
||||||
assert!(picker.query.is_empty());
|
assert!(picker.query.is_empty());
|
||||||
}
|
}
|
||||||
@@ -3326,9 +3459,11 @@ mod tests {
|
|||||||
assert!(rect.right() <= width && rect.bottom() <= height);
|
assert!(rect.right() <= width && rect.bottom() <= height);
|
||||||
}
|
}
|
||||||
let [navigator, before, after] = geometry.regions.map(|(rect, _)| rect);
|
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 {
|
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(&["add", "."]);
|
||||||
git(&["commit", "-qm", "base"]);
|
git(&["commit", "-qm", "base"]);
|
||||||
for index in 0..8 {
|
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 mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||||||
let original = session.editor().lines().to_vec();
|
let original = session.editor().lines().to_vec();
|
||||||
@@ -3616,8 +3751,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
session.execute("diff");
|
session.execute("diff");
|
||||||
ready(&mut session, Phase::Before);
|
ready(&mut session, Phase::Before);
|
||||||
session.handle(Input::Down);
|
|
||||||
session.handle(Input::Enter);
|
session.handle(Input::Enter);
|
||||||
|
session.handle(Input::Down);
|
||||||
session.handle(Input::Enter);
|
session.handle(Input::Enter);
|
||||||
ready(&mut session, Phase::View);
|
ready(&mut session, Phase::View);
|
||||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user