feat: basic pr diff rendering

This commit is contained in:
2026-05-24 16:44:10 +01:00
parent 1843622540
commit b3e041a257
23 changed files with 903 additions and 353 deletions

View File

@@ -1,21 +1,43 @@
use std::sync::Arc;
use crate::{
api, app,
component::text::text,
query::{self, QueryStatus, read_query, use_query},
component::{
diff_view::{DiffViewContent, DiffViewState, diff_view},
text::text,
},
query::{self, QueryStatus, observe_query, read_query, use_query},
};
use gpui::{ParentElement, Styled, div};
pub(crate) struct PullRequestDiffView {
selected_file_path: Option<String>,
selected_file_path: Option<Arc<str>>,
pr_query: query::Entity<api::issues::FetchPullRequest>,
file_tree_query: query::Entity<api::issues::FetchPullRequestFileTree>,
content_diff_query: Option<query::Entity<api::repo::FetchFileDiff>>,
diff_view_state: DiffViewState,
diff_view_content: Option<DiffViewContent>,
}
fn new(pr_id: api::issues::Id, cx: &mut gpui::Context<PullRequestDiffView>) -> PullRequestDiffView {
pub(crate) fn new(
pr_id: api::issues::Id,
cx: &mut gpui::Context<PullRequestDiffView>,
) -> PullRequestDiffView {
let mut view = PullRequestDiffView {
selected_file_path: None,
pr_query: use_query(api::issues::FetchPullRequest { id: pr_id }, cx),
pr_query: use_query(api::issues::FetchPullRequest { id: pr_id.clone() }, cx),
file_tree_query: use_query(
api::issues::FetchPullRequestFileTree {
id: pr_id,
first: 100,
},
cx,
),
content_diff_query: None,
diff_view_state: DiffViewState::new(),
diff_view_content: None,
};
view.on_create(cx);
view
@@ -29,22 +51,42 @@ impl PullRequestDiffView {
})
.detach();
_ = cx
.observe(&self.file_tree_query, |this, _, cx| {
this.start_content_queries(cx);
})
.detach();
// if pr is already loaded, start content queries
self.start_content_queries(cx);
}
fn start_content_queries(&mut self, cx: &mut gpui::Context<Self>) {
if self.content_diff_query.is_some() {
return;
}
if self.selected_file_path.is_none()
&& let QueryStatus::Loaded(files) = read_query(&self.file_tree_query, cx)
{
self.selected_file_path = files.first().map(|file| Arc::clone(&file.path));
}
let Some(selected_file_path) = self.selected_file_path.as_deref() else {
return;
};
let Some((old_file_ref, new_file_ref)) = ({
if let QueryStatus::Loaded(pr) = read_query(&self.pr_query, cx) {
Some((
api::repo::FileRef {
repo_slug: pr.base_repo_slug.clone(),
path: pr.base_branch_name.clone(),
path: Arc::from(selected_file_path),
reff: Some(pr.base_ref.clone()),
},
api::repo::FileRef {
repo_slug: pr.head_repo_slug.clone(),
path: pr.head_branch_name.clone(),
path: Arc::from(selected_file_path),
reff: Some(pr.head_ref.clone()),
},
))
@@ -63,6 +105,20 @@ impl PullRequestDiffView {
cx,
);
_ = observe_query(
&content_diff_query,
|this, query, cx| {
if let QueryStatus::Loaded(diff) = read_query(query, cx) {
println!("diff len {}", diff.len());
this.diff_view_state.reset(diff.len());
this.diff_view_content = Some(Arc::clone(diff).into());
}
cx.notify();
},
cx,
)
.detach();
self.content_diff_query = Some(content_diff_query);
}
}
@@ -73,20 +129,27 @@ impl gpui::Render for PullRequestDiffView {
_window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
use gpui::{ParentElement, Styled, div};
let theme = app::current_theme(cx);
div()
let content_diff = self
.content_diff_query
.as_ref()
.map(|q| read_query(q, cx))
.unwrap_or(QueryStatus::Loading);
match content_diff {
| QueryStatus::Err(_) | QueryStatus::Loading => div()
.size_full()
.bg(theme.colors.surface)
.p_4()
.child(
text(
"Pull request diff rendering is still under construction. Launch the DiffOps playground with NOVEM_DIFFOPS_PLAYGROUND=1 cargo run.",
)
.text_sm()
.text_color(theme.colors.text_muted),
)
.child(text("asd")),
| QueryStatus::Loaded(_) => match &self.diff_view_content {
| Some(content) => div()
.size_full()
.child(diff_view(self.diff_view_state.clone(), content.clone())),
| None => div(),
},
}
}
}

View File

@@ -15,7 +15,7 @@ use crate::{
text::text,
},
query::{self, QueryStatus, read_query, use_query},
screen::dashboard::pull_request_diff_view::PullRequestDiffView,
screen::dashboard::pull_request_diff_view::{self, PullRequestDiffView},
};
pub(crate) struct PullRequestView {
@@ -49,12 +49,14 @@ impl PullRequestView {
_ = cx
.observe(&query.clone(), move |this, _, cx| {
this.load_markdown_content(cx);
this.load_pr_diff(cx);
})
.detach();
// cached query will not trigger observe callback
// this is required so that content is loaded immediately for cached query
self.load_markdown_content(cx);
self.load_pr_diff(cx);
cx.notify();
}
@@ -78,6 +80,25 @@ impl PullRequestView {
cx.notify();
}
fn load_pr_diff(&mut self, cx: &mut gpui::Context<Self>) {
let Some(query) = &self.pull_request_query else {
return;
};
let pr_id = {
let data = read_query(&query, cx);
if let QueryStatus::Loaded(pr) = data {
Some(pr.id.clone())
} else {
None
}
};
self.diff_view = pr_id.map(|id| cx.new(|cx| pull_request_diff_view::new(id, cx)));
cx.notify();
}
fn pr_content(
&self,
pr: &api::issues::DetailedPullRequest,
@@ -264,7 +285,11 @@ impl gpui::Render for PullRequestView {
) -> impl gpui::IntoElement {
div().size_full().child(match &self.pull_request_query {
| Some(q) => match read_query(q, cx) {
| QueryStatus::Loaded(pr) => self.pr_content(pr, cx),
| QueryStatus::Loaded(pr) => match &self.diff_view {
| Some(v) => v.clone().into_any_element(),
| None => self.pr_content(pr, cx),
},
| QueryStatus::Err(e) => div()
.size_full()
.child(format!("{:?}", e))
@@ -274,6 +299,7 @@ impl gpui::Render for PullRequestView {
.child("loading pr content")
.into_any_element(),
},
| None => div().size_full().child("no pr selected").into_any_element(),
})
}

View File

@@ -4,6 +4,7 @@ use crate::{
api, app,
screen::dashboard::{
issue_list::{self, IssueList},
pull_request_diff_view::{self, PullRequestDiffView},
pull_request_view::{self, PullRequestView},
titlebar::{self, TitleBar},
},
@@ -13,6 +14,7 @@ pub(crate) struct Screen {
titlebar: gpui::Entity<TitleBar>,
issue_list: gpui::Entity<IssueList>,
pull_request_view: gpui::Entity<PullRequestView>,
pull_request_diff_view: Option<gpui::Entity<PullRequestDiffView>>,
issue_filter: Option<&'static str>,
}
@@ -22,6 +24,7 @@ pub(crate) fn new(cx: &mut gpui::Context<Screen>) -> Screen {
titlebar: cx.new(titlebar::new),
issue_list: cx.new(issue_list::new),
pull_request_view: cx.new(pull_request_view::new),
pull_request_diff_view: None,
issue_filter: None,
};
@@ -33,9 +36,9 @@ impl Screen {
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
_ = cx
.subscribe(&self.issue_list, |this, _, event, cx| match event {
| issue_list::Event::ItemSelected(pr_id) => {
this.handle_issue_list_item_selected(pr_id, cx);
}
| issue_list::Event::ItemSelected(pr_id) => {
this.handle_issue_list_item_selected(pr_id, cx);
}
})
.detach();
}
@@ -50,7 +53,9 @@ impl Screen {
view.change_displayed_pull_request(id.clone(), cx);
println!("change displayed pull request: {:?}", id);
cx.notify();
})
});
self.pull_request_diff_view =
Some(cx.new(|cx| pull_request_diff_view::new(id.clone(), cx)));
}
}

