use std::{
collections::{BTreeMap, BTreeSet},
fmt,
};
use crate::code_review_tui::{
CachedReviewFile, ReviewAgentThreadState, ReviewDraftComment, ReviewFile,
ReviewSuggestedComment, ReviewSuggestionStatus,
};
use crate::code_review_tui_display::{
ReviewDisplayBuilder, ReviewDisplayRow, ReviewDisplayRowSource,
};
const CONTEXT_EXPAND_STEP: u32 = 20;
pub(crate) fn inline_prefix(branch: &str, label: &str, suggestion: bool, width: u16) -> String {
let label_width = if suggestion { 8 } else { 6 };
bmux_tui::text::Line::raw(format!(" {branch} {label:<label_width$} "))
.viewport(0, usize::from(width.saturating_sub(1)))
.plain_text()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewViewDocument {
pub rows: Vec<ReviewViewRow>,
}
impl ReviewViewDocument {
#[must_use]
pub fn build_diff_file(
file_index: usize,
file: &ReviewFile,
syntax_highlighting: bool,
syntax_palette: Option<bcode_syntax_render::SyntaxPalette>,
cached_file: Option<&CachedReviewFile>,
context_load_states: &BTreeMap<DiffContextKey, DiffContextLoadState>,
) -> Self {
let display = ReviewDisplayBuilder::new()
.syntax_highlighting(syntax_highlighting)
.syntax_palette(syntax_palette)
.build_file(file);
let hunk_start_rows = hunk_start_rows(file);
let display_by_source = display
.rows
.into_iter()
.enumerate()
.collect::<BTreeMap<_, _>>();
let mut rows = Vec::with_capacity(display_by_source.len());
if let Some(first_hunk) = file.hunks.first()
&& first_hunk.new_start > 1
{
push_diff_context_rows(
DiffContextRange {
file_index,
hunk_index: 0,
placement: DiffContextPlacement::Top,
start_line: 1,
end_line: first_hunk.new_start.saturating_sub(1),
},
cached_file,
context_load_states,
&mut rows,
);
}
let mut source_row;
let mut previous_hunk_end_new_line: Option<u32> = None;
for (hunk_index, hunk) in file.hunks.iter().enumerate() {
if let Some(previous_new_line) = previous_hunk_end_new_line
&& hunk.new_start > previous_new_line.saturating_add(1)
{
let start_line = previous_new_line.saturating_add(1);
let end_line = hunk.new_start.saturating_sub(1);
push_diff_context_rows(
DiffContextRange {
file_index,
hunk_index,
placement: DiffContextPlacement::Middle,
start_line,
end_line,
},
cached_file,
context_load_states,
&mut rows,
);
}
let hunk_source_row = hunk_start_rows[hunk_index];
if let Some(display_row) = display_by_source.get(&hunk_source_row).cloned() {
rows.push(view_row_for_display(
file_index,
hunk_source_row,
display_row,
));
}
source_row = hunk_source_row.saturating_add(1);
for _line in &hunk.lines {
if let Some(display_row) = display_by_source.get(&source_row).cloned() {
rows.push(view_row_for_display(file_index, source_row, display_row));
}
source_row = source_row.saturating_add(1);
}
previous_hunk_end_new_line = Some(
hunk.new_start
.saturating_add(hunk.new_count)
.saturating_sub(1),
);
}
if let (Some(previous_new_line), Some(cached_file)) =
(previous_hunk_end_new_line, cached_file)
{
let total_lines = u32::try_from(cached_file.line_spans.len()).unwrap_or(u32::MAX);
if total_lines > previous_new_line {
push_diff_context_rows(
DiffContextRange {
file_index,
hunk_index: file.hunks.len(),
placement: DiffContextPlacement::Bottom,
start_line: previous_new_line.saturating_add(1),
end_line: total_lines,
},
Some(cached_file),
context_load_states,
&mut rows,
);
}
}
renumber_rows(&mut rows);
Self { rows }
}
#[must_use]
pub fn build_materialized_file_surface(file_index: usize, file: &ReviewFile) -> Self {
let rows = materialized_file_surface_rows(file)
.into_iter()
.enumerate()
.map(|(source_row, (line_number, content))| ReviewViewRow {
visual_row: source_row,
source_row: Some(source_row),
target: line_number.map_or(
ReviewViewTarget::HunkHeader {
file_index,
source_row,
},
|line_number| ReviewViewTarget::SourceLine {
file_index,
source_row,
old_line: None,
new_line: Some(line_number),
},
),
block: ReviewViewBlock::FileLine {
line_number,
content,
},
})
.collect();
Self { rows }
}
#[must_use]
pub fn build_repository_file(file_index: usize, file: &CachedReviewFile) -> Self {
let rows = file
.line_spans
.iter()
.enumerate()
.filter_map(|(source_row, _)| {
let content = file.line(source_row)?.to_string();
let line_number = u32::try_from(source_row.saturating_add(1)).ok();
Some(ReviewViewRow {
visual_row: source_row,
source_row: Some(source_row),
target: ReviewViewTarget::SourceLine {
file_index,
source_row,
old_line: None,
new_line: line_number,
},
block: ReviewViewBlock::FileLine {
line_number,
content,
},
})
})
.collect();
Self { rows }
}
#[must_use]
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub fn with_inline_draft_threads(
mut self,
file_index: usize,
drafts: impl Iterator<Item = (ReviewThreadAnchor, Vec<ReviewDraftComment>)>,
suggestions: impl Iterator<Item = (ReviewThreadAnchor, Vec<ReviewSuggestedComment>)>,
agent_states: &BTreeMap<String, ReviewAgentThreadState>,
expanded_agent_answers: &BTreeSet<String>,
collapsed_threads: &BTreeSet<String>,
resolved_threads: &BTreeSet<String>,
show_resolved_threads: bool,
) -> Self {
let mut threads = drafts
.filter(|(anchor, comments)| anchor.file_index == file_index && !comments.is_empty())
.collect::<Vec<_>>();
let mut suggestions = suggestions
.filter(|(anchor, suggestions)| {
anchor.file_index == file_index && !suggestions.is_empty()
})
.collect::<BTreeMap<_, _>>();
threads.sort_by_key(|(anchor, _)| anchor.end_source_row());
let mut rows = Vec::with_capacity(self.rows.len().saturating_add(threads.len()));
for row in self.rows.drain(..) {
let source_row = row.source_row;
rows.push(row);
let Some(source_row) = source_row else {
continue;
};
for (anchor, comments) in threads
.iter()
.filter(|(anchor, _)| anchor.end_source_row() == source_row)
{
let thread_key = anchor.thread_key();
let collapsed = collapsed_threads.contains(&thread_key);
let resolved = resolved_threads.contains(&thread_key);
if resolved && !show_resolved_threads {
continue;
}
rows.push(ReviewViewRow {
visual_row: 0,
source_row: None,
target: ReviewViewTarget::Thread {
thread_key: thread_key.clone(),
},
block: ReviewViewBlock::InlineThreadHeader {
thread_key: thread_key.clone(),
anchor: anchor.clone(),
comment_count: comments.len(),
collapsed,
resolved,
},
});
if collapsed {
continue;
}
for (comment_index, comment) in comments.iter().cloned().enumerate() {
let body_line_count = comment.body.lines().count().max(1);
for body_line_index in 0..body_line_count {
rows.push(ReviewViewRow {
visual_row: 0,
source_row: None,
target: ReviewViewTarget::Comment {
thread_key: thread_key.clone(),
comment_index,
},
block: ReviewViewBlock::InlineComment {
thread_key: thread_key.clone(),
comment_index,
body_line_index,
body_line_count,
comment: comment.clone(),
rendered: None,
source_ranges: Vec::new(),
},
});
}
}
let thread_suggestions = suggestions.remove(anchor).unwrap_or_default();
let has_pending_suggestion = thread_suggestions.iter().any(|suggestion| {
matches!(
suggestion.status,
ReviewSuggestionStatus::Suggested | ReviewSuggestionStatus::Refining
)
});
for (suggestion_index, suggestion) in thread_suggestions.into_iter().enumerate() {
let body_line_count = suggestion.body.lines().count().max(1);
for body_line_index in 0..body_line_count {
rows.push(ReviewViewRow {
visual_row: 0,
source_row: None,
target: ReviewViewTarget::Thread {
thread_key: thread_key.clone(),
},
block: ReviewViewBlock::InlineSuggestion {
thread_key: thread_key.clone(),
suggestion_index,
body_line_index,
body_line_count,
suggestion: suggestion.clone(),
rendered: None,
source_ranges: Vec::new(),
},
});
}
}
if let Some(agent_state) = agent_states.get(&thread_key) {
rows.push(ReviewViewRow {
visual_row: 0,
source_row: None,
target: ReviewViewTarget::AgentThread {
thread_key: thread_key.clone(),
},
block: ReviewViewBlock::InlineAgentThread {
thread_key: thread_key.clone(),
state: agent_state.clone(),
expanded: expanded_agent_answers.contains(&thread_key),
rendered: None,
body_line_index: 0,
body_line_count: 1,
},
});
}
let has_agent_answer = agent_states
.get(&thread_key)
.is_some_and(|state| !state.answer.trim().is_empty());
let has_linked_session =
comments.iter().any(|comment| comment.session_id.is_some())
|| agent_states
.get(&thread_key)
.is_some_and(|state| state.session_id.is_some());
let has_stream_error = agent_states
.get(&thread_key)
.is_some_and(|state| state.error.is_some());
let has_ai_exchange = agent_states.contains_key(&thread_key);
for action in ReviewThreadAction::all_for_state(
resolved,
has_agent_answer,
has_linked_session,
has_stream_error,
has_ai_exchange,
has_pending_suggestion,
) {
rows.push(ReviewViewRow {
visual_row: 0,
source_row: None,
target: ReviewViewTarget::ThreadAction {
thread_key: thread_key.clone(),
action: action.id().to_string(),
},
block: ReviewViewBlock::InlineThreadAction {
thread_key: thread_key.clone(),
action,
},
});
}
}
}
for (visual_row, row) in rows.iter_mut().enumerate() {
row.visual_row = visual_row;
}
Self { rows }
}
#[must_use]
pub fn layout_inline_threads(mut self, width: u16) -> Self {
let prefix_width =
bmux_tui::text_width::display_width(&inline_prefix("│", "", false, width));
let content_width = width
.saturating_sub(u16::try_from(prefix_width).unwrap_or(u16::MAX))
.max(1);
let mut rows = Vec::new();
for row in self.rows {
if let ReviewViewBlock::InlineComment {
body_line_index,
comment,
..
} = &row.block
{
if *body_line_index != 0 {
continue;
}
let (mut rendered, ranges) =
markdown_rows_with_sources(&comment.body, content_width);
if rendered.is_empty() {
rendered.push(bmux_tui::text::Line::default());
}
let count = rendered.len();
for (index, line) in rendered.into_iter().enumerate() {
let mut projected = row.clone();
if let ReviewViewBlock::InlineComment {
body_line_index,
body_line_count,
rendered,
source_ranges,
..
} = &mut projected.block
{
*body_line_index = index;
*body_line_count = count;
*rendered = Some(line);
*source_ranges = ranges.get(index).cloned().unwrap_or_default();
}
rows.push(projected);
}
} else if let ReviewViewBlock::InlineSuggestion {
body_line_index,
suggestion,
..
} = &row.block
{
if *body_line_index != 0 {
continue;
}
let content_width = usize::from(width)
.saturating_sub(bmux_tui::text_width::display_width(&inline_prefix(
"│", "", true, width,
)))
.max(1);
let (lines, ranges) = plain_rows_with_sources(&suggestion.body, content_width);
let count = lines.len();
for (index, line) in lines.into_iter().enumerate() {
let mut projected = row.clone();
if let ReviewViewBlock::InlineSuggestion {
body_line_index,
body_line_count,
rendered,
source_ranges,
..
} = &mut projected.block
{
*body_line_index = index;
*body_line_count = count;
*rendered = Some(line);
*source_ranges = ranges.get(index).cloned().unwrap_or_default();
}
rows.push(projected);
}
} else if let ReviewViewBlock::InlineAgentThread {
body_line_index,
state,
expanded,
..
} = &row.block
{
if *body_line_index != 0 {
continue;
}
let lines = agent_thread_rows(state, *expanded, width);
append_agent_rows(&mut rows, &row, lines);
} else {
rows.push(row);
}
}
for (index, row) in rows.iter_mut().enumerate() {
row.visual_row = index;
}
self.rows = rows;
self
}
pub(crate) fn text_anchor(&self, row: usize) -> Option<(ReviewViewTarget, String, usize)> {
let row = self.rows.get(row)?;
let (id, ranges) = row.block.text_source()?;
Some((
row.target.clone(),
id.to_owned(),
ranges.iter().map(|range| range.start).min()?,
))
}
pub(crate) fn row_for_text_anchor(
&self,
target: &ReviewViewTarget,
id: &str,
byte: usize,
) -> Option<usize> {
self.rows.iter().find_map(|row| {
let (source_id, ranges) = row.block.text_source()?;
(&row.target == target
&& source_id == id
&& ranges.iter().any(|range| range.contains(&byte)))
.then_some(row.visual_row)
})
}
#[must_use]
pub fn row_for_visual_row(&self, visual_row: usize) -> Option<&ReviewViewRow> {
self.rows.get(visual_row)
}
#[must_use]
pub fn target_for_visual_row(&self, visual_row: usize) -> Option<&ReviewViewTarget> {
self.row_for_visual_row(visual_row).map(|row| &row.target)
}
#[must_use]
pub fn target_for_source_row(&self, source_row: usize) -> Option<&ReviewViewTarget> {
self.visual_row_for_source_row(source_row)
.and_then(|visual_row| self.target_for_visual_row(visual_row))
}
#[must_use]
pub fn visual_row_for_source_row(&self, source_row: usize) -> Option<usize> {
self.rows
.iter()
.find(|row| row.source_row == Some(source_row))
.map(|row| row.visual_row)
}
#[must_use]
pub fn visual_row_for_target(&self, target: &ReviewViewTarget) -> Option<usize> {
self.rows
.iter()
.find(|row| &row.target == target)
.map(|row| row.visual_row)
}
#[must_use]
pub fn hunk_targets(&self) -> Vec<ReviewViewTarget> {
self.rows
.iter()
.filter_map(|row| match &row.target {
ReviewViewTarget::HunkHeader { .. } => Some(row.target.clone()),
_ => None,
})
.collect()
}
#[must_use]
pub fn next_hunk_target_after_visual_row(&self, visual_row: usize) -> Option<ReviewViewTarget> {
self.rows.iter().find_map(|row| {
(row.visual_row > visual_row
&& matches!(row.target, ReviewViewTarget::HunkHeader { .. }))
.then(|| row.target.clone())
})
}
#[must_use]
pub fn previous_hunk_target_before_visual_row(
&self,
visual_row: usize,
) -> Option<ReviewViewTarget> {
self.rows.iter().rev().find_map(|row| {
(row.visual_row < visual_row
&& matches!(row.target, ReviewViewTarget::HunkHeader { .. }))
.then(|| row.target.clone())
})
}
#[must_use]
pub fn source_row_for_target(&self, target: &ReviewViewTarget) -> Option<usize> {
self.rows
.iter()
.find(|row| &row.target == target)
.and_then(|row| row.source_row)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewViewRow {
pub visual_row: usize,
pub source_row: Option<usize>,
pub target: ReviewViewTarget,
pub block: ReviewViewBlock,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewViewBlock {
OmittedContext {
key: DiffContextKey,
hidden_line_count: usize,
start_line: u32,
end_line: u32,
},
LoadingContext {
key: DiffContextKey,
hidden_line_count: usize,
start_line: u32,
end_line: u32,
},
UnavailableContext {
key: DiffContextKey,
hidden_line_count: usize,
start_line: u32,
end_line: u32,
reason: String,
},
DisplayRow(ReviewDisplayRow),
FileLine {
line_number: Option<u32>,
content: String,
},
InlineThreadHeader {
thread_key: String,
anchor: ReviewThreadAnchor,
comment_count: usize,
collapsed: bool,
resolved: bool,
},
InlineComment {
thread_key: String,
comment_index: usize,
body_line_index: usize,
body_line_count: usize,
comment: ReviewDraftComment,
rendered: Option<bmux_tui::text::Line>,
source_ranges: Vec<std::ops::Range<usize>>,
},
InlineSuggestion {
thread_key: String,
suggestion_index: usize,
body_line_index: usize,
body_line_count: usize,
suggestion: ReviewSuggestedComment,
rendered: Option<bmux_tui::text::Line>,
source_ranges: Vec<std::ops::Range<usize>>,
},
InlineAgentThread {
thread_key: String,
state: ReviewAgentThreadState,
expanded: bool,
rendered: Option<AgentThreadRow>,
body_line_index: usize,
body_line_count: usize,
},
InlineThreadAction {
thread_key: String,
action: ReviewThreadAction,
},
}
impl ReviewViewBlock {
fn text_source(&self) -> Option<(&str, &[std::ops::Range<usize>])> {
match self {
Self::InlineComment { source_ranges, .. }
| Self::InlineSuggestion { source_ranges, .. } => Some(("body", source_ranges)),
Self::InlineAgentThread {
rendered: Some(row),
..
} => Some((&row.source_id, &row.source_ranges)),
_ => None,
}
}
}
fn markdown_rows_with_sources(
text: &str,
width: u16,
) -> (Vec<bmux_tui::text::Line>, Vec<Vec<std::ops::Range<usize>>>) {
let result = bcode_markdown_render::render_markdown(
text,
&bcode_markdown_render::MarkdownRenderOptions::new(width),
);
let mut ranges = vec![Vec::new(); result.lines.len()];
for unit in result.selection_provenance() {
for rect in &unit.rects {
if let Some(row) = ranges.get_mut(usize::from(rect.y)) {
row.extend(unit.source_ranges.iter().cloned());
}
}
}
(result.lines, ranges)
}
fn plain_rows_with_sources(
text: &str,
width: usize,
) -> (Vec<bmux_tui::text::Line>, Vec<Vec<std::ops::Range<usize>>>) {
use bmux_tui::component::{Component, Constraints, LayoutCx};
use bmux_tui::composition::TextBlock;
let block = TextBlock::new(text).wrap(bmux_tui::text::TextWrap::Word);
let layout = block.layout(Constraints::for_width(width as u64), &mut LayoutCx::new());
let mut lines = Vec::new();
let mut ranges = Vec::new();
for row in block.projection(&layout) {
ranges.push(
if row.line.width() <= width && !row.source_range.is_empty() {
std::iter::once(row.source_range).collect()
} else {
Vec::new()
},
);
lines.push(row.line.viewport(0, width));
}
if lines.is_empty() {
lines.push(bmux_tui::text::Line::default());
ranges.push(Vec::new());
}
(lines, ranges)
}
fn append_agent_rows(
rows: &mut Vec<ReviewViewRow>,
row: &ReviewViewRow,
lines: Vec<AgentThreadRow>,
) {
let count = lines.len();
for (index, line) in lines.into_iter().enumerate() {
let mut projected = row.clone();
if let ReviewViewBlock::InlineAgentThread {
body_line_index,
body_line_count,
rendered,
..
} = &mut projected.block
{
*body_line_index = index;
*body_line_count = count;
*rendered = Some(line);
}
rows.push(projected);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentThreadRow {
pub prefix: String,
pub content: bmux_tui::text::Line,
pub source_id: String,
pub source_ranges: Vec<std::ops::Range<usize>>,
pub kind: Option<crate::code_review_tui::ReviewAgentSessionItemKind>,
}
fn agent_text_rows(prefix: &str, text: &str, markdown: bool, width: u16) -> Vec<AgentThreadRow> {
use bmux_tui::text::Line;
let prefix = Line::raw(prefix)
.viewport(0, usize::from(width.saturating_sub(1)))
.plain_text();
let available = width
.saturating_sub(
u16::try_from(bmux_tui::text_width::display_width(&prefix)).unwrap_or(u16::MAX),
)
.max(1);
let (content, ranges) = if markdown {
markdown_rows_with_sources(text, available)
} else {
plain_rows_with_sources(text, usize::from(available))
};
content
.into_iter()
.zip(ranges)
.map(|(content, source_ranges)| AgentThreadRow {
prefix: prefix.clone(),
content: content.viewport(0, usize::from(available)),
source_id: String::new(),
source_ranges,
kind: None,
})
.collect()
}
pub(crate) fn agent_thread_rows(
state: &ReviewAgentThreadState,
expanded: bool,
width: u16,
) -> Vec<AgentThreadRow> {
let mut rows = agent_text_rows(
&format!(" │ 🤖 Bcode · {} ", state.live_state_label()),
state.error.as_deref().unwrap_or(&state.status),
false,
width,
);
for row in &mut rows {
"status".clone_into(&mut row.source_id);
}
for (prefix, text) in [
(" │ context ", Some(state.context_summary.as_str())),
(" │ ⚠stream ", state.stream_warning.as_deref()),
(" │ activity ", state.activity.as_deref()),
] {
if let Some(text) = text.filter(|text| !text.is_empty()) {
rows.extend(
agent_text_rows(prefix, text, false, width)
.into_iter()
.map(|mut row| {
prefix.clone_into(&mut row.source_id);
row
}),
);
}
}
let limit = if expanded { 24 } else { 6 };
for item in state
.session_items
.iter()
.skip(state.session_items.len().saturating_sub(limit))
{
let marker = if item.degraded {
"âš "
} else if item.streaming {
"…"
} else {
"·"
};
if let Some(mut row) = agent_text_rows(
&format!(" │ {marker} {} ", item.label),
&item.text,
item.format == bcode_session_view_models::TextFormat::Markdown,
width,
)
.into_iter()
.next()
{
row.kind = Some(item.kind);
row.source_id = format!("session:{}", item.id);
rows.push(row);
}
}
if !state.answer.trim().is_empty() {
let mut answer = agent_text_rows(" │ answer ", &state.answer, true, width);
for row in &mut answer {
"answer".clone_into(&mut row.source_id);
}
if !expanded && answer.len() > 4 {
answer.truncate(4);
if let Some(last) = answer.last_mut() {
last.content.push_span(bmux_tui::text::Span::raw(" …"));
let available = usize::from(width)
.saturating_sub(bmux_tui::text_width::display_width(&last.prefix));
last.content = last.content.truncate(available);
last.source_ranges.clear();
}
}
rows.extend(answer);
}
rows
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum ReviewThreadAction {
Reply,
Edit,
Delete,
DeleteExchange,
AskBcode,
FollowUp,
RetrySession,
ToggleAnswer,
CopyAnswer,
SuggestAnswer,
AcceptSuggestion,
RefineSuggestion,
RejectSuggestion,
DraftAnswer,
OpenSession,
Publish,
Resolve,
Reopen,
}
impl ReviewThreadAction {
#[must_use]
#[allow(clippy::fn_params_excessive_bools)]
pub fn all_for_state(
resolved: bool,
has_agent_answer: bool,
has_linked_session: bool,
has_stream_error: bool,
has_ai_exchange: bool,
has_pending_suggestion: bool,
) -> Vec<Self> {
let mut actions = vec![Self::Reply, Self::Edit, Self::Delete];
if has_ai_exchange {
actions.push(Self::DeleteExchange);
}
if has_linked_session {
actions.push(Self::FollowUp);
actions.push(Self::OpenSession);
if has_stream_error {
actions.push(Self::RetrySession);
}
} else {
actions.push(Self::AskBcode);
}
if has_agent_answer {
actions.push(Self::ToggleAnswer);
actions.push(Self::CopyAnswer);
actions.push(Self::SuggestAnswer);
actions.push(Self::DraftAnswer);
}
if has_pending_suggestion {
actions.push(Self::AcceptSuggestion);
actions.push(Self::RefineSuggestion);
actions.push(Self::RejectSuggestion);
}
actions.push(Self::Publish);
actions.push(if resolved {
Self::Reopen
} else {
Self::Resolve
});
actions
}
#[must_use]
pub const fn id(self) -> &'static str {
match self {
Self::Reply => "reply",
Self::Edit => "edit",
Self::Delete => "delete",
Self::DeleteExchange => "delete-exchange",
Self::AskBcode => "ask",
Self::FollowUp => "follow-up",
Self::OpenSession => "open-session",
Self::RetrySession => "retry-session",
Self::ToggleAnswer => "toggle-answer",
Self::CopyAnswer => "copy-answer",
Self::SuggestAnswer => "suggest-answer",
Self::AcceptSuggestion => "accept-suggestion",
Self::RefineSuggestion => "refine-suggestion",
Self::RejectSuggestion => "reject-suggestion",
Self::DraftAnswer => "draft-answer",
Self::Publish => "publish",
Self::Resolve => "resolve",
Self::Reopen => "reopen",
}
}
#[must_use]
pub const fn shortcut(self) -> &'static str {
match self {
Self::Reply => "c",
Self::Edit => "e",
Self::Delete => "D",
Self::DeleteExchange => "d",
Self::AskBcode | Self::FollowUp => "a",
Self::OpenSession => "o",
Self::RetrySession => "y",
Self::ToggleAnswer => "A",
Self::CopyAnswer => "Y",
Self::SuggestAnswer => "s",
Self::AcceptSuggestion => ";",
Self::RefineSuggestion => "f",
Self::RejectSuggestion => ":",
Self::DraftAnswer => "m",
Self::Publish => "x",
Self::Resolve | Self::Reopen => "r",
}
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Reply => "reply",
Self::Edit => "edit",
Self::Delete => "delete draft",
Self::DeleteExchange => "delete AI exchange",
Self::AskBcode => "ask Bcode",
Self::FollowUp => "ask follow-up",
Self::OpenSession => "open session",
Self::RetrySession => "retry stream",
Self::ToggleAnswer => "expand answer",
Self::CopyAnswer => "copy answer",
Self::SuggestAnswer => "suggest comment",
Self::AcceptSuggestion => "accept suggestion",
Self::RefineSuggestion => "refine suggestion",
Self::RejectSuggestion => "reject suggestion",
Self::DraftAnswer => "draft answer",
Self::Publish => "publish",
Self::Resolve => "resolve",
Self::Reopen => "reopen",
}
}
#[must_use]
pub const fn from_id(id: &str) -> Option<Self> {
match id.as_bytes() {
b"reply" => Some(Self::Reply),
b"edit" => Some(Self::Edit),
b"delete" => Some(Self::Delete),
b"delete-exchange" => Some(Self::DeleteExchange),
b"ask" => Some(Self::AskBcode),
b"follow-up" => Some(Self::FollowUp),
b"open-session" => Some(Self::OpenSession),
b"retry-session" => Some(Self::RetrySession),
b"toggle-answer" => Some(Self::ToggleAnswer),
b"copy-answer" => Some(Self::CopyAnswer),
b"suggest-answer" => Some(Self::SuggestAnswer),
b"accept-suggestion" => Some(Self::AcceptSuggestion),
b"refine-suggestion" => Some(Self::RefineSuggestion),
b"reject-suggestion" => Some(Self::RejectSuggestion),
b"draft-answer" => Some(Self::DraftAnswer),
b"publish" => Some(Self::Publish),
b"resolve" => Some(Self::Resolve),
b"reopen" => Some(Self::Reopen),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum DiffContextDirection {
Down,
Up,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct DiffContextKey {
pub file_index: usize,
pub hunk_index: usize,
pub start_line: u32,
pub end_line: u32,
pub direction: DiffContextDirection,
}
impl DiffContextKey {
#[must_use]
pub const fn new(file_index: usize, hunk_index: usize, start_line: u32, end_line: u32) -> Self {
Self::with_direction(
file_index,
hunk_index,
start_line,
end_line,
DiffContextDirection::Down,
)
}
#[must_use]
pub const fn with_direction(
file_index: usize,
hunk_index: usize,
start_line: u32,
end_line: u32,
direction: DiffContextDirection,
) -> Self {
Self {
file_index,
hunk_index,
start_line,
end_line,
direction,
}
}
}
impl fmt::Display for DiffContextKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{}:{}:{}-{}:{:?}",
self.file_index, self.hunk_index, self.start_line, self.end_line, self.direction
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum DiffContextLoadState {
Loading,
Loaded,
Unavailable(String),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum ReviewViewTarget {
HunkHeader {
file_index: usize,
source_row: usize,
},
SourceLine {
file_index: usize,
source_row: usize,
old_line: Option<u32>,
new_line: Option<u32>,
},
OmittedContext { key: DiffContextKey },
ExpandedContextLine {
key: DiffContextKey,
line_number: u32,
},
Thread { thread_key: String },
Comment {
thread_key: String,
comment_index: usize,
},
AgentThread { thread_key: String },
ThreadAction { thread_key: String, action: String },
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ReviewThreadAnchor {
pub file_index: usize,
pub path: String,
pub source_row: usize,
pub end_source_row: Option<usize>,
}
impl ReviewThreadAnchor {
#[must_use]
pub const fn end_source_row(&self) -> usize {
match self.end_source_row {
Some(row) => row,
None => self.source_row,
}
}
#[must_use]
pub fn thread_key(&self) -> String {
format!(
"{}:{}:{}-{}",
self.file_index,
self.path,
self.source_row,
self.end_source_row()
)
}
}
const fn view_row_for_display(
file_index: usize,
source_row: usize,
display_row: ReviewDisplayRow,
) -> ReviewViewRow {
let target = display_row_target(file_index, source_row, &display_row);
ReviewViewRow {
visual_row: source_row,
source_row: Some(source_row),
target,
block: ReviewViewBlock::DisplayRow(display_row),
}
}
fn renumber_rows(rows: &mut [ReviewViewRow]) {
for (index, row) in rows.iter_mut().enumerate() {
row.visual_row = index;
}
}
fn hunk_start_rows(file: &ReviewFile) -> Vec<usize> {
let mut rows = Vec::with_capacity(file.hunks.len());
let mut row = 0usize;
for hunk in &file.hunks {
rows.push(row);
row = row.saturating_add(hunk.lines.len()).saturating_add(1);
}
rows
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiffContextPlacement {
Top,
Middle,
Bottom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct DiffContextRange {
file_index: usize,
hunk_index: usize,
placement: DiffContextPlacement,
start_line: u32,
end_line: u32,
}
impl DiffContextRange {
const fn key(self, direction: DiffContextDirection) -> DiffContextKey {
DiffContextKey::with_direction(
self.file_index,
self.hunk_index,
self.start_line,
self.end_line,
direction,
)
}
}
fn push_diff_context_rows(
range: DiffContextRange,
cached_file: Option<&CachedReviewFile>,
context_load_states: &BTreeMap<DiffContextKey, DiffContextLoadState>,
rows: &mut Vec<ReviewViewRow>,
) {
let directions = match range.placement {
DiffContextPlacement::Top => &[DiffContextDirection::Up][..],
DiffContextPlacement::Middle => {
if hidden_line_count(range.start_line, range.end_line)
> usize::try_from(CONTEXT_EXPAND_STEP).unwrap_or(usize::MAX)
{
&[DiffContextDirection::Down, DiffContextDirection::Up][..]
} else {
&[DiffContextDirection::Down][..]
}
}
DiffContextPlacement::Bottom => &[DiffContextDirection::Down][..],
};
for direction in directions {
push_directed_diff_context_row(range, *direction, cached_file, context_load_states, rows);
}
}
fn push_directed_diff_context_row(
range: DiffContextRange,
direction: DiffContextDirection,
cached_file: Option<&CachedReviewFile>,
context_load_states: &BTreeMap<DiffContextKey, DiffContextLoadState>,
rows: &mut Vec<ReviewViewRow>,
) {
let key = range.key(direction);
if let Some(load_state) = context_load_states.get(&key) {
match (load_state, cached_file) {
(DiffContextLoadState::Unavailable(reason), _) => rows.push(context_status_row(
key,
hidden_line_count(range.start_line, range.end_line),
range.start_line,
range.end_line,
Some(reason.clone()),
)),
(DiffContextLoadState::Loaded, Some(file)) if file.unavailable_reason.is_none() => {
push_incremental_expanded_context_rows(
&key,
range,
file,
context_load_states,
rows,
);
}
(_, Some(file)) => rows.push(context_status_row(
key,
hidden_line_count(range.start_line, range.end_line),
range.start_line,
range.end_line,
file.unavailable_reason.clone(),
)),
(DiffContextLoadState::Loading, _) | (_, None) => rows.push(context_status_row(
key,
hidden_line_count(range.start_line, range.end_line),
range.start_line,
range.end_line,
None,
)),
}
} else {
rows.push(omitted_context_row(key, range));
}
}
fn push_incremental_expanded_context_rows(
context_key: &DiffContextKey,
range: DiffContextRange,
cached_file: &CachedReviewFile,
context_load_states: &BTreeMap<DiffContextKey, DiffContextLoadState>,
rows: &mut Vec<ReviewViewRow>,
) {
match context_key.direction {
DiffContextDirection::Down => {
let expanded_end_line = range
.start_line
.saturating_add(CONTEXT_EXPAND_STEP.saturating_sub(1))
.min(range.end_line);
push_expanded_context_rows(
context_key,
cached_file,
range.start_line,
expanded_end_line,
rows,
);
let remaining_start_line = expanded_end_line.saturating_add(1);
if remaining_start_line <= range.end_line {
push_diff_context_rows(
DiffContextRange {
start_line: remaining_start_line,
..range
},
Some(cached_file),
context_load_states,
rows,
);
}
}
DiffContextDirection::Up => {
let expanded_start_line = range
.end_line
.saturating_sub(CONTEXT_EXPAND_STEP.saturating_sub(1))
.max(range.start_line);
let remaining_end_line = expanded_start_line.saturating_sub(1);
if range.start_line <= remaining_end_line {
push_diff_context_rows(
DiffContextRange {
end_line: remaining_end_line,
..range
},
Some(cached_file),
context_load_states,
rows,
);
}
push_expanded_context_rows(
context_key,
cached_file,
expanded_start_line,
range.end_line,
rows,
);
}
}
}
fn omitted_context_row(key: DiffContextKey, range: DiffContextRange) -> ReviewViewRow {
ReviewViewRow {
visual_row: 0,
source_row: None,
target: ReviewViewTarget::OmittedContext { key: key.clone() },
block: ReviewViewBlock::OmittedContext {
key,
hidden_line_count: hidden_line_count(range.start_line, range.end_line),
start_line: range.start_line,
end_line: range.end_line,
},
}
}
fn hidden_line_count(start_line: u32, end_line: u32) -> usize {
usize::try_from(end_line.saturating_sub(start_line).saturating_add(1)).unwrap_or(usize::MAX)
}
fn context_status_row(
key: DiffContextKey,
hidden_line_count: usize,
start_line: u32,
end_line: u32,
reason: Option<String>,
) -> ReviewViewRow {
let target = ReviewViewTarget::OmittedContext { key: key.clone() };
let block = if let Some(reason) = reason {
ReviewViewBlock::UnavailableContext {
key,
hidden_line_count,
start_line,
end_line,
reason,
}
} else {
ReviewViewBlock::LoadingContext {
key,
hidden_line_count,
start_line,
end_line,
}
};
ReviewViewRow {
visual_row: 0,
source_row: None,
target,
block,
}
}
fn push_expanded_context_rows(
context_key: &DiffContextKey,
cached_file: &CachedReviewFile,
start_line: u32,
end_line: u32,
rows: &mut Vec<ReviewViewRow>,
) {
for line_number in start_line..=end_line {
let Some(source_index) = usize::try_from(line_number.saturating_sub(1)).ok() else {
continue;
};
let Some(content) = cached_file.line(source_index) else {
continue;
};
rows.push(ReviewViewRow {
visual_row: 0,
source_row: None,
target: ReviewViewTarget::ExpandedContextLine {
key: context_key.clone(),
line_number,
},
block: ReviewViewBlock::FileLine {
line_number: Some(line_number),
content: content.to_string(),
},
});
}
}
const fn display_row_target(
file_index: usize,
source_row: usize,
display_row: &ReviewDisplayRow,
) -> ReviewViewTarget {
match display_row.source {
ReviewDisplayRowSource::HunkHeader => ReviewViewTarget::HunkHeader {
file_index,
source_row,
},
ReviewDisplayRowSource::Context
| ReviewDisplayRowSource::Added
| ReviewDisplayRowSource::Removed => ReviewViewTarget::SourceLine {
file_index,
source_row,
old_line: display_row.old_line,
new_line: display_row.new_line,
},
}
}
fn materialized_file_surface_rows(file: &ReviewFile) -> Vec<(Option<u32>, String)> {
file.hunks
.iter()
.flat_map(|hunk| {
let heading = hunk
.heading
.iter()
.map(|heading| (None, format!("# {heading}")));
heading.chain(
hunk.lines
.iter()
.map(|line| (line.new_line.or(line.old_line), line.content.clone())),
)
})
.collect()
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use super::{
DiffContextDirection, DiffContextKey, DiffContextLoadState, ReviewThreadAnchor,
ReviewViewBlock, ReviewViewDocument, ReviewViewTarget,
};
use crate::code_review_tui::{
CachedReviewFile, ReviewDraftComment, ReviewFile, ReviewFileStatus, ReviewHunk, ReviewLine,
ReviewLineKind, ReviewSuggestedComment,
};
use bcode_code_review_models::{ReviewThreadKind, ReviewThreadSeverity};
#[test]
fn large_review_document_build_stays_within_baseline() {
use std::time::{Duration, Instant};
let hunks = (0_u32..500)
.map(|hunk_index| ReviewHunk {
old_start: hunk_index.saturating_mul(20).saturating_add(1),
old_count: 10,
new_start: hunk_index.saturating_mul(20).saturating_add(1),
new_count: 10,
heading: Some(format!("section_{hunk_index}")),
lines: (0_u32..10)
.map(|line_index| ReviewLine {
kind: if line_index % 2 == 0 {
ReviewLineKind::Added
} else {
ReviewLineKind::Context
},
old_line: Some(
hunk_index
.saturating_mul(20)
.saturating_add(line_index)
.saturating_add(1),
),
new_line: Some(
hunk_index
.saturating_mul(20)
.saturating_add(line_index)
.saturating_add(1),
),
content: format!("let value_{hunk_index}_{line_index} = {line_index};"),
})
.collect(),
})
.collect();
let file = ReviewFile {
old_path: Some("src/large.rs".to_string()),
new_path: Some("src/large.rs".to_string()),
status: ReviewFileStatus::Modified,
additions: 2_500,
deletions: 0,
hunks,
is_binary: false,
};
let start = Instant::now();
let document =
ReviewViewDocument::build_diff_file(0, &file, false, None, None, &BTreeMap::new());
let elapsed = start.elapsed();
assert_eq!(document.hunk_targets().len(), 500);
assert!(document.rows.len() >= 5_500);
assert!(
elapsed < Duration::from_millis(250),
"large review document baseline exceeded: {elapsed:?}"
);
}
#[test]
fn diff_file_document_maps_visual_rows_to_semantic_targets() {
let file = test_file();
let document =
ReviewViewDocument::build_diff_file(7, &file, true, None, None, &BTreeMap::new());
assert_eq!(document.rows.len(), 2);
assert_eq!(
document.target_for_visual_row(0),
Some(&ReviewViewTarget::HunkHeader {
file_index: 7,
source_row: 0,
})
);
assert_eq!(
document.target_for_visual_row(1),
Some(&ReviewViewTarget::SourceLine {
file_index: 7,
source_row: 1,
old_line: None,
new_line: Some(1),
})
);
assert!(matches!(
document.rows[1].block,
ReviewViewBlock::DisplayRow(_)
));
}
#[test]
fn inline_draft_threads_are_inserted_after_anchor_rows() {
let file = test_file();
let anchor = ReviewThreadAnchor {
file_index: 7,
path: "src/lib.rs".to_string(),
source_row: 1,
end_source_row: None,
};
let comment = ReviewDraftComment {
id: Some("draft-1".to_string()),
body: "Please double-check this.".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
};
let document =
ReviewViewDocument::build_diff_file(7, &file, false, None, None, &BTreeMap::new())
.with_inline_draft_threads(
7,
std::iter::once((anchor.clone(), vec![comment])),
std::iter::empty::<(ReviewThreadAnchor, Vec<ReviewSuggestedComment>)>(),
&BTreeMap::new(),
&BTreeSet::new(),
&BTreeSet::new(),
&BTreeSet::new(),
true,
);
assert_eq!(document.rows.len(), 10);
assert_eq!(document.rows[1].source_row, Some(1));
assert!(matches!(
document.rows[2].block,
ReviewViewBlock::InlineThreadHeader { .. }
));
assert_eq!(
document.target_for_visual_row(2),
Some(&ReviewViewTarget::Thread {
thread_key: anchor.thread_key(),
})
);
assert!(matches!(
document.rows[3].block,
ReviewViewBlock::InlineComment { .. }
));
assert!(matches!(
document.rows[4].block,
ReviewViewBlock::InlineThreadAction { .. }
));
}
#[test]
fn plain_and_agent_sources_survive_width_changes_without_section_confusion() {
let text = "alpha beta gamma delta epsilon";
let (_, narrow) = super::plain_rows_with_sources(text, 5);
let (_, wide) = super::plain_rows_with_sources(text, 18);
for range in narrow.iter().flatten() {
assert!(
wide.iter()
.flatten()
.any(|candidate| candidate.contains(&range.start))
);
}
let mut state = super::ReviewAgentThreadState::pending(String::new());
state.status = text.to_owned();
state.answer = text.to_owned();
let narrow = super::agent_thread_rows(&state, true, 18);
let wide = super::agent_thread_rows(&state, true, 40);
for row in &narrow {
if let Some(range) = row.source_ranges.first() {
assert!(wide.iter().any(|candidate| {
candidate.source_id == row.source_id
&& candidate
.source_ranges
.iter()
.any(|other| other.contains(&range.start))
}));
}
}
assert!(wide.iter().any(|row| row.source_id == "status"));
assert!(wide.iter().any(|row| row.source_id == "answer"));
}
#[test]
fn agent_session_preview_limits_do_not_depend_on_metadata_height() {
use crate::code_review_tui::{ReviewAgentSessionItem, ReviewAgentSessionItemKind};
let mut state = super::ReviewAgentThreadState::pending(String::new());
state.status = "long metadata that wraps into many visual rows ".repeat(8);
state.session_items = (0..10)
.map(|index| ReviewAgentSessionItem {
id: format!("item-{index}"),
revision: 1,
kind: ReviewAgentSessionItemKind::Assistant,
label: "message".to_owned(),
text: format!("item-{index}"),
format: bcode_session_view_models::TextFormat::PlainText,
streaming: false,
degraded: false,
})
.collect();
for (expanded, count, first) in [(false, 6, "item-4"), (true, 10, "item-0")] {
let rows = super::agent_thread_rows(&state, expanded, 32);
let previews = rows
.iter()
.filter(|row| row.kind.is_some())
.collect::<Vec<_>>();
assert_eq!(previews.len(), count);
assert_eq!(previews[0].content.plain_text(), first);
assert_eq!(
previews.last().expect("last preview").content.plain_text(),
"item-9"
);
}
}
#[test]
fn agent_answers_use_visual_limits_and_explicit_expansion() {
let mut state = super::ReviewAgentThreadState::pending(String::new());
state.status = "ready".to_owned();
state.answer = "alpha beta gamma delta epsilon zeta eta theta iota kappa".to_owned();
for width in 2..=30 {
let expanded = super::agent_thread_rows(&state, true, width);
let collapsed = super::agent_thread_rows(&state, false, width);
assert!(expanded.len() >= collapsed.len());
for row in expanded.iter().chain(&collapsed) {
assert!(
bmux_tui::text_width::display_width(&row.prefix) + row.content.width()
<= usize::from(width)
);
}
}
let expanded = super::agent_thread_rows(&state, true, 18);
let collapsed = super::agent_thread_rows(&state, false, 18);
assert!(expanded.len() > collapsed.len());
assert!(
expanded
.last()
.expect("answer")
.content
.plain_text()
.contains("kappa")
);
assert!(
collapsed
.last()
.expect("preview")
.content
.plain_text()
.contains('…')
);
}
#[test]
fn multiline_draft_comments_render_multiple_semantic_rows() {
let file = test_file();
let anchor = ReviewThreadAnchor {
file_index: 7,
path: "src/lib.rs".to_string(),
source_row: 1,
end_source_row: None,
};
let comment = ReviewDraftComment {
id: Some("draft-1".to_string()),
body: "first\nsecond".to_string(),
persisted: true,
created_at_ms: None,
updated_at_ms: None,
session_id: None,
thread_kind: ReviewThreadKind::Note,
severity: ReviewThreadSeverity::Info,
};
let document =
ReviewViewDocument::build_diff_file(7, &file, false, None, None, &BTreeMap::new())
.with_inline_draft_threads(
7,
std::iter::once((anchor, vec![comment])),
std::iter::empty::<(ReviewThreadAnchor, Vec<ReviewSuggestedComment>)>(),
&BTreeMap::new(),
&BTreeSet::new(),
&BTreeSet::new(),
&BTreeSet::new(),
true,
);
let narrow = document.clone().layout_inline_threads(15);
let projected = narrow
.rows
.iter()
.filter_map(|row| match &row.block {
ReviewViewBlock::InlineComment {
rendered: Some(line),
..
} => Some(line),
_ => None,
})
.collect::<Vec<_>>();
assert!(projected.len() > 2);
assert!(projected.iter().all(|line| line.width() <= 3));
let text = projected
.iter()
.map(|line| line.plain_text())
.collect::<String>();
assert!(text.contains("first"));
assert!(text.contains("second"));
let wide = document.clone().layout_inline_threads(80);
for row in &narrow.rows {
if let Some((target, id, byte)) = narrow.text_anchor(row.visual_row) {
let resolved = wide
.row_for_text_anchor(&target, &id, byte)
.expect("source byte survives resize");
let ReviewViewBlock::InlineComment { source_ranges, .. } =
&wide.rows[resolved].block
else {
panic!("comment anchor");
};
assert!(source_ranges.iter().any(|range| range.contains(&byte)));
}
}
assert!(wide.rows.len() < narrow.rows.len());
assert!(
narrow
.rows
.iter()
.enumerate()
.all(|(index, row)| row.visual_row == index)
);
assert!(matches!(
document.rows[3].block,
ReviewViewBlock::InlineComment {
body_line_index: 0,
body_line_count: 2,
..
}
));
assert!(matches!(
document.rows[4].block,
ReviewViewBlock::InlineComment {
body_line_index: 1,
body_line_count: 2,
..
}
));
}
#[test]
fn expanded_context_rows_use_full_file_source_lines() {
let file = ReviewFile {
old_path: Some("src/lib.rs".to_string()),
new_path: Some("src/lib.rs".to_string()),
status: ReviewFileStatus::Modified,
additions: 2,
deletions: 0,
hunks: vec![
ReviewHunk {
old_start: 1,
old_count: 1,
new_start: 1,
new_count: 1,
heading: Some("first".to_string()),
lines: vec![ReviewLine {
kind: ReviewLineKind::Added,
old_line: None,
new_line: Some(1),
content: "pub fn first() {}".to_string(),
}],
},
ReviewHunk {
old_start: 4,
old_count: 1,
new_start: 4,
new_count: 1,
heading: Some("second".to_string()),
lines: vec![ReviewLine {
kind: ReviewLineKind::Added,
old_line: None,
new_line: Some(4),
content: "pub fn second() {}".to_string(),
}],
},
],
is_binary: false,
};
let content = "pub fn first() {}\nlet expanded = true;\nlet also_expanded = false;\npub fn second() {}\n".to_string();
let cached_file = CachedReviewFile {
path: "src/lib.rs".to_string(),
line_spans: test_line_spans(&content),
size_bytes: u64::try_from(content.len()).expect("test content length fits u64"),
content,
mtime_ms: None,
is_binary: false,
unavailable_reason: None,
};
let context_key = DiffContextKey::new(7, 1, 2, 3);
let mut context_states = BTreeMap::new();
context_states.insert(context_key, DiffContextLoadState::Loaded);
let document = ReviewViewDocument::build_diff_file(
7,
&file,
true,
None,
Some(&cached_file),
&context_states,
);
let expanded_row = document
.rows
.iter()
.find(|row| matches!(row.target, ReviewViewTarget::ExpandedContextLine { .. }))
.expect("expanded row is rendered");
let ReviewViewBlock::FileLine {
line_number,
content,
} = &expanded_row.block
else {
panic!("expanded row should render as source file line");
};
assert_eq!(*line_number, Some(2));
assert_eq!(content, "let expanded = true;");
}
#[test]
fn diff_file_document_includes_top_and_bottom_hidden_context() {
let file = ReviewFile {
old_path: Some("src/lib.rs".to_string()),
new_path: Some("src/lib.rs".to_string()),
status: ReviewFileStatus::Modified,
additions: 1,
deletions: 0,
hunks: vec![ReviewHunk {
old_start: 30,
old_count: 1,
new_start: 30,
new_count: 1,
heading: Some("middle".to_string()),
lines: vec![ReviewLine {
kind: ReviewLineKind::Added,
old_line: None,
new_line: Some(30),
content: "changed".to_string(),
}],
}],
is_binary: false,
};
let content = numbered_lines(60);
let cached_file = cached_review_file("src/lib.rs", content);
let document = ReviewViewDocument::build_diff_file(
7,
&file,
false,
None,
Some(&cached_file),
&BTreeMap::new(),
);
assert!(document.rows.iter().any(|row| matches!(
&row.block,
ReviewViewBlock::OmittedContext {
start_line: 1,
end_line: 29,
..
}
)));
assert!(document.rows.iter().any(|row| matches!(
&row.block,
ReviewViewBlock::OmittedContext {
start_line: 31,
end_line: 60,
..
}
)));
}
#[test]
fn expanded_context_reveals_at_most_one_step_and_leaves_remainder() {
let file = two_hunk_file_with_gap(2, 52);
let content = numbered_lines(52);
let cached_file = cached_review_file("src/lib.rs", content);
let mut context_states = BTreeMap::new();
context_states.insert(
DiffContextKey::with_direction(7, 1, 2, 51, DiffContextDirection::Down),
DiffContextLoadState::Loaded,
);
let document = ReviewViewDocument::build_diff_file(
7,
&file,
false,
None,
Some(&cached_file),
&context_states,
);
let expanded_lines = document
.rows
.iter()
.filter(|row| matches!(row.target, ReviewViewTarget::ExpandedContextLine { .. }))
.count();
assert_eq!(expanded_lines, 20);
assert!(document.rows.iter().any(|row| matches!(
&row.block,
ReviewViewBlock::OmittedContext {
start_line: 22,
end_line: 51,
..
}
)));
}
#[test]
fn repeated_context_expansion_reveals_next_step() {
let file = two_hunk_file_with_gap(2, 52);
let content = numbered_lines(52);
let cached_file = cached_review_file("src/lib.rs", content);
let mut context_states = BTreeMap::new();
context_states.insert(
DiffContextKey::with_direction(7, 1, 2, 51, DiffContextDirection::Down),
DiffContextLoadState::Loaded,
);
context_states.insert(
DiffContextKey::with_direction(7, 1, 22, 51, DiffContextDirection::Down),
DiffContextLoadState::Loaded,
);
let document = ReviewViewDocument::build_diff_file(
7,
&file,
false,
None,
Some(&cached_file),
&context_states,
);
let expanded_lines = document
.rows
.iter()
.filter_map(|row| match row.target {
ReviewViewTarget::ExpandedContextLine { line_number, .. } => Some(line_number),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(expanded_lines.len(), 40);
assert!(expanded_lines.contains(&21));
assert!(expanded_lines.contains(&41));
assert!(document.rows.iter().any(|row| matches!(
&row.block,
ReviewViewBlock::OmittedContext {
start_line: 42,
end_line: 51,
..
}
)));
}
fn numbered_lines(count: u32) -> String {
(1..=count).fold(String::new(), |mut lines, line| {
use std::fmt::Write as _;
writeln!(&mut lines, "line {line}").expect("writing to String cannot fail");
lines
})
}
fn cached_review_file(path: &str, content: String) -> CachedReviewFile {
CachedReviewFile {
path: path.to_string(),
line_spans: test_line_spans(&content),
size_bytes: u64::try_from(content.len()).expect("test content length fits u64"),
content,
mtime_ms: None,
is_binary: false,
unavailable_reason: None,
}
}
fn two_hunk_file_with_gap(start_line: u32, second_hunk_line: u32) -> ReviewFile {
ReviewFile {
old_path: Some("src/lib.rs".to_string()),
new_path: Some("src/lib.rs".to_string()),
status: ReviewFileStatus::Modified,
additions: 2,
deletions: 0,
hunks: vec![
ReviewHunk {
old_start: 1,
old_count: 1,
new_start: start_line.saturating_sub(1),
new_count: 1,
heading: Some("first".to_string()),
lines: vec![ReviewLine {
kind: ReviewLineKind::Added,
old_line: None,
new_line: Some(start_line.saturating_sub(1)),
content: "first".to_string(),
}],
},
ReviewHunk {
old_start: second_hunk_line,
old_count: 1,
new_start: second_hunk_line,
new_count: 1,
heading: Some("second".to_string()),
lines: vec![ReviewLine {
kind: ReviewLineKind::Added,
old_line: None,
new_line: Some(second_hunk_line),
content: "second".to_string(),
}],
},
],
is_binary: false,
}
}
fn test_line_spans(content: &str) -> Vec<(usize, usize)> {
content
.split_inclusive('\n')
.scan(0usize, |offset, line| {
let start = *offset;
*offset = offset.saturating_add(line.len());
Some((
start,
start.saturating_add(line.trim_end_matches('\n').len()),
))
})
.collect()
}
fn test_file() -> ReviewFile {
ReviewFile {
old_path: Some("src/lib.rs".to_string()),
new_path: Some("src/lib.rs".to_string()),
status: ReviewFileStatus::Modified,
additions: 1,
deletions: 0,
hunks: vec![ReviewHunk {
old_start: 1,
old_count: 1,
new_start: 1,
new_count: 1,
heading: Some("fn demo".to_string()),
lines: vec![ReviewLine {
kind: ReviewLineKind::Added,
old_line: None,
new_line: Some(1),
content: "pub fn demo() {}".to_string(),
}],
}],
is_binary: false,
}
}
}