feat: add local diff review workflow
This commit is contained in:
+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