feat: add local diff review workflow
This commit is contained in:
+79
@@ -139,6 +139,8 @@ pub struct ChangedFile {
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct Comparison {
|
||||
/// Immutable comparison-context IDs; captured review text also freezes live edits.
|
||||
pub revisions: [String; 2],
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
pub files: Vec<ChangedFile>,
|
||||
@@ -273,6 +275,7 @@ pub struct DiffState {
|
||||
pub loading: bool,
|
||||
pub error: Option<String>,
|
||||
pub comparison: Option<Comparison>,
|
||||
pub local_review: crate::review::ReviewState,
|
||||
pub comparison_id: Option<u64>,
|
||||
pub reviewed_files: BTreeSet<usize>,
|
||||
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
|
||||
@@ -330,6 +333,7 @@ impl DiffState {
|
||||
loading: true,
|
||||
error: None,
|
||||
comparison: None,
|
||||
local_review: Default::default(),
|
||||
comparison_id: None,
|
||||
reviewed_files: BTreeSet::new(),
|
||||
files: None,
|
||||
@@ -366,6 +370,7 @@ impl DiffState {
|
||||
}
|
||||
pub(crate) fn set_resolver_paths(&mut self, root: PathBuf, paths: Vec<PathBuf>, file: usize) {
|
||||
let comparison = self.comparison.get_or_insert_with(|| Comparison {
|
||||
revisions: Default::default(),
|
||||
before: "Resolution".into(),
|
||||
after: "Other version".into(),
|
||||
files: Vec::new(),
|
||||
@@ -1710,6 +1715,40 @@ impl DiffState {
|
||||
lines
|
||||
}
|
||||
|
||||
pub fn review_anchor_current(&self, anchor: &crate::review::Anchor) -> bool {
|
||||
use crate::review::Side;
|
||||
let Some(comparison) = &self.comparison else {
|
||||
return false;
|
||||
};
|
||||
if self.local_review.document.root != comparison.root {
|
||||
return false;
|
||||
}
|
||||
comparison.files.iter().enumerate().any(|(index, file)| {
|
||||
if !file.loaded || file.notice.is_some() {
|
||||
return false;
|
||||
}
|
||||
let (path, lines, eof) = match anchor.side {
|
||||
Side::Before => (
|
||||
file.old_path.as_deref().unwrap_or(&file.path),
|
||||
&file.before,
|
||||
file.before_eof,
|
||||
),
|
||||
Side::After => (
|
||||
file.path.as_path(),
|
||||
&file.after,
|
||||
file.after_eof && !self.live_files.contains(&index),
|
||||
),
|
||||
};
|
||||
anchor.matches_lines(
|
||||
&comparison.revisions[anchor.side.index()],
|
||||
path,
|
||||
anchor.side,
|
||||
lines,
|
||||
eof,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn corresponding_row(&self, from: Focus, to: Focus, row: usize) -> Option<usize> {
|
||||
let aligned = self.rows.iter().position(|aligned| match from {
|
||||
Focus::Before => aligned.before == Some(row),
|
||||
@@ -3172,6 +3211,7 @@ mod tests {
|
||||
|
||||
fn comparison() -> Comparison {
|
||||
Comparison {
|
||||
revisions: ["abcdef123".into(), "working:snapshot".into()],
|
||||
before: "abcdef123".into(),
|
||||
after: "Working checkout".into(),
|
||||
root: PathBuf::new(),
|
||||
@@ -4159,6 +4199,7 @@ mod tests {
|
||||
assert!(editor.apply_diff_result(
|
||||
id,
|
||||
Ok(Outcome::Comparison(Comparison {
|
||||
revisions: ["working:snapshot".into(), "working:snapshot".into()],
|
||||
before: "Working".into(),
|
||||
after: "Working".into(),
|
||||
files: vec![],
|
||||
@@ -4190,6 +4231,7 @@ mod tests {
|
||||
Category::Commits,
|
||||
));
|
||||
state.comparison = Some(Comparison {
|
||||
revisions: ["base".into(), "working:snapshot".into()],
|
||||
root: PathBuf::new(),
|
||||
before: "base".into(),
|
||||
after: "Working".into(),
|
||||
@@ -4982,4 +5024,41 @@ mod tests {
|
||||
));
|
||||
assert!(editor.diff().unwrap().error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() {
|
||||
use crate::review::{Anchor, Side};
|
||||
let mut state = live_state(vec!["old".into()], vec!["new".into()], true);
|
||||
let anchor = Anchor {
|
||||
revision: "working:snapshot".into(),
|
||||
path: "file".into(),
|
||||
side: Side::After,
|
||||
start_line: 1,
|
||||
end_line: 1,
|
||||
content: "new\n".into(),
|
||||
};
|
||||
assert!(state.review_anchor_current(&anchor));
|
||||
state.live_files.insert(0);
|
||||
assert!(!state.review_anchor_current(&anchor));
|
||||
state.comparison.as_mut().unwrap().files[0]
|
||||
.after
|
||||
.push(String::new());
|
||||
assert!(state.review_anchor_current(&anchor));
|
||||
state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into();
|
||||
assert!(!state.review_anchor_current(&anchor));
|
||||
state.comparison.as_mut().unwrap().files[0].old_path = Some("original".into());
|
||||
let before = Anchor {
|
||||
revision: "base".into(),
|
||||
path: "original".into(),
|
||||
side: Side::Before,
|
||||
content: "old\n".into(),
|
||||
..anchor
|
||||
};
|
||||
assert!(state.review_anchor_current(&before));
|
||||
state.comparison.as_mut().unwrap().files[0].loaded = false;
|
||||
assert!(!state.review_anchor_current(&before));
|
||||
state.comparison.as_mut().unwrap().files[0].loaded = true;
|
||||
state.comparison.as_mut().unwrap().revisions[0] = "other".into();
|
||||
assert!(!state.review_anchor_current(&before));
|
||||
}
|
||||
}
|
||||
|
||||
+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(),
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
use super::*;
|
||||
use crate::review::{Anchor, Comment, Review, Side, Verdict};
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum ReviewText {
|
||||
Comment(u64),
|
||||
Summary,
|
||||
Overview,
|
||||
}
|
||||
|
||||
pub(super) struct ReviewBuffer {
|
||||
owner: usize,
|
||||
kind: ReviewText,
|
||||
title: String,
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn review_panel_title(&self) -> Option<&str> {
|
||||
self.active
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.map(|buffer| buffer.title.as_str())
|
||||
}
|
||||
|
||||
fn review_diff_mut(&mut self) -> Option<&mut DiffState> {
|
||||
self.diff.as_mut().or(self.parked_diff.as_mut())
|
||||
}
|
||||
|
||||
pub(super) fn local_reviews_dirty(&self) -> bool {
|
||||
self.workspace_diff()
|
||||
.is_some_and(|diff| diff.local_review.dirty)
|
||||
|| self
|
||||
.tabs
|
||||
.values()
|
||||
.any(|tab| tab.diff().is_some_and(|diff| diff.local_review.dirty))
|
||||
|| std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.any(|buffer| buffer.review_buffer.is_some() && buffer.dirty())
|
||||
}
|
||||
|
||||
pub(super) fn current_review_dirty(&self) -> bool {
|
||||
self.workspace_diff()
|
||||
.is_some_and(|diff| diff.local_review.dirty)
|
||||
|| std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.any(|buffer| {
|
||||
buffer
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||
&& buffer.dirty()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn local_review_close_blocked(&mut self) -> bool {
|
||||
if self.current_review_dirty() {
|
||||
self.set_message("Unsaved local review; use :diff.review.save <path> before closing");
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn detach_review_buffers(&mut self) {
|
||||
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
|
||||
if buffer
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||
{
|
||||
buffer.review_buffer = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_review_text(&mut self) {
|
||||
let owner = self.tab_id;
|
||||
let updates: Vec<_> = std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.filter_map(|buffer| {
|
||||
let binding = buffer.review_buffer.as_ref()?;
|
||||
(binding.owner == owner && binding.kind != ReviewText::Overview)
|
||||
.then(|| (binding.kind, buffer.lines.join("\n")))
|
||||
})
|
||||
.collect();
|
||||
let Some(diff) = self.review_diff_mut() else {
|
||||
return;
|
||||
};
|
||||
for (kind, text) in updates {
|
||||
let destination = match kind {
|
||||
ReviewText::Comment(id) => diff
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.iter_mut()
|
||||
.find(|comment| comment.id == id)
|
||||
.map(|comment| &mut comment.body),
|
||||
ReviewText::Summary => Some(&mut diff.local_review.document.summary),
|
||||
ReviewText::Overview => None,
|
||||
};
|
||||
if let Some(destination) = destination
|
||||
&& *destination != text
|
||||
{
|
||||
*destination = text;
|
||||
diff.local_review.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn save_local_review(&mut self, path: Option<&Path>) -> Result<(), String> {
|
||||
self.sync_review_text();
|
||||
let state = &mut self
|
||||
.review_diff_mut()
|
||||
.ok_or("No comparison is open")?
|
||||
.local_review;
|
||||
let path = path
|
||||
.map(Path::to_path_buf)
|
||||
.or_else(|| state.path.clone())
|
||||
.ok_or("Use :diff.review.save <path> to choose a local JSON review file")?;
|
||||
state.document.save(&path)?;
|
||||
state.path = Some(path);
|
||||
state.dirty = false;
|
||||
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
|
||||
if buffer
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||
{
|
||||
buffer.saved_lines.clone_from(&buffer.lines);
|
||||
}
|
||||
}
|
||||
self.set_message("Local review saved");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn local_review_command(
|
||||
&mut self,
|
||||
action: Action,
|
||||
args: &str,
|
||||
) -> Result<(), String> {
|
||||
if self.resolver().is_some() {
|
||||
return Err("Local reviews require an ordinary diff comparison".into());
|
||||
}
|
||||
let diff = self
|
||||
.workspace_diff()
|
||||
.filter(|diff| diff.phase == Phase::View)
|
||||
.ok_or("No comparison is open")?;
|
||||
let root = diff
|
||||
.comparison
|
||||
.as_ref()
|
||||
.ok_or("Comparison is still loading")?
|
||||
.root
|
||||
.clone();
|
||||
if !matches!(
|
||||
action,
|
||||
Action::ReviewEdit
|
||||
| Action::ReviewDelete
|
||||
| Action::ReviewVerdict
|
||||
| Action::ReviewSave
|
||||
| Action::ReviewOpen
|
||||
) && !args.is_empty()
|
||||
{
|
||||
return Err("This review command takes no arguments".into());
|
||||
}
|
||||
if self
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.root
|
||||
.as_os_str()
|
||||
.is_empty()
|
||||
{
|
||||
self.review_diff_mut().unwrap().local_review.document = Review::new(root.clone());
|
||||
}
|
||||
if action == Action::ReviewOpen {
|
||||
if args.is_empty() {
|
||||
return Err("Usage: diff.review.open <path>".into());
|
||||
}
|
||||
if self.current_review_dirty() {
|
||||
return Err("Save the current local review before opening another".into());
|
||||
}
|
||||
let document = Review::load(Path::new(args), &root)?;
|
||||
self.detach_review_buffers();
|
||||
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||
state.document = document;
|
||||
state.path = Some(PathBuf::from(args));
|
||||
state.selected = None;
|
||||
state.pending_navigation = None;
|
||||
self.open_review_overview()?;
|
||||
return Ok(());
|
||||
}
|
||||
self.sync_review_text();
|
||||
match action {
|
||||
Action::DiffComment => self.add_review_comment()?,
|
||||
Action::ReviewEdit => {
|
||||
let id = self.review_comment_id(args)?;
|
||||
self.show_review_comment(id)?;
|
||||
}
|
||||
Action::ReviewDelete => {
|
||||
let id = self.review_comment_id(args)?;
|
||||
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||
state.document.comments.retain(|comment| comment.id != id);
|
||||
state.selected = None;
|
||||
state.pending_navigation = None;
|
||||
state.dirty = true;
|
||||
// The deleted draft cannot later overwrite another comment with the same ID.
|
||||
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
|
||||
if buffer.review_buffer.as_ref().is_some_and(|binding| {
|
||||
binding.owner == self.tab_id && binding.kind == ReviewText::Comment(id)
|
||||
}) {
|
||||
buffer.review_buffer = None;
|
||||
buffer.saved_lines.clone_from(&buffer.lines);
|
||||
}
|
||||
}
|
||||
self.open_review_overview()?;
|
||||
}
|
||||
Action::ReviewNext | Action::ReviewPrevious => {
|
||||
let state = &self.workspace_diff().unwrap().local_review;
|
||||
let comments = &state.document.comments;
|
||||
if comments.is_empty() {
|
||||
return Err("No local review comments".into());
|
||||
}
|
||||
let current = state
|
||||
.selected
|
||||
.and_then(|id| comments.iter().position(|comment| comment.id == id));
|
||||
let index = match (current, action == Action::ReviewNext) {
|
||||
(Some(index), true) => (index + 1) % comments.len(),
|
||||
(Some(index), false) => (index + comments.len() - 1) % comments.len(),
|
||||
(None, true) => 0,
|
||||
(None, false) => comments.len() - 1,
|
||||
};
|
||||
self.show_review_comment(comments[index].id)?;
|
||||
}
|
||||
Action::ReviewOverview => self.open_review_overview()?,
|
||||
Action::ReviewSummary => {
|
||||
let text = self
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.summary
|
||||
.clone();
|
||||
self.open_review_buffer(
|
||||
ReviewText::Summary,
|
||||
&text,
|
||||
"Review summary · :w saves review".into(),
|
||||
)?;
|
||||
}
|
||||
Action::ReviewVerdict => {
|
||||
let verdict = match args {
|
||||
"looks_good" => Some(Verdict::LooksGood),
|
||||
"changes_requested" => Some(Verdict::ChangesRequested),
|
||||
"none" => None,
|
||||
_ => {
|
||||
return Err(
|
||||
"Usage: diff.review.verdict <looks_good|changes_requested|none>".into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||
if state.document.verdict != verdict {
|
||||
state.document.verdict = verdict;
|
||||
state.dirty = true;
|
||||
}
|
||||
self.open_review_overview()?;
|
||||
}
|
||||
Action::ReviewSave => {
|
||||
self.save_local_review((!args.is_empty()).then(|| Path::new(args)))?
|
||||
}
|
||||
_ => unreachable!("local review action"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn review_comment_id(&self, args: &str) -> Result<u64, String> {
|
||||
let state = &self
|
||||
.workspace_diff()
|
||||
.ok_or("No comparison is open")?
|
||||
.local_review;
|
||||
let under_cursor = self
|
||||
.active
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.filter(|binding| binding.kind == ReviewText::Overview)
|
||||
.and_then(|_| {
|
||||
self.active.lines[..=self.cursor().row]
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|line| {
|
||||
line.strip_prefix("## Comment #")?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
})
|
||||
});
|
||||
let id = if args.is_empty() {
|
||||
under_cursor
|
||||
.or(state.selected)
|
||||
.ok_or("Select a comment or supply its ID")?
|
||||
} else {
|
||||
args.parse()
|
||||
.map_err(|_| "Comment ID must be a positive integer")?
|
||||
};
|
||||
state
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
.any(|comment| comment.id == id)
|
||||
.then_some(id)
|
||||
.ok_or_else(|| format!("No local comment #{id}"))
|
||||
}
|
||||
|
||||
fn add_review_comment(&mut self) -> Result<(), String> {
|
||||
let owner = self.diff_owner.ok_or("No comparison is open")?;
|
||||
if self.pane_id != owner {
|
||||
self.select_pane(owner);
|
||||
}
|
||||
self.bind_diff_buffer();
|
||||
let focus = self
|
||||
.diff_buffer_focus()
|
||||
.filter(|_| self.diff_interacting())
|
||||
.ok_or("Focus a diff text pane to add a comment")?;
|
||||
if self.selection_count() != 1 {
|
||||
return Err("Use one caret or one line range for a review comment".into());
|
||||
}
|
||||
let selection = self.selections().into_iter().next();
|
||||
let (first, last) = selection.map_or((self.cursor().row, self.cursor().row), |selection| {
|
||||
let last = selection.end.row.saturating_sub(usize::from(
|
||||
selection.end.column == 0 && selection.end.row > selection.start.row,
|
||||
));
|
||||
(selection.start.row, last)
|
||||
});
|
||||
let diff = self.diff.as_ref().unwrap();
|
||||
let comparison = diff.comparison.as_ref().unwrap();
|
||||
let file = &comparison.files[diff.file];
|
||||
let side = if focus == Focus::Before {
|
||||
Side::Before
|
||||
} else {
|
||||
Side::After
|
||||
};
|
||||
let anchor = Anchor {
|
||||
revision: comparison.revisions[side.index()].clone(),
|
||||
path: if side == Side::Before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path)
|
||||
} else {
|
||||
&file.path
|
||||
}
|
||||
.clone(),
|
||||
side,
|
||||
start_line: first + 1,
|
||||
end_line: last + 1,
|
||||
content: self.active.lines.join("\n"),
|
||||
};
|
||||
let id = diff
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
.map(|comment| comment.id)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.checked_add(1)
|
||||
.ok_or("Review comment IDs exhausted")?;
|
||||
self.prepare_command();
|
||||
self.finish_command_mode();
|
||||
let state = &mut self.diff.as_mut().unwrap().local_review;
|
||||
state.document.comments.push(Comment {
|
||||
id,
|
||||
anchor,
|
||||
body: String::new(),
|
||||
});
|
||||
state.dirty = true;
|
||||
self.show_review_comment(id)
|
||||
}
|
||||
|
||||
fn show_review_comment(&mut self, id: u64) -> Result<(), String> {
|
||||
let diff = self.workspace_diff().unwrap();
|
||||
let comment = diff
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
.find(|comment| comment.id == id)
|
||||
.ok_or("Comment no longer exists")?;
|
||||
let text = comment.body.clone();
|
||||
let anchor = &comment.anchor;
|
||||
let title = format!(
|
||||
"Comment #{id} · {}:{}–{} · {:?} · :w saves review",
|
||||
anchor.path.display(),
|
||||
anchor.start_line,
|
||||
anchor.end_line,
|
||||
anchor.side
|
||||
);
|
||||
let target = diff.comparison.as_ref().and_then(|comparison| {
|
||||
(comparison.revisions[anchor.side.index()] == anchor.revision)
|
||||
.then(|| {
|
||||
comparison.files.iter().position(|file| {
|
||||
let path = if anchor.side == Side::Before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path)
|
||||
} else {
|
||||
&file.path
|
||||
};
|
||||
path == &anchor.path
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
});
|
||||
let owner = self.diff_owner.unwrap();
|
||||
self.select_pane(owner);
|
||||
let diff = self.diff.as_mut().unwrap();
|
||||
diff.local_review.selected = Some(id);
|
||||
if let Some(index) = target {
|
||||
diff.select_file_index(index);
|
||||
diff.local_review.pending_navigation = Some(id);
|
||||
}
|
||||
self.finish_review_navigation();
|
||||
self.open_review_buffer(ReviewText::Comment(id), &text, title)?;
|
||||
if target.is_none() {
|
||||
self.set_message("Comment is stale; original snapshot anchor preserved");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn finish_review_navigation(&mut self) {
|
||||
let Some(diff) = self.diff.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(id) = diff.local_review.pending_navigation else {
|
||||
return;
|
||||
};
|
||||
if !diff.selected_file_loaded() {
|
||||
return;
|
||||
}
|
||||
diff.local_review.pending_navigation = None;
|
||||
let Some(comment) = diff
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
.find(|comment| comment.id == id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let selected_path = diff
|
||||
.comparison
|
||||
.as_ref()
|
||||
.and_then(|comparison| comparison.files.get(diff.file))
|
||||
.map(|file| {
|
||||
if comment.anchor.side == Side::Before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path)
|
||||
} else {
|
||||
&file.path
|
||||
}
|
||||
});
|
||||
if selected_path != Some(&comment.anchor.path) {
|
||||
return;
|
||||
}
|
||||
if !diff.review_anchor_current(&comment.anchor) {
|
||||
self.set_message("Comment is stale; original snapshot anchor preserved");
|
||||
return;
|
||||
}
|
||||
let row = comment.anchor.start_line - 1;
|
||||
let focus = if comment.anchor.side == Side::Before {
|
||||
Focus::Before
|
||||
} else {
|
||||
Focus::After
|
||||
};
|
||||
diff.focus = focus;
|
||||
diff.reveal_source_row(focus, row);
|
||||
self.bind_diff_buffer();
|
||||
self.place_navigation(TextPosition { row, column: 0 });
|
||||
}
|
||||
|
||||
fn open_review_overview(&mut self) -> Result<(), String> {
|
||||
let diff = self.workspace_diff().unwrap();
|
||||
let state = &diff.local_review;
|
||||
let verdict = match state.document.verdict {
|
||||
Some(Verdict::LooksGood) => "Looks good",
|
||||
Some(Verdict::ChangesRequested) => "Changes requested",
|
||||
None => "Not set",
|
||||
};
|
||||
let mut text = format!(
|
||||
"# Local review\n\nVerdict: {verdict}\nFile: {}\n\n## Summary\n{}\n\nEnter edits the comment under the caret.\n:diff.review.summary · :diff.review.verdict · :diff.review.save\n",
|
||||
state
|
||||
.path
|
||||
.as_ref()
|
||||
.map_or_else(|| "not saved".into(), |path| path.display().to_string()),
|
||||
state.document.summary
|
||||
);
|
||||
for comment in &state.document.comments {
|
||||
let anchor = &comment.anchor;
|
||||
let status = if diff.review_anchor_current(anchor) {
|
||||
"current"
|
||||
} else if diff.comparison.as_ref().is_some_and(|comparison| {
|
||||
comparison.revisions[anchor.side.index()] == anchor.revision
|
||||
&& comparison.files.iter().any(|file| {
|
||||
!file.loaded
|
||||
&& if anchor.side == Side::Before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path) == &anchor.path
|
||||
} else {
|
||||
file.path == anchor.path
|
||||
}
|
||||
})
|
||||
}) {
|
||||
"not checked (file not loaded)"
|
||||
} else {
|
||||
"stale"
|
||||
};
|
||||
use std::fmt::Write as _;
|
||||
write!(
|
||||
text,
|
||||
"\n## Comment #{} · {status}\n{}:{}–{} · {:?}\nRevision: {}\n{}\n",
|
||||
comment.id,
|
||||
anchor.path.display(),
|
||||
anchor.start_line,
|
||||
anchor.end_line,
|
||||
anchor.side,
|
||||
anchor.revision,
|
||||
comment.body
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
self.open_review_buffer(
|
||||
ReviewText::Overview,
|
||||
&text,
|
||||
"Local review overview · Enter edits · :diff.review.next".into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn open_review_buffer(
|
||||
&mut self,
|
||||
kind: ReviewText,
|
||||
text: &str,
|
||||
title: String,
|
||||
) -> Result<(), String> {
|
||||
self.prepare_command();
|
||||
self.finish_command_mode();
|
||||
let existing = std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.find(|buffer| {
|
||||
buffer
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.owner == self.tab_id && binding.kind == kind)
|
||||
})
|
||||
.map(|buffer| buffer.id);
|
||||
let panel = self.panes.iter().find_map(|(id, pane)| {
|
||||
(self.pane_layout.contains(*id)
|
||||
&& self.buffer(pane.buffer_id).is_some_and(|buffer| {
|
||||
buffer
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.owner == self.tab_id)
|
||||
}))
|
||||
.then_some(*id)
|
||||
});
|
||||
if let Some(panel) = panel {
|
||||
self.select_pane(panel);
|
||||
} else {
|
||||
self.select_pane(self.diff_owner.unwrap());
|
||||
self.split_pane(SplitAxis::Vertical);
|
||||
}
|
||||
if let Some(id) = existing {
|
||||
self.switch_buffer(id);
|
||||
if kind == ReviewText::Overview {
|
||||
self.active.lines = split_lines(text);
|
||||
self.active.saved_lines.clone_from(&self.active.lines);
|
||||
self.active.text_revision = self.active.text_revision.wrapping_add(1);
|
||||
self.normalize_targets();
|
||||
}
|
||||
} else {
|
||||
let mut buffer = Buffer::from_text(self.next_buffer_id, None, None, text, None)
|
||||
.map_err(|error| error.to_string())?;
|
||||
buffer.review_buffer = Some(ReviewBuffer {
|
||||
owner: self.tab_id,
|
||||
kind,
|
||||
title,
|
||||
});
|
||||
buffer.resolver_readonly = kind == ReviewText::Overview;
|
||||
self.add_buffer(buffer);
|
||||
}
|
||||
self.set_message("Local review · :w saves JSON; first choose :diff.review.save <path>");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn handle_review_overview(&mut self, input: &Input) -> bool {
|
||||
if *input == Input::Enter
|
||||
&& self.active.view.mode == Mode::Normal
|
||||
&& self.palette.is_none()
|
||||
&& self
|
||||
.active
|
||||
.review_buffer
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.kind == ReviewText::Overview)
|
||||
{
|
||||
if let Err(error) = self.local_review_command(Action::ReviewEdit, "") {
|
||||
self.set_message(error);
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod panes;
|
||||
pub mod plugin;
|
||||
pub mod repository;
|
||||
pub mod resolve;
|
||||
pub mod review;
|
||||
pub mod ruby;
|
||||
pub mod session;
|
||||
mod snippet;
|
||||
|
||||
@@ -82,6 +82,11 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
|
||||
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
|
||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||
Local reviews: :diff.comment at caret; use palette or Visual binding for a selected line range
|
||||
:diff.review.edit/delete [ID], :diff.review.next/previous, :diff.review.overview
|
||||
:diff.review.summary; :diff.review.verdict looks_good|changes_requested|none
|
||||
:diff.review.save PATH / :diff.review.open PATH; :w in review panes saves local JSON
|
||||
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
|
||||
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search
|
||||
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
|
||||
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
|
||||
|
||||
@@ -1058,6 +1058,7 @@ struct PreparedComparison {
|
||||
id: u64,
|
||||
selected: [Revision; 2],
|
||||
selected_ids: [Option<String>; 2],
|
||||
revisions: [String; 2],
|
||||
mode: ComparisonMode,
|
||||
left: Snapshot,
|
||||
right: Snapshot,
|
||||
@@ -2284,6 +2285,34 @@ impl Repository {
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
fn snapshot_revision(
|
||||
&mut self,
|
||||
target: &RevisionTarget,
|
||||
snapshot: &Snapshot,
|
||||
) -> Result<String, String> {
|
||||
if let RevisionTarget::Commit(id) = target {
|
||||
return Ok(id.clone());
|
||||
}
|
||||
self.ensure_private()?;
|
||||
let store = &self.private.as_ref().unwrap().1;
|
||||
let mut input = b"vamp-working-snapshot-v1\0".to_vec();
|
||||
for (path, file) in snapshot {
|
||||
self.check_cancelled()?;
|
||||
for field in [
|
||||
path.as_os_str().as_encoded_bytes(),
|
||||
file.mode.as_bytes(),
|
||||
file.bytes.as_slice(),
|
||||
file.notice.as_deref().unwrap_or("").as_bytes(),
|
||||
] {
|
||||
input.extend_from_slice(&(field.len() as u64).to_be_bytes());
|
||||
input.extend_from_slice(field);
|
||||
}
|
||||
}
|
||||
let mut command = git_command(store);
|
||||
command.args(["hash-object", "--no-filters", "--stdin"]);
|
||||
let id = text(checked(run(command, input, &self.cancelled)?)?)?;
|
||||
Ok(format!("working:{}", id.trim()))
|
||||
}
|
||||
fn snapshot(&mut self, target: &RevisionTarget) -> Result<Snapshot, String> {
|
||||
if *target == RevisionTarget::Working {
|
||||
self.working_snapshot(&[])
|
||||
@@ -2454,10 +2483,17 @@ impl Repository {
|
||||
let (store, id) = pinned.as_ref().unwrap();
|
||||
self.commit_snapshot(store, id)?
|
||||
};
|
||||
let before_revision = self.snapshot_revision(before_target, &left)?;
|
||||
let after_revision = if same {
|
||||
before_revision.clone()
|
||||
} else {
|
||||
self.snapshot_revision(after_target, &right)?
|
||||
};
|
||||
let mut prepared = PreparedComparison {
|
||||
id: comparison_id,
|
||||
selected: [before.clone(), after.clone()],
|
||||
selected_ids,
|
||||
revisions: [before_revision, after_revision],
|
||||
mode,
|
||||
left,
|
||||
right,
|
||||
@@ -2574,9 +2610,11 @@ impl Repository {
|
||||
return Ok(None);
|
||||
}
|
||||
let right = self.working_snapshot(buffers)?;
|
||||
let revision = self.snapshot_revision(&RevisionTarget::Working, &right)?;
|
||||
let mut prepared = self.comparison.take().unwrap();
|
||||
let previous_right = std::mem::replace(&mut prepared.right, right);
|
||||
let previous_files = std::mem::take(&mut prepared.files);
|
||||
let previous_revision = std::mem::replace(&mut prepared.revisions[1], revision);
|
||||
let result = self
|
||||
.build_comparison(&mut prepared, false)
|
||||
.and_then(|comparison| {
|
||||
@@ -2598,6 +2636,7 @@ impl Repository {
|
||||
} else {
|
||||
prepared.right = previous_right;
|
||||
prepared.files = previous_files;
|
||||
prepared.revisions[1] = previous_revision;
|
||||
}
|
||||
self.comparison = Some(prepared);
|
||||
result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison)))
|
||||
@@ -2628,11 +2667,14 @@ impl Repository {
|
||||
let advance_working = working_after
|
||||
&& follow_working
|
||||
&& self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())?;
|
||||
let mut revisions = prepared.revisions.clone();
|
||||
let (left, before_label) = if branch {
|
||||
let base = self.merge_base(ids[0].as_deref().unwrap(), ids[1].as_deref().unwrap())?;
|
||||
let label = format!("merge base of {label} and {after_label} · {base}");
|
||||
revisions[0] = base.clone();
|
||||
(Some(self.snapshot(&RevisionTarget::Commit(base))?), label)
|
||||
} else if changed_before {
|
||||
revisions[0] = ids[0].clone().unwrap();
|
||||
(
|
||||
Some(self.snapshot(&RevisionTarget::Commit(ids[0].clone().unwrap()))?),
|
||||
label,
|
||||
@@ -2647,6 +2689,13 @@ impl Repository {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(right) = &right {
|
||||
revisions[1] = if advance_working {
|
||||
self.snapshot_revision(&RevisionTarget::Working, right)?
|
||||
} else {
|
||||
ids[1].clone().unwrap()
|
||||
};
|
||||
}
|
||||
let pinned_checkout = ids[1]
|
||||
.as_deref()
|
||||
.map(|id| self.pinned_checkout(id))
|
||||
@@ -2658,6 +2707,7 @@ impl Repository {
|
||||
let previous_files = std::mem::take(&mut prepared.files);
|
||||
let previous_before = std::mem::replace(&mut prepared.before, before_label);
|
||||
let previous_editable = prepared.editable_after;
|
||||
let previous_revisions = std::mem::replace(&mut prepared.revisions, revisions);
|
||||
prepared.editable_after =
|
||||
working_after || (pinned_checkout.is_some() && pinned_checkout == anchor);
|
||||
let result = self
|
||||
@@ -2693,6 +2743,7 @@ impl Repository {
|
||||
prepared.files = previous_files;
|
||||
prepared.before = previous_before;
|
||||
prepared.editable_after = previous_editable;
|
||||
prepared.revisions = previous_revisions;
|
||||
}
|
||||
self.comparison = Some(prepared);
|
||||
result
|
||||
@@ -2810,6 +2861,7 @@ impl Repository {
|
||||
BTreeSet::new()
|
||||
};
|
||||
Comparison {
|
||||
revisions: prepared.revisions.clone(),
|
||||
before: prepared.before.clone(),
|
||||
after: prepared.after.clone(),
|
||||
files,
|
||||
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::BTreeSet,
|
||||
fs::{self, OpenOptions},
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
const LIMIT: usize = 64 * 1024 * 1024;
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Side {
|
||||
Before,
|
||||
After,
|
||||
}
|
||||
impl Side {
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Before => 0,
|
||||
Self::After => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Verdict {
|
||||
LooksGood,
|
||||
ChangesRequested,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Anchor {
|
||||
/// Frozen comparison context; `content` additionally captures any live buffer edits.
|
||||
pub revision: String,
|
||||
pub path: PathBuf,
|
||||
pub side: Side,
|
||||
pub start_line: usize,
|
||||
pub end_line: usize,
|
||||
/// Exact whole-side text, including its final newline when present.
|
||||
// ponytail: whole-file text per comment; deduplicate snapshots if the 64 MiB ceiling matters.
|
||||
pub content: String,
|
||||
}
|
||||
impl Anchor {
|
||||
pub fn matches(&self, revision: &str, path: &Path, side: Side, text: &str) -> bool {
|
||||
self.revision == revision && self.path == path && self.side == side && self.content == text
|
||||
}
|
||||
|
||||
pub fn matches_lines(
|
||||
&self,
|
||||
revision: &str,
|
||||
path: &Path,
|
||||
side: Side,
|
||||
lines: &[String],
|
||||
eof: bool,
|
||||
) -> bool {
|
||||
self.revision == revision
|
||||
&& self.path == path
|
||||
&& self.side == side
|
||||
&& self.content.split('\n').eq(lines
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.chain((eof || lines.is_empty()).then_some("")))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Comment {
|
||||
pub id: u64,
|
||||
pub anchor: Anchor,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Review {
|
||||
pub version: u32,
|
||||
pub root: PathBuf,
|
||||
pub summary: String,
|
||||
pub verdict: Option<Verdict>,
|
||||
pub comments: Vec<Comment>,
|
||||
#[serde(skip)]
|
||||
saved: RefCell<Option<(PathBuf, Arc<Vec<u8>>)>>,
|
||||
}
|
||||
impl Default for Review {
|
||||
fn default() -> Self {
|
||||
Self::new(PathBuf::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ReviewState {
|
||||
pub document: Review,
|
||||
pub path: Option<PathBuf>,
|
||||
pub selected: Option<u64>,
|
||||
pub pending_navigation: Option<u64>,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl Review {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
let root = if root.as_os_str().is_empty() {
|
||||
root
|
||||
} else {
|
||||
fs::canonicalize(&root).unwrap_or(root)
|
||||
};
|
||||
Self {
|
||||
version: 1,
|
||||
root,
|
||||
summary: String::new(),
|
||||
verdict: None,
|
||||
comments: Vec::new(),
|
||||
saved: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
if self.version != 1 {
|
||||
return Err(format!("Unsupported review version {}", self.version));
|
||||
}
|
||||
if !self.root.is_absolute() || !self.root.is_dir() {
|
||||
return Err("Review repository root must be an existing absolute directory".into());
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for comment in &self.comments {
|
||||
if comment.id == 0 || !ids.insert(comment.id) {
|
||||
return Err("Review comment IDs must be unique and nonzero".into());
|
||||
}
|
||||
let anchor = &comment.anchor;
|
||||
crate::repository::safe_path(&anchor.path)?;
|
||||
if anchor.path.to_str().is_none_or(|path| path.contains('\0')) {
|
||||
return Err("Review paths must be UTF-8 without NUL bytes".into());
|
||||
}
|
||||
if anchor.revision.is_empty() || anchor.revision.chars().any(char::is_control) {
|
||||
return Err("Review anchors require an immutable revision identity".into());
|
||||
}
|
||||
if anchor.start_line == 0
|
||||
|| anchor.end_line < anchor.start_line
|
||||
|| anchor.end_line > anchor.content.split('\n').count()
|
||||
{
|
||||
return Err(
|
||||
"Review ranges must be inclusive, nonzero and within the anchor text".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(path: &Path, root: &Path) -> Result<Self, String> {
|
||||
let bytes = read_file(path)?.ok_or("Review file does not exist")?;
|
||||
let mut review: Self = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("Invalid review JSON: {error}"))?;
|
||||
review.validate()?;
|
||||
let expected = fs::canonicalize(root).map_err(|error| error.to_string())?;
|
||||
let actual = fs::canonicalize(&review.root).map_err(|error| error.to_string())?;
|
||||
if actual != expected {
|
||||
return Err("Review belongs to a different repository".into());
|
||||
}
|
||||
review.root = actual;
|
||||
*review.saved.borrow_mut() = Some((identity(path)?, Arc::new(bytes)));
|
||||
Ok(review)
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
let bytes = serde_json::to_vec_pretty(self).map_err(|error| error.to_string())?;
|
||||
if bytes.len() > LIMIT {
|
||||
return Err("Review exceeds the 64 MiB limit".into());
|
||||
}
|
||||
let path = identity(path)?;
|
||||
let saved = self.saved.borrow();
|
||||
let expected = saved
|
||||
.as_ref()
|
||||
.filter(|(previous, _)| previous == &path)
|
||||
.map(|(_, bytes)| bytes.as_slice());
|
||||
check_file(&path, expected)?;
|
||||
let parent = path.parent().ok_or("Review path has no parent")?;
|
||||
let (temporary, mut file) = loop {
|
||||
let id = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
|
||||
let temporary = parent.join(format!(".vamp-review-{}-{id}.tmp", std::process::id()));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
match options.open(&temporary) {
|
||||
Ok(file) => break (temporary, file),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
};
|
||||
let result: Result<(), String> = (|| {
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| error.to_string())?;
|
||||
drop(file);
|
||||
check_file(&path, expected)?;
|
||||
if expected.is_some() {
|
||||
// ponytail: optimistic check like editor saves; OS compare-and-swap for hostile writers.
|
||||
fs::rename(&temporary, &path).map_err(|error| error.to_string())?;
|
||||
} else {
|
||||
// Publishing a new file must not replace a concurrently created target.
|
||||
fs::hard_link(&temporary, &path).map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
let _ = fs::remove_file(&temporary);
|
||||
result?;
|
||||
drop(saved);
|
||||
*self.saved.borrow_mut() = Some((path, Arc::new(bytes)));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn identity(path: &Path) -> Result<PathBuf, String> {
|
||||
// Resolve the parent only: never silently follow a target symlink.
|
||||
let parent = path
|
||||
.parent()
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
let name = path.file_name().ok_or("Review path must name a file")?;
|
||||
Ok(fs::canonicalize(parent)
|
||||
.map_err(|error| error.to_string())?
|
||||
.join(name))
|
||||
}
|
||||
|
||||
fn read_file(path: &Path) -> Result<Option<Vec<u8>>, String> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err("Review file must be a regular file, not a symbolic link".into());
|
||||
}
|
||||
if metadata.len() > LIMIT as u64 {
|
||||
return Err("Review exceeds the 64 MiB limit".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
fs::File::open(path)
|
||||
.and_then(|file| file.take(LIMIT as u64 + 1).read_to_end(&mut bytes))
|
||||
.map_err(|error| error.to_string())?;
|
||||
if bytes.len() > LIMIT {
|
||||
return Err("Review exceeds the 64 MiB limit".into());
|
||||
}
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
fn check_file(path: &Path, expected: Option<&[u8]>) -> Result<(), String> {
|
||||
let actual = read_file(path)?;
|
||||
if actual.as_deref() != expected {
|
||||
return Err(
|
||||
"Review file changed outside vamp or already exists; open it before saving".into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Directory(PathBuf);
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_validates_anchors_and_preserves_files_on_failure() {
|
||||
let directory = Directory(std::env::temp_dir().join(format!(
|
||||
"vamp-review-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMP.fetch_add(1, Ordering::Relaxed),
|
||||
)));
|
||||
fs::create_dir(&directory.0).unwrap();
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let path = root.join("review.json");
|
||||
let mut review = Review::new(root.clone());
|
||||
review.summary = "Local review".into();
|
||||
review.verdict = Some(Verdict::ChangesRequested);
|
||||
review.comments.push(Comment {
|
||||
id: 1,
|
||||
anchor: Anchor {
|
||||
revision: "working:immutable".into(),
|
||||
path: "source.rs".into(),
|
||||
side: Side::After,
|
||||
start_line: 1,
|
||||
end_line: 2,
|
||||
content: "first\nlast\n".into(),
|
||||
},
|
||||
body: "Please adjust this".into(),
|
||||
});
|
||||
review.save(&path).unwrap();
|
||||
let mut loaded = Review::load(&path, &root).unwrap();
|
||||
assert_eq!(loaded.comments, review.comments);
|
||||
assert_eq!(loaded.verdict, review.verdict);
|
||||
let anchor = &loaded.comments[0].anchor;
|
||||
assert!(anchor.matches(
|
||||
"working:immutable",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
"first\nlast\n"
|
||||
));
|
||||
assert!(!anchor.matches(
|
||||
"working:other",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
"first\nlast\n"
|
||||
));
|
||||
assert!(anchor.matches_lines(
|
||||
"working:immutable",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&["first".into(), "last".into()],
|
||||
true
|
||||
));
|
||||
assert!(!anchor.matches_lines(
|
||||
"working:immutable",
|
||||
Path::new("source.rs"),
|
||||
Side::After,
|
||||
&["first".into(), "last".into()],
|
||||
false
|
||||
));
|
||||
loaded.summary = "Updated".into();
|
||||
loaded.save(&path).unwrap();
|
||||
assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated");
|
||||
assert!(Review::new(root.clone()).save(&path).is_err());
|
||||
let valid = fs::read(&path).unwrap();
|
||||
for (field, value) in [
|
||||
("version", serde_json::json!(2)),
|
||||
("root", serde_json::json!(root.join("other"))),
|
||||
(
|
||||
"comments",
|
||||
serde_json::json!([loaded.comments[0], loaded.comments[0]]),
|
||||
),
|
||||
] {
|
||||
let mut invalid = serde_json::to_value(&loaded).unwrap();
|
||||
invalid[field] = value;
|
||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(Review::load(&path, &root).is_err());
|
||||
}
|
||||
for (field, value) in [
|
||||
("path", serde_json::json!("../outside")),
|
||||
("path", serde_json::json!("/absolute")),
|
||||
("path", serde_json::json!(".git/config")),
|
||||
("start_line", serde_json::json!(0)),
|
||||
("start_line", serde_json::json!(3)),
|
||||
("end_line", serde_json::json!(4)),
|
||||
("revision", serde_json::json!("")),
|
||||
] {
|
||||
let mut invalid = serde_json::to_value(&loaded).unwrap();
|
||||
invalid["comments"][0]["anchor"][field] = value;
|
||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(Review::load(&path, &root).is_err());
|
||||
}
|
||||
fs::create_dir(root.join("other")).unwrap();
|
||||
fs::write(&path, &valid).unwrap();
|
||||
assert!(Review::load(&path, &root.join("other")).is_err());
|
||||
fs::write(&path, b"external edit").unwrap();
|
||||
assert!(loaded.save(&path).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
||||
assert_eq!(loaded.summary, "Updated");
|
||||
let invalid = root.join("invalid.json");
|
||||
loaded.comments[0].anchor.start_line = 0;
|
||||
assert!(loaded.save(&invalid).is_err());
|
||||
assert!(!invalid.exists());
|
||||
let large = root.join("large.json");
|
||||
fs::File::create(&large)
|
||||
.unwrap()
|
||||
.set_len(LIMIT as u64 + 1)
|
||||
.unwrap();
|
||||
assert!(Review::load(&large, &root).is_err());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = root.join("link.json");
|
||||
std::os::unix::fs::symlink(&path, &link).unwrap();
|
||||
assert!(Review::load(&link, &root).is_err());
|
||||
assert!(review.save(&link).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), b"external edit");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2226,6 +2226,7 @@ mod tests {
|
||||
session.editor.apply_diff_result(
|
||||
id,
|
||||
Ok(DiffOutcome::Comparison(Comparison {
|
||||
revisions: ["base".into(), "after".into()],
|
||||
root: fixture.0.clone(),
|
||||
before: "base".into(),
|
||||
after: "after".into(),
|
||||
@@ -5343,6 +5344,7 @@ end
|
||||
session.editor.apply_diff_result(
|
||||
id,
|
||||
Ok(DiffOutcome::Comparison(Comparison {
|
||||
revisions: ["base".into(), "working:snapshot".into()],
|
||||
root: fixture.0.canonicalize().unwrap(),
|
||||
before: "base".into(),
|
||||
after: "Working".into(),
|
||||
|
||||
+255
@@ -737,6 +737,9 @@ impl BufferViewports {
|
||||
.retain(|id, _| view.has_buffer(*id));
|
||||
let (top, left) = self.offsets.entry(view.buffer_id()).or_default();
|
||||
let syntax = self.syntax.entry(view.buffer_id()).or_default();
|
||||
let leaf = view.review_panel_title().map_or(leaf, |title| {
|
||||
resolver_document_panel(frame, leaf, title, id == focused)
|
||||
});
|
||||
draw_document(
|
||||
frame,
|
||||
view,
|
||||
@@ -4324,6 +4327,22 @@ fn draw_diff_pane(
|
||||
*left = diff.bound_horizontal(focus, *left, width);
|
||||
}
|
||||
let left = *left;
|
||||
let review_comments = diff
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
.filter(|comment| {
|
||||
let before = comment.anchor.side == crate::review::Side::Before;
|
||||
let path = if before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path)
|
||||
} else {
|
||||
&file.path
|
||||
};
|
||||
(unified || before == (focus == Focus::Before))
|
||||
&& comment.anchor.path == *path
|
||||
&& diff.review_anchor_current(&comment.anchor)
|
||||
});
|
||||
for (screen, display_row, segments) in diff_screen_rows(diff, top, content.height, wrapped) {
|
||||
let row = &diff.display_rows[display_row];
|
||||
let row_area = Rect::new(content.x, content.y + screen as u16, content.width, 1);
|
||||
@@ -4495,6 +4514,46 @@ fn draw_diff_pane(
|
||||
paint_trailing_whitespace(frame, &text, text_area, left);
|
||||
}
|
||||
}
|
||||
if gutter > 0 {
|
||||
for comment in review_comments {
|
||||
let side = match comment.anchor.side {
|
||||
crate::review::Side::Before => Focus::Before,
|
||||
crate::review::Side::After => Focus::After,
|
||||
};
|
||||
let selected = diff.local_review.selected == Some(comment.id);
|
||||
for (screen, display, _) in diff_screen_rows(diff, top, content.height, wrapped) {
|
||||
let source = diff.display_source_row(side, display).or_else(|| {
|
||||
let DisplayRow::Line(index) = diff.display_rows[display] else {
|
||||
return None;
|
||||
};
|
||||
let row = &diff.rows[index];
|
||||
(unified && row.kind == RowKind::Equal)
|
||||
.then_some(match side {
|
||||
Focus::Before => row.before,
|
||||
Focus::After => row.after,
|
||||
Focus::Files => None,
|
||||
})
|
||||
.flatten()
|
||||
});
|
||||
if wrapped.is_some_and(|rows| rows[screen].position.continuation != 0)
|
||||
|| !source.is_some_and(|row| {
|
||||
(comment.anchor.start_line..=comment.anchor.end_line).contains(&(row + 1))
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let cell = &mut frame.buffer_mut()
|
||||
[(content.x + gutter as u16 - 1, content.y + screen as u16)];
|
||||
if selected || cell.symbol() != "●" {
|
||||
cell.set_symbol(if selected { "●" } else { "○" })
|
||||
.set_fg(Color::Magenta);
|
||||
if selected {
|
||||
cell.modifier.insert(Modifier::BOLD);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Rect::new(
|
||||
content.x + gutter as u16,
|
||||
content.y,
|
||||
@@ -8872,6 +8931,7 @@ mod tests {
|
||||
editable_paths: Default::default(),
|
||||
before: "old revision".into(),
|
||||
after: "Working checkout".into(),
|
||||
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||
files,
|
||||
})),
|
||||
);
|
||||
@@ -9234,6 +9294,193 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn commented_diff() -> DiffState {
|
||||
use crate::review::{Anchor, Comment, Side};
|
||||
let before = format!("{}\nold second\nkeep", "old ".repeat(24));
|
||||
let after = format!("{}\nnew second\nkeep", "new ".repeat(24));
|
||||
let mut file = syntax_diff_file("new.txt", &before, &after);
|
||||
file.hunks =
|
||||
crate::diff::line_hunks(&file.before, file.before_eof, &file.after, file.after_eof);
|
||||
file.old_path = Some("old.txt".into());
|
||||
let mut diff = diff_comparison(vec![file]);
|
||||
diff.files_visible = false;
|
||||
diff.local_review.document.root = diff.comparison.as_ref().unwrap().root.clone();
|
||||
diff.local_review.document.comments = vec![
|
||||
Comment {
|
||||
id: 1,
|
||||
anchor: Anchor {
|
||||
revision: "before-snapshot".into(),
|
||||
path: "old.txt".into(),
|
||||
side: Side::Before,
|
||||
start_line: 1,
|
||||
end_line: 2,
|
||||
content: before,
|
||||
},
|
||||
body: "Before range".into(),
|
||||
},
|
||||
Comment {
|
||||
id: 2,
|
||||
anchor: Anchor {
|
||||
revision: "after-snapshot".into(),
|
||||
path: "new.txt".into(),
|
||||
side: Side::After,
|
||||
start_line: 1,
|
||||
end_line: 1,
|
||||
content: after,
|
||||
},
|
||||
body: "After line".into(),
|
||||
},
|
||||
];
|
||||
diff.local_review.selected = Some(1);
|
||||
diff
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_review_markers_preserve_diff_signs_ranges_and_wrapped_geometry() {
|
||||
for mode in [ViewMode::Split, ViewMode::Unified] {
|
||||
for wrapping in [false, true] {
|
||||
let mut diff = commented_diff();
|
||||
diff.set_view_mode(mode);
|
||||
diff.top = 0;
|
||||
diff.word_wrap = wrapping;
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(90, 40)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let buffer = terminal.backend().buffer();
|
||||
let markers: Vec<_> = buffer
|
||||
.content
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, cell)| matches!(cell.symbol(), "●" | "○"))
|
||||
.collect();
|
||||
assert_eq!(markers.len(), 3, "{mode:?} {wrapping}");
|
||||
assert_eq!(
|
||||
markers
|
||||
.iter()
|
||||
.filter(|(_, cell)| cell.symbol() == "●")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
for &(offset, cell) in &markers {
|
||||
assert_eq!(cell.fg, Color::Magenta);
|
||||
assert_eq!(cell.modifier.contains(Modifier::BOLD), cell.symbol() == "●");
|
||||
assert_eq!(
|
||||
buffer.content[offset - 1].symbol(),
|
||||
if cell.symbol() == "●" { "-" } else { "+" }
|
||||
);
|
||||
}
|
||||
if wrapping {
|
||||
let rows = &panels.diff_wrap.as_ref().unwrap().rows;
|
||||
assert!(rows.iter().any(|row| row.position.continuation > 0));
|
||||
for (area, _) in panels.diff.as_ref().unwrap().regions.into_iter().skip(1) {
|
||||
let area = diff_inner(area);
|
||||
for (screen, row) in rows.iter().take(area.height as usize).enumerate() {
|
||||
if row.position.continuation > 0 {
|
||||
for x in area.x..area.right() {
|
||||
assert!(!matches!(
|
||||
buffer[(x, area.y + screen as u16)].symbol(),
|
||||
"●" | "○"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_review_markers_reject_stale_identity_text_and_rename_paths() {
|
||||
for mode in [ViewMode::Split, ViewMode::Unified] {
|
||||
for mismatch in ["revision", "content", "path", "side", "root"] {
|
||||
let mut diff = commented_diff();
|
||||
diff.set_view_mode(mode);
|
||||
diff.local_review.document.comments.truncate(1);
|
||||
let anchor = &mut diff.local_review.document.comments[0].anchor;
|
||||
match mismatch {
|
||||
"revision" => anchor.revision.push_str("-newer"),
|
||||
"content" => anchor.content.push('\n'),
|
||||
"path" => anchor.path = "new.txt".into(),
|
||||
"side" => anchor.side = crate::review::Side::After,
|
||||
"root" => diff.local_review.document.root = "another-repository".into(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap();
|
||||
render_diff(&mut terminal, &diff, &mut WheelPanels::default());
|
||||
assert!(
|
||||
terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.all(|cell| !matches!(cell.symbol(), "●" | "○")),
|
||||
"{mode:?} {mismatch}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_context_shows_comments_from_either_snapshot_side() {
|
||||
for side in [crate::review::Side::Before, crate::review::Side::After] {
|
||||
let mut diff = commented_diff();
|
||||
diff.set_view_mode(ViewMode::Unified);
|
||||
diff.local_review
|
||||
.document
|
||||
.comments
|
||||
.retain(|comment| comment.anchor.side == side);
|
||||
let comment = &mut diff.local_review.document.comments[0];
|
||||
comment.anchor.start_line = 3;
|
||||
comment.anchor.end_line = 3;
|
||||
diff.local_review.selected = Some(comment.id);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap();
|
||||
render_diff(&mut terminal, &diff, &mut WheelPanels::default());
|
||||
assert!(
|
||||
screen_text(&terminal).contains("●keep"),
|
||||
"{side:?}: {}",
|
||||
screen_text(&terminal)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_review_markers_yield_to_text_on_narrow_surfaces() {
|
||||
for mode in [ViewMode::Split, ViewMode::Unified] {
|
||||
for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (16, 8), (30, 12)] {
|
||||
let mut diff = commented_diff();
|
||||
diff.set_view_mode(mode);
|
||||
diff.word_wrap = true;
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
for (area, focus) in panels
|
||||
.diff
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flat_map(|geometry| geometry.regions.into_iter().skip(1))
|
||||
{
|
||||
assert!(area.right() <= width && area.bottom() <= height);
|
||||
let content = diff_inner(area);
|
||||
let file = &diff.comparison.as_ref().unwrap().files[0];
|
||||
let gutter = diff_gutter(&diff, file, content.width as usize, focus);
|
||||
for y in content.y..content.bottom() {
|
||||
for x in content.x..content.right() {
|
||||
if matches!(terminal.backend().buffer()[(x, y)].symbol(), "●" | "○")
|
||||
{
|
||||
assert!(gutter > 0);
|
||||
assert_eq!(x, content.x + gutter as u16 - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LIGHT_DIFF_THEME: ThemeColors = ThemeColors {
|
||||
background: [250, 245, 240],
|
||||
red: [210, 30, 50],
|
||||
@@ -10658,6 +10905,7 @@ mod tests {
|
||||
editable_paths: Default::default(),
|
||||
before: "old revision".into(),
|
||||
after: "Working checkout".into(),
|
||||
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||
files: vec![deletion_file()],
|
||||
})),
|
||||
);
|
||||
@@ -11480,6 +11728,7 @@ end
|
||||
editable_paths: Default::default(),
|
||||
before: "baseline".into(),
|
||||
after: "historic".into(),
|
||||
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||
files: vec![file],
|
||||
})),
|
||||
);
|
||||
@@ -11573,6 +11822,7 @@ end
|
||||
editable_paths: ["live.rs".into()].into(),
|
||||
before: "baseline".into(),
|
||||
after: "Working checkout".into(),
|
||||
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||
files: vec![syntax_diff_file("live.rs", "old", "new")],
|
||||
})),
|
||||
);
|
||||
@@ -12208,6 +12458,7 @@ end
|
||||
editable_paths: Default::default(),
|
||||
before: "baseline".into(),
|
||||
after: "Working checkout".into(),
|
||||
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||
files: vec![deletion_file()],
|
||||
})),
|
||||
);
|
||||
@@ -13617,6 +13868,7 @@ end
|
||||
editable_paths: Default::default(),
|
||||
before: "baseline".into(),
|
||||
after: "historic".into(),
|
||||
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||
files: vec![source, unloaded],
|
||||
})),
|
||||
);
|
||||
@@ -13714,6 +13966,7 @@ end
|
||||
editable_paths: Default::default(),
|
||||
before: "baseline".into(),
|
||||
after: "historic".into(),
|
||||
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||
files: vec![file],
|
||||
})),
|
||||
);
|
||||
@@ -14336,6 +14589,7 @@ end
|
||||
},
|
||||
before: "baseline".into(),
|
||||
after: "Working checkout".into(),
|
||||
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||
files: vec![syntax_diff_file(
|
||||
"live.rs",
|
||||
"let value = 1;",
|
||||
@@ -14462,6 +14716,7 @@ end
|
||||
editable_paths: [std::path::PathBuf::from("live.rs")].into(),
|
||||
before: "baseline".into(),
|
||||
after: "Working checkout".into(),
|
||||
revisions: ["before-snapshot".into(), "after-snapshot".into()],
|
||||
files: vec![syntax_diff_file(
|
||||
"live.rs",
|
||||
"let value = \"old\";",
|
||||
|
||||
Reference in New Issue
Block a user