feat: syntax highlighting for diff view

This commit is contained in:
2026-05-25 00:08:22 +01:00
parent b3e041a257
commit a6cf96ea96
20 changed files with 1295 additions and 722 deletions

View File

@@ -1,13 +1,8 @@
use std::{ops::Range, slice::Iter, sync::Arc, thread::current};
use std::sync::Arc;
use memchr::{memchr2, memchr2_iter, memchr3_iter};
use similar::DiffableStr;
pub(crate) struct Span {
pub(crate) op: Op,
pub(crate) old_range: Range<usize>,
pub(crate) new_range: Range<usize>,
}
use crate::util;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Op {
@@ -17,39 +12,32 @@ pub(crate) enum Op {
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>>,
}
#[derive(Clone)]
pub(crate) struct DiffLine {
pub(crate) op: Op,
pub(crate) old_content: Option<Arc<str>>,
pub(crate) old_line: usize,
pub(crate) old_line: Option<usize>,
pub(crate) old_byte_range: std::ops::Range<usize>,
pub(crate) new_content: Option<Arc<str>>,
pub(crate) new_line: usize,
pub(crate) new_line: Option<usize>,
pub(crate) new_byte_range: std::ops::Range<usize>,
}
#[derive(Clone)]
pub(crate) struct ContentDiff(Vec<DiffLine>);
pub(crate) struct ContentDiff {
pub(crate) diff_lines: Vec<DiffLine>,
pub(crate) old_content: bytes::Bytes,
pub(crate) old_line_count: usize,
pub(crate) new_content: bytes::Bytes,
pub(crate) new_line_count: usize,
}
pub(crate) fn diff_content(
old_content: bytes::Bytes,
new_content: bytes::Bytes,
) -> Option<ContentDiff> {
let old_line_ranges = line_ranges(&old_content);
let new_line_ranges = line_ranges(&new_content);
let old_line_ranges = util::file::line_ranges(&old_content);
let new_line_ranges = util::file::line_ranges(&new_content);
let diff = similar::TextDiff::from_lines::<[u8]>(&old_content, &new_content);
let mut diff_lines: Vec<DiffLine> = Vec::new();
@@ -68,28 +56,30 @@ pub(crate) fn diff_content(
let content = Arc::from(old_content.slice(old_line_range.clone()).as_str()?);
diff_lines.push(DiffLine {
op: Op::Equal,
old_line,
old_line: Some(old_line),
old_content: Some(Arc::clone(&content)),
new_line,
old_byte_range: old_line_range.clone(),
new_line: Some(new_line),
new_content: Some(content),
new_byte_range: new_line_ranges[new_line].clone(),
});
}
}
| &similar::DiffOp::Insert {
old_index,
new_index,
new_len,
new_index, new_len, ..
} => {
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,
old_line: old_index,
old_line: None,
old_content: None,
new_line: new_index + i,
old_byte_range: 0..0,
new_line: Some(new_index + i),
new_content: Some(content),
new_byte_range: new_line_range.clone(),
})
}
}
@@ -108,26 +98,32 @@ pub(crate) fn diff_content(
{
| (Some(old_range), Some(new_range)) => DiffLine {
op: Op::Replace,
old_line,
old_line: Some(old_line),
old_content: Some(Arc::from(old_content.slice(old_range.clone()).as_str()?)),
new_line: new_index + i,
old_byte_range: old_range.clone(),
new_line: Some(new_line),
new_content: Some(Arc::from(new_content.slice(new_range.clone()).as_str()?)),
new_byte_range: new_range.clone(),
},
| (None, Some(new_range)) => DiffLine {
op: Op::Replace,
old_line: old_index + old_len,
old_line: None,
old_content: None,
new_line: new_index + i,
old_byte_range: 0..0,
new_line: Some(new_index + i),
new_content: Some(Arc::from(new_content.slice(new_range.clone()).as_str()?)),
new_byte_range: new_range.clone(),
},
| (Some(old_range), None) => DiffLine {
op: Op::Replace,
old_line: old_index + i,
old_line: Some(old_index + i),
old_content: Some(Arc::from(old_content.slice(old_range.clone()).as_str()?)),
new_line: new_index + new_len,
old_byte_range: old_range.clone(),
new_line: None,
new_content: None,
new_byte_range: 0..0,
},
| (None, None) => {
@@ -143,74 +139,44 @@ pub(crate) fn diff_content(
}
| &similar::DiffOp::Delete {
old_index,
old_len,
new_index,
old_index, old_len, ..
} => {
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_line: Some(old_index + i),
old_content: Some(content),
new_line: new_index,
old_byte_range: old_line_range.clone(),
new_line: None,
new_content: None,
new_byte_range: 0..0,
})
}
}
}
}
Some(ContentDiff(diff_lines))
Some(ContentDiff {
diff_lines,
old_content,
old_line_count: old_line_ranges.len(),
new_content,
new_line_count: new_line_ranges.len(),
})
}
impl ContentDiff {
pub(crate) fn len(&self) -> usize {
self.0.len()
self.diff_lines.len()
}
pub(crate) fn get(&self, i: usize) -> &DiffLine {
&self.0[i]
&self.diff_lines[i]
}
pub(crate) fn last(&self) -> Option<&DiffLine> {
self.0.last()
self.diff_lines.last()
}
}
fn line_ranges(content: &[u8]) -> Vec<Range<usize>> {
let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
let mut line_start: usize = 0;
let mut skip_next = false;
for i in memchr2_iter(b'\n', b'\r', content) {
if skip_next {
skip_next = false;
continue;
}
let c = content[i];
match (c, content.get(i + 1)) {
| (b'\r', Some(b'\n')) => {
// if \r found, check if its \r\n or if its a lone \r
// 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;
}
| _ => {
ranges.push(line_start..i);
line_start = i + 1;
}
}
}
if line_start < content.len() {
ranges.push(line_start..content.len());
}
ranges
}