View File

@@ -1,7 +1,10 @@
use std::{ops::Range, sync::Arc};
use bytes::Bytes;
use gpui::{
AnyElement, AppContext, InteractiveElement, IntoElement, ParentElement,
StatefulInteractiveElement, Styled, div, point, px, size,
StatefulInteractiveElement,
Styled, div, point, px, size,
};
use crate::{
@@ -10,7 +13,7 @@ use crate::{
button::{self, button},
text::text,
},
util::diff::{ContentDiff, DiffRow, DiffSide, Op, Span, diff_content},
util::diff::{ContentDiff, DiffLine, Op, diff_content},
};
pub(crate) fn is_enabled() -> bool {
@@ -51,7 +54,29 @@ pub(crate) struct Screen {
struct DiffCase {
title: &'static str,
description: &'static str,
diff: ContentDiff,
old_lines: Vec<SourceLine>,
new_lines: Vec<SourceLine>,
op_groups: Vec<OpGroup>,
}
#[derive(Clone)]
struct SourceLine {
line_number: usize,
content: Arc<str>,
}
#[derive(Clone)]
struct OpGroup {
op: Op,
old_range: Range<usize>,
new_range: Range<usize>,
rows: Vec<DiffLine>,
}
#[derive(Clone, Copy)]
enum SourceSide {
Old,
New,
}
fn new(_cx: &mut gpui::Context<Screen>) -> Screen {
@@ -108,28 +133,17 @@ impl gpui::Render for Screen {
.collect();
let op_cards: Vec<AnyElement> = case
.diff
.spans()
.op_groups
.iter()
.enumerate()
.map(|(index, span)| render_op_card(index, span, theme).into_any_element())
.map(|(index, group)| render_op_card(index, group, theme).into_any_element())
.collect();
let op_groups: Vec<AnyElement> = case
.diff
.spans()
.op_groups
.iter()
.enumerate()
.map(|(index, span)| {
render_op_group(
index,
span,
case.diff.rows_for_span(index),
&case.diff,
theme,
)
.into_any_element()
})
.map(|(index, group)| render_op_group(index, group, theme).into_any_element())
.collect();
div()
@@ -160,7 +174,7 @@ impl gpui::Render for Screen {
.child(text("DiffOps Playground").text_lg())
.child(
text(
"Sample content is diffed once at startup, then the UI renders the stored DiffOps and aligned rows.",
"Sample content is diffed once at startup, then the UI derives grouped ops and aligned rows from the stored diff rows.",
)
.text_sm()
.text_color(theme.colors.text_muted),
@@ -188,7 +202,7 @@ impl gpui::Render for Screen {
.flex()
.flex_col()
.gap_2()
.child(text("Precomputed DiffOps").text_sm())
.child(text("Derived Op Groups").text_sm())
.children(op_cards),
),
),
@@ -218,9 +232,9 @@ impl gpui::Render for Screen {
.child(
text(format!(
"{} ops, {} old lines, {} new lines",
case.diff.spans().len(),
case.diff.old_line_count(),
case.diff.new_line_count(),
case.op_groups.len(),
line_count(&case.old_lines),
line_count(&case.new_lines),
))
.text_xs()
.font_family("Menlo")
@@ -238,11 +252,11 @@ impl gpui::Render for Screen {
.border_b_1()
.border_color(theme.colors.border_muted)
.child(
panel_header("Old", case.diff.old_line_count(), theme)
panel_header("Old", line_count(&case.old_lines), theme)
.flex_1(),
)
.child(
panel_header("New", case.diff.new_line_count(), theme)
panel_header("New", line_count(&case.new_lines), theme)
.flex_1(),
),
)
@@ -259,8 +273,12 @@ impl gpui::Render for Screen {
.flex_col()
.gap_3()
.child(text("Source Content").text_sm())
.child(render_source_content(&case.diff, theme))
.child(text("DiffOps Render").text_sm())
.child(render_source_content(
&case.old_lines,
&case.new_lines,
theme,
))
.child(text("Diff Rows Render").text_sm())
.children(op_groups),
),
),
@@ -273,7 +291,7 @@ fn sample_cases() -> Vec<DiffCase> {
vec![
DiffCase::new(
"Insert Block",
"A pure insert leaves the old side with an empty anchor span such as 2..2 while the new side grows.",
"A pure insert leaves the old side with an empty anchor range such as 2..2 while the new side grows.",
r#"fn config() {
let host = "localhost";
start(host);
@@ -288,7 +306,7 @@ fn sample_cases() -> Vec<DiffCase> {
),
DiffCase::new(
"Delete Block",
"A delete keeps the old side non-empty and gives the new side an empty anchor span at the removal point.",
"A delete keeps the old side non-empty and gives the new side an empty anchor range at the removal point.",
r#"fn handle(req: Request) {
trace_request(&req);
authorize(&req);
@@ -303,7 +321,7 @@ fn sample_cases() -> Vec<DiffCase> {
),
DiffCase::new(
"Replace Span",
"A replace can cover different line counts on each side. The viewer pairs rows by position inside the op span.",
"A replace can cover different line counts on each side. The viewer pairs rows by position inside the derived op group.",
r#"fn render() {
let theme = current_theme(cx);
layout(theme);
@@ -318,7 +336,7 @@ fn sample_cases() -> Vec<DiffCase> {
),
DiffCase::new(
"Mixed Hunk",
"This sample produces several DiffOps in sequence so you can see equal, replace, insert, and delete spans together.",
"This sample produces several op groups in sequence so you can see equal, replace, insert, and delete rows together.",
r#"use crate::auth::Token;
use crate::http::Client;
@@ -350,13 +368,18 @@ impl DiffCase {
old: &'static str,
new: &'static str,
) -> Self {
let diff = diff_content(
Bytes::from_static(old.as_bytes()),
Bytes::from_static(new.as_bytes()),
)
.expect("sample content should always be valid utf-8");
Self {
title,
description,
diff: diff_content(
Bytes::from_static(old.as_bytes()),
Bytes::from_static(new.as_bytes()),
),
old_lines: collect_source_lines(&diff, SourceSide::Old),
new_lines: collect_source_lines(&diff, SourceSide::New),
op_groups: collect_op_groups(&diff),
}
}
}
@@ -382,27 +405,28 @@ fn panel_header(label: &'static str, line_count: usize, theme: &crate::theme::Th
)
}
fn render_source_content(diff: &ContentDiff, theme: &crate::theme::Theme) -> gpui::Div {
fn render_source_content(
old_lines: &[SourceLine],
new_lines: &[SourceLine],
theme: &crate::theme::Theme,
) -> gpui::Div {
div()
.flex()
.flex_row()
.gap_2()
.child(render_source_panel("Old Content", DiffSide::Old, diff, theme).flex_1())
.child(render_source_panel("New Content", DiffSide::New, diff, theme).flex_1())
.child(render_source_panel("Old Content", old_lines, theme).flex_1())
.child(render_source_panel("New Content", new_lines, theme).flex_1())
}
fn render_source_panel(
title: &'static str,
side: DiffSide,
diff: &ContentDiff,
lines: &[SourceLine],
theme: &crate::theme::Theme,
) -> gpui::Div {
let line_count = match side {
| DiffSide::Old => diff.old_line_count(),
| DiffSide::New => diff.new_line_count(),
};
let line_count = line_count(lines);
let lines: Vec<AnyElement> = (0..line_count)
let rows: Vec<AnyElement> = lines
.iter()
.map(|line| {
div()
.flex()
@@ -418,7 +442,7 @@ fn render_source_panel(
.font_family("Menlo")
.text_xs()
.text_color(theme.colors.text_subtle)
.child(format!("{:>4}", line + 1)),
.child(format!("{:>4}", line.line_number + 1)),
)
.child(
div()
@@ -429,7 +453,7 @@ fn render_source_panel(
.font_family("Menlo")
.text_xs()
.text_color(theme.colors.text)
.child(display_bytes(diff.line_slice_at(side, line))),
.child(display_text(&line.content)),
)
.into_any_element()
})
@@ -460,11 +484,11 @@ fn render_source_panel(
.text_color(theme.colors.text_subtle),
),
)
.child(div().flex().flex_col().children(lines))
.child(div().flex().flex_col().children(rows))
}
fn render_op_card(index: usize, span: &Span, theme: &crate::theme::Theme) -> gpui::Div {
let colors = tag_colors(span.op, theme);
fn render_op_card(index: usize, group: &OpGroup, theme: &crate::theme::Theme) -> gpui::Div {
let colors = tag_colors(group.op, theme);
div()
.rounded_md()
@@ -476,38 +500,33 @@ fn render_op_card(index: usize, span: &Span, theme: &crate::theme::Theme) -> gpu
.flex_col()
.gap_1()
.child(
text(format!("Op {index}: {}", tag_label(span.op)))
text(format!("Op {index}: {}", tag_label(group.op)))
.text_sm()
.text_color(colors.foreground),
)
.child(
text(format!(
"old {:?} new {:?}",
span.old_range, span.new_range
group.old_range, group.new_range
))
.text_xs()
.font_family("Menlo")
.text_color(theme.colors.text_muted),
)
.child(
text(format!("{:?}", span.op))
text(format!("{} aligned rows", group.rows.len()))
.text_xs()
.font_family("Menlo")
.text_color(theme.colors.text_subtle),
)
}
fn render_op_group(
index: usize,
span: &Span,
rows: Vec<DiffRow>,
diff: &ContentDiff,
theme: &crate::theme::Theme,
) -> gpui::Div {
let colors = tag_colors(span.op, theme);
let row_elements: Vec<AnyElement> = rows
.into_iter()
.map(|row| render_row(row, diff, theme).into_any_element())
fn render_op_group(index: usize, group: &OpGroup, theme: &crate::theme::Theme) -> gpui::Div {
let colors = tag_colors(group.op, theme);
let row_elements: Vec<AnyElement> = group
.rows
.iter()
.map(|row| render_row(index, row, theme).into_any_element())
.collect();
div()
@@ -527,14 +546,14 @@ fn render_op_group(
.justify_between()
.items_center()
.child(
text(format!("Op {index}: {}", tag_label(span.op)))
text(format!("Op {index}: {}", tag_label(group.op)))
.text_sm()
.text_color(colors.foreground),
)
.child(
text(format!(
"old {:?} new {:?}",
span.old_range, span.new_range
group.old_range, group.new_range
))
.text_xs()
.font_family("Menlo")
@@ -544,16 +563,7 @@ fn render_op_group(
.child(div().flex().flex_col().children(row_elements))
}
fn render_row(row: DiffRow, diff: &ContentDiff, theme: &crate::theme::Theme) -> gpui::Div {
let old_text = row
.old_content_range
.as_ref()
.map(|range| display_bytes(diff.line_slice(DiffSide::Old, range)));
let new_text = row
.new_content_range
.as_ref()
.map(|range| display_bytes(diff.line_slice(DiffSide::New, range)));
fn render_row(op_index: usize, row: &DiffLine, theme: &crate::theme::Theme) -> gpui::Div {
div()
.flex()
.flex_row()
@@ -561,18 +571,18 @@ fn render_row(row: DiffRow, diff: &ContentDiff, theme: &crate::theme::Theme) ->
.border_b_1()
.border_color(theme.colors.border_muted)
.child(render_line_cell(
row.op_index,
op_index,
row.op,
row.old_line,
old_text,
row.old_content.as_ref().map(|_| row.old_line),
row.old_content.as_deref().map(display_text),
true,
theme,
))
.child(render_line_cell(
row.op_index,
op_index,
row.op,
row.new_line,
new_text,
row.new_content.as_ref().map(|_| row.new_line),
row.new_content.as_deref().map(display_text),
false,
theme,
))
@@ -619,7 +629,7 @@ fn render_line_cell(
.font_family("Menlo")
.text_xs()
.text_color(colors.foreground)
.child(content.unwrap_or_else(|| format!("anchor for span {op_index}"))),
.child(content.unwrap_or_else(|| format!("anchor for op {op_index}"))),
)
}
@@ -632,10 +642,10 @@ fn tag_label(op: Op) -> &'static str {
}
}
fn display_bytes(bytes: &[u8]) -> String {
fn display_text(text: &str) -> String {
let mut rendered = String::new();
for ch in String::from_utf8_lossy(bytes).chars() {
for ch in text.chars() {
match ch {
| '\n' => rendered.push_str("\\n"),
| '\r' => rendered.push_str("\\r"),
@@ -651,6 +661,91 @@ fn display_bytes(bytes: &[u8]) -> String {
rendered
}
fn line_count(lines: &[SourceLine]) -> usize {
lines.last().map(|line| line.line_number + 1).unwrap_or(0)
}
fn collect_source_lines(diff: &ContentDiff, side: SourceSide) -> Vec<SourceLine> {
let mut lines = Vec::new();
for i in 0..diff.len() {
let row = diff.get(i);
match side {
| SourceSide::Old => {
if let Some(content) = &row.old_content {
lines.push(SourceLine {
line_number: row.old_line,
content: Arc::clone(content),
});
}
}
| SourceSide::New => {
if let Some(content) = &row.new_content {
lines.push(SourceLine {
line_number: row.new_line,
content: Arc::clone(content),
});
}
}
}
}
lines
}
fn collect_op_groups(diff: &ContentDiff) -> Vec<OpGroup> {
let mut groups = Vec::new();
let mut start = 0;
while start < diff.len() {
let op = diff.get(start).op;
let mut end = start + 1;
while end < diff.len() && diff.get(end).op == op {
end += 1;
}
let rows: Vec<DiffLine> = (start..end).map(|i| diff.get(i).clone()).collect();
groups.push(OpGroup {
op,
old_range: group_range(&rows, SourceSide::Old),
new_range: group_range(&rows, SourceSide::New),
rows,
});
start = end;
}
groups
}
fn group_range(rows: &[DiffLine], side: SourceSide) -> Range<usize> {
let anchor = match side {
| SourceSide::Old => rows.first().map(|row| row.old_line).unwrap_or(0),
| SourceSide::New => rows.first().map(|row| row.new_line).unwrap_or(0),
};
let mut first = None;
let mut last = None;
for line_number in rows.iter().filter_map(|row| match side {
| SourceSide::Old => row.old_content.as_ref().map(|_| row.old_line),
| SourceSide::New => row.new_content.as_ref().map(|_| row.new_line),
}) {
if first.is_none() {
first = Some(line_number);
}
last = Some(line_number);
}
match (first, last) {
| (Some(start), Some(end)) => start..end + 1,
| _ => anchor..anchor,
}
}
struct Colors {
background: gpui::Rgba,
border: gpui::Rgba,