#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))] // Adding this file level gate as some of the code around editability is not used in WASM yet. use std::{collections::HashMap, ops::Range, rc::Rc, sync::Arc}; use futures::stream::AbortHandle; use itertools::Itertools; use pathfinder_color::ColorU; use rangemap::RangeMap; use similar::{ChangeTag, DiffOp, TextDiff}; use string_offset::CharOffset; use galaxy_core::ui::theme::Fill; use galaxy_editor::{ content::{edit::TemporaryBlock, version::BufferVersion}, multiline::{AnyMultilineString, MultilineStr, MultilineString, LF}, render::model::{Decoration, LineCount, LineDecoration}, }; use galaxyui::{Entity, ModelContext}; use super::super::DiffResult; use crate::{ appearance::Appearance, code::editor::{line::EditorLineLocation, line_iterator::LineIterator}, }; use galaxy_core::ui::theme::AnsiColorIdentifier; const OVERLAY_ALPHA: u8 = 56; const INLINE_OVERLAY_ALPHA: u8 = 71; /// Get the theme-appropriate add color pub(crate) fn add_color(appearance: &Appearance) -> ColorU { AnsiColorIdentifier::Green .to_ansi_color(&appearance.theme().terminal_colors().normal) .into() } /// Get the theme-appropriate remove color pub(crate) fn remove_color(appearance: &Appearance) -> ColorU { AnsiColorIdentifier::Red .to_ansi_color(&appearance.theme().terminal_colors().normal) .into() } /// Get the theme-appropriate replace color pub(crate) fn replace_color(appearance: &Appearance) -> ColorU { AnsiColorIdentifier::Yellow .to_ansi_color(&appearance.theme().terminal_colors().normal) .into() } /// Get the theme-appropriate remove overlay color pub(crate) fn remove_overlay_color(appearance: &Appearance) -> ColorU { let ansi_color = AnsiColorIdentifier::Red.to_ansi_color(&appearance.theme().terminal_colors().normal); let mut color: ColorU = ansi_color.into(); color.a = OVERLAY_ALPHA; color } /// Get the theme-appropriate add overlay color pub(crate) fn add_overlay_color(appearance: &Appearance) -> ColorU { let ansi_color = AnsiColorIdentifier::Green.to_ansi_color(&appearance.theme().terminal_colors().normal); let mut color: ColorU = ansi_color.into(); color.a = OVERLAY_ALPHA; color } /// Get the theme-appropriate add inline overlay color pub(crate) fn add_inline_overlay_color(appearance: &Appearance) -> ColorU { let ansi_color = AnsiColorIdentifier::Green.to_ansi_color(&appearance.theme().terminal_colors().normal); let mut color: ColorU = ansi_color.into(); color.a = INLINE_OVERLAY_ALPHA; color } /// Get the theme-appropriate remove inline overlay color pub(crate) fn remove_inline_overlay_color(appearance: &Appearance) -> ColorU { let ansi_color = AnsiColorIdentifier::Red.to_ansi_color(&appearance.theme().terminal_colors().normal); let mut color: ColorU = ansi_color.into(); color.a = INLINE_OVERLAY_ALPHA; color } pub enum DiffModelEvent { DiffUpdated { version: BufferVersion, should_recalculate_hidden_lines: bool, }, UnifiedDiffComputed(Rc), } #[derive(Debug, Clone, Eq, PartialEq)] pub enum ChangeType { Replacement { replaced_range: Range, insertion: Vec>, deletion: Vec>, }, Addition, } #[derive(Debug, Default, Clone)] pub struct DiffStatus { /// A non-deletion change that maps the current range of content to an old range in base. change_mapping: RangeMap, /// A deletion that maps a line index in current content to an old range in base. deletion_mapping: HashMap>, } impl DiffStatus { /// Returns the number of lines added and removed in the current diff. pub fn get_diff_lines(&self) -> (usize, usize) { let mut lines_added = 0; let mut lines_removed = 0; // Count changes/additions for (new_range, change_type) in self.change_mapping.iter() { let new_range_lines = (LineCount::from(new_range.end) - LineCount::from(new_range.start)).as_usize(); match change_type { ChangeType::Addition => lines_added += new_range_lines, ChangeType::Replacement { replaced_range, .. } => { let old_range_lines = (LineCount::from(replaced_range.end) - LineCount::from(replaced_range.start)) .as_usize(); lines_added += new_range_lines; lines_removed += old_range_lines; } } } // Count deletions for range in self.deletion_mapping.values() { lines_removed += (LineCount::from(range.end) - LineCount::from(range.start)).as_usize(); } (lines_added, lines_removed) } /// Retrieve the hunk to render for a given line number. pub fn diff_hunk( &self, line_num: LineCount, appearance: &Appearance, ) -> Option { let line_num = line_num.as_usize(); if self.deletion_mapping.contains_key(&line_num) { return Some(DiffHunkDisplay::Remove(remove_color(appearance))); } match self.change_mapping.get(&line_num) { Some(ChangeType::Replacement { .. }) => Some(DiffHunkDisplay::Replacement { collapsed_color: replace_color(appearance), add_color: add_color(appearance), remove_color: remove_color(appearance), }), Some(ChangeType::Addition) => Some(DiffHunkDisplay::Add(add_color(appearance))), None => None, } } /// Return the range of diff hunk lines (if any) containing the given line. /// line_count is the line count assigned in the EditorWrapper element. /// This is 0-indexed. /// For deleted diff hunks, all lines will have the line_count of the line directly after /// the deleted section in the new current state of the file. /// For changed diff hunks, all lines in the old version (removed section of the hunk) /// will have the line number of the first line in the new version of the hunk. pub fn removed_diff_range(&self, line_count: LineCount) -> Option> { let line_num = line_count.as_usize(); if self.deletion_mapping.contains_key(&(line_num)) { return Some(LineCount::from(line_num)..LineCount::from(line_num)); } // Check if this is a replacement (change with removed lines) self.added_diff_range(line_count) } /// Return the range of diff hunk lines (if any) containing the given line. /// line_count is the line count assigned in the EditorWrapper element. /// This is 0-indexed. pub fn added_diff_range(&self, line_num: LineCount) -> Option> { let line_num = line_num.as_usize(); self.change_mapping .get_key_value(&line_num) .map(|(key, _)| LineCount::from(key.start)..LineCount::from(key.end)) } } /// The colors used to represent the diff hunks in the editor. #[derive(Debug, Clone, Copy)] pub enum DiffHunkDisplay { Add(ColorU), Replacement { collapsed_color: ColorU, add_color: ColorU, remove_color: ColorU, }, Remove(ColorU), } /// A single renderable diff hunk. It contains the information needed to decorate the render model. #[derive(Debug, Clone)] pub enum RenderableDiffHunk { Add { line_decoration: LineDecoration, }, Replace { line_decoration: LineDecoration, inline_highlights: Vec<(usize, Range)>, removed_lines: Vec, }, Deletion { removed_lines: Vec, }, } /// Model that tracks the line-by-line diff status of the editor content. pub struct DiffModel { /// Store base in an Arc to avoid cloning the underlying data on every content change. base: Option>>, status: DiffStatus, abort_handle: Option<(AbortHandle, BufferVersion)>, } impl DiffModel { pub fn new() -> Self { Self { base: None, status: DiffStatus::default(), abort_handle: None, } } /// Total number of diff hunks in the current diff model. pub fn diff_hunk_count(&self) -> usize { self.status.change_mapping.len() + self.status.deletion_mapping.len() } /// Given an index of a diff hunk, expand it to the range of lines the hunk describes /// in the current buffer. pub fn line_range_by_diff_hunk_index(&self, index: usize) -> Option> { self.added_or_changed_lines() .chain( self.status .deletion_mapping .keys() .map(|index| *index..*index), ) .sorted_by(|a, b| Ord::cmp(&a.start, &b.start)) .nth(index) } /// Get a single renderable diff hunk by its index. pub fn renderable_diff_hunk_by_index<'a>( &self, index: usize, lines: &mut LineIterator<'a, impl Iterator>, appearance: &Appearance, ) -> Option { let (range, is_addition) = self.diff_by_index(index)?; if !is_addition { let deleted_range = self.status.deletion_mapping.get(&range.start)?; let mut removed_lines = Vec::with_capacity(deleted_range.len()); if let Ok(lines_in_range) = lines.lines_in_range(deleted_range) { for line in lines_in_range { let mut content = line.to_string(); if !content.ends_with('\n') { content.push('\n'); } removed_lines.push(TemporaryBlock { content, insert_before: LineCount::from(range.start), line_decoration: Some(remove_overlay_color(appearance).into()), inline_text_decorations: Vec::new(), }); } } Some(RenderableDiffHunk::Deletion { removed_lines }) } else { match self.status.change_mapping.get(&range.start)? { ChangeType::Replacement { replaced_range, insertion, deletion, } => { let mut removed_lines = Vec::new(); // Inline highlight indices are given over the entire multiline range. // We need to split them into per-line decorations. let mut start_char = 0; if let Ok(lines_in_range) = lines.lines_in_range(replaced_range) { for line in lines_in_range { let mut content = line.to_string(); if !content.ends_with('\n') { content.push('\n'); } let inline_text_decorations = deletion .iter() .filter_map(|inline| { let line_start = start_char; let line_end = start_char + line.chars().count(); let overlap_start = inline.start.max(line_start); let overlap_end = inline.end.min(line_end); if overlap_start < overlap_end { Some( Decoration::new( CharOffset::from(overlap_start - line_start), CharOffset::from(overlap_end - line_start), ) .with_background(Fill::Solid( remove_inline_overlay_color(appearance), )), ) } else { None } }) .collect_vec(); removed_lines.push(TemporaryBlock { content, insert_before: LineCount::from(range.start), line_decoration: Some(remove_overlay_color(appearance).into()), inline_text_decorations, }); start_char += line.chars().count() + 1; } } Some(RenderableDiffHunk::Replace { line_decoration: LineDecoration { start: LineCount::from(range.start), end: LineCount::from(range.end), overlay: add_overlay_color(appearance).into(), }, inline_highlights: insertion .iter() .map(|inline_change| (range.start, inline_change.clone())) .collect(), removed_lines, }) } ChangeType::Addition => Some(RenderableDiffHunk::Add { line_decoration: LineDecoration { start: LineCount::from(range.start), end: LineCount::from(range.end), overlay: add_overlay_color(appearance).into(), }, }), } } } /// Returns the number of diff hunks before the given line number. pub fn diff_hunk_count_before_line(&self, line: usize) -> usize { self.added_or_changed_lines() .chain( self.status .deletion_mapping .keys() .map(|index| *index..*index + 1), ) .filter(|range| range.start < line) .count() } /// Given a diff hunk index, calculate what is the reverse action that undo this diff. pub fn reverse_action_by_diff_hunk_index( &self, index: usize, ) -> Option<(Range, String)> { let line_range = self.line_range_by_diff_hunk_index(index)?; if let Some(replaced_range) = self.status.deletion_mapping.get(&line_range.start) { let text = self.base_text_by_line_range(replaced_range)?; return Some((line_range, text)); } if let Some(change) = self.status.change_mapping.get(&line_range.start) { return match change { ChangeType::Addition => Some((line_range, "".to_string())), ChangeType::Replacement { replaced_range, .. } => { let text = self.base_text_by_line_range(replaced_range)?; return Some((line_range, text)); } }; } None } /// Given a range of lines, return the corresponding substring in the base text. /// Note that all lines will end with a trailing newline. fn base_text_by_line_range(&self, replaced_range: &Range) -> Option { let base_text = self.base.as_ref()?; let mut text = base_text .lines() .skip(replaced_range.start) .take(replaced_range.len()) .join("\n"); text.push('\n'); Some(text) } /// Returns the content of a deleted line given the location of a removed line. /// Returns None if the info is not for a removed line or if any lookup fails. pub fn deleted_line_content(&self, info: &EditorLineLocation) -> Option { let index = self.deleted_line_to_base_line_index(info)?; self.base_line(index) } /// Convert a deleted line location to a line index in the base version of the text. pub fn deleted_line_to_base_line_index(&self, info: &EditorLineLocation) -> Option { // Extract line_number and index from Removed variant let (line_number, index) = match info { EditorLineLocation::Removed { line_number, index, .. } => (line_number.as_usize(), *index), _ => return None, }; // First check if this is a pure deletion. Note that deletion maps to line AFTER the removed line. // CODE-1638: Use line_number + 1 to align with deletion_mapping's off-by-one convention. if let Some(removed_range) = self.status.deletion_mapping.get(&line_number) { return Some(removed_range.start + index); } // Check if this is a replacement (change with removed lines) if let Some(ChangeType::Replacement { replaced_range, .. }) = self.status.change_mapping.get(&line_number) { return Some(replaced_range.start + index); } None } /// Convert a line index in the base version of the text to an editor line location. pub fn base_line_index_to_line_location(&self, index: usize) -> Option { for (line_range, change) in self.status.change_mapping.iter() { if let ChangeType::Replacement { replaced_range, .. } = change { if replaced_range.contains(&index) { return Some(EditorLineLocation::Removed { // Subtracting 1 as the diff is currently represented as attaching to the _previous line_. line_number: LineCount::from(line_range.start), line_range: LineCount::from(line_range.start) ..LineCount::from(line_range.end), index: index - replaced_range.start, }); } } } for (line_range, replaced_range) in self.status.deletion_mapping.iter() { if replaced_range.contains(&index) { return Some(EditorLineLocation::Removed { line_number: LineCount::from(*line_range), line_range: LineCount::from(*line_range)..LineCount::from(*line_range), index: index - replaced_range.start, }); } } None } pub fn is_line_added_or_changed(&self, line_num: &LineCount) -> bool { let line_num = line_num.as_usize(); self.status.change_mapping.get(&line_num).is_some() } pub fn base_line(&self, line_index: usize) -> Option { // Extract the line content let line = self.base.as_ref()?.lines().nth(line_index)?; Some(line.to_string()) } pub fn base_line_count(&self) -> usize { self.base .as_ref() .map(|base| base.lines().count()) .unwrap_or_default() } /// Returns an iterator over all the line numbers in the current buffer that are /// added or changed from the base buffer. pub fn added_or_changed_lines(&self) -> impl Iterator> + '_ { self.status .change_mapping .iter() .map(|(row_range, _)| row_range.clone()) } pub fn modified_lines(&self) -> impl Iterator> + '_ { self.added_or_changed_lines().chain( self.status .deletion_mapping .keys() .map(|index| *index..*index + 1), ) } fn diff_by_index(&self, index: usize) -> Option<(Range, bool)> { self.added_or_changed_lines() .map(|range| (range, true)) .chain( self.status .deletion_mapping .keys() .map(|index| (*index..*index + 1, false)), ) .sorted_by(|a, b| Ord::cmp(&a.0.start, &b.0.start)) .nth(index) } pub fn diff_status(&self) -> &DiffStatus { &self.status } pub fn set_base(&mut self, base: MultilineString) { self.base = Some(Arc::new(base)); if let Some((abort_handle, _)) = self.abort_handle.take() { abort_handle.abort(); } } pub fn base(&self) -> Option>> { self.base.clone() } /// Given the new content, compute the new set of diff contents. pub fn compute_diff( &mut self, new: MultilineString, should_recalculate_hidden_lines: bool, version: BufferVersion, ctx: &mut ModelContext, ) { if let Some((abort_handle, current_version)) = self.abort_handle.take() { // Do not abort a diff computation for the same buffer version. Early return instead. if current_version != version { abort_handle.abort(); } else { self.abort_handle = Some((abort_handle, version)); return; } } let Some(base_text) = self.base.clone() else { return; }; let handle = ctx .spawn( async move { Self::compute_diff_internal(&base_text, &new).await }, move |model, (change_mapping, deletion_mapping), ctx| { model.status.change_mapping = change_mapping; model.status.deletion_mapping = deletion_mapping; log::debug!("diff status updated: {:#?}", &model.status); ctx.emit(DiffModelEvent::DiffUpdated { should_recalculate_hidden_lines, version, }); }, ) .abort_handle(); self.abort_handle = Some((handle, version)); } #[cfg(test)] async fn compute_diff_for_test(&mut self, new: String) { let Some(base_text) = self.base.clone() else { return; }; let new = AnyMultilineString::infer(new); let (change_mapping, deletion_mapping) = Self::compute_diff_internal(&base_text, new.to_format().as_ref()).await; self.status.change_mapping = change_mapping; self.status.deletion_mapping = deletion_mapping; } pub fn retrieve_unified_diff( &mut self, new: AnyMultilineString, file_name: String, ctx: &mut ModelContext, ) { let Some(base_text) = self.base.clone() else { return; }; ctx.spawn( async move { let new = new.to_format(); Self::retrieve_unified_diff_internal(&base_text, new.as_ref(), file_name.as_str()) .await }, |_, unified_diff, ctx| { ctx.emit(DiffModelEvent::UnifiedDiffComputed(Rc::new(unified_diff))); }, ); } async fn retrieve_unified_diff_internal( base: &MultilineStr, new: &MultilineStr, file_name: &str, ) -> DiffResult { if base == new { return DiffResult { unified_diff: String::new(), lines_added: 0, lines_removed: 0, }; } // Show 3 context lines (standard of git diff). let text_diff = TextDiff::from_lines(base.as_str(), new.as_str()); // Calculate diff statistics. let mut lines_added = 0; let mut lines_removed = 0; for op in text_diff.ops() { match op { DiffOp::Equal { .. } => (), DiffOp::Delete { old_len, .. } => lines_removed += old_len, DiffOp::Insert { new_len, .. } => lines_added += new_len, DiffOp::Replace { old_len, new_len, .. } => { lines_added += new_len; lines_removed += old_len; } } } DiffResult { unified_diff: text_diff .unified_diff() .context_radius(3) .header(file_name, file_name) .missing_newline_hint(false) .to_string(), lines_added, lines_removed, } } async fn compute_diff_internal( base: &MultilineStr, new: &MultilineStr, ) -> (RangeMap, HashMap>) { let diffs = TextDiff::configure() .algorithm(similar::Algorithm::Patience) .diff_lines(base.as_str(), new.as_str()); let mut deletion_mapping = HashMap::new(); let mut change_mapping = RangeMap::new(); for change in diffs.ops() { futures_lite::future::yield_now().await; match change { DiffOp::Equal { .. } => continue, DiffOp::Delete { old_index, old_len, new_index, } => { deletion_mapping.insert(*new_index, *old_index..*old_index + *old_len); } DiffOp::Insert { new_index, new_len, .. } => change_mapping.insert(*new_index..*new_index + *new_len, ChangeType::Addition), DiffOp::Replace { old_index, old_len, new_index, new_len, } => { let (inline_deletion, inline_insertion) = record_replacement(change, &diffs); change_mapping.insert( *new_index..*new_index + *new_len, ChangeType::Replacement { replaced_range: *old_index..*old_index + *old_len, insertion: inline_insertion, deletion: inline_deletion, }, ) } } } coalesce_replacements(&diffs, &mut deletion_mapping, &mut change_mapping); (change_mapping, deletion_mapping) } } /// `similar` can represent a logical replacement as separate Delete + Insert ops at the same /// `new_index`. When that happens, we end up with a deletion hunk and an addition hunk for /// the same logical change. Coalesce those into a single Replacement entry. fn coalesce_replacements<'a>( diffs: &'a TextDiff<'a, 'a, 'a, str>, deletion_mapping: &mut HashMap>, change_mapping: &mut RangeMap, ) { let mut deletions_to_remove = Vec::new(); let mut additions_to_remove = Vec::new(); let mut replacements_to_insert = Vec::new(); for (&new_index, old_range) in deletion_mapping.iter() { let Some((new_range, change)) = change_mapping.get_key_value(&new_index) else { continue; }; if new_range.start != new_index { continue; } if !matches!(change, ChangeType::Addition) { continue; } let old_len = old_range.len(); let new_len = new_range.len(); let replace_op = DiffOp::Replace { old_index: old_range.start, old_len, new_index, new_len, }; let (inline_deletion, inline_insertion) = record_replacement(&replace_op, diffs); deletions_to_remove.push(new_index); additions_to_remove.push(new_range.clone()); replacements_to_insert.push(( new_range.clone(), ChangeType::Replacement { replaced_range: old_range.clone(), insertion: inline_insertion, deletion: inline_deletion, }, )); } for new_range in additions_to_remove { change_mapping.remove(new_range); } deletion_mapping.retain(|new_index, _| !deletions_to_remove.contains(new_index)); change_mapping.extend(replacements_to_insert); } /// Given a replace operation, iterate through its associated diff hunks and collect /// the deletions and insertions from the generated diff. /// Return order: (deletions, insertions) fn record_replacement<'a>( replace_op: &DiffOp, diffs: &'a TextDiff<'a, 'a, 'a, str>, ) -> (Vec>, Vec>) { let mut old_offset = 0; let mut new_offset = 0; let mut inline_deletion = Vec::new(); let mut inline_insertion = Vec::new(); for inline_diff in diffs.iter_inline_changes(replace_op) { match inline_diff.tag() { ChangeTag::Equal => { log::warn!("Unexpected equal change tag in a replace op"); continue; } ChangeTag::Delete => { record_inline_diff_as_changes(&mut inline_deletion, &mut old_offset, inline_diff); } ChangeTag::Insert => { record_inline_diff_as_changes(&mut inline_insertion, &mut new_offset, inline_diff); } } } (inline_deletion, inline_insertion) } fn record_inline_diff_as_changes( changes: &mut Vec>, offset: &mut usize, inline_diff: similar::InlineChange, ) { for (highlight, val) in inline_diff.values() { let char_len = val.chars().count(); if *highlight { changes.push(*offset..*offset + char_len); } *offset += char_len; } } impl Entity for DiffModel { type Event = DiffModelEvent; } #[cfg(test)] #[path = "diff_tests.rs"] mod tests;