mod projection;
use projection::PreparedSnapshot;
use std::sync::Arc;
mod render;
#[cfg(test)]
mod tests;
pub(crate) mod worker;
use super::state::{MissionControlState, RightColumnTab, TuiFocusPane};
use crate::diff_review::{CommentChange, ReviewAnchor, ReviewComment, ReviewSide};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
pub(crate) use render::{draw, draw_dialog};
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum DiffCommand {
Key(KeyEvent),
Paste(String),
Mouse(MouseEvent, Rect),
}
#[derive(Default)]
pub(crate) struct DiffPage {
pub(crate) snapshot: Option<Arc<PreparedSnapshot>>,
pub(crate) selected: usize,
tree_row: usize,
clicked_source: Option<(usize, usize)>,
pub(crate) original_row: usize,
pub(crate) diff_row: usize,
viewport: DiffViewport,
original_anchors: Vec<usize>,
pub(crate) focus: usize,
pub(crate) dialog: Option<CommentDialog>,
pub(crate) error: Option<String>,
pub(crate) saving: bool,
pub(crate) pending: Option<CommentChange>,
}
#[derive(Default)]
struct DiffViewport {
tree: usize,
original: usize,
diff: usize,
reveal: usize,
}
impl std::fmt::Debug for DiffPage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DiffPage")
.field("selected", &self.selected)
.field("saving", &self.saving)
.finish_non_exhaustive()
}
}
#[derive(Debug)]
pub(crate) struct CommentDialog {
id: Option<String>,
path: String,
side: ReviewSide,
line: usize,
anchor: Option<ReviewAnchor>,
editor: super::prompt_editor::PromptEditor,
resolved: bool,
stale: bool,
}
impl CommentDialog {
fn from_comment(comment: &ReviewComment) -> Self {
Self {
id: Some(comment.id.clone()),
path: comment.path.clone(),
side: comment.side,
line: comment.line,
anchor: None,
editor: super::prompt_editor::PromptEditor::new(&comment.text, comment.text.len()),
resolved: comment.resolved,
stale: comment.stale,
}
}
}
impl DiffPage {
pub(crate) fn apply_snapshot(&mut self, snapshot: Arc<PreparedSnapshot>) {
self.clicked_source = None;
let tree_identity = self
.snapshot
.as_ref()
.and_then(|s| s.tree.get(self.tree_row))
.map(|entry| entry.identity.as_str());
let selected_path = self
.snapshot
.as_ref()
.and_then(|s| s.files.get(self.selected))
.map(|f| f.path.as_str());
self.selected = selected_path
.and_then(|path| snapshot.files.iter().position(|file| file.path == path))
.unwrap_or(0);
self.original_row = self
.original_row
.min(snapshot.files.get(self.selected).map_or(0, |_| {
snapshot.files_display[self.selected]
.original
.len()
.saturating_sub(1)
}));
self.diff_row = self.diff_row.min(
snapshot
.files
.get(self.selected)
.map_or(0, |file| file.rows.len().saturating_sub(1)),
);
self.tree_row = tree_identity
.and_then(|identity| {
snapshot
.tree
.iter()
.position(|entry| entry.identity == identity)
})
.or_else(|| {
snapshot
.tree
.iter()
.position(|entry| entry.file == Some(self.selected) && entry.comment.is_none())
})
.unwrap_or(0);
self.snapshot = Some(snapshot);
self.rebuild_scroll_mapping();
self.viewport.tree = self
.viewport
.tree
.min(self.tree_entries().len().saturating_sub(1));
self.viewport.original = self
.viewport
.original
.min(self.row_count(2).saturating_sub(1));
self.viewport.diff = self.viewport.diff.min(self.row_count(3).saturating_sub(1));
}
fn tree_entries(&self) -> &[projection::TreeEntry] {
self.snapshot
.as_ref()
.map_or(&[], |snapshot| &snapshot.tree)
}
fn open_line(&mut self, original: bool) {
let Some(snapshot) = &self.snapshot else {
return;
};
let Some(file) = snapshot.files.get(self.selected) else {
return;
};
let (side, line) = if original {
if self.original_row >= file.original.lines().count() {
return;
}
(ReviewSide::Original, self.original_row + 1)
} else {
let Some(row) = file.rows.get(self.diff_row) else {
return;
};
if let Some(line) = row.new_line {
(ReviewSide::Changed, line)
} else if let Some(line) = row.old_line {
(ReviewSide::Original, line)
} else {
return;
}
};
self.dialog = Some(
if let Some(comment) = snapshot.comments.iter().find(|comment| {
comment.path == file.path && comment.side == side && comment.line == line
}) {
CommentDialog::from_comment(comment)
} else {
CommentDialog {
id: None,
path: file.path.clone(),
side,
line,
anchor: ReviewAnchor::capture(file, side, line),
editor: super::prompt_editor::PromptEditor::new("", 0),
resolved: false,
stale: false,
}
},
);
}
fn select_tree(&mut self, open: bool) {
self.clicked_source = None;
let Some(entry) = self.tree_entries().get(self.tree_row) else {
return;
};
let Some(file) = entry.file else {
return;
};
let comment = entry.comment;
if self.selected != file {
self.selected = file;
self.original_row = 0;
self.diff_row = 0;
self.viewport.original = 0;
self.viewport.diff = 0;
self.viewport.reveal = 0;
self.rebuild_scroll_mapping();
}
if open && let Some(index) = comment {
self.dialog = self
.snapshot
.as_ref()
.and_then(|s| s.comments.get(index))
.map(CommentDialog::from_comment);
}
}
fn save_comment(&mut self) {
let Some(dialog) = &self.dialog else {
return;
};
self.pending = match dialog.editor.text().trim().is_empty() {
false => Some(CommentChange::Save {
id: dialog.id.clone(),
path: dialog.path.clone(),
side: dialog.side,
line: dialog.line,
anchor: dialog.anchor.clone(),
text: dialog.editor.text(),
}),
true => None,
};
self.saving = self.pending.is_some();
if !self.saving {
self.error = Some("Enter a comment before saving".into());
}
}
fn dialog_key(&mut self, key: KeyEvent) {
if self.saving {
return;
}
let Some(dialog) = &mut self.dialog else {
return;
};
match key.code {
KeyCode::Esc => self.dialog = None,
KeyCode::Char('s' | 'S') if key.modifiers == KeyModifiers::ALT => {
self.save_comment();
}
KeyCode::Char('r' | 'R') if key.modifiers == KeyModifiers::ALT => {
self.pending = dialog.id.clone().map(|id| CommentChange::Resolve {
id,
resolved: !dialog.resolved,
});
self.saving = self.pending.is_some();
}
KeyCode::Char('d' | 'D') if key.modifiers == KeyModifiers::ALT => {
self.pending = dialog.id.clone().map(CommentChange::Delete);
self.saving = self.pending.is_some();
if !self.saving {
self.dialog = None;
}
}
KeyCode::Enter => insert_comment_text(&mut dialog.editor, "\n"),
KeyCode::Backspace => {
dialog.editor.backspace(10, 70);
}
KeyCode::Delete => {
dialog.editor.delete_forward(10, 70);
}
KeyCode::Left => {
dialog.editor.move_left(10, 70);
}
KeyCode::Right => {
dialog.editor.move_right(10, 70);
}
KeyCode::Up => {
dialog.editor.move_up(10, 70);
}
KeyCode::Down => {
dialog.editor.move_down(10, 70);
}
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
insert_comment_text(&mut dialog.editor, &character.to_string())
}
_ => {}
}
}
fn navigate(&mut self, down: bool) {
self.clicked_source = None;
if self.viewport.reveal != 0 {
let [tree, original, diff] = self.viewport_offsets(usize::MAX);
self.viewport.tree = tree;
self.viewport.original = original;
self.viewport.diff = diff;
}
let (row, count) = match self.focus {
1 => {
let count = self.tree_entries().len();
(&mut self.tree_row, count)
}
2 => (
&mut self.original_row,
self.snapshot
.as_ref()
.and_then(|s| s.files.get(self.selected))
.map_or(0, |_| {
self.snapshot.as_ref().unwrap().files_display[self.selected]
.original
.len()
}),
),
_ => (
&mut self.diff_row,
self.snapshot
.as_ref()
.and_then(|s| s.files.get(self.selected))
.map_or(0, |file| file.rows.len()),
),
};
*row = if down {
row.saturating_add(1).min(count.saturating_sub(1))
} else {
row.saturating_sub(1)
};
if self.focus == 1 {
self.select_tree(false);
}
self.viewport.reveal = self.focus;
}
fn row_count(&self, pane: usize) -> usize {
if pane == 1 {
return self.tree_entries().len();
}
let Some(snapshot) = &self.snapshot else {
return 0;
};
if pane == 2 {
snapshot
.files_display
.get(self.selected)
.map_or(0, |file| file.original.len())
} else {
snapshot
.files
.get(self.selected)
.map_or(0, |file| file.rows.len())
}
}
fn rebuild_scroll_mapping(&mut self) {
self.original_anchors.clear();
let Some(file) = self
.snapshot
.as_ref()
.and_then(|s| s.files.get(self.selected))
else {
return;
};
let mut anchor = self.row_count(2).saturating_sub(1);
self.original_anchors.resize(file.rows.len(), anchor);
for (index, row) in file.rows.iter().enumerate().rev() {
if let Some(line) = row.old_line {
anchor = line.saturating_sub(1);
}
self.original_anchors[index] = anchor;
}
}
fn synchronized_offsets(&self, pane: usize, offset: usize) -> (usize, usize) {
if pane == 2 {
let diff = self
.original_anchors
.partition_point(|line| *line < offset)
.min(self.row_count(3).saturating_sub(1));
(offset, diff)
} else {
(
self.original_anchors.get(offset).copied().unwrap_or(0),
offset,
)
}
}
fn viewport_offsets(&self, visible: usize) -> [usize; 3] {
let mut offsets = [
self.viewport.tree,
self.viewport.original,
self.viewport.diff,
];
let pane = self.viewport.reveal;
if (1..=3).contains(&pane) {
let selected = [self.tree_row, self.original_row, self.diff_row][pane - 1];
let offset = offsets[pane - 1];
let revealed = if selected < offset {
selected
} else {
offset.max(selected.saturating_sub(visible.max(1) - 1))
};
if pane == 1 {
offsets[0] = revealed;
} else {
(offsets[1], offsets[2]) = self.synchronized_offsets(pane, revealed);
}
}
for (index, offset) in offsets.iter_mut().enumerate() {
*offset = (*offset).min(self.row_count(index + 1).saturating_sub(1));
}
offsets
}
fn scroll_viewport(&mut self, pane: usize, down: bool, visible: usize) {
let [tree, original, diff] = self.viewport_offsets(visible);
self.viewport = DiffViewport {
tree,
original,
diff,
reveal: 0,
};
let offset = [tree, original, diff][pane - 1];
let offset = if down {
offset
.saturating_add(1)
.min(self.row_count(pane).saturating_sub(1))
} else {
offset.saturating_sub(1)
};
if pane == 1 {
self.viewport.tree = offset;
} else {
(self.viewport.original, self.viewport.diff) = self.synchronized_offsets(pane, offset);
}
}
}
fn insert_comment_text(editor: &mut super::prompt_editor::PromptEditor, input: &str) {
let mut length = editor.text().len();
for character in input.chars() {
if length + character.len_utf8() > 16_384 {
break;
}
if !character.is_control() || character == '\n' || character == '\t' {
editor.insert_char(character, 10, 70);
length += character.len_utf8();
}
}
}
pub(crate) fn owns_key(state: &MissionControlState, key: KeyEvent) -> bool {
state.top_modal().is_none()
&& (state.diff.dialog.is_some()
|| (state.right_column_tab == RightColumnTab::Diff
&& !(key.modifiers == KeyModifiers::ALT
&& matches!(key.code, KeyCode::Char('1' | '2' | '3')))
&& (matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
|| !state.is_prompt_focused())))
}
pub(crate) fn apply(state: &mut MissionControlState, command: DiffCommand) {
if state.top_modal().is_some() {
return;
}
match command {
DiffCommand::Paste(text) => {
if !state.diff.saving
&& let Some(dialog) = &mut state.diff.dialog
{
insert_comment_text(&mut dialog.editor, &text);
}
}
DiffCommand::Key(key) if state.diff.dialog.is_some() => state.diff.dialog_key(key),
DiffCommand::Key(key) => match key.code {
KeyCode::Tab | KeyCode::BackTab => {
state.diff.clicked_source = None;
if state.is_prompt_focused() {
state.diff.focus = 0;
}
state.diff.focus =
(state.diff.focus + if key.code == KeyCode::Tab { 1 } else { 3 }) % 4;
if state.diff.focus == 0 {
state.focus_prompt();
} else {
state.focus_pane = TuiFocusPane::Transcript;
state.prompt_editor.set_cursor_visible(false);
}
}
KeyCode::Up | KeyCode::Down => state.diff.navigate(key.code == KeyCode::Down),
KeyCode::Enter => match state.diff.focus {
1 => state.diff.select_tree(true),
2 => state.diff.open_line(true),
3 => state.diff.open_line(false),
_ => {}
},
KeyCode::Esc => {
state.diff.focus = 0;
state.focus_prompt();
}
_ => {}
},
DiffCommand::Mouse(mouse, area) => mouse_input(state, mouse, area),
}
}
fn mouse_input(state: &mut MissionControlState, mouse: MouseEvent, area: Rect) {
if state.diff.dialog.is_some() {
return;
}
let columns = render::columns(super::layout::layout_for(area, state).transcript);
for (index, column) in columns.iter().enumerate() {
if !column.contains((mouse.column, mouse.row).into()) {
continue;
}
let visible = column.height.saturating_sub(2).max(1) as usize;
match mouse.kind {
MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => state.diff.scroll_viewport(
index + 1,
mouse.kind == MouseEventKind::ScrollDown,
visible,
),
MouseEventKind::Down(MouseButton::Left) => {
state.diff.focus = index + 1;
state.focus_pane = TuiFocusPane::Transcript;
state.prompt_editor.set_cursor_visible(false);
if !render::content_area(*column).contains((mouse.column, mouse.row).into()) {
return;
}
let offsets = state.diff.viewport_offsets(visible);
let row = offsets[index] + mouse.row.saturating_sub(column.y + 1) as usize;
if row >= state.diff.row_count(index + 1) {
return;
}
state.diff.viewport = DiffViewport {
tree: offsets[0],
original: offsets[1],
diff: offsets[2],
reveal: 0,
};
let clicked = (index + 1, row);
let open = state.diff.clicked_source == Some(clicked);
state.diff.clicked_source = (index > 0).then_some(clicked);
match index {
0 => {
state.diff.tree_row = row;
state.diff.select_tree(true);
}
1 => {
state.diff.original_row = row;
if open {
state.diff.open_line(true);
}
}
_ => {
state.diff.diff_row = row;
if open {
state.diff.open_line(false);
}
}
}
}
_ => {}
}
break;
}
}
#[cfg(test)]
mod draft_anchor_tests {
use super::*;
use crate::diff_review::{ReviewFile, ReviewSnapshot};
fn prepared(original: &str) -> Arc<PreparedSnapshot> {
Arc::new(PreparedSnapshot::new(
ReviewSnapshot {
root: "/review".into(),
files: vec![ReviewFile {
path: "file.txt".into(),
original: original.into(),
current: String::new(),
rows: vec![],
notice: None,
}],
comments: vec![],
},
None,
))
}
#[test]
fn refresh_while_dialog_is_open_does_not_replace_the_draft_anchor() {
let mut page = DiffPage::default();
page.apply_snapshot(prepared("before\nselected\nafter\n"));
page.original_row = 1;
page.open_line(true);
page.apply_snapshot(prepared("before\nreplacement\nafter\n"));
let dialog = page.dialog.as_mut().unwrap();
dialog.editor = super::super::prompt_editor::PromptEditor::new("Review", 6);
page.save_comment();
let Some(CommentChange::Save {
anchor: Some(anchor),
line,
..
}) = page.pending
else {
panic!("missing saved snapshot anchor");
};
assert_eq!(line, 2);
assert_eq!(anchor.text, "selected");
assert_eq!(anchor.before.as_deref(), Some("before"));
assert_eq!(anchor.after.as_deref(), Some("after"));
}
}