feat: add local diff review workflow
This commit is contained in:
+361
-6
@@ -1,3 +1,7 @@
|
||||
#[path = "editor_review.rs"]
|
||||
mod local_review;
|
||||
use local_review::ReviewBuffer;
|
||||
|
||||
use crate::chrome::{ChromeLine, PopupConfig, validate_name};
|
||||
use crate::clipboard::{ClipboardMode, SystemClipboard};
|
||||
use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget, ViewMode};
|
||||
@@ -917,6 +921,71 @@ core_actions![
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
),
|
||||
(
|
||||
"core.diff.comment",
|
||||
Action::DiffComment,
|
||||
&[Mode::Normal, Mode::Visual],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.edit",
|
||||
Action::ReviewEdit,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.delete",
|
||||
Action::ReviewDelete,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.next",
|
||||
Action::ReviewNext,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.previous",
|
||||
Action::ReviewPrevious,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.overview",
|
||||
Action::ReviewOverview,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.summary",
|
||||
Action::ReviewSummary,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.verdict",
|
||||
Action::ReviewVerdict,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.save",
|
||||
Action::ReviewSave,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.open",
|
||||
Action::ReviewOpen,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.word_wrap.toggle",
|
||||
Action::WordWrapToggle,
|
||||
@@ -1758,6 +1827,7 @@ struct GutterChanges {
|
||||
struct Buffer {
|
||||
id: usize,
|
||||
diff_snapshot: Option<(u64, usize, Focus)>,
|
||||
review_buffer: Option<ReviewBuffer>,
|
||||
resolver_readonly: bool,
|
||||
snapshot_document_path: Option<PathBuf>,
|
||||
snapshot_root: Option<PathBuf>,
|
||||
@@ -1816,6 +1886,7 @@ impl Buffer {
|
||||
Ok(Self {
|
||||
id,
|
||||
diff_snapshot: None,
|
||||
review_buffer: None,
|
||||
resolver_readonly: false,
|
||||
snapshot_document_path: None,
|
||||
snapshot_root: None,
|
||||
@@ -3844,6 +3915,9 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn close_tab(&mut self, force: bool) {
|
||||
if !force && self.local_review_close_blocked() {
|
||||
return;
|
||||
}
|
||||
if self.resolver_close_blocked() {
|
||||
self.set_message(
|
||||
"Resolution drafts are unsaved; write them before closing the workspace",
|
||||
@@ -3887,6 +3961,7 @@ impl Editor {
|
||||
self.finish_pane_interaction();
|
||||
if let Some(owner) = self.diff_owner {
|
||||
self.with_pane(owner, |editor| {
|
||||
editor.detach_review_buffers();
|
||||
editor.release_diff_buffers();
|
||||
editor.diff = None;
|
||||
});
|
||||
@@ -3954,7 +4029,11 @@ impl Editor {
|
||||
return;
|
||||
}
|
||||
self.finish_pane_interaction();
|
||||
if self.diff_owner == Some(id) && !force && self.local_review_close_blocked() {
|
||||
return;
|
||||
}
|
||||
if self.diff_owner == Some(id) {
|
||||
self.detach_review_buffers();
|
||||
self.release_diff_buffers();
|
||||
self.diff = None;
|
||||
self.parked_diff = None;
|
||||
@@ -4402,10 +4481,15 @@ impl Editor {
|
||||
self.set_message(format!("LSP: definition failed: {error}"));
|
||||
}
|
||||
}
|
||||
self.finish_review_navigation();
|
||||
true
|
||||
}
|
||||
|
||||
fn open_diff(&mut self, mut diff: DiffState) {
|
||||
if self.local_review_close_blocked() {
|
||||
return;
|
||||
}
|
||||
self.detach_review_buffers();
|
||||
if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) {
|
||||
self.with_pane(owner, |editor| {
|
||||
editor.release_diff_buffers();
|
||||
@@ -4588,6 +4672,11 @@ impl Editor {
|
||||
.diff_bound
|
||||
.is_some_and(|bound| bound.3 != self.active.id)
|
||||
{
|
||||
if self.current_review_dirty() {
|
||||
self.bind_diff_buffer();
|
||||
self.set_message("Save the local review before replacing its comparison");
|
||||
return;
|
||||
}
|
||||
if let Some(role) = self.active.diff_snapshot {
|
||||
if self
|
||||
.diff
|
||||
@@ -5131,6 +5220,16 @@ impl Editor {
|
||||
| Action::DiffViewToggle
|
||||
| Action::DiffOrientationToggle
|
||||
| Action::DiffReviewToggle
|
||||
| Action::DiffComment
|
||||
| Action::ReviewEdit
|
||||
| Action::ReviewDelete
|
||||
| Action::ReviewNext
|
||||
| Action::ReviewPrevious
|
||||
| Action::ReviewOverview
|
||||
| Action::ReviewSummary
|
||||
| Action::ReviewVerdict
|
||||
| Action::ReviewSave
|
||||
| Action::ReviewOpen
|
||||
| Action::WordWrapToggle
|
||||
| Action::GitBlameToggle
|
||||
| Action::Quit
|
||||
@@ -7701,6 +7800,9 @@ impl Editor {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
if self.handle_review_overview(&input) {
|
||||
return None;
|
||||
}
|
||||
if self.handle_resolver_history_focus(&input) {
|
||||
return None;
|
||||
}
|
||||
@@ -8178,12 +8280,13 @@ impl Editor {
|
||||
&& !(matches!(
|
||||
action,
|
||||
Action::Save | Action::ForceSave | Action::SaveAndQuit | Action::ForceSaveAndQuit
|
||||
) && self.resolvers.values().any(|resolver| {
|
||||
resolver
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.draft_buffer == self.active.id)
|
||||
}))
|
||||
) && (self.active.review_buffer.is_some()
|
||||
|| self.resolvers.values().any(|resolver| {
|
||||
resolver
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.draft_buffer == self.active.id)
|
||||
})))
|
||||
{
|
||||
self.set_message("Input pane is read-only");
|
||||
self.count = None;
|
||||
@@ -8303,6 +8406,16 @@ impl Editor {
|
||||
| Action::DiffViewToggle
|
||||
| Action::DiffOrientationToggle
|
||||
| Action::DiffReviewToggle
|
||||
| Action::DiffComment
|
||||
| Action::ReviewEdit
|
||||
| Action::ReviewDelete
|
||||
| Action::ReviewNext
|
||||
| Action::ReviewPrevious
|
||||
| Action::ReviewOverview
|
||||
| Action::ReviewSummary
|
||||
| Action::ReviewVerdict
|
||||
| Action::ReviewSave
|
||||
| Action::ReviewOpen
|
||||
| Action::WordWrapToggle
|
||||
| Action::GitBlameToggle
|
||||
| Action::LspDefinition
|
||||
@@ -8973,8 +9086,17 @@ impl Editor {
|
||||
.filter_map(|tab| tab.diff_owner),
|
||||
)
|
||||
.collect();
|
||||
if !force
|
||||
&& owners
|
||||
.iter()
|
||||
.any(|owner| self.with_pane(*owner, |editor| editor.current_review_dirty()))
|
||||
{
|
||||
self.set_message("Save the local review before deleting its comparison buffer");
|
||||
return;
|
||||
}
|
||||
for owner in owners {
|
||||
self.with_pane(owner, |editor| {
|
||||
editor.detach_review_buffers();
|
||||
editor.release_diff_buffers();
|
||||
editor.diff = None;
|
||||
editor.parked_diff = None;
|
||||
@@ -9078,6 +9200,7 @@ impl Editor {
|
||||
fn any_dirty(&self) -> bool {
|
||||
self.active.dirty()
|
||||
|| self.parked.values().any(Buffer::dirty)
|
||||
|| self.local_reviews_dirty()
|
||||
|| self.resolvers.values().any(|resolver| {
|
||||
resolver.pending
|
||||
|| resolver
|
||||
@@ -9088,6 +9211,10 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn restart(&mut self, force: bool) {
|
||||
if !force && self.local_reviews_dirty() {
|
||||
self.set_message("Unsaved local review; use :diff.review.save <path>");
|
||||
return;
|
||||
}
|
||||
if force || !self.any_dirty() {
|
||||
self.restart = true;
|
||||
return;
|
||||
@@ -9184,6 +9311,25 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option<Invocation> {
|
||||
if matches!(
|
||||
entry.action,
|
||||
Action::DiffComment
|
||||
| Action::ReviewEdit
|
||||
| Action::ReviewDelete
|
||||
| Action::ReviewNext
|
||||
| Action::ReviewPrevious
|
||||
| Action::ReviewOverview
|
||||
| Action::ReviewSummary
|
||||
| Action::ReviewVerdict
|
||||
| Action::ReviewSave
|
||||
| Action::ReviewOpen
|
||||
) {
|
||||
self.finish_diff_prompt();
|
||||
if let Err(error) = self.local_review_command(entry.action, args) {
|
||||
self.set_message(error);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if matches!(
|
||||
entry.action,
|
||||
Action::Resolve
|
||||
@@ -9210,6 +9356,14 @@ impl Editor {
|
||||
self.prepare_command();
|
||||
self.finish_command_mode();
|
||||
let quit = matches!(entry.action, Action::SaveAndQuit | Action::ForceSaveAndQuit);
|
||||
if self.active.review_buffer.is_some() {
|
||||
match self.save_local_review((!args.is_empty()).then(|| Path::new(args))) {
|
||||
Ok(()) if quit && !self.any_dirty() => self.quit = true,
|
||||
Ok(()) => {}
|
||||
Err(error) => self.set_message(error),
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if self.queue_resolver_save((!args.is_empty()).then(|| Path::new(args)), quit) {
|
||||
return None;
|
||||
}
|
||||
@@ -9418,6 +9572,10 @@ impl Editor {
|
||||
return None;
|
||||
}
|
||||
self.diff.as_ref()?;
|
||||
if self.local_review_close_blocked() {
|
||||
return None;
|
||||
}
|
||||
self.detach_review_buffers();
|
||||
self.release_diff_buffers();
|
||||
self.diff = None;
|
||||
self.diff_owner = None;
|
||||
@@ -9695,6 +9853,15 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn save(&mut self, path: Option<PathBuf>, force: bool) -> bool {
|
||||
if self.active.review_buffer.is_some() {
|
||||
return match self.save_local_review(path.as_deref()) {
|
||||
Ok(()) => true,
|
||||
Err(error) => {
|
||||
self.set_message(error);
|
||||
false
|
||||
}
|
||||
};
|
||||
}
|
||||
if self.queue_resolver_save(path.as_deref(), false) {
|
||||
return false;
|
||||
}
|
||||
@@ -14435,6 +14602,7 @@ mod tests {
|
||||
comparison_request,
|
||||
Ok(Outcome::Comparison(Comparison {
|
||||
root: editor.project_root().to_owned(),
|
||||
revisions: ["base".into(), "working-snapshot".into()],
|
||||
before: "base".into(),
|
||||
after: "working".into(),
|
||||
editable_paths: Default::default(),
|
||||
@@ -15623,6 +15791,7 @@ mod tests {
|
||||
id,
|
||||
Ok(Outcome::Comparison(Comparison {
|
||||
root: root.canonicalize().unwrap(),
|
||||
revisions: ["base".into(), "working-snapshot".into()],
|
||||
before: "base".into(),
|
||||
after: "Working".into(),
|
||||
editable_paths: paths.iter().map(|path| PathBuf::from(*path)).collect(),
|
||||
@@ -15661,6 +15830,7 @@ mod tests {
|
||||
id,
|
||||
Ok(Outcome::Comparison(Comparison {
|
||||
root: root.to_owned(),
|
||||
revisions: ["base".into(), "head".into()],
|
||||
before: "base".into(),
|
||||
after: "head".into(),
|
||||
editable_paths: Default::default(),
|
||||
@@ -15683,6 +15853,189 @@ mod tests {
|
||||
assert!(editor.diff_interacting());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_review_native_composition_roundtrip_and_draft_lifecycle() {
|
||||
use crate::review::{Review, Side, Verdict};
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let path = root.join("feedback.json");
|
||||
let mut editor = editor();
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&root,
|
||||
vec!["before".into(), "removed".into()],
|
||||
vec!["after".into(), "added".into()],
|
||||
);
|
||||
let owner = editor.pane_id();
|
||||
let source = editor.context().text;
|
||||
editor.execute("diff.comment");
|
||||
assert_ne!(editor.pane_id(), owner);
|
||||
assert!(
|
||||
editor
|
||||
.review_panel_title()
|
||||
.unwrap()
|
||||
.starts_with("Comment #1")
|
||||
);
|
||||
type_text(&mut editor, "iExplain the removed branch");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.review.summary");
|
||||
type_text(&mut editor, "iPlease simplify the branch");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.review.verdict changes_requested");
|
||||
assert!(editor.context().text.contains("Changes requested"));
|
||||
editor.execute(&format!("diff.review.save {}", path.display()));
|
||||
let review = Review::load(&path, &root).unwrap();
|
||||
assert_eq!(review.comments[0].body, "Explain the removed branch");
|
||||
assert_eq!(review.comments[0].anchor.side, Side::Before);
|
||||
assert_eq!(review.comments[0].anchor.content, source);
|
||||
assert_eq!(review.summary, "Please simplify the branch");
|
||||
assert_eq!(review.verdict, Some(Verdict::ChangesRequested));
|
||||
assert!(!editor.local_reviews_dirty());
|
||||
editor.execute("diff.review.edit 1");
|
||||
// Native undo applies to comment text, never to the reviewed source.
|
||||
type_text(&mut editor, "A!");
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "Explain the removed branch");
|
||||
editor.execute("w");
|
||||
editor.execute("diff.close");
|
||||
assert!(editor.workspace_diff().is_none());
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&root,
|
||||
vec!["before".into(), "removed".into()],
|
||||
vec!["after".into(), "added".into()],
|
||||
);
|
||||
editor.execute(&format!("diff.review.open {}", path.display()));
|
||||
assert!(editor.context().text.contains("Explain the removed branch"));
|
||||
editor.execute("diff.review.next");
|
||||
assert_eq!(editor.context().text, "Explain the removed branch");
|
||||
editor.execute("diff.review.delete");
|
||||
assert!(editor.context().text.starts_with("# Local review"));
|
||||
editor.execute("w");
|
||||
assert!(Review::load(&path, &root).unwrap().comments.is_empty());
|
||||
assert_eq!(
|
||||
editor
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.comparison
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.files[0]
|
||||
.before,
|
||||
["before", "removed"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_review_visual_palette_anchors_both_sides_and_preserves_stale_ranges() {
|
||||
use crate::review::Side;
|
||||
for focus in [Focus::Before, Focus::After] {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let mut editor = editor();
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&root,
|
||||
vec!["old first".into(), "old middle".into(), "old last".into()],
|
||||
vec!["new first".into(), "new middle".into(), "new last".into()],
|
||||
);
|
||||
editor.diff.as_mut().unwrap().focus = focus;
|
||||
editor.bind_diff_buffer();
|
||||
type_text(&mut editor, "Vj");
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
type_text(&mut editor, "diff.comment");
|
||||
editor.handle(Input::Enter);
|
||||
let diff = editor.workspace_diff().unwrap();
|
||||
let anchor = diff.local_review.document.comments[0].anchor.clone();
|
||||
assert_eq!((anchor.start_line, anchor.end_line), (1, 2));
|
||||
assert_eq!(
|
||||
anchor.side,
|
||||
if focus == Focus::Before {
|
||||
Side::Before
|
||||
} else {
|
||||
Side::After
|
||||
}
|
||||
);
|
||||
assert!(diff.review_anchor_current(&anchor));
|
||||
let source_pane = editor.diff_owner.unwrap();
|
||||
editor.select_pane(source_pane);
|
||||
editor
|
||||
.diff
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.comparison
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.revisions[anchor.side.index()] = "changed-snapshot".into();
|
||||
editor.execute("diff.review.overview");
|
||||
assert!(editor.context().text.contains("stale"));
|
||||
editor.execute("diff.review.edit 1");
|
||||
assert_eq!(
|
||||
editor
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.comments[0]
|
||||
.anchor,
|
||||
anchor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_review_guards_metadata_and_hidden_native_drafts_against_loss() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let mut editor = editor();
|
||||
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
|
||||
editor.execute("diff.comment");
|
||||
type_text(&mut editor, "iUnsaved feedback");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.review.overview");
|
||||
let panel = editor.pane_id();
|
||||
let source_buffer = editor.diff_return_buffer.unwrap();
|
||||
editor.delete_buffer(source_buffer, false);
|
||||
assert!(editor.has_buffer(source_buffer));
|
||||
assert!(editor.workspace_diff().is_some());
|
||||
editor.select_pane(editor.diff_owner.unwrap());
|
||||
fs::write(root.join("other.txt"), "other document").unwrap();
|
||||
editor.execute(&format!("edit {}", root.join("other.txt").display()));
|
||||
assert!(editor.workspace_diff().is_some());
|
||||
assert_eq!(editor.context().text, "old");
|
||||
editor.select_pane(panel);
|
||||
editor.execute("diff.close");
|
||||
assert!(editor.workspace_diff().is_some());
|
||||
editor.execute("tab.close");
|
||||
assert!(!editor.should_quit());
|
||||
editor.execute("restart");
|
||||
assert!(!editor.should_restart());
|
||||
editor.execute("q");
|
||||
assert!(!editor.should_quit());
|
||||
let invalid = root.join("missing/review.json");
|
||||
editor.execute(&format!("diff.review.save {}", invalid.display()));
|
||||
assert!(editor.local_reviews_dirty());
|
||||
assert_eq!(
|
||||
editor
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.comments[0]
|
||||
.body,
|
||||
"Unsaved feedback"
|
||||
);
|
||||
let path = root.join("review.json");
|
||||
editor.execute(&format!("diff.review.save {}", path.display()));
|
||||
assert!(!editor.local_reviews_dirty());
|
||||
fs::write(&path, "external change").unwrap();
|
||||
editor.execute("diff.review.verdict looks_good");
|
||||
editor.execute("w");
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "external change");
|
||||
assert!(editor.local_reviews_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_refresh_routes_hidden_owners_and_keeps_obsolete_shared_snapshots_readonly() {
|
||||
let directory = Directory::new();
|
||||
@@ -15807,6 +16160,7 @@ mod tests {
|
||||
refresh,
|
||||
Ok(Outcome::BookmarkRefresh(Comparison {
|
||||
root: directory.0.clone(),
|
||||
revisions: ["base".into(), "topic-snapshot".into()],
|
||||
before: "base".into(),
|
||||
after: "topic".into(),
|
||||
editable_paths: Default::default(),
|
||||
@@ -15927,6 +16281,7 @@ mod tests {
|
||||
target.loaded = false;
|
||||
let replacement = crate::diff::Comparison {
|
||||
root: directory.0.clone(),
|
||||
revisions: ["base".into(), "ahead-snapshot".into()],
|
||||
before: "base".into(),
|
||||
after: "ahead".into(),
|
||||
editable_paths: Default::default(),
|
||||
|
||||
Reference in New Issue
Block a user