View File

@@ -1,10 +1,20 @@
use memchr::memchr;
use std::path::Path;
use memchr::{memchr, memchr2_iter};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ContentType {
Text,
Binary,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FileType {
Rust,
JavaScript,
Unknown,
}
pub(crate) fn classify_content(content: &[u8]) -> ContentType {
if content.is_empty() {
ContentType::Text
@@ -17,8 +27,52 @@ pub(crate) fn classify_content(content: &[u8]) -> ContentType {
ContentType::Text
} else {
match memchr(0, &content[..content.len().min(8192)]) {
| None => ContentType::Text,
| Some(_) => ContentType::Binary,
| None => ContentType::Text,
| Some(_) => ContentType::Binary,
}
}
}
pub(crate) fn file_type_from_path(path: &str) -> FileType {
match Path::new(path).extension().map(|it| it.to_str()).flatten() {
| Some("rs") => FileType::Rust,
| Some("js") | Some("jsx") => FileType::JavaScript,
| _ => FileType::Unknown,
}
}
pub(crate) fn line_ranges(content: &[u8]) -> Vec<std::ops::Range<usize>> {
let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
let mut line_start: usize = 0;
let mut skip_next = false;
for i in memchr2_iter(b'\n', b'\r', content) {
if skip_next {
skip_next = false;
continue;
}
let c = content[i];
match (c, content.get(i + 1)) {
| (b'\r', Some(b'\n')) => {
// if \r found, check if its \r\n or if its a lone \r
// 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;
}
| _ => {
ranges.push(line_start..i);
line_start = i + 1;
}
}
}
if line_start < content.len() {
ranges.push(line_start..content.len());
}
ranges
}

View File

@@ -1,4 +1,5 @@
pub(crate) mod diff;
pub(crate) mod file;
pub(crate) mod str;
pub(crate) mod syntax_highlight;
pub(crate) mod timeout;

View File

@@ -0,0 +1,91 @@
use crate::{theme, util};
pub(crate) struct HighlightedContent(Vec<Vec<(std::ops::Range<usize>, gpui::HighlightStyle)>>);
fn ts_highlight_configuration_for_file_type(
file_type: util::file::FileType,
) -> Option<tree_sitter_highlight::HighlightConfiguration> {
match file_type {
| util::file::FileType::Rust => tree_sitter_highlight::HighlightConfiguration::new(
tree_sitter_rust::LANGUAGE.into(),
"rust",
tree_sitter_rust::HIGHLIGHTS_QUERY,
tree_sitter_rust::INJECTIONS_QUERY,
"",
)
.ok(),
| _ => None,
}
}
pub(crate) fn highlight_content(
content: impl AsRef<[u8]>,
file_type: util::file::FileType,
theme_syntax: &theme::ThemeSyntax,
) -> Option<HighlightedContent> {
let mut config = ts_highlight_configuration_for_file_type(file_type)?;
config.configure(theme::syntax::HIGHLIGHT_NAMES);
let mut highlighter = tree_sitter_highlight::Highlighter::new();
let events = highlighter
.highlight(&config, content.as_ref(), None, |_| None)
.ok()?;
let default_highlight = theme_syntax.resolve(theme::syntax::key::TEXT_LITERAL);
let mut highlight = default_highlight;
let line_ranges = util::file::line_ranges(content.as_ref());
let mut highlights: Vec<Vec<(std::ops::Range<usize>, gpui::HighlightStyle)>> =
Vec::with_capacity(line_ranges.len());
let mut current_line: usize = 0;
for highlight_event in events {
match highlight_event.ok()? {
| tree_sitter_highlight::HighlightEvent::HighlightStart(h) => {
highlight = theme_syntax.as_slice()[h.0];
}
| tree_sitter_highlight::HighlightEvent::Source { start, end } => {
while current_line < line_ranges.len() && start >= line_ranges[current_line].end {
highlights.push(Vec::new());
current_line += 1;
}
let mut line = current_line;
while line < line_ranges.len() && end > line_ranges[line].start {
if highlights.get(line).is_none() {
highlights.push(Vec::new());
}
let line_range = &line_ranges[line];
let highlight_start = start.max(line_range.start);
let highlight_end = end.min(line_range.end);
highlights[line].push((
(highlight_start - line_range.start)..(highlight_end - line_range.start),
gpui::HighlightStyle {
color: Some(highlight.color.into()),
font_weight: highlight.font_weight,
font_style: Some(highlight.font_style),
..Default::default()
},
));
line += 1;
}
}
| tree_sitter_highlight::HighlightEvent::HighlightEnd => {
highlight = default_highlight;
}
}
}
Some(HighlightedContent(highlights))
}
impl HighlightedContent {
pub(crate) fn highlights_at_line(
&self,
line: usize,
) -> &Vec<(std::ops::Range<usize>, gpui::HighlightStyle)> {
&self.0[line]
}
}