diff --git a/src/api/repo.rs b/src/api/repo.rs index 54e64b3..7d57ce7 100644 --- a/src/api/repo.rs +++ b/src/api/repo.rs @@ -6,7 +6,7 @@ use tokio::sync::OwnedRwLockReadGuard; use crate::{ api, query::{self, Query, fetch_query}, - util::file, + util::{self, file}, }; #[derive(Debug, Deserialize)] @@ -79,8 +79,8 @@ impl query::QueryFn for FetchFileContent { fn key(&self) -> query::Key { match &self.reff { - | Some(reff) => format!("repo/fetch/{}/{}/{}", self.repo_slug, self.path, reff).into(), - | None => format!("repo/fetch/{}/{}", self.repo_slug, self.path).into(), + | Some(reff) => format!("repo/fetch/{}/{}/{}", self.repo_slug, self.path, reff).into(), + | None => format!("repo/fetch/{}/{}", self.repo_slug, self.path).into(), } } @@ -95,11 +95,11 @@ impl query::QueryFn for FetchFileContent { } let path = match &self.reff { - | Some(reff) => format!( - "/repos/{}/contents/{}?ref={}", - self.repo_slug, self.path, reff - ), - | None => format!("/repos/{}/contents/{}", self.repo_slug, self.path), + | Some(reff) => format!( + "/repos/{}/contents/{}?ref={}", + self.repo_slug, self.path, reff + ), + | None => format!("/repos/{}/contents/{}", self.repo_slug, self.path), }; let res = c @@ -113,13 +113,13 @@ impl query::QueryFn for FetchFileContent { } #[derive(Debug, Clone)] -pub struct QueryFileDiff { +pub struct FetchFileDiff { pub base: FileRef, pub head: FileRef, } -impl query::QueryFn for QueryFileDiff { - type Data = Option<()>; +impl query::QueryFn for FetchFileDiff { + type Data = util::diff::ContentDiff; type Error = api::Error; type Context = api::QueryContext; @@ -134,11 +134,11 @@ impl query::QueryFn for QueryFileDiff { async fn run(&self, c: &Self::Context) -> Result { async fn fetch_content( r: &FileRef, - c: &::Context, + c: &::Context, ) -> Result, api::Error> { 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), + | Some(reff) => format!("/repos/{}/contents/{}?ref={}", r.repo_slug, r.path, reff), + | None => format!("/repos/{}/contents/{}", r.repo_slug, r.path), }; let res = c @@ -160,17 +160,10 @@ impl query::QueryFn for QueryFileDiff { let (old, new) = tokio::join!(fetch_content(&self.base, c), fetch_content(&self.head, c),); match (old, new) { - | (Ok(Some(ref old)), Ok(Some(ref new))) => { - let diff = similar::TextDiff::from_lines::<[u8]>(old, new); - for change in diff.iter_all_changes() {} - } - | _ => { - return Err(api::Error::MalformedResponse( + | (Ok(Some(old)), Ok(Some(new))) => Ok(util::diff::diff_content(old, new)), + | _ => Err(api::Error::MalformedResponse( "failed to fetch content".to_string(), - )); + )), } - } - - todo!() } } diff --git a/src/main.rs b/src/main.rs index 08fdb86..8d10603 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use gpui::{bounds, point, prelude::*, px, size}; -use crate::screen::{dashboard, setup_wizard}; +use crate::screen::{dashboard, diffops_playground, setup_wizard}; mod api; mod app; @@ -62,6 +62,11 @@ fn setup_application(cx: &mut gpui::App) { cx.set_global(global); cx.set_global(query_store); + if diffops_playground::is_enabled() { + _ = diffops_playground::open_window(cx); + return; + } + // TODO: handle failure _ = storage::ensure_data_dir(); diff --git a/src/screen/dashboard/pull_request_diff_view.rs b/src/screen/dashboard/pull_request_diff_view.rs index 73bbf5e..839051b 100644 --- a/src/screen/dashboard/pull_request_diff_view.rs +++ b/src/screen/dashboard/pull_request_diff_view.rs @@ -1,5 +1,6 @@ use crate::{ - api, + api, app, + component::text::text, query::{self, QueryStatus, read_query, use_query}, }; @@ -7,16 +8,14 @@ pub(crate) struct PullRequestDiffView { selected_file_path: Option, pr_query: query::Entity, - old_content_query: Option>, - new_content_query: Option>, + content_diff_query: Option>, } fn new(pr_id: api::issues::Id, cx: &mut gpui::Context) -> PullRequestDiffView { let mut view = PullRequestDiffView { selected_file_path: None, pr_query: use_query(api::issues::FetchPullRequest { id: pr_id }, cx), - old_content_query: None, - new_content_query: None, + content_diff_query: None, }; view.on_create(cx); view @@ -35,15 +34,15 @@ impl PullRequestDiffView { } fn start_content_queries(&mut self, cx: &mut gpui::Context) { - let Some((old_content_query, new_content_query)) = ({ + let Some((old_file_ref, new_file_ref)) = ({ if let QueryStatus::Loaded(pr) = read_query(&self.pr_query, cx) { Some(( - api::repo::FetchFileContent { + api::repo::FileRef { repo_slug: pr.base_repo_slug.clone(), path: pr.base_branch_name.clone(), reff: Some(pr.base_ref.clone()), }, - api::repo::FetchFileContent { + api::repo::FileRef { repo_slug: pr.head_repo_slug.clone(), path: pr.head_branch_name.clone(), reff: Some(pr.head_ref.clone()), @@ -56,24 +55,38 @@ impl PullRequestDiffView { return; }; - let old_content_query = use_query(old_content_query, cx); - let new_content_query = use_query(new_content_query, cx); + let content_diff_query = use_query( + api::repo::FetchFileDiff { + base: old_file_ref, + head: new_file_ref, + }, + cx, + ); - _ = cx.observe(&old_content_query, |this, _, cx| {}).detach(); - - _ = cx.observe(&new_content_query, |this, _, cx| {}).detach(); - - self.old_content_query = Some(old_content_query); - self.new_content_query = Some(new_content_query); + self.content_diff_query = Some(content_diff_query); } } impl gpui::Render for PullRequestDiffView { fn render( &mut self, - window: &mut gpui::Window, + _window: &mut gpui::Window, cx: &mut gpui::Context, ) -> impl gpui::IntoElement { - todo!() + use gpui::{ParentElement, Styled, div}; + + let theme = app::current_theme(cx); + + 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), + ) } } diff --git a/src/screen/diffops_playground.rs b/src/screen/diffops_playground.rs new file mode 100644 index 0000000..c109c22 --- /dev/null +++ b/src/screen/diffops_playground.rs @@ -0,0 +1,721 @@ +use bytes::Bytes; +use gpui::{ + AnyElement, AppContext, InteractiveElement, IntoElement, ParentElement, + StatefulInteractiveElement, Styled, div, point, px, size, +}; + +use crate::{ + app, + component::{ + button::{self, button}, + text::text, + }, + util::diff::{ContentDiff, DiffRow, DiffSide, Op, Span, diff_content}, +}; + +pub(crate) fn is_enabled() -> bool { + match std::env::var("NOVEM_DIFFOPS_PLAYGROUND") { + | Ok(value) => matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "on"), + | Err(_) => false, + } +} + +pub(crate) fn open_window(cx: &mut gpui::App) -> anyhow::Result<()> { + let (top_left, window_bounds) = cx.read_global::(|global, cx| { + ( + global.safe_area.origin, + gpui::Bounds::centered(None, size(px(1440.), px(900.)), cx), + ) + }); + + app::open_window( + cx, + gpui::WindowOptions { + window_bounds: Some(gpui::WindowBounds::Windowed(window_bounds)), + titlebar: Some(gpui::TitlebarOptions { + appears_transparent: true, + traffic_light_position: Some(top_left + point(px(12.), px(12.))), + ..Default::default() + }), + ..Default::default() + }, + |_window, cx| new(cx), + ) +} + +pub(crate) struct Screen { + cases: Vec, + selected_case: usize, +} + +struct DiffCase { + title: &'static str, + description: &'static str, + diff: ContentDiff, +} + +fn new(_cx: &mut gpui::Context) -> Screen { + Screen { + cases: sample_cases(), + selected_case: 0, + } +} + +impl Screen { + fn selected_case(&self) -> &DiffCase { + &self.cases[self.selected_case] + } + + fn select_case(&mut self, index: usize, cx: &mut gpui::Context) { + if index < self.cases.len() { + self.selected_case = index; + cx.notify(); + } + } +} + +impl gpui::Render for Screen { + fn render( + &mut self, + _window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + let theme = app::current_theme(cx); + let weak = cx.entity(); + let case = self.selected_case(); + + let case_buttons: Vec = self + .cases + .iter() + .enumerate() + .map(|(index, case)| { + let weak = weak.clone(); + button(("diffops-case", index)) + .label(case.title) + .variant(if index == self.selected_case { + button::Variant::Primary + } else { + button::Variant::Secondary + }) + .w_full() + .on_click(move |_, _, cx| { + _ = weak.update(cx, |this, cx| { + this.select_case(index, cx); + }); + }) + .into_any_element() + }) + .collect(); + + let op_cards: Vec = case + .diff + .spans() + .iter() + .enumerate() + .map(|(index, span)| render_op_card(index, span, theme).into_any_element()) + .collect(); + + let op_groups: Vec = case + .diff + .spans() + .iter() + .enumerate() + .map(|(index, span)| { + render_op_group( + index, + span, + case.diff.rows_for_span(index), + &case.diff, + theme, + ) + .into_any_element() + }) + .collect(); + + div() + .size_full() + .bg(theme.colors.surface_chrome) + .child( + div() + .size_full() + .flex() + .flex_row() + .child( + div() + .w_64() + .h_full() + .flex() + .flex_col() + .border_r_1() + .border_color(theme.colors.border_muted) + .bg(theme.colors.surface) + .child( + div() + .p_4() + .border_b_1() + .border_color(theme.colors.border_muted) + .flex() + .flex_col() + .gap_2() + .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.", + ) + .text_sm() + .text_color(theme.colors.text_muted), + ), + ) + .child( + div() + .p_4() + .border_b_1() + .border_color(theme.colors.border_muted) + .flex() + .flex_col() + .gap_2() + .children(case_buttons), + ) + .child( + div() + .id("diffops-sidebar-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .child( + div() + .p_4() + .flex() + .flex_col() + .gap_2() + .child(text("Precomputed DiffOps").text_sm()) + .children(op_cards), + ), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .min_h_0() + .flex() + .flex_col() + .child( + div() + .p_4() + .border_b_1() + .border_color(theme.colors.border_muted) + .bg(theme.colors.surface) + .flex() + .flex_col() + .gap_2() + .child(text(case.title).text_xl()) + .child( + text(case.description) + .text_sm() + .text_color(theme.colors.text_muted), + ) + .child( + text(format!( + "{} ops, {} old lines, {} new lines", + case.diff.spans().len(), + case.diff.old_line_count(), + case.diff.new_line_count(), + )) + .text_xs() + .font_family("Menlo") + .text_color(theme.colors.text_subtle), + ), + ) + .child( + div() + .flex() + .flex_row() + .px_4() + .py_2() + .gap_2() + .bg(theme.colors.surface_elevated) + .border_b_1() + .border_color(theme.colors.border_muted) + .child( + panel_header("Old", case.diff.old_line_count(), theme) + .flex_1(), + ) + .child( + panel_header("New", case.diff.new_line_count(), theme) + .flex_1(), + ), + ) + .child( + div() + .id("diffops-main-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .child( + div() + .p_4() + .flex() + .flex_col() + .gap_3() + .child(text("Source Content").text_sm()) + .child(render_source_content(&case.diff, theme)) + .child(text("DiffOps Render").text_sm()) + .children(op_groups), + ), + ), + ), + ) + } +} + +fn sample_cases() -> Vec { + 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.", + r#"fn config() { + let host = "localhost"; + start(host); +} +"#, + r#"fn config() { + let host = "localhost"; + let port = 8080; + start(host); +} +"#, + ), + 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.", + r#"fn handle(req: Request) { + trace_request(&req); + authorize(&req); + dispatch(req); +} +"#, + r#"fn handle(req: Request) { + authorize(&req); + dispatch(req); +} +"#, + ), + DiffCase::new( + "Replace Span", + "A replace can cover different line counts on each side. The viewer pairs rows by position inside the op span.", + r#"fn render() { + let theme = current_theme(cx); + layout(theme); +} +"#, + r#"fn render() { + let palette = current_palette(cx); + let spacing = spacing_scale(); + layout(palette, spacing); +} +"#, + ), + DiffCase::new( + "Mixed Hunk", + "This sample produces several DiffOps in sequence so you can see equal, replace, insert, and delete spans together.", + r#"use crate::auth::Token; +use crate::http::Client; + +fn fetch() { + let timeout = 30; + let retries = 1; + request(timeout, retries); +} +"#, + r#"use crate::auth::Session; +use crate::http::Client; + +fn fetch() { + let timeout = 45; + let retries = 3; + let backoff = 250; + request(timeout, retries); + log_request(); +} +"#, + ), + ] +} + +impl DiffCase { + fn new( + title: &'static str, + description: &'static str, + old: &'static str, + new: &'static str, + ) -> Self { + Self { + title, + description, + diff: diff_content( + Bytes::from_static(old.as_bytes()), + Bytes::from_static(new.as_bytes()), + ), + } + } +} + +fn panel_header(label: &'static str, line_count: usize, theme: &crate::theme::Theme) -> gpui::Div { + div() + .rounded_md() + .border_1() + .border_color(theme.colors.border) + .bg(theme.colors.surface) + .px_3() + .py_2() + .flex() + .flex_row() + .justify_between() + .items_center() + .child(text(label).text_sm()) + .child( + text(format!("{line_count} lines")) + .text_xs() + .font_family("Menlo") + .text_color(theme.colors.text_subtle), + ) +} + +fn render_source_content(diff: &ContentDiff, 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()) +} + +fn render_source_panel( + title: &'static str, + side: DiffSide, + diff: &ContentDiff, + theme: &crate::theme::Theme, +) -> gpui::Div { + let line_count = match side { + | DiffSide::Old => diff.old_line_count(), + | DiffSide::New => diff.new_line_count(), + }; + + let lines: Vec = (0..line_count) + .map(|line| { + div() + .flex() + .flex_row() + .items_start() + .border_b_1() + .border_color(theme.colors.border_muted) + .child( + div() + .w(px(64.)) + .px_2() + .py_2() + .font_family("Menlo") + .text_xs() + .text_color(theme.colors.text_subtle) + .child(format!("{:>4}", line + 1)), + ) + .child( + div() + .flex_1() + .min_w_0() + .px_2() + .py_2() + .font_family("Menlo") + .text_xs() + .text_color(theme.colors.text) + .child(display_bytes(diff.line_slice_at(side, line))), + ) + .into_any_element() + }) + .collect(); + + div() + .rounded_lg() + .overflow_hidden() + .border_1() + .border_color(theme.colors.border) + .bg(theme.colors.surface) + .child( + div() + .bg(theme.colors.surface_elevated) + .border_b_1() + .border_color(theme.colors.border_muted) + .px_3() + .py_2() + .flex() + .flex_row() + .justify_between() + .items_center() + .child(text(title).text_sm()) + .child( + text(format!("{line_count} lines")) + .text_xs() + .font_family("Menlo") + .text_color(theme.colors.text_subtle), + ), + ) + .child(div().flex().flex_col().children(lines)) +} + +fn render_op_card(index: usize, span: &Span, theme: &crate::theme::Theme) -> gpui::Div { + let colors = tag_colors(span.op, theme); + + div() + .rounded_md() + .border_1() + .border_color(colors.border) + .bg(colors.background) + .p_3() + .flex() + .flex_col() + .gap_1() + .child( + text(format!("Op {index}: {}", tag_label(span.op))) + .text_sm() + .text_color(colors.foreground), + ) + .child( + text(format!( + "old {:?} new {:?}", + span.old_range, span.new_range + )) + .text_xs() + .font_family("Menlo") + .text_color(theme.colors.text_muted), + ) + .child( + text(format!("{:?}", span.op)) + .text_xs() + .font_family("Menlo") + .text_color(theme.colors.text_subtle), + ) +} + +fn render_op_group( + index: usize, + span: &Span, + rows: Vec, + diff: &ContentDiff, + theme: &crate::theme::Theme, +) -> gpui::Div { + let colors = tag_colors(span.op, theme); + let row_elements: Vec = rows + .into_iter() + .map(|row| render_row(row, diff, theme).into_any_element()) + .collect(); + + div() + .rounded_lg() + .overflow_hidden() + .border_1() + .border_color(colors.border) + .child( + div() + .bg(colors.background) + .border_b_1() + .border_color(colors.border) + .px_3() + .py_2() + .flex() + .flex_row() + .justify_between() + .items_center() + .child( + text(format!("Op {index}: {}", tag_label(span.op))) + .text_sm() + .text_color(colors.foreground), + ) + .child( + text(format!( + "old {:?} new {:?}", + span.old_range, span.new_range + )) + .text_xs() + .font_family("Menlo") + .text_color(theme.colors.text_muted), + ), + ) + .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))); + + div() + .flex() + .flex_row() + .bg(theme.colors.surface) + .border_b_1() + .border_color(theme.colors.border_muted) + .child(render_line_cell( + row.op_index, + row.op, + row.old_line, + old_text, + true, + theme, + )) + .child(render_line_cell( + row.op_index, + row.op, + row.new_line, + new_text, + false, + theme, + )) +} + +fn render_line_cell( + op_index: usize, + op: Op, + line_number: Option, + content: Option, + is_old_side: bool, + theme: &crate::theme::Theme, +) -> gpui::Div { + let colors = row_colors(op, is_old_side, content.is_some(), theme); + let line_label = line_number + .map(|line| format!("{:>4}", line + 1)) + .unwrap_or_else(|| " ".to_string()); + + div() + .flex_1() + .min_w_0() + .flex() + .flex_row() + .items_start() + .border_r_1() + .border_color(theme.colors.border_muted) + .bg(colors.background) + .child( + div() + .w(px(64.)) + .px_2() + .py_2() + .font_family("Menlo") + .text_xs() + .text_color(theme.colors.text_subtle) + .child(line_label), + ) + .child( + div() + .flex_1() + .min_w_0() + .px_2() + .py_2() + .font_family("Menlo") + .text_xs() + .text_color(colors.foreground) + .child(content.unwrap_or_else(|| format!("anchor for span {op_index}"))), + ) +} + +fn tag_label(op: Op) -> &'static str { + match op { + | Op::Equal => "Equal", + | Op::Delete => "Delete", + | Op::Insert => "Insert", + | Op::Replace => "Replace", + } +} + +fn display_bytes(bytes: &[u8]) -> String { + let mut rendered = String::new(); + + for ch in String::from_utf8_lossy(bytes).chars() { + match ch { + | '\n' => rendered.push_str("\\n"), + | '\r' => rendered.push_str("\\r"), + | '\t' => rendered.push_str("\\t"), + | _ => rendered.push(ch), + } + } + + if rendered.is_empty() { + rendered.push(' '); + } + + rendered +} + +struct Colors { + background: gpui::Rgba, + border: gpui::Rgba, + foreground: gpui::Rgba, +} + +fn tag_colors(op: Op, theme: &crate::theme::Theme) -> Colors { + match op { + | Op::Equal => Colors { + background: theme.colors.surface_elevated, + border: theme.colors.border, + foreground: theme.colors.text, + }, + | Op::Delete => Colors { + background: theme.colors.danger_muted, + border: theme.colors.danger_border, + foreground: theme.colors.danger_fg, + }, + | Op::Insert => Colors { + background: theme.colors.success_muted, + border: theme.colors.success_border, + foreground: theme.colors.success_fg, + }, + | Op::Replace => Colors { + background: theme.colors.warning_muted, + border: theme.colors.warning_border, + foreground: theme.colors.warning_fg, + }, + } +} + +fn row_colors(op: Op, is_old_side: bool, has_content: bool, theme: &crate::theme::Theme) -> Colors { + if !has_content { + return Colors { + background: theme.colors.surface_chrome, + border: theme.colors.border_muted, + foreground: theme.colors.text_subtle, + }; + } + + match op { + | Op::Equal => Colors { + background: theme.colors.surface, + border: theme.colors.border_muted, + foreground: theme.colors.text, + }, + | Op::Delete => Colors { + background: theme.colors.danger_muted, + border: theme.colors.danger_border, + foreground: theme.colors.danger_fg, + }, + | Op::Insert => Colors { + background: theme.colors.success_muted, + border: theme.colors.success_border, + foreground: theme.colors.success_fg, + }, + | Op::Replace if is_old_side => Colors { + background: theme.colors.danger_muted, + border: theme.colors.danger_border, + foreground: theme.colors.danger_fg, + }, + | Op::Replace => Colors { + background: theme.colors.success_muted, + border: theme.colors.success_border, + foreground: theme.colors.success_fg, + }, + } +} diff --git a/src/screen/mod.rs b/src/screen/mod.rs index eaba9b1..5da9224 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod dashboard; +pub(crate) mod diffops_playground; pub(crate) mod setup_wizard; diff --git a/src/util/diff.rs b/src/util/diff.rs new file mode 100644 index 0000000..4de0a5e --- /dev/null +++ b/src/util/diff.rs @@ -0,0 +1,184 @@ +use std::ops::Range; + +pub(crate) struct ContentDiff { + pub(crate) old_content: bytes::Bytes, + pub(crate) new_content: bytes::Bytes, + pub(crate) spans: Vec, + old_line_ranges: Vec>, + new_line_ranges: Vec>, +} + +pub(crate) struct Span { + pub(crate) op: Op, + pub(crate) old_range: Range, + pub(crate) new_range: Range, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum Op { + Equal, + Delete, + Insert, + Replace, +} + +#[derive(Clone, Copy)] +pub(crate) enum DiffSide { + Old, + New, +} + +pub(crate) struct DiffRow { + pub(crate) op_index: usize, + pub(crate) op: Op, + pub(crate) old_line: Option, + pub(crate) old_content_range: Option>, + pub(crate) new_line: Option, + pub(crate) new_content_range: Option>, +} + +pub(crate) fn diff_content(old_content: bytes::Bytes, new_content: bytes::Bytes) -> ContentDiff { + let old_line_ranges = line_ranges(&old_content); + let new_line_ranges = line_ranges(&new_content); + let diff = similar::TextDiff::from_lines::<[u8]>(&old_content, &new_content); + + let spans = diff + .ops() + .iter() + .map(|op| match op { + | &similar::DiffOp::Equal { + old_index, + new_index, + len, + } => Span { + op: Op::Equal, + old_range: old_index..(old_index + len), + new_range: new_index..(new_index + len), + }, + + | &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 { + old_index, + new_index, + new_len, + } => Span { + op: Op::Insert, + old_range: old_index..old_index, + new_range: new_index..(new_index + new_len), + }, + + | &similar::DiffOp::Replace { + old_index, + old_len, + new_index, + new_len, + } => Span { + op: Op::Replace, + old_range: old_index..(old_index + old_len), + new_range: new_index..(new_index + new_len), + }, + }) + .collect(); + + ContentDiff { + old_content, + new_content, + spans, + old_line_ranges, + new_line_ranges, + } +} + +impl ContentDiff { + pub(crate) fn spans(&self) -> &[Span] { + &self.spans + } + + pub(crate) fn old_line_count(&self) -> usize { + self.old_line_ranges.len() + } + + pub(crate) fn new_line_count(&self) -> usize { + self.new_line_ranges.len() + } + + pub(crate) fn line_slice(&self, side: DiffSide, range: &Range) -> &[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 { + 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> { + let mut ranges = Vec::new(); + let mut start = 0; + let mut index = 0; + + while index < content.len() { + match content[index] { + | b'\r' => { + index += 1; + if index < content.len() && content[index] == b'\n' { + index += 1; + } + ranges.push(start..index); + start = index; + } + | b'\n' => { + index += 1; + ranges.push(start..index); + start = index; + } + | _ => { + index += 1; + } + } + } + + if start < content.len() { + ranges.push(start..content.len()); + } + + ranges +} diff --git a/src/util/mod.rs b/src/util/mod.rs index 6c83e09..5a5b402 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,2 +1,3 @@ +pub(crate) mod diff; pub(crate) mod file; pub(crate) mod timeout;