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

@@ -22,6 +22,7 @@ tokio = { version = "1.52.1", features = ["rt-multi-thread", "net", "time", "mac
tree-sitter = "0.19.5" tree-sitter = "0.19.5"
tree-sitter-markdown = "0.7.1" tree-sitter-markdown = "0.7.1"
memchr = "2.8.0" memchr = "2.8.0"
thiserror = "2.0.18"
[build-dependencies] [build-dependencies]
serde_json = "1.0.149" serde_json = "1.0.149"

View File

@@ -1,4 +1,5 @@
{ {
"id": "PR_kwDOAgent47",
"title": "feat(prompts): split context loading from execution workers", "title": "feat(prompts): split context loading from execution workers",
"state": "OPEN", "state": "OPEN",
"is_draft": true, "is_draft": true,

View File

@@ -1,4 +1,5 @@
{ {
"id": "PR_kwDODesign31",
"title": "chore(tokens): tighten dashboard spacing scale", "title": "chore(tokens): tighten dashboard spacing scale",
"state": "OPEN", "state": "OPEN",
"is_draft": false, "is_draft": false,

View File

@@ -1,4 +1,5 @@
{ {
"id": "PR_kwDOInfra19",
"title": "docs(deploy): document manual failover steps", "title": "docs(deploy): document manual failover steps",
"state": "CLOSED", "state": "CLOSED",
"is_draft": false, "is_draft": false,

View File

@@ -1,4 +1,5 @@
{ {
"id": "PR_kwDONovem84",
"title": "feat(dashboard): hydrate issue pane from cached query state", "title": "feat(dashboard): hydrate issue pane from cached query state",
"state": "OPEN", "state": "OPEN",
"is_draft": false, "is_draft": false,

View File

@@ -1,4 +1,5 @@
{ {
"id": "PR_kwDONovem85",
"title": "feat(repo): add cached repository query for titlebar picker", "title": "feat(repo): add cached repository query for titlebar picker",
"state": "OPEN", "state": "OPEN",
"is_draft": false, "is_draft": false,

View File

@@ -1,4 +1,5 @@
{ {
"id": "PR_kwDOSprint62",
"title": "feat(calendar): ship release handoff checklist in weekly planner", "title": "feat(calendar): ship release handoff checklist in weekly planner",
"state": "MERGED", "state": "MERGED",
"is_draft": false, "is_draft": false,

View File

@@ -0,0 +1,19 @@
{
"node": {
"__typename": "PullRequest",
"files": {
"edges": [
{
"cursor": "file:PR_kwDONovem85:1",
"node": {
"changeType": "MODIFIED",
"additions": 42,
"deletions": 7,
"path": "src/api/repo.rs",
"viewerViewedState": "UNVIEWED"
}
}
]
}
}
}

View File

@@ -33,20 +33,29 @@ pub(crate) struct GithubCredentials {
pub(crate) client_id: &'static str, pub(crate) client_id: &'static str,
} }
#[derive(Debug)] #[derive(Debug, thiserror::Error)]
pub(crate) enum Error { pub(crate) enum Error {
#[error("unauthenticated api request")]
Unauthenticated, Unauthenticated,
#[error("request not allowed")]
NotAllowed, NotAllowed,
#[error("requested resource does not exist")]
DoesNotExist, DoesNotExist,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
#[error("missing mock fixture for {0:?}")]
MissingMockFixture(String), MissingMockFixture(String),
#[error(transparent)]
Github(GithubError), Github(GithubError),
#[error("malformed response")]
MalformedResponse(String), MalformedResponse(String),
HttpError(reqwest::Error), #[error("generic http error: {0:?}")]
HttpError(#[from] reqwest::Error),
#[error("graphql api error: {0:?}")]
GraphQLError(Vec<graphql_client::Error>), GraphQLError(Vec<graphql_client::Error>),
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, thiserror::Error)]
#[error("github error {error:?}: {error_description:?}")]
pub(crate) struct GithubError { pub(crate) struct GithubError {
pub error: String, pub error: String,
pub error_description: Option<String>, pub error_description: Option<String>,
@@ -96,12 +105,6 @@ pub(crate) fn use_github_fixtures() -> bool {
impl query::Context for QueryContext {} impl query::Context for QueryContext {}
impl From<reqwest::Error> for Error {
fn from(value: reqwest::Error) -> Self {
Self::HttpError(value)
}
}
impl From<serde_json::Error> for Error { impl From<serde_json::Error> for Error {
fn from(value: serde_json::Error) -> Self { fn from(value: serde_json::Error) -> Self {
Self::MalformedResponse(value.to_string()) Self::MalformedResponse(value.to_string())

View File

@@ -2,6 +2,7 @@ query PullRequestQuery($id: ID!) {
node(id: $id) { node(id: $id) {
__typename __typename
... on PullRequest { ... on PullRequest {
id
title title
body body
state state

View File

@@ -41,6 +41,7 @@ pub(crate) struct PullRequest {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct DetailedPullRequest { pub(crate) struct DetailedPullRequest {
pub(crate) id: Id,
pub(crate) title: Arc<str>, pub(crate) title: Arc<str>,
pub(crate) state: PullRequestState, pub(crate) state: PullRequestState,
pub(crate) is_draft: bool, pub(crate) is_draft: bool,
@@ -189,7 +190,7 @@ pub(crate) struct ChangedFile {
pub(crate) change_type: ChangeType, pub(crate) change_type: ChangeType,
pub(crate) additions: i64, pub(crate) additions: i64,
pub(crate) deletions: i64, pub(crate) deletions: i64,
pub(crate) path: String, pub(crate) path: Arc<str>,
pub(crate) viewer_viewed_state: FileViewedState, pub(crate) viewer_viewed_state: FileViewedState,
} }
@@ -408,6 +409,7 @@ impl query::QueryFn for FetchPullRequest {
})?; })?;
Ok(DetailedPullRequest { Ok(DetailedPullRequest {
id: Id(p.id.into()),
title: p.title.into(), title: p.title.into(),
state: p.state, state: p.state,
is_draft: p.is_draft, is_draft: p.is_draft,
@@ -531,7 +533,7 @@ impl query::QueryFn for FetchPullRequestFileTree {
}, },
additions: node.additions, additions: node.additions,
deletions: node.deletions, deletions: node.deletions,
path: node.path, path: node.path.into(),
viewer_viewed_state: node.viewer_viewed_state, viewer_viewed_state: node.viewer_viewed_state,
}) })
}) })

View File

@@ -376,6 +376,29 @@ mod tests {
) )
.unwrap_or_else(|_| panic!("head fixture should exist for {path}")); .unwrap_or_else(|_| panic!("head fixture should exist for {path}"));
} }
let _ = fetch_pull_request_file_tree(&issues::Id::from("PR_kwDONovem85"))
.expect("repo picker pull request file tree fixture should parse");
let repo_picker_file_tree_json: serde_json::Value = serde_json::from_str(
issues_pull_request_file_tree("PR_kwDONovem85")
.expect("repo picker pull request file tree fixture json should exist"),
)
.expect("repo picker pull request file tree fixture json should parse");
let repo_picker_file_paths = repo_picker_file_tree_json
.get("node")
.and_then(|node| node.get("files"))
.and_then(|files| files.get("edges"))
.and_then(serde_json::Value::as_array)
.expect("repo picker pull request file tree fixture should contain file edges")
.iter()
.filter_map(|edge| edge.get("node"))
.filter_map(|node| node.get("path"))
.filter_map(serde_json::Value::as_str)
.collect::<Vec<_>>();
assert_eq!(repo_picker_file_paths, vec!["src/api/repo.rs"]);
} }
#[test] #[test]

View File

@@ -117,9 +117,18 @@ pub struct FetchFileDiff {
pub head: FileRef, pub head: FileRef,
} }
#[derive(Debug, thiserror::Error)]
pub enum FetchFileDiffError {
#[error("api error when fetching file diff: {0:?}")]
ApiError(#[from] api::Error),
#[error("invalid utf8 content or unsupported file type")]
InvalidTextContent,
}
impl query::QueryFn for FetchFileDiff { impl query::QueryFn for FetchFileDiff {
type Data = util::diff::ContentDiff; type Data = Arc<util::diff::ContentDiff>;
type Error = api::Error; type Error = FetchFileDiffError;
type Context = api::QueryContext; type Context = api::QueryContext;
fn key(&self) -> query::Key { fn key(&self) -> query::Key {
@@ -134,9 +143,15 @@ impl query::QueryFn for FetchFileDiff {
async fn fetch_content( async fn fetch_content(
r: &FileRef, r: &FileRef,
c: &<FetchFileDiff as query::QueryFn>::Context, c: &<FetchFileDiff as query::QueryFn>::Context,
) -> Result<Option<bytes::Bytes>, api::Error> { ) -> Result<bytes::Bytes, FetchFileDiffError> {
#[cfg(debug_assertions)]
let bytes = if c.should_use_fixtures {
super::mock::fetch_file_content(&r.repo_slug, &r.path, r.reff.as_deref())?
} else {
let path = match &r.reff { let path = match &r.reff {
| Some(reff) => format!("/repos/{}/contents/{}?ref={}", r.repo_slug, r.path, reff), | Some(reff) => {
format!("/repos/{}/contents/{}?ref={}", r.repo_slug, r.path, reff)
}
| None => format!("/repos/{}/contents/{}", r.repo_slug, r.path), | None => format!("/repos/{}/contents/{}", r.repo_slug, r.path),
}; };
@@ -144,25 +159,92 @@ impl query::QueryFn for FetchFileDiff {
.github_request(Method::GET, &path)? .github_request(Method::GET, &path)?
.header("Accept", "application/vnd.github.raw+json") .header("Accept", "application/vnd.github.raw+json")
.send() .send()
.await?; .await
.map_err(api::Error::HttpError)?;
res.headers().get("Content-Type"); api::raw_content(res).await?
let bytes = api::raw_content(res).await?;
let file::ContentType::Text = file::classify_content(&bytes) else {
return Ok(None);
}; };
Ok(Some(bytes)) #[cfg(not(debug_assertions))]
let bytes = {
let path = match &r.reff {
| Some(reff) => {
format!("/repos/{}/contents/{}?ref={}", r.repo_slug, r.path, reff)
}
| None => format!("/repos/{}/contents/{}", r.repo_slug, r.path),
};
let res = c
.github_request(Method::GET, &path)?
.header("Accept", "application/vnd.github.raw+json")
.send()
.await
.map_err(api::Error::HttpError)?;
api::raw_content(res).await?
};
match file::classify_content(&bytes) {
| file::ContentType::Text => Ok(bytes),
| _ => Err(FetchFileDiffError::InvalidTextContent),
}
} }
let (old, new) = tokio::join!(fetch_content(&self.base, c), fetch_content(&self.head, c),); let (old, new) =
tokio::try_join!(fetch_content(&self.base, c), fetch_content(&self.head, c),)?;
match (old, new) { util::diff::diff_content(old, new)
| (Ok(Some(old)), Ok(Some(new))) => Ok(util::diff::diff_content(old, new)), .map(|diff| Arc::new(diff))
| _ => Err(api::Error::MalformedResponse( .ok_or(FetchFileDiffError::InvalidTextContent)
"failed to fetch content".to_string(),
)),
} }
} }
#[cfg(test)]
mod tests {
use crate::query::QueryFn;
use super::*;
#[tokio::test]
async fn fetch_file_diff_uses_repo_file_content_fixtures() {
let pull_request =
super::super::mock::fetch_pull_request(&api::issues::Id::from("PR_kwDONovem84"))
.expect("pull request fixture should parse");
let diff = FetchFileDiff {
base: FileRef {
repo_slug: pull_request.base_repo_slug.clone(),
path: Arc::from("src/query.rs"),
reff: Some(pull_request.base_ref.clone()),
},
head: FileRef {
repo_slug: pull_request.head_repo_slug.clone(),
path: Arc::from("src/query.rs"),
reff: Some(pull_request.head_ref.clone()),
},
}
.run(&api::QueryContext {
http: reqwest::Client::new(),
auth: None,
github: api::GithubCredentials {
base_url: "",
client_id: "",
},
should_use_fixtures: true,
})
.await
.expect("fetch file diff should succeed from fixtures");
assert!(diff.len() > 0);
assert!(
(0..diff.len())
.filter_map(|i| diff.get(i).old_content.as_deref())
.any(|line| { line.contains("pub struct CachedSelection") })
);
assert!(
(0..diff.len())
.filter_map(|i| diff.get(i).new_content.as_deref())
.any(|line| { line.contains("pub struct CachedQueryState") })
);
}
} }

View File

@@ -1,108 +1,158 @@
use std::{rc::Rc, sync::Arc}; use std::{num::NonZeroUsize, rc::Rc, sync::Arc};
use gpui::{IntoElement, ParentElement, Styled, div, list, px, rems}; use gpui::{
IntoElement, ParentElement, Refineable, Styled, div, list, prelude::FluentBuilder, px, rems,
};
use crate::app; use crate::app;
#[derive(gpui::IntoElement, Clone)] #[derive(gpui::IntoElement, Clone)]
pub(crate) struct Line { pub(crate) struct CodeLine {
line_number_col_width: gpui::Pixels, line_number: Option<NonZeroUsize>,
line_number: usize, content: Option<gpui::SharedString>,
content: gpui::SharedString, diff_marker: CodeLineMarker,
diff_marker: DiffMarker, gutter_width: gpui::Pixels,
style: gpui::StyleRefinement,
} }
#[derive(Clone)] #[derive(Clone)]
enum DiffMarker { pub(crate) enum CodeLineMarker {
Added, Added,
Deleted, Deleted,
Unchanged, Unchanged,
Placeholder,
} }
#[derive(Clone)] #[derive(Clone)]
struct CodeViewState(gpui::ListState); pub(crate) struct CodeViewState(gpui::ListState);
#[derive(Clone)] pub(crate) struct CodeView {
struct Lines(Rc<Vec<Line>>);
struct CodeView {
state: CodeViewState, state: CodeViewState,
lines: Lines, content: CodeViewContent,
} }
pub(crate) fn line( pub(crate) struct CodeViewContent {
line_number: usize, lines: Rc<[CodeLine]>,
content: impl Into<Arc<str>>, }
diff_marker: DiffMarker,
) -> Line { pub(crate) fn code_view(state: CodeViewState, content: CodeViewContent) -> CodeView {
Line { CodeView { state, content }
line_number, }
diff_marker,
content: gpui::SharedString::new(content), pub(crate) fn code_line(
line_number_col_width: px(0.), line_index: Option<usize>,
content: Option<gpui::SharedString>,
marker: CodeLineMarker,
) -> CodeLine {
CodeLine {
line_number: line_index.map(|i| unsafe { NonZeroUsize::new_unchecked(i + 1) }),
content,
diff_marker: marker,
gutter_width: px(0.),
style: gpui::StyleRefinement::default(),
} }
} }
pub(crate) fn code_view(state: CodeViewState, lines: Lines) -> CodeView { impl CodeViewContent {
CodeView { state, lines } pub(crate) fn new(lines: Vec<CodeLine>) -> Self {
Self {
lines: lines.into(),
}
}
} }
impl FromIterator<Line> for Lines { impl CodeLine {
fn from_iter<T: IntoIterator<Item = Line>>(iter: T) -> Self { pub(crate) fn gutter_width(mut self, width: gpui::Pixels) -> Self {
Lines(Rc::new(iter.into_iter().collect())) self.gutter_width = width;
self
} }
} }
impl gpui::RenderOnce for CodeView { impl gpui::RenderOnce for CodeView {
fn render(self, window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement { fn render(self, window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
let digits = self let digits = self
.content
.lines .lines
.0 .iter()
.last() .rfind(|l| l.line_number.is_some())
.map(|l| l.line_number.to_string().len()) .map(|l| l.line_number.unwrap().to_string().len())
.unwrap_or(0); .unwrap_or(0);
let text_style = window.text_style(); let text_style = window.text_style();
let font_size = text_style.font_size.to_pixels(window.rem_size()); let font_size = text_style.font_size.to_pixels(window.rem_size());
let font_id = window.text_system().resolve_font(&gpui::font("Menlo")); let font_id = window.text_system().resolve_font(&gpui::font("Menlo"));
let line_number_col_width = window let gutter_width = window
.text_system() .text_system()
.ch_advance(font_id, font_size) .ch_advance(font_id, font_size)
.unwrap_or(px(7.2)) .unwrap_or(px(7.2))
* digits; * digits;
list(self.state.0, move |i, _window, _app| { println!("gutter width {}", gutter_width);
let mut line = self.lines.0[i].clone();
line.line_number_col_width = line_number_col_width;
list(self.state.0, move |i, _window, _app| {
let line = self.content.lines[i].clone();
div() div()
.flex() .flex()
.flex_row() .flex_row()
.items_start() .items_start()
.w_full() .w_full()
.child(line) .child(line.gutter_width(gutter_width))
.into_any_element() .into_any_element()
}) })
} }
} }
impl gpui::RenderOnce for Line { impl gpui::Styled for CodeLine {
#[doc = " Returns a reference to the style memory of this element."]
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl gpui::RenderOnce for CodeLine {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement { fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
let theme = app::current_theme(cx); let theme = app::current_theme(cx);
div() let mut div = div()
.flex() .flex()
.flex_row() .flex_row()
.font_family("Menlo") .font_family("Menlo")
.text_color(theme.colors.text) .text_color(theme.colors.text)
.text_xs()
.child( .child(
div() div()
.bg(theme.colors.surface) .bg(theme.colors.surface)
.w(self.line_number_col_width) .w(self.gutter_width + px(16.))
.text_align(gpui::TextAlign::Right) .text_align(gpui::TextAlign::Right)
.child(self.line_number.to_string()), .px_2()
.when_some(self.line_number, |it, line_number| {
it.child(line_number.to_string())
})
.when(matches!(self.diff_marker, CodeLineMarker::Added), |it| {
it.bg(theme.colors.success_muted)
.text_color(theme.colors.success_fg)
.border_l_2()
.border_color(theme.colors.success_border)
})
.when(matches!(self.diff_marker, CodeLineMarker::Deleted), |it| {
it.bg(theme.colors.danger_muted)
.text_color(theme.colors.danger_fg)
.border_l_2()
.border_color(theme.colors.danger_border)
}),
) )
.child(self.content) .when_some(self.content, |it, content| {
it.child(div().px_2().w_full().min_w_0().child(content))
})
.when(matches!(self.diff_marker, CodeLineMarker::Added), |it| {
it.bg(theme.colors.success_muted)
})
.when(matches!(self.diff_marker, CodeLineMarker::Deleted), |it| {
it.bg(theme.colors.danger_muted)
});
div.style().refine(&self.style);
div
} }
} }

138
src/component/diff_view.rs Normal file
View File

@@ -0,0 +1,138 @@
use std::sync::Arc;
use gpui::{IntoElement, ParentElement, Styled, div, list, px};
use crate::{
component::code_view::{self, CodeLine, code_line},
util::{self, str::ToSharedString},
};
#[derive(gpui::IntoElement)]
pub(crate) struct DiffView {
state: DiffViewState,
content: DiffViewContent,
}
#[derive(Clone)]
pub(crate) struct DiffViewState(gpui::ListState);
#[derive(Clone)]
pub(crate) struct DiffViewContent {
diff: Arc<util::diff::ContentDiff>,
}
#[derive(Clone, gpui::IntoElement)]
struct DiffRow {
line: util::diff::DiffLine,
old_side_gutter_width: gpui::Pixels,
new_side_gutter_width: gpui::Pixels,
}
pub(crate) fn diff_view(state: DiffViewState, content: DiffViewContent) -> DiffView {
DiffView { state, content }
}
impl From<Arc<util::diff::ContentDiff>> for DiffViewContent {
fn from(value: Arc<util::diff::ContentDiff>) -> Self {
Self { diff: value }
}
}
impl DiffViewState {
pub(crate) fn new() -> Self {
Self(gpui::ListState::new(0, gpui::ListAlignment::Top, px(100.)))
}
pub(crate) fn reset(&mut self, line_count: usize) {
self.0.reset(line_count);
}
}
impl gpui::RenderOnce for DiffView {
fn render(self, window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
let (old_digits, new_digits) = self
.content
.diff
.last()
.map(|l| (l.old_line.to_string().len(), l.new_line.to_string().len()))
.unwrap_or((1, 1));
let text_style = window.text_style();
let font_size = text_style.font_size.to_pixels(window.rem_size());
let font_id = window.text_system().resolve_font(&gpui::font("Menlo"));
let ch = window
.text_system()
.ch_advance(font_id, font_size)
.unwrap_or(px(7.2));
let old_side_gutter_width = ch * old_digits;
let new_side_gutter_width = ch * new_digits;
list(self.state.0, move |i, _, cx| {
DiffRow {
line: self.content.diff.get(i).clone(),
old_side_gutter_width,
new_side_gutter_width,
}
.into_any_element()
})
.bg(gpui::red())
.size_full()
}
}
impl DiffRow {
fn old_code_line(&self) -> CodeLine {
code_line(
Some(self.line.old_line),
self.line
.old_content
.as_ref()
.map(|it| it.to_shared_string()),
match self.line.op {
| util::diff::Op::Equal => code_view::CodeLineMarker::Unchanged,
| util::diff::Op::Insert => code_view::CodeLineMarker::Placeholder,
| util::diff::Op::Replace | util::diff::Op::Delete => {
code_view::CodeLineMarker::Deleted
}
},
)
}
fn new_code_line(&self) -> CodeLine {
code_line(
Some(self.line.new_line),
self.line
.new_content
.as_ref()
.map(|it| it.to_shared_string()),
match self.line.op {
| util::diff::Op::Equal => code_view::CodeLineMarker::Unchanged,
| util::diff::Op::Insert | util::diff::Op::Replace => code_view::CodeLineMarker::Added,
| util::diff::Op::Delete => code_view::CodeLineMarker::Deleted,
},
)
}
}
impl gpui::RenderOnce for DiffRow {
fn render(self, window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
div()
.w_full()
.flex()
.flex_row()
.child(
self.old_code_line()
.gutter_width(self.old_side_gutter_width)
.min_w_0()
.flex_1(),
)
.child(
self.new_code_line()
.gutter_width(self.new_side_gutter_width)
.min_w_0()
.flex_1(),
)
}
}

View File

@@ -1,5 +1,6 @@
pub(crate) mod button; pub(crate) mod button;
pub(crate) mod code_view; pub(crate) mod code_view;
pub(crate) mod diff_view;
pub(crate) mod font_icon; pub(crate) mod font_icon;
pub(crate) mod markdown; pub(crate) mod markdown;
pub(crate) mod text; pub(crate) mod text;

View File

@@ -187,6 +187,21 @@ where
} }
} }
pub fn observe_query<E, F>(
query: &Entity<F>,
mut on_notify: impl FnMut(&mut E, &Entity<F>, &mut gpui::Context<E>) + 'static,
cx: &mut gpui::Context<E>,
) -> gpui::Subscription
where
E: 'static,
F: QueryFn,
{
let q = query.clone();
cx.observe(&query, move |this, _, cx| {
on_notify(this, &q, cx);
})
}
// ================= Store ================== // ================= Store ==================
pub(crate) trait Context: Clone {} pub(crate) trait Context: Clone {}

View File

@@ -1,21 +1,43 @@
use std::sync::Arc;
use crate::{ use crate::{
api, app, api, app,
component::text::text, component::{
query::{self, QueryStatus, read_query, use_query}, 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 { pub(crate) struct PullRequestDiffView {
selected_file_path: Option<String>, selected_file_path: Option<Arc<str>>,
pr_query: query::Entity<api::issues::FetchPullRequest>, pr_query: query::Entity<api::issues::FetchPullRequest>,
file_tree_query: query::Entity<api::issues::FetchPullRequestFileTree>,
content_diff_query: Option<query::Entity<api::repo::FetchFileDiff>>, 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 { let mut view = PullRequestDiffView {
selected_file_path: None, 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, content_diff_query: None,
diff_view_state: DiffViewState::new(),
diff_view_content: None,
}; };
view.on_create(cx); view.on_create(cx);
view view
@@ -29,22 +51,42 @@ impl PullRequestDiffView {
}) })
.detach(); .detach();
_ = cx
.observe(&self.file_tree_query, |this, _, cx| {
this.start_content_queries(cx);
})
.detach();
// if pr is already loaded, start content queries // if pr is already loaded, start content queries
self.start_content_queries(cx); self.start_content_queries(cx);
} }
fn start_content_queries(&mut self, cx: &mut gpui::Context<Self>) { 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)) = ({ let Some((old_file_ref, new_file_ref)) = ({
if let QueryStatus::Loaded(pr) = read_query(&self.pr_query, cx) { if let QueryStatus::Loaded(pr) = read_query(&self.pr_query, cx) {
Some(( Some((
api::repo::FileRef { api::repo::FileRef {
repo_slug: pr.base_repo_slug.clone(), 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()), reff: Some(pr.base_ref.clone()),
}, },
api::repo::FileRef { api::repo::FileRef {
repo_slug: pr.head_repo_slug.clone(), 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()), reff: Some(pr.head_ref.clone()),
}, },
)) ))
@@ -63,6 +105,20 @@ impl PullRequestDiffView {
cx, 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); self.content_diff_query = Some(content_diff_query);
} }
} }
@@ -73,20 +129,27 @@ impl gpui::Render for PullRequestDiffView {
_window: &mut gpui::Window, _window: &mut gpui::Window,
cx: &mut gpui::Context<Self>, cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement { ) -> impl gpui::IntoElement {
use gpui::{ParentElement, Styled, div};
let theme = app::current_theme(cx); 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() .size_full()
.bg(theme.colors.surface) .bg(theme.colors.surface)
.p_4() .p_4()
.child( .child(text("asd")),
text(
"Pull request diff rendering is still under construction. Launch the DiffOps playground with NOVEM_DIFFOPS_PLAYGROUND=1 cargo run.", | QueryStatus::Loaded(_) => match &self.diff_view_content {
) | Some(content) => div()
.text_sm() .size_full()
.text_color(theme.colors.text_muted), .child(diff_view(self.diff_view_state.clone(), content.clone())),
) | None => div(),
},
}
} }
} }

View File

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

View File

@@ -4,6 +4,7 @@ use crate::{
api, app, api, app,
screen::dashboard::{ screen::dashboard::{
issue_list::{self, IssueList}, issue_list::{self, IssueList},
pull_request_diff_view::{self, PullRequestDiffView},
pull_request_view::{self, PullRequestView}, pull_request_view::{self, PullRequestView},
titlebar::{self, TitleBar}, titlebar::{self, TitleBar},
}, },
@@ -13,6 +14,7 @@ pub(crate) struct Screen {
titlebar: gpui::Entity<TitleBar>, titlebar: gpui::Entity<TitleBar>,
issue_list: gpui::Entity<IssueList>, issue_list: gpui::Entity<IssueList>,
pull_request_view: gpui::Entity<PullRequestView>, pull_request_view: gpui::Entity<PullRequestView>,
pull_request_diff_view: Option<gpui::Entity<PullRequestDiffView>>,
issue_filter: Option<&'static str>, issue_filter: Option<&'static str>,
} }
@@ -22,6 +24,7 @@ pub(crate) fn new(cx: &mut gpui::Context<Screen>) -> Screen {
titlebar: cx.new(titlebar::new), titlebar: cx.new(titlebar::new),
issue_list: cx.new(issue_list::new), issue_list: cx.new(issue_list::new),
pull_request_view: cx.new(pull_request_view::new), pull_request_view: cx.new(pull_request_view::new),
pull_request_diff_view: None,
issue_filter: None, issue_filter: None,
}; };
@@ -50,7 +53,9 @@ impl Screen {
view.change_displayed_pull_request(id.clone(), cx); view.change_displayed_pull_request(id.clone(), cx);
println!("change displayed pull request: {:?}", id); println!("change displayed pull request: {:?}", id);
cx.notify(); 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 bytes::Bytes;
use gpui::{ use gpui::{
AnyElement, AppContext, InteractiveElement, IntoElement, ParentElement, AnyElement, AppContext, InteractiveElement, IntoElement, ParentElement,
StatefulInteractiveElement, Styled, div, point, px, size, StatefulInteractiveElement,
Styled, div, point, px, size,
}; };
use crate::{ use crate::{
@@ -10,7 +13,7 @@ use crate::{
button::{self, button}, button::{self, button},
text::text, text::text,
}, },
util::diff::{ContentDiff, DiffRow, DiffSide, Op, Span, diff_content}, util::diff::{ContentDiff, DiffLine, Op, diff_content},
}; };
pub(crate) fn is_enabled() -> bool { pub(crate) fn is_enabled() -> bool {
@@ -51,7 +54,29 @@ pub(crate) struct Screen {
struct DiffCase { struct DiffCase {
title: &'static str, title: &'static str,
description: &'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 { fn new(_cx: &mut gpui::Context<Screen>) -> Screen {
@@ -108,28 +133,17 @@ impl gpui::Render for Screen {
.collect(); .collect();
let op_cards: Vec<AnyElement> = case let op_cards: Vec<AnyElement> = case
.diff .op_groups
.spans()
.iter() .iter()
.enumerate() .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(); .collect();
let op_groups: Vec<AnyElement> = case let op_groups: Vec<AnyElement> = case
.diff .op_groups
.spans()
.iter() .iter()
.enumerate() .enumerate()
.map(|(index, span)| { .map(|(index, group)| render_op_group(index, group, theme).into_any_element())
render_op_group(
index,
span,
case.diff.rows_for_span(index),
&case.diff,
theme,
)
.into_any_element()
})
.collect(); .collect();
div() div()
@@ -160,7 +174,7 @@ impl gpui::Render for Screen {
.child(text("DiffOps Playground").text_lg()) .child(text("DiffOps Playground").text_lg())
.child( .child(
text( 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_sm()
.text_color(theme.colors.text_muted), .text_color(theme.colors.text_muted),
@@ -188,7 +202,7 @@ impl gpui::Render for Screen {
.flex() .flex()
.flex_col() .flex_col()
.gap_2() .gap_2()
.child(text("Precomputed DiffOps").text_sm()) .child(text("Derived Op Groups").text_sm())
.children(op_cards), .children(op_cards),
), ),
), ),
@@ -218,9 +232,9 @@ impl gpui::Render for Screen {
.child( .child(
text(format!( text(format!(
"{} ops, {} old lines, {} new lines", "{} ops, {} old lines, {} new lines",
case.diff.spans().len(), case.op_groups.len(),
case.diff.old_line_count(), line_count(&case.old_lines),
case.diff.new_line_count(), line_count(&case.new_lines),
)) ))
.text_xs() .text_xs()
.font_family("Menlo") .font_family("Menlo")
@@ -238,11 +252,11 @@ impl gpui::Render for Screen {
.border_b_1() .border_b_1()
.border_color(theme.colors.border_muted) .border_color(theme.colors.border_muted)
.child( .child(
panel_header("Old", case.diff.old_line_count(), theme) panel_header("Old", line_count(&case.old_lines), theme)
.flex_1(), .flex_1(),
) )
.child( .child(
panel_header("New", case.diff.new_line_count(), theme) panel_header("New", line_count(&case.new_lines), theme)
.flex_1(), .flex_1(),
), ),
) )
@@ -259,8 +273,12 @@ impl gpui::Render for Screen {
.flex_col() .flex_col()
.gap_3() .gap_3()
.child(text("Source Content").text_sm()) .child(text("Source Content").text_sm())
.child(render_source_content(&case.diff, theme)) .child(render_source_content(
.child(text("DiffOps Render").text_sm()) &case.old_lines,
&case.new_lines,
theme,
))
.child(text("Diff Rows Render").text_sm())
.children(op_groups), .children(op_groups),
), ),
), ),
@@ -273,7 +291,7 @@ fn sample_cases() -> Vec<DiffCase> {
vec![ vec![
DiffCase::new( DiffCase::new(
"Insert Block", "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() { r#"fn config() {
let host = "localhost"; let host = "localhost";
start(host); start(host);
@@ -288,7 +306,7 @@ fn sample_cases() -> Vec<DiffCase> {
), ),
DiffCase::new( DiffCase::new(
"Delete Block", "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) { r#"fn handle(req: Request) {
trace_request(&req); trace_request(&req);
authorize(&req); authorize(&req);
@@ -303,7 +321,7 @@ fn sample_cases() -> Vec<DiffCase> {
), ),
DiffCase::new( DiffCase::new(
"Replace Span", "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() { r#"fn render() {
let theme = current_theme(cx); let theme = current_theme(cx);
layout(theme); layout(theme);
@@ -318,7 +336,7 @@ fn sample_cases() -> Vec<DiffCase> {
), ),
DiffCase::new( DiffCase::new(
"Mixed Hunk", "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; r#"use crate::auth::Token;
use crate::http::Client; use crate::http::Client;
@@ -350,13 +368,18 @@ impl DiffCase {
old: &'static str, old: &'static str,
new: &'static str, new: &'static str,
) -> Self { ) -> 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 { Self {
title, title,
description, description,
diff: diff_content( old_lines: collect_source_lines(&diff, SourceSide::Old),
Bytes::from_static(old.as_bytes()), new_lines: collect_source_lines(&diff, SourceSide::New),
Bytes::from_static(new.as_bytes()), 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() div()
.flex() .flex()
.flex_row() .flex_row()
.gap_2() .gap_2()
.child(render_source_panel("Old Content", DiffSide::Old, diff, theme).flex_1()) .child(render_source_panel("Old Content", old_lines, theme).flex_1())
.child(render_source_panel("New Content", DiffSide::New, diff, theme).flex_1()) .child(render_source_panel("New Content", new_lines, theme).flex_1())
} }
fn render_source_panel( fn render_source_panel(
title: &'static str, title: &'static str,
side: DiffSide, lines: &[SourceLine],
diff: &ContentDiff,
theme: &crate::theme::Theme, theme: &crate::theme::Theme,
) -> gpui::Div { ) -> gpui::Div {
let line_count = match side { let line_count = line_count(lines);
| DiffSide::Old => diff.old_line_count(),
| DiffSide::New => diff.new_line_count(),
};
let lines: Vec<AnyElement> = (0..line_count) let rows: Vec<AnyElement> = lines
.iter()
.map(|line| { .map(|line| {
div() div()
.flex() .flex()
@@ -418,7 +442,7 @@ fn render_source_panel(
.font_family("Menlo") .font_family("Menlo")
.text_xs() .text_xs()
.text_color(theme.colors.text_subtle) .text_color(theme.colors.text_subtle)
.child(format!("{:>4}", line + 1)), .child(format!("{:>4}", line.line_number + 1)),
) )
.child( .child(
div() div()
@@ -429,7 +453,7 @@ fn render_source_panel(
.font_family("Menlo") .font_family("Menlo")
.text_xs() .text_xs()
.text_color(theme.colors.text) .text_color(theme.colors.text)
.child(display_bytes(diff.line_slice_at(side, line))), .child(display_text(&line.content)),
) )
.into_any_element() .into_any_element()
}) })
@@ -460,11 +484,11 @@ fn render_source_panel(
.text_color(theme.colors.text_subtle), .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 { fn render_op_card(index: usize, group: &OpGroup, theme: &crate::theme::Theme) -> gpui::Div {
let colors = tag_colors(span.op, theme); let colors = tag_colors(group.op, theme);
div() div()
.rounded_md() .rounded_md()
@@ -476,38 +500,33 @@ fn render_op_card(index: usize, span: &Span, theme: &crate::theme::Theme) -> gpu
.flex_col() .flex_col()
.gap_1() .gap_1()
.child( .child(
text(format!("Op {index}: {}", tag_label(span.op))) text(format!("Op {index}: {}", tag_label(group.op)))
.text_sm() .text_sm()
.text_color(colors.foreground), .text_color(colors.foreground),
) )
.child( .child(
text(format!( text(format!(
"old {:?} new {:?}", "old {:?} new {:?}",
span.old_range, span.new_range group.old_range, group.new_range
)) ))
.text_xs() .text_xs()
.font_family("Menlo") .font_family("Menlo")
.text_color(theme.colors.text_muted), .text_color(theme.colors.text_muted),
) )
.child( .child(
text(format!("{:?}", span.op)) text(format!("{} aligned rows", group.rows.len()))
.text_xs() .text_xs()
.font_family("Menlo") .font_family("Menlo")
.text_color(theme.colors.text_subtle), .text_color(theme.colors.text_subtle),
) )
} }
fn render_op_group( fn render_op_group(index: usize, group: &OpGroup, theme: &crate::theme::Theme) -> gpui::Div {
index: usize, let colors = tag_colors(group.op, theme);
span: &Span, let row_elements: Vec<AnyElement> = group
rows: Vec<DiffRow>, .rows
diff: &ContentDiff, .iter()
theme: &crate::theme::Theme, .map(|row| render_row(index, row, theme).into_any_element())
) -> 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())
.collect(); .collect();
div() div()
@@ -527,14 +546,14 @@ fn render_op_group(
.justify_between() .justify_between()
.items_center() .items_center()
.child( .child(
text(format!("Op {index}: {}", tag_label(span.op))) text(format!("Op {index}: {}", tag_label(group.op)))
.text_sm() .text_sm()
.text_color(colors.foreground), .text_color(colors.foreground),
) )
.child( .child(
text(format!( text(format!(
"old {:?} new {:?}", "old {:?} new {:?}",
span.old_range, span.new_range group.old_range, group.new_range
)) ))
.text_xs() .text_xs()
.font_family("Menlo") .font_family("Menlo")
@@ -544,16 +563,7 @@ fn render_op_group(
.child(div().flex().flex_col().children(row_elements)) .child(div().flex().flex_col().children(row_elements))
} }
fn render_row(row: DiffRow, diff: &ContentDiff, theme: &crate::theme::Theme) -> gpui::Div { fn render_row(op_index: usize, row: &DiffLine, 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)));
div() div()
.flex() .flex()
.flex_row() .flex_row()
@@ -561,18 +571,18 @@ fn render_row(row: DiffRow, diff: &ContentDiff, theme: &crate::theme::Theme) ->
.border_b_1() .border_b_1()
.border_color(theme.colors.border_muted) .border_color(theme.colors.border_muted)
.child(render_line_cell( .child(render_line_cell(
row.op_index, op_index,
row.op, row.op,
row.old_line, row.old_content.as_ref().map(|_| row.old_line),
old_text, row.old_content.as_deref().map(display_text),
true, true,
theme, theme,
)) ))
.child(render_line_cell( .child(render_line_cell(
row.op_index, op_index,
row.op, row.op,
row.new_line, row.new_content.as_ref().map(|_| row.new_line),
new_text, row.new_content.as_deref().map(display_text),
false, false,
theme, theme,
)) ))
@@ -619,7 +629,7 @@ fn render_line_cell(
.font_family("Menlo") .font_family("Menlo")
.text_xs() .text_xs()
.text_color(colors.foreground) .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(); let mut rendered = String::new();
for ch in String::from_utf8_lossy(bytes).chars() { for ch in text.chars() {
match ch { match ch {
| '\n' => rendered.push_str("\\n"), | '\n' => rendered.push_str("\\n"),
| '\r' => rendered.push_str("\\r"), | '\r' => rendered.push_str("\\r"),
@@ -651,6 +661,91 @@ fn display_bytes(bytes: &[u8]) -> String {
rendered 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 { struct Colors {
background: gpui::Rgba, background: gpui::Rgba,
border: gpui::Rgba, border: gpui::Rgba,

View File

@@ -1,12 +1,7 @@
use std::ops::Range; use std::{ops::Range, slice::Iter, sync::Arc, thread::current};
pub(crate) struct ContentDiff { use memchr::{memchr2, memchr2_iter, memchr3_iter};
pub(crate) old_content: bytes::Bytes, use similar::DiffableStr;
pub(crate) new_content: bytes::Bytes,
pub(crate) spans: Vec<Span>,
old_line_ranges: Vec<Range<usize>>,
new_line_ranges: Vec<Range<usize>>,
}
pub(crate) struct Span { pub(crate) struct Span {
pub(crate) op: Op, pub(crate) op: Op,
@@ -14,7 +9,7 @@ pub(crate) struct Span {
pub(crate) new_range: Range<usize>, pub(crate) new_range: Range<usize>,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Op { pub(crate) enum Op {
Equal, Equal,
Delete, Delete,
@@ -37,147 +32,184 @@ pub(crate) struct DiffRow {
pub(crate) new_content_range: Option<Range<usize>>, pub(crate) new_content_range: Option<Range<usize>>,
} }
pub(crate) fn diff_content(old_content: bytes::Bytes, new_content: bytes::Bytes) -> ContentDiff { #[derive(Clone)]
pub(crate) struct DiffLine {
pub(crate) op: Op,
pub(crate) old_content: Option<Arc<str>>,
pub(crate) old_line: usize,
pub(crate) new_content: Option<Arc<str>>,
pub(crate) new_line: usize,
}
#[derive(Clone)]
pub(crate) struct ContentDiff(Vec<DiffLine>);
pub(crate) fn diff_content(
old_content: bytes::Bytes,
new_content: bytes::Bytes,
) -> Option<ContentDiff> {
let old_line_ranges = line_ranges(&old_content); let old_line_ranges = line_ranges(&old_content);
let new_line_ranges = line_ranges(&new_content); let new_line_ranges = line_ranges(&new_content);
let diff = similar::TextDiff::from_lines::<[u8]>(&old_content, &new_content); let diff = similar::TextDiff::from_lines::<[u8]>(&old_content, &new_content);
let spans = diff let mut diff_lines: Vec<DiffLine> = Vec::new();
.ops()
.iter() for op in diff.ops() {
.map(|op| match op { match op {
| &similar::DiffOp::Equal { | &similar::DiffOp::Equal {
old_index, old_index,
new_index, new_index,
len, len,
} => Span { } => {
for i in 0..len {
let old_line = old_index + i;
let new_line = new_index + i;
let old_line_range = &old_line_ranges[old_line];
let content = Arc::from(old_content.slice(old_line_range.clone()).as_str()?);
diff_lines.push(DiffLine {
op: Op::Equal, op: Op::Equal,
old_range: old_index..(old_index + len), old_line,
new_range: new_index..(new_index + len), old_content: Some(Arc::clone(&content)),
}, new_line,
new_content: Some(content),
| &similar::DiffOp::Delete { });
old_index, }
old_len, }
new_index,
} => Span {
op: Op::Delete,
old_range: old_index..(old_index + old_len),
new_range: new_index..new_index,
},
| &similar::DiffOp::Insert { | &similar::DiffOp::Insert {
old_index, old_index,
new_index, new_index,
new_len, new_len,
} => Span { } => {
for i in 0..new_len {
let new_line_range = &new_line_ranges[new_index + i];
let content = Arc::from(new_content.slice(new_line_range.clone()).as_str()?);
diff_lines.push(DiffLine {
op: Op::Insert, op: Op::Insert,
old_range: old_index..old_index, old_line: old_index,
new_range: new_index..(new_index + new_len), old_content: None,
}, new_line: new_index + i,
new_content: Some(content),
})
}
}
| &similar::DiffOp::Replace { | &similar::DiffOp::Replace {
old_index, old_index,
old_len, old_len,
new_index, new_index,
new_len, new_len,
} => Span { } => {
op: Op::Replace, for i in 0..new_len.max(old_len) {
old_range: old_index..(old_index + old_len), let old_line = old_index + i;
new_range: new_index..(new_index + new_len), let new_line = new_index + i;
},
})
.collect();
ContentDiff { let diff_line = match (old_line_ranges.get(old_line), new_line_ranges.get(new_line))
old_content, {
new_content, | (Some(old_range), Some(new_range)) => DiffLine {
spans, op: Op::Replace,
old_line_ranges, old_line,
new_line_ranges, old_content: Some(Arc::from(old_content.slice(old_range.clone()).as_str()?)),
new_line: new_index + i,
new_content: Some(Arc::from(new_content.slice(new_range.clone()).as_str()?)),
},
| (None, Some(new_range)) => DiffLine {
op: Op::Replace,
old_line: old_index + old_len,
old_content: None,
new_line: new_index + i,
new_content: Some(Arc::from(new_content.slice(new_range.clone()).as_str()?)),
},
| (Some(old_range), None) => DiffLine {
op: Op::Replace,
old_line: old_index + i,
old_content: Some(Arc::from(old_content.slice(old_range.clone()).as_str()?)),
new_line: new_index + new_len,
new_content: None,
},
| (None, None) => {
// unlickly to happen, but if it does, idk
panic!(
"the unlikely happened: both old & new index of DiffOps::Replace don't point to any line in the parsed line ranges."
)
} }
};
diff_lines.push(diff_line);
}
}
| &similar::DiffOp::Delete {
old_index,
old_len,
new_index,
} => {
for i in 0..old_len {
let old_line_range = &old_line_ranges[old_index];
let content = Arc::from(old_content.slice(old_line_range.clone()).as_str()?);
diff_lines.push(DiffLine {
op: Op::Delete,
old_line: old_index + i,
old_content: Some(content),
new_line: new_index,
new_content: None,
})
}
}
}
}
Some(ContentDiff(diff_lines))
} }
impl ContentDiff { impl ContentDiff {
pub(crate) fn spans(&self) -> &[Span] { pub(crate) fn len(&self) -> usize {
&self.spans self.0.len()
} }
pub(crate) fn old_line_count(&self) -> usize { pub(crate) fn get(&self, i: usize) -> &DiffLine {
self.old_line_ranges.len() &self.0[i]
} }
pub(crate) fn new_line_count(&self) -> usize { pub(crate) fn last(&self) -> Option<&DiffLine> {
self.new_line_ranges.len() self.0.last()
}
pub(crate) fn line_slice(&self, side: DiffSide, range: &Range<usize>) -> &[u8] {
match side {
| DiffSide::Old => &self.old_content[range.clone()],
| DiffSide::New => &self.new_content[range.clone()],
}
}
pub(crate) fn line_slice_at(&self, side: DiffSide, line: usize) -> &[u8] {
match side {
| DiffSide::Old => self.line_slice(DiffSide::Old, &self.old_line_ranges[line]),
| DiffSide::New => self.line_slice(DiffSide::New, &self.new_line_ranges[line]),
}
}
pub(crate) fn rows_for_span(&self, span_index: usize) -> Vec<DiffRow> {
let span = &self.spans[span_index];
let old_len = span.old_range.end.saturating_sub(span.old_range.start);
let new_len = span.new_range.end.saturating_sub(span.new_range.start);
let row_count = old_len.max(new_len);
let mut rows = Vec::with_capacity(row_count);
for offset in 0..row_count {
let old_line = (offset < old_len).then_some(span.old_range.start + offset);
let new_line = (offset < new_len).then_some(span.new_range.start + offset);
rows.push(DiffRow {
op_index: span_index,
op: span.op,
old_line,
old_content_range: old_line.map(|line| self.old_line_ranges[line].clone()),
new_line,
new_content_range: new_line.map(|line| self.new_line_ranges[line].clone()),
});
}
rows
} }
} }
fn line_ranges(content: &[u8]) -> Vec<Range<usize>> { fn line_ranges(content: &[u8]) -> Vec<Range<usize>> {
let mut ranges = Vec::new(); let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
let mut start = 0; let mut line_start: usize = 0;
let mut index = 0; let mut skip_next = false;
while index < content.len() { for i in memchr2_iter(b'\n', b'\r', content) {
match content[index] { if skip_next {
| b'\r' => { skip_next = false;
index += 1; continue;
if index < content.len() && content[index] == b'\n' {
index += 1;
} }
ranges.push(start..index);
start = index; let c = content[i];
}
| b'\n' => { match (c, content.get(i + 1)) {
index += 1; | (b'\r', Some(b'\n')) => {
ranges.push(start..index); // if \r found, check if its \r\n or if its a lone \r
start = index; // if \r\n, then treat as one line break
ranges.push(line_start..i + 1);
// because we already counted the \n byte, the next iter into it needs to be skipped
skip_next = true;
line_start = i + 2;
} }
| _ => { | _ => {
index += 1; ranges.push(line_start..i);
line_start = i + 1;
} }
} }
} }
if start < content.len() { if line_start < content.len() {
ranges.push(start..content.len()); ranges.push(line_start..content.len());
} }
ranges ranges

View File

@@ -5,18 +5,6 @@ pub(crate) enum ContentType {
Binary, Binary,
} }
pub(crate) struct ContentDiff {
old_content: bytes::Bytes,
new_content: bytes::Bytes,
}
pub(crate) struct LineDiff {
old_line: Option<usize>,
old_content_range: std::ops::Range<usize>,
new_line: Option<usize>,
new_content_range: std::ops::Range<usize>,
}
pub(crate) fn classify_content(content: &[u8]) -> ContentType { pub(crate) fn classify_content(content: &[u8]) -> ContentType {
if content.is_empty() { if content.is_empty() {
ContentType::Text ContentType::Text
@@ -28,7 +16,7 @@ pub(crate) fn classify_content(content: &[u8]) -> ContentType {
{ {
ContentType::Text ContentType::Text
} else { } else {
match memchr(0, &content[0..8192]) { match memchr(0, &content[..content.len().min(8192)]) {
| None => ContentType::Text, | None => ContentType::Text,
| Some(_) => ContentType::Binary, | Some(_) => ContentType::Binary,
} }