feat: add local diff review workflow

This commit is contained in:
Valdemar Lorenzen
2026-10-16 13:10:07 +02:00
parent 930735c537
commit 0446ec86c7
15 changed files with 1871 additions and 7 deletions
+361 -6
View File
@@ -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(),