use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::api::model::{NodeId, PatchFile, PrRef, RepoId, ReviewThread, ViewedFiles};
use crate::diff::anchor::{Commentable, Side};
use crate::diff::{FileDiff, Whitespace};
use crate::git::Oid;
use crate::git::repo::{Commit, PrRefs};
use crate::store::ReviewState;
use crate::ui::annotations::Annotation;
use crate::ui::bars::Notice;
use crate::ui::diff_doc::{Doc, DocInputs, Note, Pos, Row, ViewOptions, Viewed};
use crate::ui::file_tree::{TreeRow, row_of_file, tree_rows};
use crate::ui::pages::url as links;
use crate::ui::text::short_sha;
use ratatui::layout::Rect;
use crate::diff_job::{DiffFiles, JobId, JobMsg, next_job};
use crate::join;
use crate::keymap::Action;
use crate::notice::Notices;
use crate::picker;
use crate::review::{annotations, on_submitted};
use crate::state::{Api, Cmd, DiffMsg, Git, Overlay, Problem, State};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum LastReview {
#[default]
Unknown,
None,
At(Oid),
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum DiffOf {
Pr(PrRef),
Commit(RepoId, Oid),
Range(RepoId, Oid, Oid),
}
impl DiffOf {
pub fn pr(&self) -> Option<&PrRef> {
match self {
DiffOf::Pr(pr) => Some(pr),
DiffOf::Commit(..) | DiffOf::Range(..) => None,
}
}
pub fn repo(&self) -> &RepoId {
match self {
DiffOf::Pr(pr) => &pr.repo,
DiffOf::Commit(repo, _) | DiffOf::Range(repo, ..) => repo,
}
}
pub fn url(&self) -> String {
match self {
DiffOf::Pr(pr) => format!("{}/files", pr.url()),
DiffOf::Commit(repo, oid) => format!("{}#files", links::commit(repo, oid)),
DiffOf::Range(repo, from, to) => format!("{}#files", links::compare(repo, from, to)),
}
}
}
impl From<PrRef> for DiffOf {
fn from(pr: PrRef) -> Self {
DiffOf::Pr(pr)
}
}
impl std::fmt::Display for DiffOf {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DiffOf::Pr(pr) => pr.fmt(f),
DiffOf::Commit(repo, oid) => write!(f, "{repo}@{}", short_sha(oid)),
DiffOf::Range(repo, from, to) => {
write!(f, "{repo}@{}...{}", short_sha(from), short_sha(to))
}
}
}
}
pub const SPLIT_MIN_WIDTH: u16 = 160;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
Tree,
Diff,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiffPrefs {
pub tree_visible: bool,
pub split_override: Option<bool>,
pub ignore_whitespace: bool,
}
impl DiffPrefs {
pub fn fit(width: u16) -> Self {
Self {
tree_visible: width >= 100,
split_override: None,
ignore_whitespace: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffScreen {
pub of: DiffOf,
pub cursor: Pos,
pub half: Side,
pub top: Pos,
pub prefs: DiffPrefs,
pub focus: Pane,
pub tree_selected: usize,
pub tree_scroll: usize,
pub search: Option<String>,
pub selection: Option<Pos>,
pub anchor: Option<String>,
}
impl DiffScreen {
pub fn new(of: DiffOf, prefs: DiffPrefs) -> Self {
Self {
of,
cursor: Pos::default(),
half: Side::Right,
top: Pos::default(),
prefs,
focus: Pane::Diff,
tree_selected: 0,
tree_scroll: 0,
search: None,
selection: None,
anchor: None,
}
}
pub fn restart(&mut self) {
*self = Self::new(self.of.clone(), self.prefs);
}
pub fn options(&self, content: Rect) -> ViewOptions {
let lay = layout(content, self.prefs.tree_visible);
ViewOptions {
split: self
.prefs
.split_override
.unwrap_or(lay.diff.width >= SPLIT_MIN_WIDTH),
whitespace: if self.prefs.ignore_whitespace {
Whitespace::Ignore
} else {
Whitespace::Exact
},
wrap: lay.diff.width.saturating_sub(24).clamp(20, 100),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RangeView {
pub label: String,
pub from: String,
pub to: String,
}
#[derive(Debug, Default)]
pub struct DiffInputs {
pub viewed: Option<ViewedFiles>,
pub review: ReviewState,
pub review_loaded: bool,
pub threads: Vec<ReviewThread>,
pub patches: Arc<HashMap<String, Commentable>>,
pub last_review: LastReview,
pub commits: Vec<Commit>,
pub deleted: Option<crate::store::DraftComment>,
}
impl DiffInputs {
pub fn set_viewed(&mut self, path: String, viewed: Viewed) {
if let Some(files) = &mut self.viewed {
files.states.insert(path, viewed);
}
}
pub fn set_resolved(&mut self, id: &NodeId, resolved: bool) {
if let Some(t) = self.threads.iter_mut().find(|t| t.id == *id) {
t.resolved = resolved;
}
}
pub fn merge_saved_review(&mut self, saved: ReviewState) -> bool {
let early = std::mem::replace(&mut self.review, saved);
let changed = early != ReviewState::default();
let review = &mut self.review;
for hunk in early.reviewed_hunks {
if !review.reviewed_hunks.contains(&hunk) {
review.reviewed_hunks.push(hunk);
}
}
review.reviewed_hunks.sort();
for mut draft in early.pending {
draft.id = review.next_draft_id();
review.pending.push(draft);
}
if early.last_reviewed_head.is_some() {
review.last_reviewed_head = early.last_reviewed_head;
}
self.review_loaded = true;
changed
}
}
#[derive(Debug)]
pub struct DiffState {
inputs: DiffInputs,
pub doc: Doc,
pub tree: Vec<TreeRow>,
pub refs: Option<PrRefs>,
pub progress: Option<String>,
pub error: Option<String>,
mapped: HashMap<NodeId, Option<u32>>,
pub(crate) joins: join::Joins,
pub since_requested: bool,
range: Option<RangeView>,
requested: HashSet<usize>,
pub job: JobId,
}
impl DiffState {
pub fn fresh() -> Self {
Self {
inputs: DiffInputs::default(),
doc: Doc::default(),
tree: Vec::new(),
refs: None,
progress: Some("Preparing".into()),
error: None,
mapped: HashMap::new(),
joins: join::Joins::default(),
since_requested: false,
range: None,
requested: HashSet::new(),
job: next_job(),
}
}
pub fn restart(&mut self, range: Option<RangeView>) {
*self = Self {
inputs: std::mem::take(&mut self.inputs),
range,
..Self::fresh()
};
}
pub fn range(&self) -> Option<&RangeView> {
self.range.as_ref()
}
pub fn inputs(&self) -> &DiffInputs {
&self.inputs
}
fn doc_inputs(&self) -> DocInputs {
let i = &self.inputs;
DocInputs {
patches: if self.range.is_none() {
Arc::clone(&i.patches)
} else {
Arc::default()
},
viewed: i.viewed.iter().flat_map(|v| v.states.clone()).collect(),
reviewed: i.review.reviewed_hunks.iter().cloned().collect(),
annotations: annotations(&i.threads, &self.mapped, &i.review.pending),
}
}
pub fn edit<R>(&mut self, f: impl FnOnce(&mut DiffInputs) -> R) -> R {
let r = f(&mut self.inputs);
self.doc.set_inputs(self.doc_inputs());
r
}
pub fn add_mapped(&mut self, mapped: impl IntoIterator<Item = (NodeId, Option<u32>)>) {
self.mapped.extend(mapped);
self.doc.set_inputs(self.doc_inputs());
}
#[must_use]
pub fn edit_review(&mut self, pr: &PrRef, f: impl FnOnce(&mut DiffInputs)) -> Vec<Cmd> {
self.edit(f);
let i = &self.inputs;
let save = || Cmd::SaveReview(pr.clone(), i.review.clone());
i.review_loaded.then(save).into_iter().collect()
}
pub fn listed(&self) -> bool {
self.refs.is_some()
}
pub fn head(&self) -> Option<Oid> {
self.refs.as_ref().map(|r| r.head.clone())
}
pub fn set_files(&mut self, files: DiffFiles) {
self.doc = Doc::new(files.files, &files.generated, self.doc_inputs());
self.tree = tree_rows(&self.doc);
self.refs = Some(files.refs);
self.progress = (!self.doc.is_empty()).then(|| "Computing diffs".into());
}
pub fn set_file(&mut self, index: usize, diff: Arc<FileDiff>) {
self.doc.set_diff(index, diff);
if self.doc.ready_count() == self.doc.files().len() {
self.progress = None;
}
}
pub fn status(&self) -> Option<String> {
let progress = self.progress.as_ref()?;
if self.listed() && !self.doc.is_empty() {
Some(format!(
"Diffing {}/{} files",
self.doc.ready_count(),
self.doc.files().len()
))
} else {
Some(progress.clone())
}
}
}
pub struct Layout {
pub tree: Option<Rect>,
pub diff: Rect,
}
pub fn layout(content: Rect, tree_visible: bool) -> Layout {
if !tree_visible || content.width < 60 {
return Layout {
tree: None,
diff: content,
};
}
let width = (content.width / 4).clamp(24, 40);
Layout {
tree: Some(Rect { width, ..content }),
diff: Rect {
x: content.x + width,
width: content.width - width,
..content
},
}
}
fn tree_list_height(tree: Rect) -> usize {
usize::from(tree.height.saturating_sub(3))
}
pub fn preserving_position<R>(
screen: &mut DiffScreen,
diff: &mut DiffState,
change: impl FnOnce(&mut DiffState) -> R,
) -> R {
let doc = &diff.doc;
let [cursor, top] = [screen.cursor, screen.top].map(|p| doc.anchor(p, screen.half));
let selection = screen.selection.map(|p| doc.anchor(p, screen.half));
let r = change(diff);
let doc = &diff.doc;
screen.half = cursor.side().unwrap_or(screen.half);
screen.cursor = doc.locate(cursor);
screen.top = doc.locate(top);
screen.selection = selection.map(|a| doc.locate(a));
r
}
#[must_use]
pub fn apply(
screen: &mut DiffScreen,
state: &mut DiffState,
action: Action,
content: Rect,
notice: &mut Notices,
) -> Vec<Cmd> {
let lay = layout(content, screen.prefs.tree_visible);
let half = (usize::from(lay.diff.height) / 2).max(1).cast_signed();
let tree_focused = screen.focus == Pane::Tree && lay.tree.is_some();
let action = match action {
Action::NextHunk if screen.search.is_some() => Action::SearchNext,
Action::PrevHunk if screen.search.is_some() => Action::SearchPrev,
action => action,
};
let mut cmds = Vec::new();
match action {
Action::PageDown | Action::PageUp => {
let half = if action == Action::PageDown {
Action::HalfPageDown
} else {
Action::HalfPageUp
};
cmds = apply(screen, state, half, content, notice);
cmds.extend(apply(screen, state, half, content, notice));
}
Action::ToggleTree => {
screen.prefs.tree_visible = !screen.prefs.tree_visible;
if !screen.prefs.tree_visible {
screen.focus = Pane::Diff;
}
}
Action::SwitchPane => {
if lay.tree.is_some() {
screen.focus = match screen.focus {
Pane::Tree => Pane::Diff,
Pane::Diff => Pane::Tree,
};
}
}
Action::ToggleSplit => {
screen.prefs.split_override = Some(!screen.options(content).split);
}
Action::IgnoreWhitespace => {
screen.prefs.ignore_whitespace = !screen.prefs.ignore_whitespace;
notice.info(if screen.prefs.ignore_whitespace {
"Ignoring whitespace changes"
} else {
"Showing whitespace changes"
});
}
Action::Open if tree_focused => screen.focus = Pane::Diff,
Action::Down
| Action::Up
| Action::HalfPageDown
| Action::HalfPageUp
| Action::Top
| Action::Bottom
if tree_focused =>
{
let tree_half = (tree_list_height(lay.tree.unwrap_or_default()) / 2)
.max(1)
.cast_signed();
let delta = match action {
Action::Down => 1,
Action::Up => -1,
Action::HalfPageDown => tree_half,
Action::HalfPageUp => -tree_half,
Action::Top => isize::MIN,
_ => isize::MAX,
};
let last = state.tree.len().saturating_sub(1);
screen.tree_selected = screen.tree_selected.saturating_add_signed(delta).min(last);
if let Some(TreeRow::File { index, .. }) = state.tree.get(screen.tree_selected) {
jump(
screen,
Pos {
file: *index,
row: 0,
},
);
}
}
Action::Down => screen.cursor = state.doc.offset(screen.cursor, 1),
Action::Up => screen.cursor = state.doc.offset(screen.cursor, -1),
Action::HalfPageDown => {
screen.cursor = state.doc.offset(screen.cursor, half);
screen.top = state.doc.offset(screen.top, half);
}
Action::HalfPageUp => {
screen.cursor = state.doc.offset(screen.cursor, -half);
screen.top = state.doc.offset(screen.top, -half);
}
Action::Top => screen.cursor = Pos::default(),
Action::Bottom => screen.cursor = state.doc.last(),
Action::NextHunk
| Action::PrevHunk
| Action::NextFile
| Action::PrevFile
| Action::NextThread
| Action::PrevThread => {
let (doc, at) = (&state.doc, screen.cursor);
let (found, none) = match action {
Action::NextHunk => (doc.next_hunk(at), "No more changes"),
Action::PrevHunk => (doc.prev_hunk(at), "No more changes"),
Action::NextFile => (doc.next_file(at), "No more files"),
Action::PrevFile => (doc.prev_file(at), "No more files"),
Action::NextThread => (doc.next_thread(at), "No more unresolved threads"),
_ => (doc.prev_thread(at), "No more unresolved threads"),
};
match found {
Some(pos) if matches!(action, Action::NextFile | Action::PrevFile) => {
jump(screen, pos);
}
Some(pos) => {
screen.cursor = pos;
face_thread(screen, state);
}
None => notice.info(none),
}
}
Action::VisualLines => {
let (selection, what) = match screen.selection {
Some(_) => (None, "Selection cleared"),
None => (Some(screen.cursor), "Selecting lines"),
};
screen.selection = selection;
notice.info(what);
}
Action::NextUnviewed => match state.doc.next_unviewed(screen.cursor) {
Some(pos) => jump(screen, pos),
None => notice.info("Every file is viewed"),
},
Action::ExpandContext => {
let pos = screen.cursor;
let rows = |doc: &Doc| doc.files().get(pos.file).map(|f| f.rows().len());
let before = rows(&state.doc);
preserving_position(screen, state, |d| d.doc.expand(pos));
if rows(&state.doc) == before {
notice.info("No more context here");
}
}
Action::FullFile => {
let file = screen.cursor.file;
preserving_position(screen, state, |d| d.doc.toggle_full(file));
}
Action::OldSide | Action::NewSide if !state.doc.opts().split => {
notice.info("Sides are split view's · S splits");
}
Action::OldSide => screen.half = Side::Left,
Action::NewSide => screen.half = Side::Right,
Action::JumpMove => match state.doc.move_at(screen.cursor, screen.half) {
Some((mv, from)) => {
if let Some(pos) = state.doc.move_target(mv, from) {
screen.cursor = pos;
screen.half = if from { Side::Right } else { Side::Left };
}
}
None => notice.info("Not on moved code"),
},
Action::Open => match state.doc.row(screen.cursor) {
Some(Row::Fold { .. }) => {
let pos = screen.cursor;
preserving_position(screen, state, |d| d.doc.unfold(pos));
}
Some(Row::Gap { .. }) => {
let pos = screen.cursor;
preserving_position(screen, state, |d| d.doc.expand(pos));
}
Some(Row::Header | Row::Note(Note::Collapsed | Note::Viewed))
if state
.doc
.files()
.get(screen.cursor.file)
.is_some_and(|f| f.collapsed() || f.expanded) =>
{
let file = screen.cursor.file;
state.doc.toggle_expanded(file);
state.requested.remove(&file);
screen.cursor.row = 0;
}
_ => notice.info("Nothing to open here"),
},
Action::ToggleViewed => cmds.extend(toggle_viewed(screen, state, notice)),
Action::MarkReviewed => cmds.extend(toggle_reviewed(screen, state, notice)),
Action::SearchNext | Action::SearchPrev => {
let Some(query) = screen.search.clone() else {
notice.info("No search yet");
return Vec::new();
};
match state
.doc
.search(&query, screen.cursor, action == Action::SearchNext)
{
Some(pos) => screen.cursor = pos,
None => notice.error(format!("No match for “{query}”")),
}
}
_ => notice.info(action.not_here()),
}
cmds
}
fn face_thread(screen: &mut DiffScreen, state: &DiffState) {
let doc = &state.doc;
let line = doc
.annotation_at(screen.cursor)
.and_then(|i| doc.annotations().get(i as usize))
.and_then(Annotation::on_line);
if let Some(line) = line {
screen.half = line.side;
}
}
fn jump(screen: &mut DiffScreen, pos: Pos) {
screen.cursor = pos;
screen.top = pos;
}
pub fn search(screen: &mut DiffScreen, state: &DiffState, query: String) -> Notice {
let notice = match state.doc.search(&query, screen.cursor, true) {
Some(pos) => {
screen.cursor = pos;
let n = state.doc.count_matches(&query);
Notice::Info(format!(
"{n} line{} match “{query}”",
if n == 1 { "" } else { "s" }
))
}
None => Notice::Error(format!("No match for “{query}”")),
};
screen.search = Some(query);
notice
}
#[must_use]
fn toggle_viewed(screen: &mut DiffScreen, state: &mut DiffState, notice: &mut Notices) -> Vec<Cmd> {
let Some(pr) = screen.of.pr().cloned() else {
notice.info("Viewed files are a pull request's");
return Vec::new();
};
let Some(viewed) = &state.inputs().viewed else {
notice.error("Viewed state hasn't loaded from GitHub yet");
return Vec::new();
};
let index = screen.cursor.file;
let Some(file) = state.doc.files().get(index) else {
return Vec::new();
};
let (previous, path) = (file.viewed, file.meta.path().to_owned());
let now = if previous == Viewed::Viewed {
Viewed::Unviewed
} else {
Viewed::Viewed
};
let cmd = Cmd::Api(Api::SetViewed {
pr,
pull_request_id: viewed.pull_request_id.clone(),
path: path.clone(),
viewed: now == Viewed::Viewed,
previous,
});
state.edit(|i| i.set_viewed(path, now));
screen.cursor = Pos {
file: index,
row: 0,
};
vec![cmd]
}
#[must_use]
fn toggle_reviewed(screen: &DiffScreen, state: &mut DiffState, notice: &mut Notices) -> Vec<Cmd> {
let Some(pr) = screen.of.pr().cloned() else {
notice.info("Reviewed marks are a pull request's");
return Vec::new();
};
let Some(block) = state.doc.block_at(screen.cursor) else {
notice.info("Move to a changed line to mark it reviewed");
return Vec::new();
};
let hash = block.hash.clone();
state.edit_review(&pr, |i| {
let hunks = &mut i.review.reviewed_hunks;
if let Some(i) = hunks.iter().position(|h| *h == hash) {
hunks.remove(i);
} else {
hunks.push(hash);
hunks.sort();
}
})
}
pub fn path_hash(path: &str) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(path.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn anchor_pos(anchor: &str, state: &DiffState, pr: bool) -> Option<Option<Pos>> {
let doc = &state.doc;
if let Some(hash) = anchor.strip_prefix("diff-") {
let hash = hash.get(..64).unwrap_or(hash);
let file = doc
.files()
.iter()
.position(|f| path_hash(f.meta.path()) == hash);
return Some(file.map(|file| Pos { file, row: 0 }));
}
let Some(id) = anchor
.strip_prefix("discussion_r")
.or_else(|| anchor.strip_prefix('r'))
.filter(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()))
else {
return Some(None);
};
if !pr {
return Some(None);
}
let threads = &state.inputs().threads;
if threads.is_empty() {
return None;
}
let comment = format!("#discussion_r{id}");
let Some((index, thread)) = threads
.iter()
.enumerate()
.find(|(_, t)| t.comments.iter().any(|c| c.url.ends_with(&comment)))
else {
return Some(None);
};
if let Some(pos) = u32::try_from(index)
.ok()
.and_then(|i| doc.annotation_pos(i))
{
return Some(Some(pos));
}
let Some(file) = doc
.files()
.iter()
.position(|f| f.meta.path() == thread.path)
else {
return Some(None);
};
let diffed = doc.files().get(file).is_some_and(|f| f.diff.is_some());
diffed.then_some(Some(Pos { file, row: 0 }))
}
#[must_use]
pub fn settle(screen: &mut DiffScreen, state: &mut DiffState, content: Rect) -> Vec<Cmd> {
let lay = layout(content, screen.prefs.tree_visible);
if lay.tree.is_none() {
screen.focus = Pane::Diff;
}
let opts = screen.options(content);
if state.doc.opts() != opts {
preserving_position(screen, state, |d| d.doc.set_options(opts));
}
let doc = &state.doc;
if doc.is_empty() {
return Vec::new();
}
if let Some(anchor) = screen.anchor.take() {
match anchor_pos(&anchor, state, screen.of.pr().is_some()) {
Some(Some(pos)) => {
screen.cursor = pos;
face_thread(screen, state);
}
None => screen.anchor = Some(anchor),
Some(None) => {}
}
}
let doc = &state.doc;
screen.cursor = doc.clamp(screen.cursor);
let height = usize::from(lay.diff.height).max(1);
let total = doc.total_rows();
let cursor = doc.to_global(screen.cursor);
let mut top = doc.to_global(screen.top).min(total.saturating_sub(height));
let upper = if screen.cursor.row == 0 {
cursor
} else {
cursor.saturating_sub(1)
};
if top > upper {
top = upper;
}
if cursor >= top + height {
top = cursor + 1 - height;
}
screen.top = doc.to_pos(top);
if screen.focus == Pane::Diff
&& let Some(row) = row_of_file(&state.tree, screen.cursor.file)
{
screen.tree_selected = row;
}
if let Some(tree) = lay.tree {
let rows = tree_list_height(tree).max(1);
if screen.tree_selected < screen.tree_scroll {
screen.tree_scroll = screen.tree_selected;
} else if screen.tree_selected >= screen.tree_scroll + rows {
screen.tree_scroll = screen.tree_selected + 1 - rows;
}
}
let mut wanted = vec![screen.cursor.file];
wanted.extend(doc.loading_in_view(screen.top, height));
let wanted: Vec<usize> = wanted
.into_iter()
.filter(|f| {
doc.files()
.get(*f)
.is_some_and(|file| file.diff.is_none() && !file.collapsed())
&& !state.requested.contains(f)
})
.collect();
if wanted.is_empty() {
return Vec::new();
}
state.requested.extend(wanted.iter().copied());
vec![Cmd::Git(Git::Prioritize(screen.of.clone(), wanted))]
}
#[must_use]
pub(crate) fn on_job(state: &mut State, of: &DiffOf, msg: JobMsg) -> Vec<Cmd> {
match msg {
JobMsg::Progress(line) => {
if let Some(diff) = state.diffs.get_mut(of)
&& !diff.listed()
{
diff.progress = Some(line);
}
}
JobMsg::Files(files) => {
change(state, of, |d| d.set_files(*files));
}
JobMsg::File(index, file) => {
if let Some(diff) = state.diffs.get_mut(of) {
diff.set_file(index, file);
}
}
JobMsg::Moves(moves) => {
change(state, of, |d| d.doc.set_moves(moves));
}
JobMsg::Mapped(mapped) => {
change(state, of, |d| d.add_mapped(mapped));
}
JobMsg::Failed(error) => {
if let Some(diff) = state.diffs.get_mut(of) {
diff.progress = None;
diff.error = Some(error.to_string());
}
}
JobMsg::Since(old_head, Ok(hashes)) => {
if let Some((screen, diff)) = state.diff_parts()
&& screen.of == *of
{
preserving_position(screen, diff, |d| d.doc.set_since(Some(hashes), true));
state.info(format!(
"Showing changes since your review of {}",
short_sha(&old_head)
));
}
}
JobMsg::Since(_, Err(err)) => {
tracing::warn!(%of, ?err, "comparing with the last review failed");
state.error(format!("Couldn't compare with your last review: {err}"));
}
}
Vec::new()
}
fn change<R>(state: &mut State, of: &DiffOf, f: impl FnOnce(&mut DiffState) -> R) -> Option<R> {
match state.diff_parts() {
Some((screen, diff)) if screen.of == *of => Some(preserving_position(screen, diff, f)),
_ => state.diffs.get_mut(of).map(f),
}
}
fn edit(state: &mut State, of: &DiffOf, f: impl FnOnce(&mut DiffInputs)) {
change(state, of, |d| d.edit(f));
}
#[must_use]
pub(crate) fn update(state: &mut State, of: &DiffOf, msg: DiffMsg) -> Vec<Cmd> {
if let DiffMsg::Job(job, msg) = msg {
if state.diffs.get(of).is_some_and(|d| d.job == job) {
return on_job(state, of, msg);
}
return Vec::new();
}
let DiffOf::Pr(pr) = of.clone() else {
return Vec::new();
};
match msg {
DiffMsg::LastReview(result) => {
if let Some(diff) = state.diffs.get_mut(of) {
let last = match result {
Ok(None) => LastReview::None,
Ok(Some(commit)) => Oid::parse(&commit)
.map_or_else(|e| LastReview::Failed(e.to_string()), LastReview::At),
Err(err) => LastReview::Failed(err.to_string()),
};
diff.edit(|i| i.last_review = last);
}
}
DiffMsg::CommitsListed(Ok(commits)) => {
edit(state, of, |i| i.commits = commits);
state.notices.dismiss();
return state.open_picker(picker::Kind::Commits { mark: None });
}
DiffMsg::CommitsListed(Err(err)) => {
tracing::warn!(%pr, ?err, "listing commits failed");
state.error(format!("Couldn't list commits: {err}"));
}
DiffMsg::ViewedLoaded(result) => match *result {
Ok(viewed) => {
edit(state, of, |i| i.viewed = Some(viewed));
}
Err(err) => {
tracing::warn!(%pr, %err, "viewed state fetch failed");
state.error(format!("Couldn't load viewed files: {err}"));
}
},
DiffMsg::ViewedSaved {
path,
previous,
result,
} => {
if let Err(err) = result {
tracing::warn!(%pr, %err, "viewed state update failed");
edit(state, of, |i| i.set_viewed(path, previous));
state.error(format!("GitHub didn't save “viewed”: {err}"));
}
}
DiffMsg::ReviewLoaded(Ok(saved)) if !state.diffs.contains_key(of) => {
if let Some(Overlay::Submit(dialog)) = &mut state.overlay
&& dialog.pr == pr
{
dialog.saved = Some(saved);
}
}
DiffMsg::ReviewLoaded(Ok(saved)) => {
let mut changed = false;
let save = change(state, of, |d| {
d.edit_review(&pr, |i| changed = i.merge_saved_review(saved))
});
return save.filter(|_| changed).unwrap_or_default();
}
DiffMsg::ReviewLoaded(Err(err)) => {
tracing::warn!(%pr, ?err, "reading review state failed");
state.problems.insert(
Problem::Drafts,
format!("Drafts for {pr} are not being saved: {err}"),
);
}
DiffMsg::ThreadsLoaded(Ok(threads)) => {
edit(state, of, |i| i.threads = threads);
}
DiffMsg::ThreadsLoaded(Err(err)) => {
tracing::warn!(%pr, %err, "review threads fetch failed");
state.error(format!("Couldn't load review threads: {err}"));
}
DiffMsg::PatchesLoaded(Ok(patches)) => {
let commentable = |f: PatchFile| Some((f.filename, Commentable::from_patch(&f.patch?)));
let patches = Arc::new(patches.into_iter().filter_map(commentable).collect());
edit(state, of, |i| i.patches = patches);
}
DiffMsg::PatchesLoaded(Err(err)) => {
tracing::warn!(%pr, %err, "GitHub patches unavailable; using local hunks");
}
DiffMsg::ResolvedSet {
thread_id,
resolved,
result: Err(err),
} => {
edit(state, of, |i| i.set_resolved(&thread_id, !resolved));
state.error(format!("GitHub didn't save that: {err}"));
}
DiffMsg::ResolvedSet { .. } | DiffMsg::Job(..) => {}
DiffMsg::ReviewSubmitted(outcome) => return on_submitted(state, &pr, &outcome),
DiffMsg::PendingReview(result) => match (result, &mut state.overlay) {
(Ok(n), Some(Overlay::Submit(dialog))) if dialog.pr == pr => {
dialog.on_github = Some(n);
}
(Ok(_), _) => {}
(Err(err), _) => tracing::warn!(%pr, %err, "couldn't read the pending review"),
},
}
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
impl DiffState {
pub(crate) fn mapped(&self) -> &HashMap<NodeId, Option<u32>> {
&self.mapped
}
pub(crate) fn with_doc(mut self, refs: PrRefs, doc: &Doc) -> Self {
let files = doc.files().iter().map(|f| f.meta.clone()).collect();
let generated = HashSet::new();
self.set_files(DiffFiles {
refs,
files,
generated,
});
for (i, f) in doc.files().iter().enumerate() {
if let Some(diff) = &f.diff {
self.doc.set_diff(i, Arc::clone(diff));
}
}
self.doc.set_moves(doc.moves().to_vec());
self
}
}
}