feat: mark diff files as reviewed
This commit is contained in:
@@ -25,7 +25,9 @@
|
||||
- [x] better fuzzy search when searching revisions and bookmarks (or the git alternatives) in before and after picker for diff view.
|
||||
- [x] git blame (enabled by default).
|
||||
- [x] when jumping to lsp definitions, that line should be center of the buffer (currently placed at the top of the buffer view)
|
||||
- [ ] diff view: add ability to mark files as reviewed. this should just put a check mark left of the file modification indicator (e.g., M, A, D, maybe others)
|
||||
- [x] diff view: add ability to mark files as reviewed. this should just put a check mark left of the file modification indicator (e.g., M, A, D, maybe others)
|
||||
- [ ] make `<line>g` go to that line.
|
||||
- [ ] diff view: if the after point is selected to be current working changes (i.e. `@`), diff view should update to include this after point accordingly (and automatically) - i.e. it should move the after point. this should only be the case when the new checkout is ahead of the after point selected. should be enabled by default.
|
||||
- [ ] buffer picker (with search).
|
||||
- [ ] pull a pr description and display it in a buffer:
|
||||
- open a selector that allows you to pick a pr from remote
|
||||
|
||||
@@ -169,7 +169,7 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
|
||||
| Normal | `save`, `force_save`, `quit`, `force_quit`, `restart`, `force_restart`, `save_and_quit`, `force_save_and_quit`, `plugins` |
|
||||
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
|
||||
| Normal | `follow_link` |
|
||||
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle` |
|
||||
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` |
|
||||
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
||||
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
||||
| Normal, Insert, Visual, Command, Search | `git_blame.toggle` |
|
||||
@@ -249,6 +249,8 @@ The diff's changed-file explorer and floating picker use these same tree and sel
|
||||
|
||||
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `mode.toggle`, `view.toggle`, and `orientation.toggle` children. New comparisons default to Branch changes (merge base → selected After); Normal-mode Ctrl+b, `:diff.mode.toggle`, or the palette toggles to direct Snapshots and back, preserving the selected endpoints. Selecting Working checkout on either side forces Snapshots.
|
||||
|
||||
`config.core.diff.review.toggle` is a non-mutating Normal-mode action, also available as `:diff.review.toggle` and in the palette. It marks/unmarks the highlighted changed-file leaf when the comparison's explorer is focused, or the current comparison file from a text pane. Read-only snapshots permit review marks. For example, `config.bind "ctrl+g", config.core.diff.review.toggle`; use the palette or a control-key binding while filtering so printable input stays in the query. No default review shortcut is installed.
|
||||
|
||||
`config.core.pane.close` and `config.core.pane.force_close` close a focused **docked** file list instead of its editor pane, without discarding buffers or exiting the comparison. A floating picker does not intercept these actions: normal close still protects underlying dirty buffers, while force-close retains its existing discard semantics. For example, bind `config.bind "ctrl+x", config.core.pane.close`; focused tree/selection overrides and printable filter input retain priority. Palette-originated close actions retain the originating dock target through force confirmation.
|
||||
|
||||
Focused selector/filter input receives printable text before Normal-mode action bindings, so `:` stays query text instead of opening Command input. Explicit tree/selection navigation overrides remain effective. Control-key editor action bindings and Ctrl+p remain available.
|
||||
|
||||
@@ -370,6 +370,10 @@ Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` o
|
||||
|
||||
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.
|
||||
|
||||
Mark or unmark a diff file with **`:diff.review.toggle`** or the command-palette action. With the changed-file explorer focused, it toggles the highlighted file without opening it; from a text pane, it toggles the displayed file. Reviewed files show a green **`✓` immediately before A/M/D/R** in Tree and List views. Directories and ordinary filesystem pickers cannot be marked. There is no default shortcut; a control-key binding is useful while a filter is focused, since printable keys remain filter text.
|
||||
|
||||
Review marks belong only to the open comparison and survive filtering, file navigation, lazy loading, tabs, and split/unified/orientation changes. A new or recomputed comparison clears them. Editing a Working After file clears only that file's mark; unchanged refreshes and saves preserve it. Marks do not change document text, undo history, or repository state, and are not persisted after closing the comparison.
|
||||
|
||||
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.
|
||||
|
||||
In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below** the excerpt containing the caret, revealing up to **15 lines** per keypress in either pane. In editable After's Insert mode these keys insert literal text. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored. Opening a file resets its excerpts to the configured initial context. Live edits recompute changes while retaining expanded context; the caret stays visible even when the last text change is reverted.
|
||||
|
||||
+190
@@ -213,6 +213,7 @@ pub struct DiffState {
|
||||
pub error: Option<String>,
|
||||
pub comparison: Option<Comparison>,
|
||||
pub comparison_id: Option<u64>,
|
||||
pub reviewed_files: BTreeSet<usize>,
|
||||
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
|
||||
pub files: Option<FilePicker>,
|
||||
pub files_visible: bool,
|
||||
@@ -259,6 +260,7 @@ impl DiffState {
|
||||
error: None,
|
||||
comparison: None,
|
||||
comparison_id: None,
|
||||
reviewed_files: BTreeSet::new(),
|
||||
files: None,
|
||||
snapshot_roots: [None, None],
|
||||
files_visible: true,
|
||||
@@ -371,6 +373,7 @@ impl DiffState {
|
||||
}
|
||||
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
|
||||
self.live_files.clear();
|
||||
self.reviewed_files.clear();
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
self.follow_caret = false;
|
||||
@@ -649,6 +652,7 @@ impl DiffState {
|
||||
self.phase = Phase::After;
|
||||
self.comparison = None;
|
||||
self.comparison_id = None;
|
||||
self.reviewed_files.clear();
|
||||
self.queue(operation);
|
||||
}
|
||||
}
|
||||
@@ -872,6 +876,45 @@ impl DiffState {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_reviewed(&mut self) -> Result<bool, &'static str> {
|
||||
let comparison = self
|
||||
.comparison
|
||||
.as_ref()
|
||||
.filter(|_| self.phase == Phase::View)
|
||||
.ok_or("No comparison is open")?;
|
||||
let index = if self.focus == Focus::Files {
|
||||
let entry = self
|
||||
.files
|
||||
.as_ref()
|
||||
.filter(|_| self.files_visible)
|
||||
.and_then(|files| files.entries().nth(files.selected))
|
||||
.ok_or("No changed file is selected")?;
|
||||
if entry.is_dir {
|
||||
return Err("Select a changed file, not a directory");
|
||||
}
|
||||
entry
|
||||
.catalog_index
|
||||
.filter(|&index| {
|
||||
comparison
|
||||
.files
|
||||
.get(index)
|
||||
.is_some_and(|file| file.path == entry.path)
|
||||
})
|
||||
.ok_or("No changed file is selected")?
|
||||
} else {
|
||||
self.file
|
||||
};
|
||||
if comparison.files.get(index).is_none() {
|
||||
return Err("No changed file is selected");
|
||||
}
|
||||
if self.reviewed_files.remove(&index) {
|
||||
Ok(false)
|
||||
} else {
|
||||
self.reviewed_files.insert(index);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn select_file(&mut self, path: &Path) -> bool {
|
||||
let Some(index) = self.comparison.as_ref().and_then(|comparison| {
|
||||
let relative = path.strip_prefix(&comparison.root).unwrap_or(path);
|
||||
@@ -1138,6 +1181,17 @@ impl DiffState {
|
||||
let file = &self.comparison.as_ref().unwrap().files[self.file];
|
||||
let was_live = self.live_files.contains(&self.file);
|
||||
let changed_lines = changed_line_range(&file.after, lines);
|
||||
if self.reviewed_files.contains(&self.file) {
|
||||
let unchanged = if was_live {
|
||||
changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2
|
||||
} else {
|
||||
let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
|
||||
file.after_eof == eof && file.after == live_content(lines, eof)
|
||||
};
|
||||
if !unchanged {
|
||||
self.reviewed_files.remove(&self.file);
|
||||
}
|
||||
}
|
||||
if was_live && changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2 {
|
||||
self.live_revision = Some(revision);
|
||||
return;
|
||||
@@ -3094,6 +3148,142 @@ mod tests {
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows() {
|
||||
let mut state = DiffState::new();
|
||||
let mut changes = comparison();
|
||||
changes.files[0].path = "nested/z.txt".into();
|
||||
changes.files[1].path = "nested/a.txt".into();
|
||||
state.request_kind = Some(RequestKind::Comparison);
|
||||
state.apply_result(1, Ok(Outcome::Comparison(changes)));
|
||||
for view in [FileView::List, FileView::Tree] {
|
||||
let files = state.files.as_mut().unwrap();
|
||||
files.set_view(view);
|
||||
files.clear_filter();
|
||||
files.start_search();
|
||||
for ch in "nested/a".chars() {
|
||||
files.handle_filter_input(&Input::Char(ch));
|
||||
}
|
||||
files.reveal_path(Path::new("nested/a.txt"));
|
||||
assert_eq!(state.file, 0);
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
|
||||
assert_eq!(state.toggle_reviewed(), Ok(false));
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
}
|
||||
let files = state.files.as_mut().unwrap();
|
||||
files.clear_filter();
|
||||
files.finish_search();
|
||||
let selected = files.entries().position(|entry| entry.is_dir).unwrap();
|
||||
files.selected = selected;
|
||||
assert_eq!(
|
||||
state.toggle_reviewed(),
|
||||
Err("Select a changed file, not a directory")
|
||||
);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
let files = state.files.as_mut().unwrap();
|
||||
files.start_search();
|
||||
for ch in "missing".chars() {
|
||||
files.handle_filter_input(&Input::Char(ch));
|
||||
}
|
||||
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
|
||||
state.files = Some(FilePicker::from_paths(
|
||||
PathBuf::new(),
|
||||
[(0, PathBuf::from("ordinary.txt"))],
|
||||
));
|
||||
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
|
||||
state.focus = Focus::Before;
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
assert!(state.select_file_index(2));
|
||||
state.focus = Focus::Before;
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0, 2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() {
|
||||
let mut state = DiffState::new();
|
||||
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
|
||||
let mut changes = comparison();
|
||||
changes.files[0].loaded = false;
|
||||
changes.files[0].before.clear();
|
||||
changes.files[0].after.clear();
|
||||
state.request_kind = Some(RequestKind::Comparison);
|
||||
state.apply_result(1, Ok(Outcome::Comparison(changes)));
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
let file = comparison().files.remove(0);
|
||||
state.apply_result(
|
||||
2,
|
||||
Ok(Outcome::File {
|
||||
comparison_id: 1,
|
||||
file_index: 0,
|
||||
file,
|
||||
}),
|
||||
);
|
||||
assert!(state.selected_file_loaded());
|
||||
state.set_view_mode(ViewMode::Unified);
|
||||
state.set_view_mode(ViewMode::Split);
|
||||
state.split_axis = crate::panes::SplitAxis::Horizontal;
|
||||
assert!(state.select_file_index(1));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
state.before = Some(revision(
|
||||
RevisionTarget::Commit("base".into()),
|
||||
"base",
|
||||
"",
|
||||
Category::Commits,
|
||||
));
|
||||
state.after = Some(revision(
|
||||
RevisionTarget::Commit("head".into()),
|
||||
"head",
|
||||
"",
|
||||
Category::Commits,
|
||||
));
|
||||
state.toggle_comparison_mode().unwrap();
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
|
||||
state.apply_result(3, Ok(Outcome::Comparison(comparison())));
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.request_kind = Some(RequestKind::Comparison);
|
||||
state.apply_result(4, Ok(Outcome::Comparison(comparison())));
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_review_marks_clear_only_for_changed_bytes_or_eof() {
|
||||
for (after, eof, lines) in [
|
||||
(vec!["changed".into()], false, vec!["changed".into()]),
|
||||
(
|
||||
vec!["changed".into()],
|
||||
true,
|
||||
vec!["changed".into(), String::new()],
|
||||
),
|
||||
(Vec::new(), false, vec![String::new()]),
|
||||
] {
|
||||
let mut state = live_state(vec!["base".into()], after, eof);
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.refresh_after(&lines, 1);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
state.refresh_after(&lines, 2);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
state.refresh_after(&["edited".into()], 3);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.refresh_after(&["edited".into(), String::new()], 4);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.refresh_after(&["edited".into()], 5);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
}
|
||||
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
|
||||
state.comparison.as_mut().unwrap().files[0].loaded = false;
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.refresh_after(&["edited".into()], 1);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() {
|
||||
let mut state = live_state(
|
||||
|
||||
+186
-2
@@ -766,6 +766,12 @@ core_actions![
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
),
|
||||
(
|
||||
"core.diff.review.toggle",
|
||||
Action::DiffReviewToggle,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
),
|
||||
(
|
||||
"core.word_wrap.toggle",
|
||||
Action::WordWrapToggle,
|
||||
@@ -3246,6 +3252,7 @@ impl Editor {
|
||||
| Action::DiffModeToggle
|
||||
| Action::DiffViewToggle
|
||||
| Action::DiffOrientationToggle
|
||||
| Action::DiffReviewToggle
|
||||
| Action::WordWrapToggle
|
||||
| Action::GitBlameToggle
|
||||
| Action::Quit
|
||||
@@ -6185,6 +6192,7 @@ impl Editor {
|
||||
| Action::DiffModeToggle
|
||||
| Action::DiffViewToggle
|
||||
| Action::DiffOrientationToggle
|
||||
| Action::DiffReviewToggle
|
||||
| Action::WordWrapToggle
|
||||
| Action::GitBlameToggle
|
||||
| Action::LspDefinition
|
||||
@@ -7002,13 +7010,25 @@ impl Editor {
|
||||
}
|
||||
if matches!(
|
||||
entry.action,
|
||||
Action::DiffModeToggle | Action::DiffViewToggle | Action::DiffOrientationToggle
|
||||
Action::DiffModeToggle
|
||||
| Action::DiffViewToggle
|
||||
| Action::DiffOrientationToggle
|
||||
| Action::DiffReviewToggle
|
||||
) {
|
||||
let name = entry.ex.expect("diff display action has an Ex command");
|
||||
let name = entry.ex.expect("diff action has an Ex command");
|
||||
if !args.is_empty() {
|
||||
self.set_message(format!("The {name} command takes no arguments"));
|
||||
return None;
|
||||
}
|
||||
if entry.action == Action::DiffReviewToggle
|
||||
&& self
|
||||
.file_picker
|
||||
.as_ref()
|
||||
.is_some_and(|picker| picker.focused)
|
||||
{
|
||||
self.set_message("Select a file in the comparison");
|
||||
return None;
|
||||
}
|
||||
if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) {
|
||||
return self.with_pane(owner, |editor| editor.execute_action(entry, args));
|
||||
}
|
||||
@@ -7036,6 +7056,14 @@ impl Editor {
|
||||
self.set_message("No comparison is open");
|
||||
return None;
|
||||
};
|
||||
if entry.action == Action::DiffReviewToggle {
|
||||
match diff.toggle_reviewed() {
|
||||
Ok(true) => self.set_message("File marked reviewed"),
|
||||
Ok(false) => self.set_message("File marked unreviewed"),
|
||||
Err(error) => self.set_message(error),
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if entry.action == Action::DiffViewToggle {
|
||||
diff.set_view_mode(match diff.view_mode {
|
||||
ViewMode::Split => ViewMode::Unified,
|
||||
@@ -10285,6 +10313,7 @@ fn atomic_save(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::plugin::CompletionEdit;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
@@ -13479,6 +13508,161 @@ mod tests {
|
||||
assert_eq!(editor.lines(), ["beta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_review_actions_preserve_readonly_text_history_and_parked_tabs() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = editor();
|
||||
editor.execute("diff.review.toggle");
|
||||
assert_eq!(editor.message(), "No comparison is open");
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&directory.0,
|
||||
vec!["before".into(), "second".into()],
|
||||
vec!["after".into(), "second".into()],
|
||||
);
|
||||
editor
|
||||
.bind("normal", "ctrl+y", Some("core.diff.review.toggle".into()))
|
||||
.unwrap();
|
||||
let text = editor.lines().to_vec();
|
||||
let carets = editor.carets();
|
||||
let revision = editor.text_revision();
|
||||
let undo = editor.active.undo.len();
|
||||
let redo = editor.active.redo.len();
|
||||
let dirty = editor.dirty();
|
||||
assert!(editor.mutation_blocked());
|
||||
editor.handle(Input::Ctrl('y'));
|
||||
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0]));
|
||||
editor.execute("diff.review.toggle unexpected");
|
||||
assert!(editor.message().contains("takes no arguments"));
|
||||
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0]));
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
type_text(&mut editor, "diff.review.toggle");
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.diff().unwrap().reviewed_files.is_empty());
|
||||
assert_eq!(editor.lines(), text);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert_eq!(editor.active.undo.len(), undo);
|
||||
assert_eq!(editor.active.redo.len(), redo);
|
||||
assert_eq!(editor.dirty(), dirty);
|
||||
assert!(!directory.path("file.txt").exists());
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::After);
|
||||
assert!(editor.mutation_blocked());
|
||||
editor.execute(":diff.review.toggle");
|
||||
editor.execute("diff.view.toggle");
|
||||
editor.execute("diff.view.toggle");
|
||||
editor.execute("diff.orientation.toggle");
|
||||
let first_tab = editor.tab_id();
|
||||
editor.execute("tab.new");
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&directory.0,
|
||||
vec!["base".into()],
|
||||
vec!["different".into()],
|
||||
);
|
||||
assert!(editor.diff().unwrap().reviewed_files.is_empty());
|
||||
editor.execute("tab.previous");
|
||||
assert_eq!(editor.tab_id(), first_tab);
|
||||
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0]));
|
||||
editor.execute("diff.close");
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&directory.0,
|
||||
vec!["before".into()],
|
||||
vec!["after".into()],
|
||||
);
|
||||
assert!(editor.diff().unwrap().reviewed_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_review_files_action_targets_highlight_without_stealing_filter_text() {
|
||||
let directory = Directory::new();
|
||||
fs::write(directory.path("z.txt"), "z").unwrap();
|
||||
fs::write(directory.path("a.txt"), "a").unwrap();
|
||||
let mut editor = editor();
|
||||
working_diff(&mut editor, &directory.0, &["z.txt", "a.txt"]);
|
||||
editor
|
||||
.bind("normal", "ctrl+y", Some("core.diff.review.toggle".into()))
|
||||
.unwrap();
|
||||
editor
|
||||
.bind("normal", "Q", Some("core.diff.review.toggle".into()))
|
||||
.unwrap();
|
||||
let files = editor.diff.as_mut().unwrap().files.as_mut().unwrap();
|
||||
files.set_view(FileView::List);
|
||||
files.reveal_path(Path::new("a.txt"));
|
||||
editor.handle(Input::Ctrl('y'));
|
||||
assert_eq!(editor.diff().unwrap().file, 0);
|
||||
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
|
||||
editor.handle(Input::Char('Q'));
|
||||
assert_eq!(editor.file_picker().unwrap().query, "Q");
|
||||
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
|
||||
editor.handle(Input::Ctrl('u'));
|
||||
let files = editor.diff.as_mut().unwrap().files.as_mut().unwrap();
|
||||
files.set_view(FileView::Tree);
|
||||
files.start_search();
|
||||
editor.handle(Input::Char('Q'));
|
||||
assert_eq!(editor.file_picker().unwrap().query, "Q");
|
||||
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
|
||||
editor.handle(Input::Ctrl('y'));
|
||||
assert_eq!(editor.message(), "No changed file is selected");
|
||||
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
|
||||
editor.file_picker = Some(
|
||||
FilePicker::scan(
|
||||
directory.0.clone(),
|
||||
FilePresentation::Docked,
|
||||
FileView::List,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
editor.execute("diff.review.toggle");
|
||||
assert_eq!(editor.message(), "Select a file in the comparison");
|
||||
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_review_marks_survive_save_and_clear_only_the_edited_file() {
|
||||
let directory = Directory::new();
|
||||
let first = directory.path("first.txt");
|
||||
fs::write(&first, "base\n").unwrap();
|
||||
fs::write(directory.path("second.txt"), "second").unwrap();
|
||||
let mut editor = Editor::open(Some(first.clone()), vec![]).unwrap();
|
||||
working_diff(&mut editor, &directory.0, &["first.txt", "second.txt"]);
|
||||
editor.execute("diff.review.toggle");
|
||||
editor
|
||||
.diff
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.files
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.reveal_path(Path::new("second.txt"));
|
||||
editor.execute("diff.review.toggle");
|
||||
assert_eq!(
|
||||
editor.diff().unwrap().reviewed_files,
|
||||
BTreeSet::from([0, 1])
|
||||
);
|
||||
editor.handle(Input::BackTab);
|
||||
assert!(editor.diff_editing());
|
||||
editor.execute("w");
|
||||
assert_eq!(
|
||||
editor.diff().unwrap().reviewed_files,
|
||||
BTreeSet::from([0, 1])
|
||||
);
|
||||
type_text(&mut editor, "iX");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
|
||||
editor.execute("diff.review.toggle");
|
||||
editor.execute("w");
|
||||
assert_eq!(
|
||||
editor.diff().unwrap().reviewed_files,
|
||||
BTreeSet::from([0, 1])
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&first).unwrap(), "Xbase\n");
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_display_defaults_tabs_and_remaps_preserve_native_snapshot_carets() {
|
||||
let directory = Directory::new();
|
||||
|
||||
+121
-19
@@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::BTreeMap,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fmt::Write as _,
|
||||
io::{self, Stdout},
|
||||
panic::{self, AssertUnwindSafe, PanicHookInfo},
|
||||
@@ -514,10 +514,11 @@ impl BufferViewports {
|
||||
panel,
|
||||
editor.file_navigation_hint(),
|
||||
&mut self.panels,
|
||||
editor
|
||||
.diff()
|
||||
.and_then(|diff| diff.comparison.as_ref())
|
||||
.map(|comparison| comparison.files.as_slice()),
|
||||
editor.diff().and_then(|diff| {
|
||||
diff.comparison
|
||||
.as_ref()
|
||||
.map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files))
|
||||
}),
|
||||
);
|
||||
if let Some(geometry) = &mut self.panels.diff {
|
||||
geometry.regions[0] = (panel, Focus::Files);
|
||||
@@ -2060,10 +2061,11 @@ fn draw_overlays(
|
||||
panel,
|
||||
editor.file_navigation_hint(),
|
||||
panels,
|
||||
editor
|
||||
.diff()
|
||||
.and_then(|diff| diff.comparison.as_ref())
|
||||
.map(|comparison| comparison.files.as_slice()),
|
||||
editor.diff().and_then(|diff| {
|
||||
diff.comparison
|
||||
.as_ref()
|
||||
.map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files))
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let Some(palette) = editor.palette() {
|
||||
@@ -2774,7 +2776,7 @@ fn draw_diff(
|
||||
navigator,
|
||||
file_navigation_hint,
|
||||
panels,
|
||||
comparison.map(|comparison| comparison.files.as_slice()),
|
||||
comparison.map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files)),
|
||||
);
|
||||
}
|
||||
let mut text_widths = [0; 2];
|
||||
@@ -4128,7 +4130,7 @@ fn draw_file_list(
|
||||
panel: Rect,
|
||||
navigation_hint: &str,
|
||||
panels: &mut WheelPanels,
|
||||
changed_files: Option<&[ChangedFile]>,
|
||||
changed_files: Option<(&[ChangedFile], &BTreeSet<usize>)>,
|
||||
) {
|
||||
if panel.width == 0 || panel.height == 0 {
|
||||
return;
|
||||
@@ -4262,7 +4264,7 @@ fn draw_file_list(
|
||||
}
|
||||
let changed = entry
|
||||
.catalog_index
|
||||
.and_then(|index| changed_files.and_then(|files| files.get(index)));
|
||||
.and_then(|index| changed_files.and_then(|(files, _)| files.get(index)));
|
||||
let prefix = format!(
|
||||
"{} {:indent$}",
|
||||
if selected { ">" } else { " " },
|
||||
@@ -4273,9 +4275,16 @@ fn draw_file_list(
|
||||
0
|
||||
},
|
||||
);
|
||||
let mut spans = Vec::with_capacity(if changed.is_some() { 6 } else { 3 });
|
||||
let mut spans = Vec::with_capacity(if changed.is_some() { 7 } else { 3 });
|
||||
spans.push(Span::raw(prefix));
|
||||
if let Some(file) = changed {
|
||||
let reviewed = entry.catalog_index.is_some_and(|index| {
|
||||
changed_files.is_some_and(|(_, reviewed)| reviewed.contains(&index))
|
||||
});
|
||||
spans.push(Span::styled(
|
||||
if reviewed { "✓ " } else { " " },
|
||||
Style::default().fg(Color::Green),
|
||||
));
|
||||
let color = match file.status {
|
||||
FileStatus::Added => Color::Green,
|
||||
FileStatus::Removed => Color::Red,
|
||||
@@ -5664,6 +5673,7 @@ mod tests {
|
||||
picker.focused = focused;
|
||||
for index in 0..files.len() {
|
||||
picker.reveal_path(&files[index].path);
|
||||
let reviewed = BTreeSet::from([index]);
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
@@ -5672,15 +5682,15 @@ mod tests {
|
||||
frame.area(),
|
||||
"",
|
||||
&mut WheelPanels::default(),
|
||||
Some(&files),
|
||||
Some((&files, &reviewed)),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
for (file, color) in
|
||||
files
|
||||
for (catalog_index, (file, color)) in files
|
||||
.iter()
|
||||
.zip([Color::Green, Color::Yellow, Color::Red, Color::Cyan])
|
||||
.enumerate()
|
||||
{
|
||||
let path = if view == FileView::Tree {
|
||||
std::path::Path::new(file.path.file_name().unwrap())
|
||||
@@ -5703,6 +5713,30 @@ mod tests {
|
||||
})
|
||||
.unwrap_or_else(|| panic!("missing {expected}"));
|
||||
assert_eq!(buffer[(x, y)].fg, color);
|
||||
assert_eq!(
|
||||
buffer[(x - 2, y)].symbol(),
|
||||
if catalog_index == index { "✓" } else { " " }
|
||||
);
|
||||
assert_eq!(buffer[(x - 1, y)].symbol(), " ");
|
||||
if catalog_index == index {
|
||||
assert_eq!(buffer[(x - 2, y)].fg, Color::Green);
|
||||
assert_eq!(
|
||||
buffer[(x - 2, y)].bg,
|
||||
if focused {
|
||||
Color::Blue
|
||||
} else {
|
||||
Color::DarkGray
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
for row in screen_text(&terminal)
|
||||
.lines()
|
||||
.filter(|row| row.contains("chain/a/"))
|
||||
{
|
||||
if view == FileView::Tree {
|
||||
assert!(!row.contains('✓'));
|
||||
}
|
||||
}
|
||||
assert_eq!(picker.accept(), Some(files[index].path.clone()));
|
||||
assert!(picker.query.is_empty());
|
||||
@@ -5717,6 +5751,8 @@ mod tests {
|
||||
.skip(1)
|
||||
.map(|(index, file)| (index, file.path.clone())),
|
||||
);
|
||||
for view in [FileView::Tree, FileView::List] {
|
||||
picker.set_view(view);
|
||||
picker.query = "m.txt".into();
|
||||
picker.refilter();
|
||||
terminal
|
||||
@@ -5727,12 +5763,23 @@ mod tests {
|
||||
frame.area(),
|
||||
"",
|
||||
&mut WheelPanels::default(),
|
||||
Some(&files),
|
||||
Some((&files, &BTreeSet::from([1]))),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("M chain/a/m.txt"));
|
||||
assert!(!screen_text(&terminal).contains("R r.txt"));
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("✓ M "));
|
||||
assert_eq!(text.matches('✓').count(), 1);
|
||||
assert!(!text.contains("R r.txt"));
|
||||
assert_eq!(
|
||||
picker
|
||||
.entries()
|
||||
.find(|entry| !entry.is_dir)
|
||||
.unwrap()
|
||||
.catalog_index,
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
@@ -5746,6 +5793,61 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!screen_text(&terminal).contains("M chain/a/m.txt"));
|
||||
assert!(!screen_text(&terminal).contains('✓'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewed_file_prefix_tracks_state_and_clips_in_narrow_panes() {
|
||||
let mut file = deletion_file();
|
||||
file.path = "界.txt".into();
|
||||
file.status = FileStatus::Modified;
|
||||
let files = [file];
|
||||
let mut picker = FilePicker::from_paths(PathBuf::new(), [(0, files[0].path.clone())]);
|
||||
picker.set_view(FileView::List);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 3)).unwrap();
|
||||
let mut reviewed = BTreeSet::new();
|
||||
for marked in [true, false, true] {
|
||||
if marked {
|
||||
reviewed.insert(0);
|
||||
} else {
|
||||
reviewed.remove(&0);
|
||||
}
|
||||
for width in 1..=8 {
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
frame.render_widget(
|
||||
Paragraph::new("XXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX"),
|
||||
frame.area(),
|
||||
);
|
||||
draw_file_list(
|
||||
frame,
|
||||
&picker,
|
||||
Rect::new(0, 0, width, 3),
|
||||
"",
|
||||
&mut WheelPanels::default(),
|
||||
Some((&files, &reviewed)),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let prefix = if marked { "> ✓ M " } else { "> M " };
|
||||
for (x, symbol) in prefix.chars().take(usize::from(width)).enumerate() {
|
||||
assert_eq!(buffer[(x as u16, 1)].symbol(), symbol.to_string());
|
||||
}
|
||||
for x in width..12 {
|
||||
assert_eq!(buffer[(x, 1)].symbol(), "X");
|
||||
}
|
||||
if width >= 3 && marked {
|
||||
assert_eq!(buffer[(2, 1)].fg, Color::Green);
|
||||
}
|
||||
if width >= 5 {
|
||||
assert_eq!(buffer[(4, 1)].fg, Color::Yellow);
|
||||
}
|
||||
if width >= 8 {
|
||||
assert_eq!(buffer[(6, 1)].symbol(), "界");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user