use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::sync::Arc;
use crate::diff::anchor::{Commentable, LinePos, RangeSource, Side};
use crate::diff::blocks::ChangeBlock;
use crate::diff::moves::Move;
use crate::diff::{
CONTEXT, Content, DiffLine, FileDiff, TextDiff, Whitespace, counts, hunks, segments_by,
};
use crate::git::files::{ChangedFile, is_lockfile};
pub use crate::api::model::ViewedState as Viewed;
use crate::ui::annotations::{Annotation, AnnotationKey, Place, ThreadRow, ThreadRowKind};
use crate::ui::comment::{self, CommentLine};
use crate::ui::page::{Role, Seg};
use crate::ui::{idx, page};
const DEFAULT_WRAP: u16 = 72;
const EXPAND_STEP: u32 = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Note {
Loading,
Collapsed,
Viewed,
Binary,
TooLarge,
Submodule,
NoChanges,
NothingNew,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FoldReason {
Formatting,
Seen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Row {
Header,
Note(Note),
Gap {
start: u32,
end: u32,
},
Hunk {
seg: u32,
},
Line(u32),
Split {
left: Option<u32>,
right: Option<u32>,
},
NoNewline,
Thread(u32),
Fold {
block: u32,
reason: FoldReason,
},
Moved {
mv: u32,
from: bool,
},
Spacer,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HunkHeader {
pub range: String,
pub scope: Vec<String>,
}
fn new_line_near(lines: &[DiffLine], e: usize) -> Option<u32> {
let (before, after) = lines
.split_at_checked(e.saturating_add(1))
.unwrap_or((lines, &[]));
before
.iter()
.rev()
.find_map(|l| l.right())
.or_else(|| after.iter().find_map(|l| l.right()))
.map(|p| p.line)
}
fn split_half(left: Option<u32>, right: Option<u32>, half: Side) -> Option<(Side, u32)> {
let left = left.map(|e| (Side::Left, e));
let right = right.map(|e| (Side::Right, e));
match half {
Side::Left => left.or(right),
Side::Right => right.or(left),
}
}
fn by_path(all: &[Annotation]) -> HashMap<&str, Vec<(u32, &Annotation)>> {
let mut by_path: HashMap<&str, Vec<_>> = HashMap::new();
for (i, a) in all.iter().enumerate() {
by_path
.entry(a.path.as_str())
.or_default()
.push((idx(i), a));
}
by_path
}
impl Row {
pub fn entries(self) -> impl Iterator<Item = u32> {
let (a, b) = match self {
Row::Line(e) => (Some(e), None),
Row::Split { left, right } => (left, right),
_ => (None, None),
};
a.into_iter().chain(b)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ViewOptions {
pub split: bool,
pub whitespace: Whitespace,
pub structural: bool,
pub wrap: u16,
}
struct Extras<'a> {
anns: &'a [(u32, &'a Annotation)],
open: &'a dyn Fn(&Annotation) -> bool,
since: Option<&'a HashSet<String>>,
moves: &'a [(u32, Range<u32>, bool)],
}
#[derive(Debug, Clone)]
pub struct DocFile {
pub meta: ChangedFile,
pub generated: bool,
pub viewed: Viewed,
pub open: Option<bool>,
pub diff: Option<Arc<FileDiff>>,
pub full: bool,
pub windows: Vec<Range<u32>>,
rows: Vec<Row>,
headers: Vec<HunkHeader>,
blocks: Vec<ChangeBlock>,
pub unfolded: HashSet<u32>,
whitespace: Whitespace,
structural: bool,
thread_rows: Vec<ThreadRow>,
by_line: HashMap<LinePos, Vec<u32>>,
local_commentable: Option<Commentable>,
moves: Vec<(u32, Range<u32>, bool)>,
}
impl DocFile {
pub fn rows(&self) -> &[Row] {
&self.rows
}
pub fn headers(&self) -> &[HunkHeader] {
&self.headers
}
pub fn blocks(&self) -> &[ChangeBlock] {
&self.blocks
}
pub fn collapsed(&self) -> bool {
!self
.open
.unwrap_or(!self.generated && self.viewed != Viewed::Viewed)
}
pub fn text(&self) -> Option<&TextDiff> {
match self.diff.as_deref().map(|d| &d.content) {
Some(Content::Text(text)) => Some(text),
_ => None,
}
}
pub fn alignment(&self) -> Option<&crate::diff::Alignment> {
self.text()
.map(|t| t.view(self.whitespace, self.structural))
}
pub fn block_of(&self, entry: u32) -> Option<&ChangeBlock> {
self.blocks.iter().find(|b| b.entries.contains(&entry))
}
pub fn thread_row(&self, i: u32) -> Option<&ThreadRow> {
self.thread_rows.get(i as usize)
}
pub fn annotations_at(&self, pos: LinePos) -> &[u32] {
self.by_line.get(&pos).map_or(&[], Vec::as_slice)
}
fn push_annotation(&mut self, index: u32, ann: &Annotation, open: bool, wrap: usize) {
if !open {
let first = ann
.comments
.first()
.map(|c| {
format!(
"{}: {}",
c.author,
c.body.lines().next().unwrap_or_default()
)
})
.unwrap_or_default();
self.push_thread_rows(index, ThreadRowKind::Summary, &first, usize::MAX);
return;
}
let original = self.suggested_on(ann);
for (c, comment) in ann.comments.iter().enumerate() {
let head = ThreadRowKind::Head {
comment: c,
first: c == 0,
};
self.push_thread_rows(index, head, &comment.author, usize::MAX);
for line in comment::lines(&comment.body, wrap, original.as_deref()) {
self.push_thread_row(index, ThreadRowKind::Body, line);
}
let reactions = (comment.editable.iter()).flat_map(|e| &e.reactions);
let reactions: Vec<String> = reactions
.map(|r| format!("{} {}", r.content.emoji(), r.count))
.collect();
if !reactions.is_empty() {
let kind = ThreadRowKind::Reactions { comment: c };
self.push_thread_rows(index, kind, &reactions.join(" "), usize::MAX);
}
if c == 0 && ann.left_out > 0 {
let gap =
crate::ui::pages::left_out_text(ann.left_out, "more reply", "more replies");
self.push_thread_rows(index, ThreadRowKind::Gap, &gap, usize::MAX);
}
}
if let Some(error) = &ann.error {
let error = format!("GitHub rejected this: {error}");
self.push_thread_rows(index, ThreadRowKind::Error, &error, wrap);
}
self.push_thread_rows(index, ThreadRowKind::Footer, "", usize::MAX);
}
fn suggested_on(&self, ann: &Annotation) -> Option<Vec<String>> {
let Place::Line { at, start } = ann.place else {
return None;
};
let suggests = (ann.comments.iter()).any(|c| c.body.contains("```suggestion"));
if at.side != Side::Right || !suggests {
return None;
}
let text = self.text()?;
let lines = start.unwrap_or(at.line).min(at.line)..=at.line;
Some(
lines
.map(|line| text.line(LinePos { line, ..at }).to_owned())
.collect(),
)
}
fn push_thread_rows(&mut self, ann: u32, kind: ThreadRowKind, text: &str, wrap: usize) {
for text in page::wrap(text, wrap) {
let line = CommentLine {
segs: vec![Seg::new(text, Role::Body)],
..CommentLine::default()
};
self.push_thread_row(ann, kind, line);
}
}
fn renumber(&mut self, was: &[(u32, &Annotation)], is: &[(u32, &Annotation)]) {
let moved: HashMap<u32, u32> = was.iter().zip(is).map(|(w, i)| (w.0, i.0)).collect();
let renumber = |ann: &mut u32| *ann = moved.get(ann).copied().unwrap_or(*ann);
self.thread_rows
.iter_mut()
.for_each(|r| renumber(&mut r.ann));
self.by_line.values_mut().flatten().for_each(renumber);
}
fn push_thread_row(&mut self, ann: u32, kind: ThreadRowKind, line: CommentLine) {
self.rows.push(Row::Thread(idx(self.thread_rows.len())));
self.thread_rows.push(ThreadRow { ann, kind, line });
}
fn rebuild(&mut self, opts: ViewOptions, extras: &Extras<'_>) {
if opts.whitespace != self.whitespace {
self.windows.clear();
self.unfolded.clear();
self.whitespace = opts.whitespace;
}
self.structural = opts.structural;
let (anns, open) = (extras.anns, extras.open);
self.rows.clear();
self.headers.clear();
self.blocks.clear();
self.thread_rows.clear();
self.by_line.clear();
let wrap = usize::from(if opts.wrap == 0 {
DEFAULT_WRAP
} else {
opts.wrap
});
for (i, ann) in anns {
if let Some(pos) = ann.on_line() {
self.by_line.entry(pos).or_default().push(*i);
}
}
self.rows.push(Row::Header);
if self.open == Some(false) {
self.rows.push(Row::Spacer);
return;
}
for (i, ann) in anns {
if ann.on_line().is_none() {
self.push_annotation(*i, ann, open(ann), wrap);
}
}
let note = match self.diff.as_deref().map(|d| &d.content) {
_ if self.collapsed() && self.viewed == Viewed::Viewed => Some(Note::Viewed),
_ if self.collapsed() => Some(Note::Collapsed),
None => Some(Note::Loading),
Some(Content::Binary { .. }) => Some(Note::Binary),
Some(Content::TooLarge { .. }) => Some(Note::TooLarge),
Some(Content::Submodule { .. }) => Some(Note::Submodule),
Some(Content::Error(_)) => Some(Note::Error),
Some(Content::Text(_))
if self.alignment().is_some_and(|a| a.blocks.is_empty()) && !self.full =>
{
Some(Note::NoChanges)
}
Some(Content::Text(_)) => None,
};
if let Some(note) = note {
self.rows.push(Row::Note(note));
self.rows.push(Row::Spacer);
return;
}
let diff = self.diff.clone();
let Some(Content::Text(text)) = diff.as_deref().map(|d| &d.content) else {
return;
};
let a = text.view(self.whitespace, self.structural);
let lines = &a.lines;
self.blocks.clone_from(&a.blocks);
let fold_of: Vec<Option<FoldReason>> = self
.blocks
.iter()
.map(|b| {
if self.unfolded.contains(&b.entries.start) {
None
} else if extras.since.is_some_and(|s| s.contains(&b.hash)) {
Some(FoldReason::Seen)
} else if b.formatting_only {
Some(FoldReason::Formatting)
} else {
None
}
})
.collect();
let mut seen_entry = vec![false; lines.len()];
if extras.since.is_some() {
for (b, fold) in self.blocks.iter().zip(&fold_of) {
if *fold == Some(FoldReason::Seen) {
for e in b.entries.clone() {
if let Some(seen) = seen_entry.get_mut(e as usize) {
*seen = true;
}
}
}
}
let anything_new = lines
.iter()
.zip(&seen_entry)
.any(|(l, seen)| l.is_change() && !seen);
if !anything_new && !self.full {
self.rows.push(Row::Note(Note::NothingNew));
self.rows.push(Row::Spacer);
return;
}
}
let mut windows = self.windows.clone();
for (e, line) in lines.iter().enumerate() {
if line.lines().any(|p| self.by_line.contains_key(&p)) {
let e = idx(e);
windows.push(e..e.saturating_add(1));
}
}
let segs = segments_by(
lines.len(),
|e| lines.get(e).is_some_and(|l| l.is_change()) && seen_entry.get(e) == Some(&false),
CONTEXT,
&windows,
self.full,
);
let mut next_hidden = 0u32;
for ((s, seg), hunk) in segs.iter().enumerate().zip(hunks(lines, &segs)) {
if idx(seg.start) > next_hidden {
self.rows.push(Row::Gap {
start: next_hidden,
end: idx(seg.start),
});
}
let first_change = seg
.clone()
.find(|&e| lines.get(e).is_some_and(|l| l.is_change()))
.unwrap_or(seg.start);
self.headers.push(HunkHeader {
range: hunk.header(),
scope: new_line_near(lines, first_change)
.map(|n| text.scope(n).into_iter().map(str::to_owned).collect())
.unwrap_or_default(),
});
self.rows.push(Row::Hunk { seg: idx(s) });
let first_new_row = self.rows.len();
let push = |rows: &mut Vec<Row>, range| push_lines(rows, text, a, range, opts.split);
let mut at = seg.start;
for (i, (b, fold)) in self.blocks.iter().zip(&fold_of).enumerate() {
let (start, end) = (b.entries.start as usize, b.entries.end as usize);
let Some(reason) = fold.filter(|_| start < seg.end && end > seg.start) else {
continue;
};
push(&mut self.rows, at..start.max(seg.start));
self.rows.push(Row::Fold {
block: idx(i),
reason,
});
at = end.min(seg.end);
}
push(&mut self.rows, at..seg.end);
if !self.by_line.is_empty() || !extras.moves.is_empty() {
self.insert_extras(first_new_row, extras, wrap);
}
next_hidden = idx(seg.end);
}
if (next_hidden as usize) < lines.len() {
self.rows.push(Row::Gap {
start: next_hidden,
end: idx(lines.len()),
});
}
self.rows.push(Row::Spacer);
}
fn insert_extras(&mut self, from: usize, extras: &Extras<'_>, wrap: usize) {
let (anns, open) = (extras.anns, extras.open);
let tail = self.rows.split_off(from);
for row in tail {
self.rows.push(row);
let entries: Vec<u32> = row.entries().collect();
for (mv, range, is_from) in extras.moves {
if entries.contains(&range.start) {
self.rows.push(Row::Moved {
mv: *mv,
from: *is_from,
});
}
}
let mut here: Vec<u32> = Vec::new();
for pos in self.shows(row) {
for i in self.annotations_at(pos) {
if !here.contains(i) {
here.push(*i);
}
}
}
for i in here {
if let Some((_, ann)) = anns.iter().find(|(j, _)| *j == i)
&& open(ann)
{
self.push_annotation(i, ann, true, wrap);
}
}
}
}
pub fn number_width(&self) -> usize {
let max = self.text().map_or(0, TextDiff::max_lines);
max.to_string().len().max(3)
}
fn line_of(&self, row: Row, half: Side) -> Option<LinePos> {
let (side, entry) = match row {
Row::Line(e) | Row::Gap { start: e, .. } => (None, e),
Row::Split { left, right } => {
let (side, e) = split_half(left, right, half)?;
(Some(side), e)
}
Row::Fold { block, .. } => (None, self.blocks.get(block as usize)?.entries.start),
_ => return None,
};
let line = self.alignment()?.lines.get(entry as usize)?;
Some(side.and_then(|s| line.on(s)).unwrap_or(line.shown()))
}
fn key_at<'a>(&self, row: Row, anns: &'a [Annotation]) -> Option<&'a AnnotationKey> {
let Row::Thread(t) = row else { return None };
anns.get(self.thread_row(t)?.ann as usize).map(|a| &a.key)
}
fn held(&self, i: usize, anns: &[Annotation], half: Side) -> Held {
let row = self.rows.get(i).copied().unwrap_or(Row::Header);
let top = Held::Top(i.min(1));
match row {
Row::Header | Row::Note(_) => top,
Row::Line(_) | Row::Split { .. } | Row::Gap { .. } | Row::Fold { .. } => {
self.line_of(row, half).map_or(top, Held::Line)
}
Row::Hunk { .. } => (self.rows.iter().skip(i + 1))
.find_map(|r| self.line_of(*r, half))
.map_or(top, Held::Line),
Row::Moved { from, .. } => {
let half = if from { Side::Left } else { Side::Right };
i.checked_sub(1)
.map_or(top, |prev| self.held(prev, anns, half))
}
Row::NoNewline | Row::Spacer => i
.checked_sub(1)
.map_or(top, |prev| self.held(prev, anns, half)),
Row::Thread(t) => {
let Some(ann) = self.thread_row(t).and_then(|r| anns.get(r.ann as usize)) else {
return top;
};
let same = |r: &&Row| self.key_at(**r, anns) == Some(&ann.key);
Held::Thread {
key: ann.key.clone(),
nth: self.rows.iter().take(i).filter(same).count(),
line: ann.on_line(),
}
}
}
}
pub(crate) fn shows(&self, row: Row) -> impl Iterator<Item = LinePos> + '_ {
let one = |e: Option<u32>| e.map_or(0..0, |e| e..e.saturating_add(1));
let (a, b) = match row {
Row::Line(e) => (one(Some(e)), 0..0),
Row::Split { left, right } => (one(left), one(right)),
Row::Gap { start, end } => (start..end, 0..0),
Row::Fold { block, .. } => {
let block = self.blocks.get(block as usize);
(block.map_or(0..0, |b| b.entries.clone()), 0..0)
}
Row::Header
| Row::Note(_)
| Row::Hunk { .. }
| Row::NoNewline
| Row::Thread(_)
| Row::Moved { .. }
| Row::Spacer => (0..0, 0..0),
};
let lines = self.alignment().map_or(&[][..], |a| a.lines.as_slice());
a.chain(b)
.filter_map(|e| lines.get(e as usize))
.flat_map(|l| l.lines())
}
}
fn is_open(thread_open: &HashMap<AnnotationKey, bool>, ann: &Annotation) -> bool {
thread_open
.get(&ann.key)
.copied()
.unwrap_or_else(|| ann.open_by_default())
}
fn ends_without_newline(text: &TextDiff, lines: &[DiffLine], e: Option<u32>) -> bool {
e.and_then(|e| lines.get(e as usize))
.into_iter()
.flat_map(|l| [l.left(), l.right()])
.flatten()
.any(|p| text.ends_without_newline(p))
}
fn push_lines(
rows: &mut Vec<Row>,
text: &TextDiff,
a: &crate::diff::Alignment,
range: Range<usize>,
split: bool,
) {
let lines = &a.lines;
if split {
return push_split_rows(rows, text, a, range);
}
for e in range {
rows.push(Row::Line(idx(e)));
if ends_without_newline(text, lines, Some(idx(e))) {
rows.push(Row::NoNewline);
}
}
}
fn push_split_rows(
rows: &mut Vec<Row>,
text: &TextDiff,
a: &crate::diff::Alignment,
seg: Range<usize>,
) {
let lines = &a.lines;
let is = |e: usize, removed| {
lines
.get(e)
.is_some_and(|l| l.is_change() && l.left().is_some() == removed)
};
let ends = |e| ends_without_newline(text, lines, e);
let mut e = seg.start;
while e < seg.end {
let Some(line) = lines.get(e) else { break };
if !line.is_change() {
let (left, right) = (Some(idx(e)), Some(idx(e)));
rows.push(Row::Split { left, right });
if ends(left) {
rows.push(Row::NoNewline);
}
e += 1;
continue;
}
let removed_start = idx(e);
while e < seg.end && is(e, true) {
e += 1;
}
let added_start = idx(e);
while e < seg.end && is(e, false) {
e += 1;
}
let (removed, added) = (removed_start..added_start, added_start..idx(e));
let anchors = (a.pairs.iter()).filter(|(r, n)| removed.contains(r) && added.contains(n));
let (mut r, mut n) = (removed.start, added.start);
let mut missing_newline = false;
let end = (removed.end, added.end);
for &(to_r, to_n) in anchors.chain([&end]) {
let (left, right) = (r..to_r, n..to_n);
for i in 0..left.len().max(right.len()) {
let i = idx(i);
let left = left.start.checked_add(i).filter(|e| left.contains(e));
let right = right.start.checked_add(i).filter(|e| right.contains(e));
missing_newline |= ends(left) || ends(right);
rows.push(Row::Split { left, right });
}
if (to_r, to_n) != end {
missing_newline |= ends(Some(to_r)) || ends(Some(to_n));
rows.push(Row::Split {
left: Some(to_r),
right: Some(to_n),
});
}
(r, n) = (to_r + 1, to_n + 1);
}
if missing_newline {
rows.push(Row::NoNewline);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Pos {
pub file: usize,
pub row: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Held {
Top(usize),
Line(LinePos),
Thread {
key: AnnotationKey,
nth: usize,
line: Option<LinePos>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Anchor {
path: String,
held: Held,
}
impl Anchor {
pub fn line(path: &str, at: LinePos) -> Self {
Self {
path: path.to_owned(),
held: Held::Line(at),
}
}
pub fn side(&self) -> Option<Side> {
match &self.held {
Held::Line(line)
| Held::Thread {
line: Some(line), ..
} => Some(line.side),
Held::Top(_) | Held::Thread { line: None, .. } => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DocInputs {
pub patches: Arc<HashMap<String, Commentable>>,
pub viewed: HashMap<String, Viewed>,
pub reviewed: HashSet<String>,
pub annotations: Vec<Annotation>,
}
#[derive(Debug, Clone, Default)]
pub struct Doc {
pub(crate) files: Vec<DocFile>,
pub(crate) opts: ViewOptions,
pub(crate) inputs: DocInputs,
thread_open: HashMap<AnnotationKey, bool>,
pub(crate) moves: Vec<Move>,
pub(crate) since: Option<HashSet<String>>,
pub(crate) since_active: bool,
starts: Vec<usize>,
total: usize,
}
impl Doc {
pub fn files(&self) -> &[DocFile] {
&self.files
}
pub fn opts(&self) -> ViewOptions {
self.opts
}
pub fn annotations(&self) -> &[Annotation] {
&self.inputs.annotations
}
pub fn moves(&self) -> &[Move] {
&self.moves
}
pub fn since_active(&self) -> bool {
self.since_active
}
pub fn new(files: Vec<ChangedFile>, generated: &HashSet<String>, inputs: DocInputs) -> Self {
let files = files
.into_iter()
.map(|meta| {
let path = meta.path().to_owned();
DocFile {
generated: generated.contains(&path) || is_lockfile(&path),
meta,
viewed: inputs.viewed.get(&path).copied().unwrap_or_default(),
open: None,
diff: None,
full: false,
windows: Vec::new(),
rows: Vec::new(),
headers: Vec::new(),
blocks: Vec::new(),
unfolded: HashSet::new(),
whitespace: Whitespace::default(),
structural: false,
thread_rows: Vec::new(),
by_line: HashMap::new(),
local_commentable: None,
moves: Vec::new(),
}
})
.collect();
let mut doc = Self {
files,
inputs,
..Self::default()
};
doc.rebuild_all();
doc
}
pub fn set_inputs(&mut self, inputs: DocInputs) {
let old = std::mem::replace(&mut self.inputs, inputs);
let (was, is) = (by_path(&old.annotations), by_path(&self.inputs.annotations));
let viewed = &self.inputs.viewed;
let mut changed = Vec::new();
for (i, f) in self.files.iter_mut().enumerate() {
let now = viewed.get(f.meta.path()).copied().unwrap_or_default();
let none = Vec::new();
let was = was.get(f.meta.path()).unwrap_or(&none);
let is = is.get(f.meta.path()).unwrap_or(&none);
let same = was.iter().map(|(_, a)| a).eq(is.iter().map(|(_, a)| a));
if f.viewed != now {
(f.viewed, f.open) = (now, None);
} else if same {
if was.iter().map(|w| w.0).ne(is.iter().map(|i| i.0)) {
f.renumber(was, is);
}
continue;
}
changed.push(i);
}
if !changed.is_empty() {
self.rebuild_files(changed);
}
}
fn rebuild_all(&mut self) {
self.rebuild_files(0..self.files.len());
}
fn rebuild(&mut self, index: usize) {
if index < self.files.len() {
self.rebuild_files([index]);
}
}
fn update(&mut self, index: usize, f: impl FnOnce(&mut DocFile)) {
if let Some(file) = self.files.get_mut(index) {
f(file);
self.rebuild(index);
}
}
fn rebuild_files(&mut self, which: impl IntoIterator<Item = usize>) {
let Doc {
files,
opts,
inputs: DocInputs { annotations, .. },
thread_open,
moves,
since,
since_active,
..
} = self;
let anns = by_path(annotations);
let mut file_moves: HashMap<usize, Vec<(u32, Range<u32>, bool)>> = HashMap::new();
if opts.whitespace == Whitespace::Exact {
for (i, m) in moves.iter().enumerate() {
let from = (m.from.0, (idx(i), m.from.1.clone(), true));
let to = (m.to.0, (idx(i), m.to.1.clone(), false));
for (file, mv) in [from, to] {
file_moves.entry(file).or_default().push(mv);
}
}
}
let open = |a: &Annotation| is_open(thread_open, a);
for index in which {
let Some(file) = files.get_mut(index) else {
continue;
};
let moves = file_moves.remove(&index).unwrap_or_default();
let extras = Extras {
anns: anns.get(file.meta.path()).map_or(&[], Vec::as_slice),
open: &open,
since: since.as_ref().filter(|_| *since_active),
moves: &moves,
};
file.rebuild(*opts, &extras);
file.moves = moves;
}
self.reindex();
}
pub fn set_moves(&mut self, moves: Vec<Move>) {
self.moves = moves;
self.rebuild_all();
}
pub fn move_at_entry(&self, file: usize, e: u32) -> Option<(u32, bool)> {
let moves = &self.files.get(file)?.moves;
let (mv, _, from) = moves.iter().find(|(_, entries, _)| entries.contains(&e))?;
Some((*mv, *from))
}
pub fn move_at(&self, pos: Pos, half: Side) -> Option<(u32, bool)> {
match self.row(pos)? {
Row::Moved { mv, from } => Some((mv, from)),
Row::Split { left, right } => {
self.move_at_entry(pos.file, split_half(left, right, half)?.1)
}
row => row.entries().find_map(|e| self.move_at_entry(pos.file, e)),
}
}
pub fn move_target(&self, mv: u32, from_side: bool) -> Option<Pos> {
let m = self.moves.get(mv as usize)?;
let (file, entries) = if from_side { &m.to } else { &m.from };
let rows = self.files.get(*file)?.rows();
let row = rows
.iter()
.position(|r| r.entries().any(|e| e == entries.start))?;
Some(Pos { file: *file, row })
}
pub fn set_since(&mut self, hashes: Option<HashSet<String>>, active: bool) {
self.since = hashes;
self.since_active = active && self.since.is_some();
self.rebuild_all();
}
pub fn show_since(&mut self, active: bool) -> bool {
if self.since.is_none() {
return false;
}
self.since_active = active;
self.rebuild_all();
true
}
pub fn unfold(&mut self, pos: Pos) -> bool {
let Some(Row::Fold { block, .. }) = self.row(pos) else {
return false;
};
let Some(file) = self.files.get_mut(pos.file) else {
return false;
};
let Some(start) = file.blocks.get(block as usize).map(|b| b.entries.start) else {
return false;
};
file.unfolded.insert(start);
self.rebuild(pos.file);
true
}
pub fn has_new_changes(&self, file: usize) -> bool {
let Some(since) = &self.since else {
return true;
};
self.files
.get(file)
.is_some_and(|f| f.blocks.iter().any(|b| !since.contains(&b.hash)))
}
pub fn thread_open(&self, index: u32) -> bool {
(self.inputs.annotations.get(index as usize)).is_some_and(|a| is_open(&self.thread_open, a))
}
pub fn toggle_thread(&mut self, index: u32) {
let Some(ann) = self.inputs.annotations.get(index as usize) else {
return;
};
let open = is_open(&self.thread_open, ann);
self.thread_open.insert(ann.key.clone(), !open);
if let Some(file) = self.files.iter().position(|f| f.meta.path() == ann.path) {
self.rebuild(file);
}
}
pub fn commentable(&self, file: usize) -> Option<&Commentable> {
let f = self.files.get(file)?;
self.inputs
.patches
.get(f.meta.path())
.or(f.local_commentable.as_ref())
}
pub fn line_at(&self, pos: Pos, half: Side) -> Option<LinePos> {
let file = self.files.get(pos.file)?;
match self.row(pos)? {
row @ (Row::Line(_) | Row::Split { .. }) => file.line_of(row, half),
_ => None,
}
}
pub fn code_at(&self, pos: Pos, half: Side) -> Option<&str> {
let at = self.line_at(pos, half)?;
Some(self.files.get(pos.file)?.text()?.line(at))
}
pub fn half_at(&self, pos: Pos, half: Side) -> Option<Side> {
match self.row(pos)? {
Row::Split { left, right } => split_half(left, right, half).map(|(side, _)| side),
_ => None,
}
}
pub fn annotation_under(&self, pos: Pos, half: Side) -> Option<u32> {
let file = self.files.get(pos.file)?;
match self.row(pos)? {
row @ Row::Split { .. } => {
let line = file.line_of(row, half)?;
file.annotations_at(line).first().copied()
}
_ => self.annotation_at(pos),
}
}
pub fn annotation_at(&self, pos: Pos) -> Option<u32> {
let file = self.files.get(pos.file)?;
match self.row(pos)? {
Row::Thread(t) => file.thread_row(t).map(|r| r.ann),
row @ (Row::Line(_) | Row::Split { .. }) => file
.shows(row)
.find_map(|pos| file.annotations_at(pos).first().copied()),
_ => None,
}
}
pub fn annotation_pos(&self, ann: u32) -> Option<Pos> {
(0..self.total)
.map(|g| self.to_pos(g))
.find(|p| self.annotation_at(*p) == Some(ann))
}
fn is_open_thread_start(&self, pos: Pos) -> bool {
let Some(file) = self.files.get(pos.file) else {
return false;
};
let unresolved = |i: u32| {
self.annotations()
.get(i as usize)
.is_some_and(|a| !a.resolved)
};
match self.row(pos) {
Some(Row::Thread(t)) => file.thread_row(t).is_some_and(|r| {
matches!(
r.kind,
ThreadRowKind::Summary | ThreadRowKind::Head { first: true, .. }
) && unresolved(r.ann)
}),
Some(Row::Line(_) | Row::Split { .. }) => {
let next_is_thread = matches!(
self.row(Pos {
file: pos.file,
row: pos.row + 1
}),
Some(Row::Thread(_))
);
!next_is_thread && self.annotation_at(pos).is_some_and(unresolved)
}
_ => false,
}
}
pub fn next_thread(&self, pos: Pos) -> Option<Pos> {
self.find_after(pos, |p| self.is_open_thread_start(p))
}
pub fn prev_thread(&self, pos: Pos) -> Option<Pos> {
self.find_before(pos, |p| self.is_open_thread_start(p))
}
fn reindex(&mut self) {
self.starts.clear();
let mut total = 0;
for file in &self.files {
self.starts.push(total);
total += file.rows.len();
}
let trailing = (self.files.last()).is_some_and(|f| f.rows.last() == Some(&Row::Spacer));
self.total = total - usize::from(trailing);
}
pub fn set_diff(&mut self, index: usize, diff: Arc<FileDiff>) {
self.update(index, |file| {
file.local_commentable = match &diff.content {
Content::Text(t) => Some(Commentable {
ranges: t.local_ranges.clone(),
source: RangeSource::LocalFallback,
}),
_ => None,
};
file.diff = Some(diff);
});
}
pub fn set_options(&mut self, opts: ViewOptions) {
self.opts = opts;
self.rebuild_all();
}
pub fn set_open(&mut self, index: usize, open: bool) {
self.update(index, |file| file.open = Some(open));
}
pub fn set_all_open(&mut self, open: bool) {
for file in &mut self.files {
file.open = Some(open);
}
self.rebuild_all();
}
pub fn toggle_full(&mut self, index: usize) {
self.update(index, |file| file.full = !file.full);
}
#[expect(clippy::single_range_in_vec_init, reason = "a list of one window")]
pub fn expand(&mut self, pos: Pos) {
let Some(row) = self.row(pos) else { return };
let Some(file) = self.files.get_mut(pos.file) else {
return;
};
let Some(a) = file.alignment() else { return };
let len = idx(a.lines.len());
let step = EXPAND_STEP;
let windows = match row {
Row::Gap { start, end } if end - start <= 2 * step => vec![start..end],
Row::Gap { start, end } => vec![start..start + step, end - step..end],
_ => {
let rows = &file.rows;
let begin = rows
.get(..=pos.row)
.unwrap_or_default()
.iter()
.rposition(|r| matches!(r, Row::Hunk { .. }))
.unwrap_or(0);
let end = rows
.get(begin + 1..)
.unwrap_or_default()
.iter()
.position(|r| matches!(r, Row::Gap { .. } | Row::Spacer | Row::Hunk { .. }))
.map_or(rows.len(), |p| p + begin + 1);
let entries: Vec<u32> = rows
.get(begin..end)
.unwrap_or_default()
.iter()
.filter_map(|r| r.entries().next())
.collect();
let (Some(first), Some(last)) = (entries.iter().min(), entries.iter().max()) else {
return;
};
vec![
first.saturating_sub(step)..*first,
last + 1..(last + 1 + step).min(len),
]
}
};
file.windows
.extend(windows.into_iter().filter(|w| w.start < w.end));
self.rebuild(pos.file);
}
pub fn anchor(&self, pos: Pos, half: Side) -> Anchor {
let pos = self.clamp(pos);
let file = self.files.get(pos.file);
let held = |f: &DocFile| f.held(pos.row, self.annotations(), half);
Anchor {
path: file.map(|f| f.meta.path().to_owned()).unwrap_or_default(),
held: file.map_or(Held::Top(0), held),
}
}
pub fn locate(&self, anchor: Anchor) -> Pos {
let mut files = self.files.iter().enumerate();
let Some((index, file)) = files.find(|(_, f)| f.meta.path() == anchor.path) else {
return self.clamp(Pos::default());
};
let at = |row| self.clamp(Pos { file: index, row });
let rows = file.rows.iter().enumerate();
let line = match anchor.held {
Held::Top(row) => return at(row),
Held::Line(line) => line,
Held::Thread { key, nth, line } => {
let anns = self.annotations();
let on = |(i, r): (usize, &Row)| (file.key_at(*r, anns) == Some(&key)).then_some(i);
let rows: Vec<usize> = rows.clone().filter_map(on).collect();
match (rows.get(nth).or(rows.first()), line) {
(Some(row), _) => return at(*row),
(None, Some(line)) => line,
(None, None) => return at(1),
}
}
};
let shown = rows.flat_map(|(i, r)| file.shows(*r).map(move |p| (p, i)));
let here = shown.filter(|(p, _)| p.side == line.side && p.line >= line.line);
at(here.min_by_key(|(p, _)| p.line).map_or(0, |(_, row)| row))
}
pub fn line_row(&self, file: usize, line: LinePos) -> Option<Pos> {
let f = self.files.get(file)?;
let shown = f.rows.iter().enumerate();
let shown = shown.flat_map(|(i, r)| f.shows(*r).map(move |p| (p, i)));
let (_, row) = shown
.filter(|(p, _)| p.side == line.side && p.line >= line.line)
.min_by_key(|(p, _)| p.line)?;
Some(Pos { file, row })
}
pub fn line_range(&self, a: Pos, b: Pos, half: Side) -> Option<(LinePos, LinePos)> {
let (a, b) = (self.to_global(a), self.to_global(b));
let mut lines = (a.min(b)..=a.max(b)).filter_map(|g| self.line_at(self.to_pos(g), half));
let first = lines.next()?;
Some((first, lines.next_back().unwrap_or(first)))
}
pub fn total_rows(&self) -> usize {
self.total
}
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
pub fn row(&self, pos: Pos) -> Option<Row> {
self.files.get(pos.file)?.rows.get(pos.row).copied()
}
pub fn scope_at(&self, pos: Pos) -> Vec<&str> {
let Some(file) = self.files.get(pos.file) else {
return Vec::new();
};
let (Some(text), Some(a), Some(e)) = (
file.text(),
file.alignment(),
file.rows
.iter()
.skip(pos.row)
.find_map(|r| r.entries().next()),
) else {
return Vec::new();
};
new_line_near(&a.lines, e as usize)
.map(|n| text.scope(n))
.unwrap_or_default()
}
pub fn clamp(&self, pos: Pos) -> Pos {
let file = pos.file.min(self.files.len().saturating_sub(1));
let start = self.starts.get(file).copied().unwrap_or_default();
let end = self.starts.get(file + 1).copied().unwrap_or(self.total);
let row = pos.row.min(end.saturating_sub(start).saturating_sub(1));
Pos { file, row }
}
pub fn to_global(&self, pos: Pos) -> usize {
let pos = self.clamp(pos);
self.starts.get(pos.file).map_or(0, |s| s + pos.row)
}
pub fn to_pos(&self, global: usize) -> Pos {
if self.files.is_empty() {
return Pos::default();
}
let global = global.min(self.total.saturating_sub(1));
let Some(file) = self.starts.partition_point(|&s| s <= global).checked_sub(1) else {
return Pos::default();
};
let start = self.starts.get(file).copied().unwrap_or_default();
Pos {
file,
row: global - start,
}
}
pub fn offset(&self, pos: Pos, delta: isize) -> Pos {
self.to_pos(self.to_global(pos).saturating_add_signed(delta))
}
pub fn last(&self) -> Pos {
self.to_pos(self.total.saturating_sub(1))
}
fn find_after(&self, pos: Pos, pred: impl Fn(Pos) -> bool) -> Option<Pos> {
let start = self.to_global(pos) + 1;
(start..self.total)
.map(|g| self.to_pos(g))
.find(|p| pred(*p))
}
fn find_before(&self, pos: Pos, pred: impl Fn(Pos) -> bool) -> Option<Pos> {
let start = self.to_global(pos);
(0..start).rev().map(|g| self.to_pos(g)).find(|p| pred(*p))
}
pub fn next_hunk(&self, pos: Pos) -> Option<Pos> {
self.find_after(pos, |p| matches!(self.row(p), Some(Row::Hunk { .. })))
}
pub fn prev_hunk(&self, pos: Pos) -> Option<Pos> {
self.find_before(pos, |p| matches!(self.row(p), Some(Row::Hunk { .. })))
}
pub fn next_file(&self, pos: Pos) -> Option<Pos> {
(pos.file + 1 < self.files.len()).then(|| Pos {
file: pos.file + 1,
row: 0,
})
}
pub fn prev_file(&self, pos: Pos) -> Option<Pos> {
if pos.row > 0 {
return Some(Pos {
file: pos.file,
row: 0,
});
}
pos.file.checked_sub(1).map(|file| Pos { file, row: 0 })
}
pub fn next_unviewed(&self, pos: Pos) -> Option<Pos> {
let n = self.files.len();
(1..=n)
.map(|k| (pos.file + k) % n)
.find(|&f| {
self.files
.get(f)
.is_some_and(|f| f.viewed != Viewed::Viewed)
})
.map(|file| Pos { file, row: 0 })
}
pub fn loading_in_view(&self, top: Pos, count: usize) -> Vec<usize> {
let first = self.to_global(top);
let end = (first + count).min(self.total);
let mut out = Vec::new();
for (i, (file, &start)) in self.files.iter().zip(&self.starts).enumerate() {
let shown = start < end && start + file.rows.len() > first;
if shown && file.diff.is_none() && !file.collapsed() {
out.push(i);
}
}
out
}
pub fn totals(&self) -> (u32, u32) {
self.files
.iter()
.map(|f| self.file_counts(f))
.fold((0, 0), |(a, d), (fa, fd)| (a + fa, d + fd))
}
pub fn file_counts(&self, file: &DocFile) -> (u32, u32) {
match (file.alignment(), file.diff.as_deref()) {
(Some(a), _) => counts(&a.lines),
(None, Some(d)) => (d.additions, d.deletions),
(None, None) => (0, 0),
}
}
pub fn ready_count(&self) -> usize {
self.files.iter().filter(|f| f.diff.is_some()).count()
}
pub fn viewed_count(&self) -> usize {
self.files
.iter()
.filter(|f| f.viewed == Viewed::Viewed)
.count()
}
pub fn block_at(&self, pos: Pos) -> Option<&ChangeBlock> {
let file = self.files.get(pos.file)?;
file.block_of(self.row(pos)?.entries().next()?)
}
#[cfg(test)]
pub fn row_text(&self, pos: Pos) -> String {
let mut parts = Vec::new();
self.row_has(pos, |part| {
parts.push(part.to_owned());
false
});
parts.join("\n")
}
fn row_has(&self, pos: Pos, mut test: impl FnMut(&str) -> bool) -> bool {
let Some(file) = self.files.get(pos.file) else {
return false;
};
let text = |p: LinePos| file.text().map_or("", |t| t.line(p));
match self.row(pos) {
Some(Row::Header) => test(file.meta.path()),
Some(row @ Row::Line(_)) => test(file.line_of(row, Side::Right).map_or("", text)),
Some(row @ Row::Split { .. }) => file.shows(row).map(text).any(test),
Some(Row::Thread(t)) => file.thread_row(t).is_some_and(|r| test(&r.line.text())),
_ => false,
}
}
pub fn search(&self, query: &str, from: Pos, forward: bool) -> Option<Pos> {
self.scan(query, from, forward, false).0
}
pub fn search_counted(&self, query: &str, from: Pos, forward: bool) -> (Option<Pos>, usize) {
self.scan(query, from, forward, true)
}
fn scan(&self, query: &str, from: Pos, forward: bool, all: bool) -> (Option<Pos>, usize) {
if query.is_empty() || self.total == 0 {
return (None, 0);
}
let smart_case = query.chars().any(char::is_uppercase);
let needle = if smart_case {
query.to_owned()
} else {
query.to_lowercase()
};
let mut lower = String::new();
let mut has = |text: &str| {
if smart_case {
return crate::ui::text::has(text, &needle);
}
lower.clear();
if text.is_ascii() {
lower.push_str(text);
lower.make_ascii_lowercase();
} else {
lower.push_str(&text.to_lowercase());
}
crate::ui::text::has(&lower, &needle)
};
let (start, n) = (self.to_global(from), self.total);
let (mut found, mut count) = (None, 0);
for k in 1..=n {
let g = if forward {
(start + k) % n
} else {
(start + n - k % n) % n
};
let pos = self.to_pos(g);
if self.row_has(pos, &mut has) {
found = found.or(Some(pos));
count += 1;
if !all {
break;
}
}
}
(found, count)
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::git::files::{FileStatus, ZERO_OID};
pub(crate) fn changed(path: &str) -> ChangedFile {
ChangedFile {
status: FileStatus::Modified,
old_path: Some(path.into()),
new_path: Some(path.into()),
old_mode: 0o100644,
new_mode: 0o100644,
old_oid: crate::git::Oid::parse(ZERO_OID).unwrap(),
new_oid: crate::git::Oid::parse(ZERO_OID).unwrap(),
similarity: None,
}
}
fn numbered(n: u32) -> String {
(1..=n).map(|i| format!("line {i}\n")).collect()
}
fn compute(path: &str, old: &str, new: &str) -> Arc<FileDiff> {
Arc::new(FileDiff::compute(
path,
Some(old.as_bytes()),
Some(new.as_bytes()),
))
}
fn doc() -> Doc {
let mut doc = Doc::new(
vec![changed("a.txt"), changed("Cargo.lock"), changed("b.txt")],
&HashSet::new(),
Default::default(),
);
let old = numbered(60);
let new = old
.replace("line 5\n", "five\n")
.replace("line 45\n", "forty-five\n");
doc.set_diff(0, compute("a.txt", &old, &new));
doc
}
fn one(path: &str, old: &str, new: &str) -> Doc {
let mut doc = Doc::new(vec![changed(path)], &HashSet::new(), Default::default());
doc.set_diff(0, compute(path, old, new));
doc
}
fn view(split: bool, whitespace: Whitespace) -> ViewOptions {
ViewOptions {
split,
whitespace,
..ViewOptions::default()
}
}
fn line_row(file: &DocFile, matches: impl Fn(&DiffLine) -> bool) -> Option<usize> {
let lines = &file.alignment()?.lines;
file.rows().iter().position(|r| {
r.entries()
.next()
.is_some_and(|e| matches(&lines[e as usize]))
})
}
fn kinds(file: &DocFile) -> String {
file.rows()
.iter()
.map(|r| match r {
Row::Header => 'H',
Row::Note(_) => 'N',
Row::Gap { .. } => 'G',
Row::Hunk { .. } => '@',
Row::Line(_) => 'L',
Row::Split { .. } => 'S',
Row::NoNewline => '\\',
Row::Thread(_) => 'T',
Row::Fold { .. } => 'F',
Row::Moved { .. } => 'M',
Row::Spacer => '_',
})
.collect()
}
#[test]
fn rows_with_gaps_between_hunks() {
let doc = doc();
let a = &doc.files[0];
assert_eq!(kinds(a), "HG@LLLLLLLLG@LLLLLLLLG_");
let ranges: Vec<&str> = a.headers().iter().map(|h| h.range.as_str()).collect();
assert_eq!(ranges, ["@@ -2,7 +2,7 @@", "@@ -42,7 +42,7 @@"]);
assert!(doc.files[1].generated, "lockfiles are collapsed");
assert_eq!(doc.files[1].rows()[1], Row::Note(Note::Collapsed));
assert_eq!(doc.files[2].rows()[1], Row::Note(Note::Loading));
}
#[test]
fn global_and_local_positions_round_trip() {
let doc = doc();
for g in 0..doc.total_rows() {
assert_eq!(doc.to_global(doc.to_pos(g)), g);
}
assert_eq!(doc.to_pos(usize::MAX), doc.last());
}
#[test]
fn positions_survive_earlier_files_arriving() {
let mut doc = doc();
let cursor = Pos { file: 2, row: 1 };
let before = doc.to_global(cursor);
doc.set_open(1, true);
doc.set_diff(1, compute("Cargo.lock", "a\n", "b\nc\n"));
assert_eq!(doc.row(cursor), Some(Row::Note(Note::Loading)));
assert_ne!(doc.to_global(cursor), before);
}
#[test]
fn hunk_file_and_unviewed_navigation() {
let mut doc = doc();
let h1 = doc.next_hunk(Pos::default()).unwrap();
let h2 = doc.next_hunk(h1).unwrap();
assert_eq!(doc.row(h1), Some(Row::Hunk { seg: 0 }));
assert_eq!(doc.row(h2), Some(Row::Hunk { seg: 1 }));
assert_eq!(doc.next_hunk(h2), None);
assert_eq!(doc.prev_hunk(h2), Some(h1));
assert_eq!(doc.next_file(h2), Some(Pos { file: 1, row: 0 }));
assert_eq!(doc.prev_file(h2), Some(Pos { file: 0, row: 0 }));
let viewed = [("Cargo.lock".to_owned(), Viewed::Viewed)].into();
doc.set_inputs(DocInputs {
viewed,
..DocInputs::default()
});
assert_eq!(doc.files[1].rows()[1], Row::Note(Note::Viewed));
assert_eq!(
doc.next_unviewed(Pos::default()),
Some(Pos { file: 2, row: 0 })
);
assert_eq!(
doc.next_unviewed(Pos { file: 2, row: 0 }),
Some(Pos { file: 0, row: 0 })
);
}
#[test]
fn expanding_gaps_and_segments() {
let old = numbered(200);
let new = old
.replace("line 2\n", "two\n")
.replace("line 150\n", "x\n");
let mut doc = one("big.txt", &old, &new);
doc.expand(Pos { file: 0, row: 1 });
assert!(kinds(&doc.files[0]).starts_with("H@L"));
let middle = doc.files[0]
.rows()
.iter()
.position(|r| matches!(r, Row::Gap { start, end } if end - start > 2 * EXPAND_STEP))
.unwrap();
let before = doc.files[0].rows().len();
doc.expand(Pos {
file: 0,
row: middle,
});
assert_eq!(doc.files[0].rows().len(), before + 2 * EXPAND_STEP as usize);
doc.toggle_full(0);
assert_eq!(doc.files[0].headers().len(), 1);
assert!(
!doc.files[0]
.rows()
.iter()
.any(|r| matches!(r, Row::Gap { .. }))
);
}
#[test]
fn expanding_from_inside_a_segment_widens_it() {
let mut doc = doc();
let before = doc.files[0].rows().len();
doc.expand(Pos { file: 0, row: 4 });
assert!(doc.files[0].rows().len() > before);
}
#[test]
fn split_rows_pair_changes() {
let mut doc = one("x.rs", "a\nb\nc\nd\n", "a\nB\nC\nX\nd\n");
doc.set_options(view(true, Whitespace::Exact));
let rows: Vec<Row> = doc.files[0]
.rows()
.iter()
.copied()
.filter(|r| matches!(r, Row::Split { .. }))
.collect();
assert_eq!(rows.len(), 5);
assert!(matches!(
rows[1],
Row::Split {
left: Some(_),
right: Some(_)
}
));
assert!(matches!(
rows[3],
Row::Split {
left: None,
right: Some(_)
}
));
}
#[test]
fn a_split_row_is_two_places() {
let mut doc = one("x.rs", "a\nold\nb\n", "a\nnew\nb\n");
doc.set_options(view(true, Whitespace::Exact));
let rows = doc.files[0].rows();
let paired = rows
.iter()
.position(|r| matches!(r, Row::Split { left: Some(l), right: Some(r) } if l != r))
.unwrap();
let at = |row| Pos { file: 0, row };
let line = |side, line| Some(LinePos { side, line });
assert_eq!(doc.line_at(at(paired), Side::Left), line(Side::Left, 2));
assert_eq!(doc.line_at(at(paired), Side::Right), line(Side::Right, 2));
let context = paired - 1;
assert_eq!(doc.line_at(at(context), Side::Left), line(Side::Left, 1));
let anchors = [Side::Left, Side::Right].map(|half| doc.anchor(at(paired), half));
doc.set_options(view(false, Whitespace::Exact));
let [old, new] = anchors.map(|a| doc.row_text(doc.locate(a)));
assert_eq!((old.as_str(), new.as_str()), ("old", "new"));
}
#[test]
fn whitespace_mode_hides_whitespace_only_changes() {
let mut doc = one("x.rs", "fn a() {\n x();\n}\n", "fn a() {\n x();\n}\n");
assert_eq!(doc.totals(), (1, 1));
doc.set_options(view(false, Whitespace::Ignore));
assert_eq!(doc.totals(), (0, 0));
assert_eq!(doc.files[0].rows()[1], Row::Note(Note::NoChanges));
}
#[test]
fn anchors_keep_the_line_across_view_changes() {
let mut doc = doc();
let row = line_row(&doc.files[0], |l| l.right().is_some_and(|p| p.line == 44)).unwrap();
let anchor = doc.anchor(Pos { file: 0, row }, Side::Right);
doc.set_options(view(true, Whitespace::Exact));
let pos = doc.locate(anchor);
assert!(
doc.row_text(pos).contains("line 44"),
"{}",
doc.row_text(pos)
);
}
#[test]
fn reviewed_blocks_hash_content_not_position() {
let hash = |path, old, new| one(path, old, new).files[0].blocks()[0].hash.clone();
let base = hash("x.rs", "1\n2\n3\nold\n", "1\n2\n3\nnew\n");
assert_eq!(
base,
hash("x.rs", "0\n1\n2\n3\nold\n", "0\n1\n2\n3\nnew\n"),
"moved"
);
assert_ne!(
base,
hash("x.rs", "1\n2\n3\nold\n", "1\n2\n3\nnewer\n"),
"edited"
);
assert_ne!(
base,
hash("y.rs", "1\n2\n3\nold\n", "1\n2\n3\nnew\n"),
"other file"
);
}
#[test]
fn search_wraps_and_respects_smart_case() {
let doc = doc();
let first = doc.search("five", Pos::default(), true).unwrap();
assert!(doc.row_text(first).contains("five"));
let second = doc.search("five", first, true).unwrap();
assert!(doc.row_text(second).contains("forty-five"));
assert_eq!(doc.search("five", second, true), Some(first), "wraps");
assert_eq!(doc.search("five", first, false), Some(second), "backwards");
assert_eq!(doc.search("FIVE", Pos::default(), true), None);
assert_eq!(doc.search_counted("five", second, true), (Some(first), 2));
}
#[test]
fn missing_newline_rows() {
let mut doc = one("x.txt", "a\nb\n", "a\nb");
let count = |doc: &Doc| {
doc.files[0]
.rows()
.iter()
.filter(|r| **r == Row::NoNewline)
.count()
};
assert_eq!(count(&doc), 1);
doc.set_options(view(true, Whitespace::Exact));
assert_eq!(count(&doc), 1);
let mut doc = one("x.txt", "x\na\nb", "y\na\nb\n");
for split in [false, true] {
doc.set_options(view(split, Whitespace::Ignore));
assert_eq!(count(&doc), 1, "split: {split}");
}
}
#[test]
fn loading_files_in_view() {
let doc = doc();
assert_eq!(doc.loading_in_view(Pos::default(), 1000), [2]);
assert!(doc.loading_in_view(Pos::default(), 3).is_empty());
}
mod annotations {
use super::*;
use crate::ui::annotations::{Annotation, AnnotationComment, AnnotationKey, Place};
fn ann(key: &str, side: Side, line: Option<u32>) -> Annotation {
Annotation {
key: AnnotationKey::Thread(crate::api::model::NodeId::new(key)),
path: "a.txt".into(),
place: line.map_or(Place::File { outdated: false }, |line| Place::Line {
at: LinePos { side, line },
start: None,
}),
resolved: false,
comments: vec![AnnotationComment {
author: "alice".into(),
body: "Is this right?\nSecond line.".into(),
created_at: String::new(),
pending: false,
new: false,
editable: None,
}],
left_out: 0,
error: None,
can_reply: true,
can_resolve: true,
can_unresolve: false,
new: false,
}
}
fn set_annotations(doc: &mut Doc, annotations: Vec<Annotation>) {
doc.set_inputs(DocInputs {
annotations,
..DocInputs::default()
});
}
fn rows_of(doc: &Doc) -> String {
kinds(&doc.files[0])
}
#[test]
fn threads_sit_under_their_line_or_the_header() {
let mut doc = doc();
set_annotations(
&mut doc,
vec![
ann("line", Side::Right, Some(5)),
ann("file", Side::Right, None),
],
);
let file = &doc.files[0];
assert!(rows_of(&doc).starts_with("HTTTG"), "{}", rows_of(&doc));
let row = line_row(file, |l| l.right().is_some_and(|p| p.line == 5)).unwrap();
assert!(matches!(file.rows()[row + 1], Row::Thread(_)));
assert_eq!(doc.annotation_at(Pos { file: 0, row }), Some(0));
}
#[test]
fn a_change_to_one_files_threads_rebuilds_only_it() {
let mut doc = doc();
let old = numbered(60);
doc.set_diff(
2,
compute("b.txt", &old, &old.replace("line 7\n", "seven\n")),
);
let on_b = Annotation {
path: "b.txt".into(),
..ann("b", Side::Right, Some(7))
};
set_annotations(&mut doc, vec![on_b.clone()]);
let rows = doc.files[2].rows().to_vec();
doc.files[2].diff = Some(compute("b.txt", "x\n", "y\n"));
set_annotations(&mut doc, vec![ann("a", Side::Right, Some(5)), on_b]);
assert!(rows_of(&doc).contains('T'), "{}", rows_of(&doc));
let b = &doc.files[2];
assert_eq!(b.rows(), rows);
assert_eq!(
b.annotations_at(LinePos {
side: Side::Right,
line: 7
}),
[1]
);
let threads: Vec<u32> = (b.rows().iter())
.filter_map(|r| match r {
Row::Thread(t) => b.thread_row(*t).map(|t| t.ann),
_ => None,
})
.collect();
assert!(
!threads.is_empty() && threads.iter().all(|&a| a == 1),
"{threads:?}"
);
}
#[test]
fn a_fold_is_not_on_the_thread_it_hides() {
let mut doc = one(
"a.txt",
"a\nfoo(\n x,\n y\n);\nb\n",
"a\nfoo(x, y);\nb\n",
);
set_annotations(&mut doc, vec![ann("t", Side::Right, Some(2))]);
let rows = doc.files[0].rows();
let fold = rows.iter().position(|r| matches!(r, Row::Fold { .. }));
let thread = rows.iter().position(|r| matches!(r, Row::Thread(_)));
let at = |row| doc.annotation_at(Pos { file: 0, row });
assert_eq!(fold.and_then(at), None, "{}", rows_of(&doc));
assert_eq!(thread.and_then(at), Some(0), "{}", rows_of(&doc));
}
#[test]
fn a_long_threads_left_out_replies_show() {
let mut doc = doc();
let mut long = ann("file", Side::Right, None);
long.left_out = 120;
set_annotations(&mut doc, vec![long]);
let file = &doc.files[0];
let texts: Vec<(ThreadRowKind, String)> = file
.rows()
.iter()
.filter_map(|r| match r {
Row::Thread(t) => file.thread_row(*t).map(|t| (t.kind, t.line.text())),
_ => None,
})
.collect();
assert_eq!(
texts.get(2),
Some(&(
ThreadRowKind::Gap,
"… 120 more replies on GitHub".to_owned()
)),
"{texts:?}"
);
}
#[test]
fn commented_lines_are_always_visible() {
let mut doc = doc();
let visible = |doc: &Doc| {
line_row(&doc.files[0], |l| l.right().is_some_and(|p| p.line == 30)).is_some()
};
assert!(!visible(&doc));
set_annotations(&mut doc, vec![ann("far", Side::Right, Some(30))]);
assert!(visible(&doc));
}
#[test]
fn resolved_threads_start_collapsed_and_toggle() {
let mut doc = doc();
let mut resolved = ann("r", Side::Right, Some(5));
resolved.resolved = true;
set_annotations(&mut doc, vec![resolved]);
assert!(!rows_of(&doc).contains('T'), "collapsed: marker only");
doc.toggle_thread(0);
assert!(rows_of(&doc).contains("TTT"));
doc.toggle_thread(0);
assert!(!rows_of(&doc).contains('T'));
}
#[test]
fn next_thread_skips_resolved_ones() {
let mut doc = doc();
let mut resolved = ann("r", Side::Right, Some(5));
resolved.resolved = true;
set_annotations(&mut doc, vec![resolved, ann("open", Side::Right, Some(45))]);
let first = doc.next_thread(Pos::default()).unwrap();
assert_eq!(
doc.files[0]
.thread_row(match doc.row(first) {
Some(Row::Thread(t)) => t,
other => panic!("{other:?}"),
})
.unwrap()
.ann,
1
);
assert_eq!(doc.next_thread(first), None);
assert_eq!(doc.prev_thread(doc.last()), Some(first));
}
#[test]
fn left_side_threads_attach_to_removed_lines_in_split_view() {
let mut doc = doc();
doc.set_options(ViewOptions {
wrap: 40,
..view(true, Whitespace::Exact)
});
set_annotations(&mut doc, vec![ann("old", Side::Left, Some(5))]);
let file = &doc.files[0];
let at = line_row(file, |l| l.left().is_some_and(|p| p.line == 5)).unwrap();
assert!(matches!(file.rows()[at + 1], Row::Thread(_)));
}
#[test]
fn line_positions_for_comments() {
let doc = doc();
let file = &doc.files[0];
let removed = line_row(file, |l| matches!(l, DiffLine::Removed(_))).unwrap();
let pos = doc
.line_at(
Pos {
file: 0,
row: removed,
},
Side::Right,
)
.unwrap();
assert_eq!((pos.side, pos.line), (Side::Left, 5));
assert_eq!(
doc.line_at(
Pos {
file: 0,
row: removed + 1
},
Side::Left
)
.unwrap()
.side,
Side::Right
);
assert_eq!(
doc.line_at(Pos { file: 0, row: 0 }, Side::Right),
None,
"headers aren't lines"
);
let c = doc.commentable(0).unwrap();
assert!(c.is_commentable(pos));
}
}
mod better {
use super::*;
use crate::diff::moves::detect_moves;
#[test]
fn by_syntax_split_rows_pair_edited_lines() {
let mut doc = one(
"x.rs",
"a();\nrun(a, b);\nz();\n",
"a();\nprepare();\nrun(a, c);\nz();\n",
);
let beside = |doc: &Doc| {
let file = &doc.files[0];
let lines = &file.alignment().unwrap().lines;
file.rows().iter().find_map(|r| match *r {
Row::Split {
left: Some(l),
right,
} if lines[l as usize] == DiffLine::Removed(2) => {
Some(right.map(|r| lines[r as usize]))
}
_ => None,
})
};
doc.set_options(ViewOptions {
structural: true,
..view(true, Whitespace::Exact)
});
assert_eq!(beside(&doc), Some(Some(DiffLine::Added(3))));
doc.set_options(view(true, Whitespace::Exact));
assert_eq!(beside(&doc), Some(Some(DiffLine::Added(2))));
}
#[test]
fn formatting_only_blocks_fold_and_unfold() {
let mut doc = one(
"x.rs",
"a\nfoo(\n x,\n y\n);\nb\n",
"a\nfoo(x, y);\nb\n",
);
assert_eq!(kinds(&doc.files[0]), "H@LFL_");
let formatting = Row::Fold {
block: 0,
reason: FoldReason::Formatting,
};
let fold = doc.files[0]
.rows()
.iter()
.position(|r| *r == formatting)
.unwrap();
assert!(doc.unfold(Pos { file: 0, row: fold }));
assert!(!kinds(&doc.files[0]).contains('F'));
}
#[test]
fn since_review_shows_only_new_changes() {
let mut doc = doc();
let base = numbered(60);
let mut old = one("a.txt", &base, &base.replace("line 5\n", "five\n"));
let seen: HashSet<String> = old.files[0]
.blocks()
.iter()
.map(|b| b.hash.clone())
.collect();
doc.set_since(Some(seen.clone()), true);
let rows = kinds(&doc.files[0]);
let shows = |n: u32| {
line_row(&doc.files[0], |l| l.right().is_some_and(|p| p.line == n)).is_some()
};
assert!(shows(45), "{rows}");
assert!(!shows(5), "{rows}");
assert!(doc.has_new_changes(0));
old.set_since(Some(seen), true);
assert_eq!(old.files[0].rows()[1], Row::Note(Note::NothingNew));
old.set_since(old.since.clone(), false);
assert_ne!(old.files[0].rows()[1], Row::Note(Note::NothingNew));
}
fn reindent_and_edit() -> Doc {
one(
"x.rs",
"fn a() {\n x();\n y();\n}\n",
"fn a() {\n x();\n z();\n}\n",
)
}
fn hashes(doc: &Doc) -> HashSet<String> {
doc.files[0]
.blocks()
.iter()
.map(|b| b.hash.clone())
.collect()
}
#[test]
fn since_review_ignoring_whitespace_knows_what_was_seen() {
let seen = hashes(&reindent_and_edit());
let mut doc = reindent_and_edit();
doc.set_options(view(false, Whitespace::Ignore));
doc.set_since(Some(seen), true);
assert!(!doc.has_new_changes(0), "{}", kinds(&doc.files[0]));
assert_eq!(doc.files[0].rows()[1], Row::Note(Note::NothingNew));
}
#[test]
fn since_review_ignoring_whitespace_shows_a_change_joined_to_a_seen_one() {
let seen = hashes(&one("x.rs", "a\n", "b\na\n"));
let mut doc = one("x.rs", "a\n", "b\na\nc\n a\n");
doc.set_options(view(false, Whitespace::Ignore));
doc.set_since(Some(seen), true);
assert!(doc.has_new_changes(0), "{}", kinds(&doc.files[0]));
}
#[test]
fn reviewed_marks_survive_ignoring_whitespace() {
let mut doc = reindent_and_edit();
doc.set_inputs(DocInputs {
reviewed: hashes(&doc),
..Default::default()
});
doc.set_options(view(false, Whitespace::Ignore));
let blocks = doc.files[0].blocks();
assert!(!blocks.is_empty());
assert!(
blocks.iter().all(|b| doc.inputs.reviewed.contains(&b.hash)),
"a change marked reviewed shows as unreviewed once whitespace is ignored"
);
}
#[test]
fn opening_a_fold_opens_nothing_else_once_whitespace_is_ignored() {
let old = "a\n b\n c\nk\nm\nfoo(\nx)\n";
let mut doc = one("x.rs", old, "a\n b\n c\nk\nN\nm\nfoo(x)\n");
let seen = doc.files[0].block_of(6).map(|b| b.hash.clone());
doc.set_since(Some(seen.into_iter().collect()), true);
let n = doc.files[0].rows().iter().position(|r| {
*r == Row::Fold {
block: 1,
reason: FoldReason::Seen,
}
});
assert!(doc.unfold(Pos {
file: 0,
row: n.unwrap()
}));
doc.set_options(view(false, Whitespace::Ignore));
assert_eq!(
kinds(&doc.files[0]).matches('F').count(),
2,
"{}",
kinds(&doc.files[0])
);
}
#[test]
fn a_moved_row_holds_the_line_that_moved() {
let block = "fn helper(x: u32) -> u32 {\n let y = x * 2;\n y + 1\n}\n";
let mut doc = Doc::new(
vec![changed("a.rs"), changed("b.rs")],
&HashSet::new(),
Default::default(),
);
let old = format!("fn main() {{}}\n{block}");
doc.set_diff(0, compute("a.rs", &old, "fn main() {}\nfn other() {}\n"));
doc.set_diff(1, compute("b.rs", "// b\n", &format!("// b\n{block}")));
let texts: Vec<_> = doc
.files
.iter()
.map(|f| f.text().unwrap().clone())
.collect();
doc.set_moves(detect_moves(&[(0, &texts[0]), (1, &texts[1])]));
doc.set_options(view(true, Whitespace::Exact));
let row = doc.files[0]
.rows()
.iter()
.position(|r| matches!(r, Row::Moved { from: true, .. }))
.unwrap();
let anchor = doc.anchor(Pos { file: 0, row }, Side::Right);
assert_eq!(anchor.side(), Some(Side::Left));
doc.set_options(view(false, Whitespace::Exact));
let text = doc.row_text(doc.locate(anchor));
assert!(text.contains("fn helper"), "{text}");
}
#[test]
fn moved_blocks_get_rows_and_jump_targets() {
let block = "fn helper(x: u32) -> u32 {\n let y = x * 2;\n y + 1\n}\n";
let mut doc = Doc::new(
vec![changed("a.rs"), changed("b.rs")],
&HashSet::new(),
Default::default(),
);
doc.set_diff(
0,
compute(
"a.rs",
&format!("fn main() {{}}\n{block}"),
"fn main() {}\n",
),
);
doc.set_diff(1, compute("b.rs", "// b\n", &format!("// b\n{block}")));
let moves = {
let texts: Vec<_> = doc
.files
.iter()
.map(|f| f.text().unwrap().clone())
.collect();
detect_moves(&[(0, &texts[0]), (1, &texts[1])])
};
assert_eq!(moves.len(), 1);
doc.set_moves(moves);
let moved_from = doc.files[0]
.rows()
.iter()
.position(|r| matches!(r, Row::Moved { from: true, .. }))
.unwrap();
let moved_to = doc.files[1]
.rows()
.iter()
.position(|r| matches!(r, Row::Moved { from: false, .. }))
.unwrap();
let (mv, from) = doc
.move_at(
Pos {
file: 0,
row: moved_from,
},
Side::Right,
)
.unwrap();
let target = doc.move_target(mv, from).unwrap();
assert_eq!(target.file, 1);
assert_eq!(target.row + 1, moved_to, "lands on the block's first line");
doc.set_options(view(false, Whitespace::Ignore));
assert!(!kinds(&doc.files[0]).contains('M'));
}
}
}