use crate::comments::model::CommentStore;
use crate::diff::model::{Changeset, LineKind, Side};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use unicode_width::UnicodeWidthChar;
mod text;
use text::sanitize_into;
pub use text::{char_width, sanitize_line, str_width, take_width};
mod threads;
use threads::{
comment_rows_for, composer_lines, last_anchor_lines, last_anchor_lines_for,
new_thread_composer, threads_by_path, Injected, ThreadsByPath,
};
pub use threads::{
thread_lines, CommentKind, CommentLine, ComposerAnchor, ComposerKind, ComposerLine,
ComposerSpec,
};
#[derive(Debug, Clone)]
pub enum RowKind {
FileHeader,
HunkHeader,
Line {
kind: LineKind,
old_line: Option<u32>,
new_line: Option<u32>,
},
Comment(CommentLine),
Composer(ComposerLine),
}
#[derive(Debug, Clone)]
pub struct Row {
pub file_idx: usize,
pub kind: RowKind,
pub text: String,
}
impl Row {
pub fn anchor(&self) -> Option<(Side, u32)> {
match &self.kind {
RowKind::Line {
kind,
old_line,
new_line,
} => match kind {
LineKind::Deletion => old_line.map(|l| (Side::Old, l)),
_ => new_line.map(|l| (Side::New, l)),
},
_ => None,
}
}
pub fn is_selectable(&self) -> bool {
matches!(self.kind, RowKind::Line { .. })
}
}
#[derive(Debug, Clone)]
pub struct SideCell {
pub kind: LineKind,
pub line: Option<u32>,
pub text: String,
}
#[derive(Debug, Clone)]
pub enum SplitRowKind {
FileHeader,
HunkHeader,
Pair {
left: Option<SideCell>,
right: Option<SideCell>,
},
Comment {
side: Side,
line: CommentLine,
},
Composer {
side: Side,
line: ComposerLine,
},
}
#[derive(Debug, Clone)]
pub struct SplitRow {
pub file_idx: usize,
pub kind: SplitRowKind,
pub text: String, }
impl SplitRow {
pub fn is_selectable(&self) -> bool {
matches!(self.kind, SplitRowKind::Pair { .. })
}
pub fn anchor(&self) -> Option<(Side, u32)> {
match &self.kind {
SplitRowKind::Pair { left, right } => right
.as_ref()
.and_then(|c| c.line.map(|l| (Side::New, l)))
.or_else(|| left.as_ref().and_then(|c| c.line.map(|l| (Side::Old, l)))),
_ => None,
}
}
}
fn hunk_header(hunk: &crate::diff::model::Hunk) -> String {
format!(
"@@ -{},{} +{},{} @@{}",
hunk.old_start,
hunk.old_count,
hunk.new_start,
hunk.new_count,
hunk.section
.as_ref()
.map(|s| format!(" {s}"))
.unwrap_or_default()
)
}
pub fn build_split_rows(
changeset: &Changeset,
comments: &CommentStore,
width: usize,
composer: Option<&ComposerSpec>,
) -> Vec<SplitRow> {
build_split_rows_inner(changeset, comments, width, composer, None)
}
pub fn build_file_split_rows(
changeset: &Changeset,
comments: &CommentStore,
width: usize,
composer: Option<&ComposerSpec>,
fi: usize,
) -> Vec<SplitRow> {
build_split_rows_inner(changeset, comments, width, composer, Some(fi))
}
fn build_split_rows_inner(
changeset: &Changeset,
comments: &CommentStore,
width: usize,
composer: Option<&ComposerSpec>,
only: Option<usize>,
) -> Vec<SplitRow> {
let mut rows = Vec::new();
let mut emitted: HashSet<String> = HashSet::new();
let mut composer_emitted = false;
let by_path = threads_by_path(comments);
let last = last_for(changeset, comments, &by_path, only);
for (fi, file) in changeset.files.iter().enumerate() {
if only.is_some_and(|o| o != fi) {
continue;
}
let path = file.display_path();
rows.push(SplitRow {
file_idx: fi,
kind: SplitRowKind::FileHeader,
text: file.display_path().to_string(),
});
if file.is_binary {
rows.push(SplitRow {
file_idx: fi,
kind: SplitRowKind::HunkHeader,
text: "Binary file".into(),
});
continue;
}
for hunk in &file.hunks {
rows.push(SplitRow {
file_idx: fi,
kind: SplitRowKind::HunkHeader,
text: hunk_header(hunk),
});
let mut dels: Vec<SideCell> = Vec::new();
let mut adds: Vec<SideCell> = Vec::new();
for line in &hunk.lines {
let cell = SideCell {
kind: line.kind,
line: match line.kind {
LineKind::Deletion => line.old_line,
_ => line.new_line,
},
text: sanitize_line(&line.text),
};
match line.kind {
LineKind::Deletion => dels.push(cell),
LineKind::Addition => adds.push(cell),
LineKind::Context => {
flush_pairs(
fi,
&mut dels,
&mut adds,
&mut rows,
comments,
&by_path,
&last,
&mut emitted,
path,
width,
composer,
&mut composer_emitted,
);
rows.push(SplitRow {
file_idx: fi,
kind: SplitRowKind::Pair {
left: Some(SideCell {
kind: LineKind::Context,
line: line.old_line,
text: cell.text.clone(),
}),
right: Some(cell),
},
text: String::new(),
});
let mut anchors = Vec::new();
if let Some(l) = line.old_line {
anchors.push((Side::Old, l));
}
if let Some(l) = line.new_line {
anchors.push((Side::New, l));
}
for (side, inj) in comment_rows_for(
comments,
&by_path,
&last,
&mut emitted,
path,
&anchors,
width,
composer,
) {
rows.push(SplitRow {
file_idx: fi,
kind: split_injected(side, inj),
text: String::new(),
});
}
for (side, l) in &anchors {
for cl in new_thread_composer(
composer,
&mut composer_emitted,
fi,
*side,
*l,
width,
) {
rows.push(SplitRow {
file_idx: fi,
kind: SplitRowKind::Composer {
side: *side,
line: cl,
},
text: String::new(),
});
}
}
}
}
}
flush_pairs(
fi,
&mut dels,
&mut adds,
&mut rows,
comments,
&by_path,
&last,
&mut emitted,
path,
width,
composer,
&mut composer_emitted,
);
}
for (side, inj) in
orphan_thread_rows(comments, &by_path, &mut emitted, path, width, composer)
{
rows.push(SplitRow {
file_idx: fi,
kind: split_injected(side, inj),
text: String::new(),
});
}
}
rows
}
fn split_injected(side: Side, inj: Injected) -> SplitRowKind {
match inj {
Injected::Comment(line) => SplitRowKind::Comment { side, line },
Injected::Composer(line) => SplitRowKind::Composer { side, line },
}
}
#[allow(clippy::too_many_arguments)]
fn flush_pairs(
file_idx: usize,
dels: &mut Vec<SideCell>,
adds: &mut Vec<SideCell>,
rows: &mut Vec<SplitRow>,
comments: &CommentStore,
by_path: &ThreadsByPath<'_>,
last: &HashMap<String, (Side, u32)>,
emitted: &mut HashSet<String>,
path: &str,
width: usize,
composer: Option<&ComposerSpec>,
composer_emitted: &mut bool,
) {
let n = dels.len().max(adds.len());
let mut di = dels.drain(..);
let mut ai = adds.drain(..);
for _ in 0..n {
let left = di.next();
let right = ai.next();
let mut anchors = Vec::new();
if let Some(l) = left.as_ref().and_then(|c| c.line) {
anchors.push((Side::Old, l));
}
if let Some(l) = right.as_ref().and_then(|c| c.line) {
anchors.push((Side::New, l));
}
rows.push(SplitRow {
file_idx,
kind: SplitRowKind::Pair { left, right },
text: String::new(),
});
for (side, inj) in comment_rows_for(
comments, by_path, last, emitted, path, &anchors, width, composer,
) {
rows.push(SplitRow {
file_idx,
kind: split_injected(side, inj),
text: String::new(),
});
}
for (side, l) in &anchors {
for cl in new_thread_composer(composer, composer_emitted, file_idx, *side, *l, width) {
rows.push(SplitRow {
file_idx,
kind: SplitRowKind::Composer {
side: *side,
line: cl,
},
text: String::new(),
});
}
}
}
}
fn orphan_thread_rows(
comments: &CommentStore,
by_path: &ThreadsByPath<'_>,
emitted: &mut HashSet<String>,
path: &str,
width: usize,
composer: Option<&ComposerSpec>,
) -> Vec<(Side, Injected)> {
let mut out = Vec::new();
let Some(indices) = by_path.get(Path::new(path)) else {
return out;
};
for &i in indices {
let t = &comments.threads[i];
if emitted.contains(t.id.as_str()) {
continue;
}
emitted.insert(t.id.clone());
out.extend(
thread_lines(t, width)
.into_iter()
.map(|cl| (t.side, Injected::Comment(cl))),
);
if let Some(spec) = composer {
if matches!(&spec.anchor, ComposerAnchor::Reply { thread_id } if *thread_id == t.id) {
out.extend(
composer_lines(spec, width)
.into_iter()
.map(|cl| (t.side, Injected::Composer(cl))),
);
}
}
}
out
}
pub fn build_rows(
changeset: &Changeset,
comments: &CommentStore,
width: usize,
composer: Option<&ComposerSpec>,
) -> Vec<Row> {
build_rows_inner(changeset, comments, width, composer, None)
}
pub fn build_file_rows(
changeset: &Changeset,
comments: &CommentStore,
width: usize,
composer: Option<&ComposerSpec>,
fi: usize,
) -> Vec<Row> {
build_rows_inner(changeset, comments, width, composer, Some(fi))
}
fn last_for(
changeset: &Changeset,
comments: &CommentStore,
by_path: &ThreadsByPath<'_>,
only: Option<usize>,
) -> HashMap<String, (Side, u32)> {
match only.and_then(|fi| changeset.files.get(fi)) {
Some(file) => last_anchor_lines_for(file, comments, by_path),
None => last_anchor_lines(changeset, comments, by_path),
}
}
fn build_rows_inner(
changeset: &Changeset,
comments: &CommentStore,
width: usize,
composer: Option<&ComposerSpec>,
only: Option<usize>,
) -> Vec<Row> {
let mut rows = Vec::new();
let mut emitted: HashSet<String> = HashSet::new();
let mut composer_emitted = false;
let by_path = threads_by_path(comments);
let last = last_for(changeset, comments, &by_path, only);
for (fi, file) in changeset.files.iter().enumerate() {
if only.is_some_and(|o| o != fi) {
continue;
}
let path = file.display_path();
rows.push(Row {
file_idx: fi,
kind: RowKind::FileHeader,
text: file.display_path().to_string(),
});
if file.is_binary {
rows.push(Row {
file_idx: fi,
kind: RowKind::HunkHeader,
text: "Binary file".into(),
});
continue;
}
for hunk in &file.hunks {
rows.push(Row {
file_idx: fi,
kind: RowKind::HunkHeader,
text: hunk_header(hunk),
});
for line in &hunk.lines {
let prefix = match line.kind {
LineKind::Addition => '+',
LineKind::Deletion => '-',
LineKind::Context => ' ',
};
let mut text = String::with_capacity(line.text.len() + 1);
text.push(prefix);
sanitize_into(&mut text, &line.text);
rows.push(Row {
file_idx: fi,
kind: RowKind::Line {
kind: line.kind,
old_line: line.old_line,
new_line: line.new_line,
},
text,
});
let (side, ln) = match line.kind {
LineKind::Deletion => (Side::Old, line.old_line),
_ => (Side::New, line.new_line),
};
if let Some(ln) = ln {
for (_, inj) in comment_rows_for(
comments,
&by_path,
&last,
&mut emitted,
path,
&[(side, ln)],
width,
composer,
) {
let kind = match inj {
Injected::Comment(cl) => RowKind::Comment(cl),
Injected::Composer(cl) => RowKind::Composer(cl),
};
rows.push(Row {
file_idx: fi,
kind,
text: String::new(),
});
}
for cl in
new_thread_composer(composer, &mut composer_emitted, fi, side, ln, width)
{
rows.push(Row {
file_idx: fi,
kind: RowKind::Composer(cl),
text: String::new(),
});
}
}
}
}
for (_, inj) in orphan_thread_rows(comments, &by_path, &mut emitted, path, width, composer)
{
let kind = match inj {
Injected::Comment(cl) => RowKind::Comment(cl),
Injected::Composer(cl) => RowKind::Composer(cl),
};
rows.push(Row {
file_idx: fi,
kind,
text: String::new(),
});
}
}
rows
}
#[cfg(test)]
mod tests;