use super::text::{fmt_date, sanitize_line, wrap_preserve, wrap_text};
use crate::comments::model::{CommentStore, Thread};
use crate::diff::model::{Changeset, DiffFile, Side};
use std::collections::{HashMap, HashSet};
use std::path::Path;
#[derive(Debug, Clone)]
pub enum CommentKind {
Top,
Head { replies: usize },
Author { name: String, date: String },
Body(String),
Gap,
Actions,
Bottom,
}
#[derive(Debug, Clone)]
pub struct CommentLine {
pub kind: CommentKind,
pub resolved: bool,
pub thread_id: String,
pub comment_id: Option<String>,
}
#[derive(Debug, Clone)]
pub enum ComposerAnchor {
NewThread {
file_idx: usize,
side: Side,
line: u32,
},
Reply { thread_id: String },
}
#[derive(Debug, Clone)]
pub struct ComposerSpec {
pub anchor: ComposerAnchor,
pub title: String,
pub body: String,
}
#[derive(Debug, Clone)]
pub enum ComposerKind {
Top { title: String },
Body(String),
Hint,
Bottom,
}
#[derive(Debug, Clone)]
pub struct ComposerLine {
pub kind: ComposerKind,
}
pub(super) enum Injected {
Comment(CommentLine),
Composer(ComposerLine),
}
pub(super) fn composer_lines(spec: &ComposerSpec, width: usize) -> Vec<ComposerLine> {
let mut out = vec![ComposerLine {
kind: ComposerKind::Top {
title: spec.title.clone(),
},
}];
for raw in spec.body.split('\n') {
let s = sanitize_line(raw);
if s.is_empty() {
out.push(ComposerLine {
kind: ComposerKind::Body(String::new()),
});
} else {
for wl in wrap_preserve(&s, width) {
out.push(ComposerLine {
kind: ComposerKind::Body(wl),
});
}
}
}
out.push(ComposerLine {
kind: ComposerKind::Hint,
});
out.push(ComposerLine {
kind: ComposerKind::Bottom,
});
out
}
pub(super) fn new_thread_composer(
composer: Option<&ComposerSpec>,
emitted: &mut bool,
file_idx: usize,
side: Side,
line: u32,
width: usize,
) -> Vec<ComposerLine> {
if *emitted {
return Vec::new();
}
if let Some(spec) = composer {
if let ComposerAnchor::NewThread {
file_idx: f,
side: s,
line: l,
} = spec.anchor
{
if f == file_idx && s == side && l == line {
*emitted = true;
return composer_lines(spec, width);
}
}
}
Vec::new()
}
pub fn thread_lines(t: &Thread, width: usize) -> Vec<CommentLine> {
let chrome = |kind: CommentKind| CommentLine {
kind,
resolved: t.resolved,
thread_id: t.id.clone(),
comment_id: None,
};
let content = |kind: CommentKind, cid: &str| CommentLine {
kind,
resolved: t.resolved,
thread_id: t.id.clone(),
comment_id: Some(cid.to_string()),
};
let mut out = vec![
chrome(CommentKind::Top),
chrome(CommentKind::Head {
replies: t.comments.len(),
}),
];
for (i, c) in t.comments.iter().enumerate() {
out.push(content(
CommentKind::Author {
name: c.author.clone().unwrap_or_else(|| "?".into()),
date: fmt_date(c.created_at),
},
&c.id,
));
for raw in c.body.split('\n') {
let s = sanitize_line(raw);
if s.is_empty() {
out.push(content(CommentKind::Body(String::new()), &c.id));
} else {
for wl in wrap_text(&s, width) {
out.push(content(CommentKind::Body(wl), &c.id));
}
}
}
if i + 1 < t.comments.len() {
out.push(content(CommentKind::Gap, &c.id));
}
}
out.push(chrome(CommentKind::Actions));
out.push(chrome(CommentKind::Bottom));
out
}
pub(super) type ThreadsByPath<'a> = std::collections::HashMap<&'a Path, Vec<usize>>;
pub(super) fn threads_by_path(comments: &CommentStore) -> ThreadsByPath<'_> {
let mut map: ThreadsByPath<'_> = std::collections::HashMap::new();
for (i, t) in comments.threads.iter().enumerate() {
map.entry(t.file.as_path()).or_default().push(i);
}
map
}
pub(super) fn last_anchor_lines(
changeset: &Changeset,
comments: &CommentStore,
by_path: &ThreadsByPath<'_>,
) -> HashMap<String, (Side, u32)> {
let mut m = HashMap::new();
for file in &changeset.files {
last_anchor_lines_in_file(file, comments, by_path, &mut m);
}
m
}
pub(super) fn last_anchor_lines_for(
file: &DiffFile,
comments: &CommentStore,
by_path: &ThreadsByPath<'_>,
) -> HashMap<String, (Side, u32)> {
let mut m = HashMap::new();
last_anchor_lines_in_file(file, comments, by_path, &mut m);
m
}
fn last_anchor_lines_in_file(
file: &DiffFile,
comments: &CommentStore,
by_path: &ThreadsByPath<'_>,
m: &mut HashMap<String, (Side, u32)>,
) {
let Some(indices) = by_path.get(Path::new(file.display_path())) else {
return;
};
let mut old_lines: Vec<u32> = Vec::new();
let mut new_lines: Vec<u32> = Vec::new();
for line in file.hunks.iter().flat_map(|h| h.lines.iter()) {
if let Some(l) = line.old_line {
old_lines.push(l);
}
if let Some(l) = line.new_line {
new_lines.push(l);
}
}
for &i in indices {
let t = &comments.threads[i];
let lines = match t.side {
Side::Old => &old_lines,
Side::New => &new_lines,
};
let idx = lines.partition_point(|&l| l <= t.range.end);
if idx > 0 && lines[idx - 1] >= t.range.start {
m.insert(t.id.clone(), (t.side, lines[idx - 1]));
}
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn comment_rows_for(
comments: &CommentStore,
by_path: &ThreadsByPath<'_>,
last: &HashMap<String, (Side, u32)>,
emitted: &mut HashSet<String>,
path: &str,
anchors: &[(Side, u32)],
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) {
continue;
}
if last
.get(&t.id)
.is_some_and(|&(ts, tl)| anchors.iter().any(|&(s, l)| s == ts && l == tl))
{
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
}