Add DiffOps playground

This commit is contained in:
2026-05-23 12:28:45 +01:00
parent 553af0290f
commit 1ef91cb41e
7 changed files with 961 additions and 43 deletions

View File

@@ -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<Self::Data, Self::Error> {
async fn fetch_content(
r: &FileRef,
c: &<QueryFileDiff as query::QueryFn>::Context,
c: &<FetchFileDiff as query::QueryFn>::Context,
) -> Result<Option<bytes::Bytes>, 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!()
}
}

View File

@@ -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();

View File

@@ -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<String>,
pr_query: query::Entity<api::issues::FetchPullRequest>,
old_content_query: Option<query::Entity<api::repo::FetchFileContent>>,
new_content_query: Option<query::Entity<api::repo::FetchFileContent>>,
content_diff_query: Option<query::Entity<api::repo::FetchFileDiff>>,
}
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),
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<Self>) {
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<Self>,
) -> 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),
)
}
}

View File

@@ -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::<app::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<DiffCase>,
selected_case: usize,
}
struct DiffCase {
title: &'static str,
description: &'static str,
diff: ContentDiff,
}
fn new(_cx: &mut gpui::Context<Screen>) -> 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<Self>) {
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<Self>,
) -> impl gpui::IntoElement {
let theme = app::current_theme(cx);
let weak = cx.entity();
let case = self.selected_case();
let case_buttons: Vec<AnyElement> = 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<AnyElement> = case
.diff
.spans()
.iter()
.enumerate()
.map(|(index, span)| render_op_card(index, span, theme).into_any_element())
.collect();
let op_groups: Vec<AnyElement> = 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<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.",
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<AnyElement> = (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<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())
.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<usize>,
content: Option<String>,
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,
},
}
}

View File

@@ -1,2 +1,3 @@
pub(crate) mod dashboard;
pub(crate) mod diffops_playground;
pub(crate) mod setup_wizard;

184
src/util/diff.rs Normal file
View File

@@ -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<Span>,
old_line_ranges: Vec<Range<usize>>,
new_line_ranges: Vec<Range<usize>>,
}
pub(crate) struct Span {
pub(crate) op: Op,
pub(crate) old_range: Range<usize>,
pub(crate) new_range: Range<usize>,
}
#[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<usize>,
pub(crate) old_content_range: Option<Range<usize>>,
pub(crate) new_line: Option<usize>,
pub(crate) new_content_range: Option<Range<usize>>,
}
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<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>> {
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
}

View File

@@ -1,2 +1,3 @@
pub(crate) mod diff;
pub(crate) mod file;
pub(crate) mod timeout;