From 240d48ff1e32f350bcc00c23beb2bbbd8d31c40e Mon Sep 17 00:00:00 2001 From: Kenneth Date: Mon, 1 Jun 2026 01:15:47 +0100 Subject: [PATCH] feat: search result highlight in pr diff view --- src/component/code_view.rs | 14 +- src/component/diff_view.rs | 98 +++++-- .../dashboard/pull_request_diff_view.rs | 247 +++++++++++++++++- src/util/syntax_highlight.rs | 209 ++++++++++++--- 4 files changed, 488 insertions(+), 80 deletions(-) diff --git a/src/component/code_view.rs b/src/component/code_view.rs index b24d41e..4123942 100644 --- a/src/component/code_view.rs +++ b/src/component/code_view.rs @@ -2,7 +2,7 @@ use std::{num::NonZeroUsize, rc::Rc}; use gpui::{IntoElement, ParentElement, Refineable, Styled, div, list, prelude::FluentBuilder, px}; -use crate::app; +use crate::{app, theme, util}; #[derive(gpui::IntoElement)] pub(crate) struct CodeLine { @@ -50,6 +50,16 @@ pub(crate) fn code_line( } } +/// creates a [gpui::HighlightStyle] that highlights some text with a subtle background color +/// can be used in e.g. highlighting search results. +pub(crate) fn symbol_highlight_style(theme: &theme::Theme) -> gpui::HighlightStyle { + gpui::HighlightStyle { + background_color: Some(gpui::yellow()), + font_weight: Some(gpui::FontWeight::BOLD), + ..Default::default() + } +} + pub(crate) fn code_line_with_highlights( line_index: Option, content: Option, @@ -96,8 +106,6 @@ impl gpui::RenderOnce for CodeView { .unwrap_or(px(7.2)) * digits; - println!("gutter width {}", gutter_width); - list(self.state.0, move |i, _window, _app| todo!()) } } diff --git a/src/component/diff_view.rs b/src/component/diff_view.rs index 73dbda3..9c048f6 100644 --- a/src/component/diff_view.rs +++ b/src/component/diff_view.rs @@ -1,4 +1,8 @@ -use std::{cell::RefCell, rc::Rc, sync::Arc}; +use std::{ + cell::{Ref, RefCell, RefMut}, + rc::Rc, + sync::Arc, +}; use gpui::{IntoElement, ParentElement, Styled, div, list, px}; @@ -54,10 +58,55 @@ impl DiffViewState { }))) } + pub(crate) fn fork_from(other: &DiffViewState) -> Self { + let other_state = other.0.borrow(); + Self(Rc::new(RefCell::new(DiffViewStateInner { + list_state: other_state.list_state.clone(), + old_side_highlights: other_state + .old_side_highlights + .as_ref() + .map(|it| it.clone()), + new_side_highlights: other_state + .new_side_highlights + .as_ref() + .map(|it| it.clone()), + }))) + } + pub(crate) fn reset(&mut self, line_count: usize) { self.0.borrow().list_state.reset(line_count); } + pub(crate) fn old_side_highlights( + &self, + ) -> Option> { + Ref::filter_map(self.0.borrow(), |state| state.old_side_highlights.as_ref()).ok() + } + + pub(crate) fn old_side_highlights_mut( + &self, + ) -> Option> { + RefMut::filter_map(self.0.borrow_mut(), |state| { + state.old_side_highlights.as_mut() + }) + .ok() + } + + pub(crate) fn new_side_highlights( + &self, + ) -> Option> { + Ref::filter_map(self.0.borrow(), |state| state.new_side_highlights.as_ref()).ok() + } + + pub(crate) fn new_side_highlights_mut( + &self, + ) -> Option> { + RefMut::filter_map(self.0.borrow_mut(), |state| { + state.new_side_highlights.as_mut() + }) + .ok() + } + pub(crate) fn set_old_side_highlights( &mut self, highlights: util::syntax_highlight::HighlightedContent, @@ -101,7 +150,6 @@ impl gpui::RenderOnce for DiffView { } .into_any_element() }) - .bg(gpui::red()) .size_full() } } @@ -117,13 +165,11 @@ impl DiffRow { .map(|it| it.to_shared_string()); let marker = match self.line.op { - | util::diff::Op::Equal => code_view::CodeLineMarker::Unchanged, - // inserting on new side, so placeholder on old side - | util::diff::Op::Insert => code_view::CodeLineMarker::Placeholder, - // old side replaced, so delete - | util::diff::Op::Replace | util::diff::Op::Delete => { - code_view::CodeLineMarker::Deleted - } + | util::diff::Op::Equal => code_view::CodeLineMarker::Unchanged, + // inserting on new side, so placeholder on old side + | util::diff::Op::Insert => code_view::CodeLineMarker::Placeholder, + // old side replaced, so delete + | util::diff::Op::Replace | util::diff::Op::Delete => code_view::CodeLineMarker::Deleted, }; match self.line.old_line.and_then(|line| { @@ -132,14 +178,14 @@ impl DiffRow { .as_ref() .map(|it| it.highlights_at_line(line)) }) { - | Some(highlights) => code_line_with_highlights( - self.line.old_line, - content, - highlights.iter().cloned(), - marker, - ), + | Some(highlights) => code_line_with_highlights( + self.line.old_line, + content, + highlights.iter().cloned(), + marker, + ), - | None => code_line(self.line.old_line, content, marker), + | None => code_line(self.line.old_line, content, marker), } } @@ -153,9 +199,9 @@ impl DiffRow { .map(|it| it.to_shared_string()); let marker = 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, + | 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, }; match self.line.new_line.and_then(|line| { @@ -164,14 +210,14 @@ impl DiffRow { .as_ref() .map(|it| it.highlights_at_line(line)) }) { - | Some(highlights) => code_line_with_highlights( - self.line.new_line, - content, - highlights.iter().cloned(), - marker, - ), + | Some(highlights) => code_line_with_highlights( + self.line.new_line, + content, + highlights.iter().cloned(), + marker, + ), - | None => code_line(self.line.new_line, content, marker), + | None => code_line(self.line.new_line, content, marker), } } } diff --git a/src/screen/dashboard/pull_request_diff_view.rs b/src/screen/dashboard/pull_request_diff_view.rs index 94286ea..4bc328f 100644 --- a/src/screen/dashboard/pull_request_diff_view.rs +++ b/src/screen/dashboard/pull_request_diff_view.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{sync::Arc, usize}; use gpui::{ AppContext, FocusHandle, InteractiveElement, IntoElement, ParentElement, Styled, div, @@ -9,8 +9,9 @@ use crate::{ api::{self}, app, component::{ + code_view::symbol_highlight_style, diff_view::{DiffViewContent, DiffViewState, diff_view}, - text_input::{TextInput, text_input}, + text_input::{self, TextInput, text_input}, }, query::{self, QueryStatus, read_query, use_query, watch_query}, util, @@ -19,15 +20,31 @@ use crate::{ pub(crate) struct PullRequestDiffView { pr_query: query::Entity, content_diff_query: Option>, + diff_view_state: DiffViewState, + diff_view_state_in_search_mode: Option, diff_view_content: Option, + diff_search_result: Option, + diff_search_result_cursor: Option, + current_file_path: Option>, focus_handle: gpui::FocusHandle, - is_search_input_visible: bool, search_input: gpui::Entity, } +struct DiffSearchResult { + cursor: DiffSearchResultCursor, + old_side: Vec<(usize, std::ops::Range)>, + new_side: Vec<(usize, std::ops::Range)>, +} + +struct DiffSearchResultCursor { + is_old: bool, + line_index: usize, + index: usize, +} + gpui::actions!([Search, Escape]); const KEY_CONTEXT: &'static str = "PullRequestDiffView"; @@ -39,10 +56,12 @@ impl PullRequestDiffView { content_diff_query: None, diff_view_state: DiffViewState::new(), diff_view_content: None, + diff_view_state_in_search_mode: None, + diff_search_result: None, + diff_search_result_cursor: None, current_file_path: None, focus_handle: cx.focus_handle(), - is_search_input_visible: false, search_input: cx.new(|cx| TextInput::with_placeholder("Search", cx)), }; s.on_create(cx); @@ -112,6 +131,18 @@ impl PullRequestDiffView { this.start_content_queries(cx); }) .detach(); + + _ = cx + .subscribe(&self.search_input, |this, _, event, cx| match event { + | text_input::Event::Changed(value) => { + this.search_in_diff(&value, cx); + } + + | text_input::Event::Submitted(_query) => { + this.advance_search_result_cursor(cx); + } + }) + .detach(); } fn sync_content_diff_query( @@ -172,14 +203,205 @@ impl PullRequestDiffView { } } + fn search_in_diff(&mut self, search_str: &str, cx: &mut gpui::Context) { + let diff_view_state_in_search_mode = self + .diff_view_state_in_search_mode + .get_or_insert(DiffViewState::fork_from(&self.diff_view_state)); + + let Some(query) = &self.content_diff_query else { + return; + }; + let QueryStatus::Loaded(diff) = read_query(query, cx) else { + return; + }; + + let theme = app::current_theme(cx); + + let search_str_len = search_str.len(); + + let old_search_result = memchr::memmem::find_iter(&diff.old_content, search_str.as_bytes()) + .filter_map(|idx| { + let range = idx..idx + search_str_len; + let line_idx = self + .diff_view_state + .old_side_highlights()? + .line_index_of_range(&range)?; + Some((line_idx, range)) + }) + .collect::>(); + let new_search_result = memchr::memmem::find_iter(&diff.new_content, search_str.as_bytes()) + .filter_map(|idx| { + let range = idx..idx + search_str_len; + let line_idx = self + .diff_view_state + .new_side_highlights()? + .line_index_of_range(&range)?; + Some((line_idx, range)) + }) + .collect::>(); + + let old_side_highlights = + old_search_result + .iter() + .map(|(_, r)| -> util::syntax_highlight::HighlightedRange { + (r.clone(), symbol_highlight_style(theme)) + }); + let new_side_highlights = + new_search_result + .iter() + .map(|(_, r)| -> util::syntax_highlight::HighlightedRange { + (r.clone(), symbol_highlight_style(theme)) + }); + + if let Some(h) = self + .diff_view_state + .old_side_highlights() + .map(|it| it.clone()) + { + diff_view_state_in_search_mode + .set_old_side_highlights(h.extended_with_highlights(old_side_highlights)); + } + + if let Some(h) = self + .diff_view_state + .new_side_highlights() + .map(|it| it.clone()) + { + diff_view_state_in_search_mode + .set_new_side_highlights(h.extended_with_highlights(new_side_highlights)); + } + + if old_search_result.is_empty() && new_search_result.is_empty() { + self.diff_search_result = None; + } else { + let cursor = if !old_search_result.is_empty() { + DiffSearchResultCursor { + is_old: true, + line_index: old_search_result[0].0, + index: 0, + } + } else { + DiffSearchResultCursor { + is_old: false, + line_index: new_search_result[0].0, + index: 0, + } + }; + self.diff_search_result = Some(DiffSearchResult { + cursor, + old_side: old_search_result, + new_side: new_search_result, + }); + } + + cx.notify(); + } + + fn advance_search_result_cursor(&mut self, cx: &mut gpui::Context) { + let (Some(search_result), Some(diff_view_state)) = ( + &mut self.diff_search_result, + &mut self.diff_view_state_in_search_mode, + ) else { + return; + }; + + let (current_side, other_side) = if search_result.cursor.is_old { + (&search_result.old_side, &search_result.new_side) + } else { + (&search_result.new_side, &search_result.old_side) + }; + + let theme = app::current_theme(cx); + + let current_line_index = search_result.cursor.line_index; + let highlight_range = match current_side.get(search_result.cursor.index + 1) { + | Some((next_highlight_line, next_range)) if *next_highlight_line == current_line_index => { + // go to next search result on same side & same line + search_result.cursor.index += 1; + Some(( + next_range.clone(), + gpui::HighlightStyle { + background_color: Some(gpui::red()), + ..symbol_highlight_style(theme) + }, + )) + } + | next => { + let next_highlight_line = next.map(|(line, _)| *line).unwrap_or(usize::MAX); + + let mut i = 0; + let mut other_side_result: Option<(usize, usize, &std::ops::Range)> = None; + while let Some((other_side_line_i, r)) = &other_side.get(i) { + if *other_side_line_i == current_line_index { + // found other side highlight on the current line + other_side_result = Some((*other_side_line_i, i, r)); + break; + } + if *other_side_line_i > current_line_index + && *other_side_line_i < next_highlight_line + { + // found other side highlight in between current side highlight and next side highlight + other_side_result = Some((*other_side_line_i, i, r)); + break; + } + if *other_side_line_i > next_highlight_line { + break; + } + i += 1; + } + + if let Some((other_side_line_i, other_side_i, r)) = other_side_result { + // next cursor should be on other side with the found index + search_result.cursor.is_old = !search_result.cursor.is_old; + search_result.cursor.line_index = other_side_line_i; + search_result.cursor.index = other_side_i; + Some(( + r.clone(), + gpui::HighlightStyle { + background_color: Some(gpui::red()), + ..symbol_highlight_style(theme) + }, + )) + } else if let Some(next) = next { + // stay on old side, point to next old side highlight + search_result.cursor.line_index = next_highlight_line; + search_result.cursor.index = search_result.cursor.index + 1; + Some(( + next.1.clone(), + gpui::HighlightStyle { + background_color: Some(gpui::red()), + ..symbol_highlight_style(theme) + }, + )) + } else { + None + } + } + }; + + if let Some(highlight_range) = highlight_range { + let content = if search_result.cursor.is_old { + diff_view_state.old_side_highlights_mut() + } else { + diff_view_state.new_side_highlights_mut() + }; + if let Some(mut content) = content { + println!("replacing highlight range {:?}", highlight_range); + content.replace_highlight_range(&highlight_range); + } + } + + cx.notify(); + } + fn open_search_box(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context) { - self.is_search_input_visible = true; + self.diff_view_state_in_search_mode = Some(DiffViewState::fork_from(&self.diff_view_state)); cx.focus_view(&self.search_input, window); cx.notify(); } fn close_search_box(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context) { - self.is_search_input_visible = false; + self.diff_view_state_in_search_mode = None; self.focus_handle.focus(window); cx.notify(); } @@ -204,6 +426,8 @@ impl gpui::Render for PullRequestDiffView { .map(|q| read_query(q, cx)) .unwrap_or(QueryStatus::Loading); + let is_search_input_visible = self.diff_view_state_in_search_mode.is_some(); + div() .id(KEY_CONTEXT) .key_context(KEY_CONTEXT) @@ -211,7 +435,7 @@ impl gpui::Render for PullRequestDiffView { .flex() .flex_col() .size_full() - .when(self.is_search_input_visible, |it| { + .when(is_search_input_visible, |it| { it.child( text_input(self.search_input.clone()) .w_full() @@ -231,7 +455,14 @@ impl gpui::Render for PullRequestDiffView { }, |it, content| { it.child( - diff_view(self.diff_view_state.clone(), content.clone()).into_any_element(), + diff_view( + self.diff_view_state_in_search_mode + .as_ref() + .unwrap_or_else(|| &self.diff_view_state) + .clone(), + content.clone(), + ) + .into_any_element(), ) }, ) diff --git a/src/util/syntax_highlight.rs b/src/util/syntax_highlight.rs index 1e04f97..2f8185a 100644 --- a/src/util/syntax_highlight.rs +++ b/src/util/syntax_highlight.rs @@ -1,21 +1,30 @@ -use crate::{theme, util}; +use crate::{component::code_view::symbol_highlight_style, theme, util}; -pub(crate) struct HighlightedContent(Vec, gpui::HighlightStyle)>>); +pub(crate) type HighlightedRange = (std::ops::Range, gpui::HighlightStyle); + +#[derive(Clone)] +pub(crate) struct HighlightedContent(Vec); + +#[derive(Default, Clone, Debug)] +pub(crate) struct HighlightedLine { + line_range: std::ops::Range, + highlights: Vec<(std::ops::Range, gpui::HighlightStyle)>, +} fn ts_highlight_configuration_for_file_type( file_type: util::file::FileType, ) -> Option { 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(), + | 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, + | _ => None, } } @@ -38,43 +47,63 @@ pub(crate) fn highlight_content( let line_ranges = util::file::line_ranges(content.as_ref()); - let mut highlights: Vec, gpui::HighlightStyle)>> = - Vec::with_capacity(line_ranges.len()); + let mut highlights: Vec = Vec::with_capacity(line_ranges.len()); let mut current_line: usize = 0; + // Tree-sitter produces a flat event stream: + // + // HighlightStart(style) -> Source { start, end } -> HighlightEnd + // + // `highlight` tracks the currently active style while each Source span is + // converted from absolute byte offsets into per-line, line-relative ranges. + // Empty lines before the span are emitted as empty highlight lists, then a + // span that crosses line boundaries is clipped into one range per touched + // line: + // + // bytes: [ line 0 ][ line 1 ][ line 2 ] + // span: [===========) + // out: [--] [------] [--] 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; + | 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 { + if highlights.get(current_line).is_none() { + highlights.push(HighlightedLine { + line_range: line_ranges[current_line].clone(), + highlights: Vec::new(), + }); } - 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; + current_line += 1; + } + let mut line = current_line; + while line < line_ranges.len() && end > line_ranges[line].start { + let line_range = &line_ranges[line]; + if highlights.get(line).is_none() { + highlights.push(HighlightedLine { + line_range: line_range.clone(), + highlights: Vec::new(), + }); } + let highlight_start = start.max(line_range.start); + let highlight_end = end.min(line_range.end); + highlights[line].highlights.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; - } + } + | tree_sitter_highlight::HighlightEvent::HighlightEnd => { + highlight = default_highlight; + } } } @@ -85,7 +114,101 @@ impl HighlightedContent { pub(crate) fn highlights_at_line( &self, line: usize, - ) -> &Vec<(std::ops::Range, gpui::HighlightStyle)> { - &self.0[line] + ) -> &[(std::ops::Range, gpui::HighlightStyle)] { + &self.0[line].highlights + } + + pub(crate) fn line_range(&self, line: usize) -> &std::ops::Range { + &self.0[line].line_range + } + + pub(crate) fn extended_with_highlights( + mut self, + highlights: impl Iterator, + ) -> Self { + let total_line_count = self.0.len(); + let mut current_line: usize = 0; + + let mut new_highlights: Vec = Vec::new(); + for (range, highlight) in highlights { + // move to next line until the highlight range start is within bounds of a line (ie range start is before line range end) + while current_line < total_line_count + && range.start >= self.line_range(current_line).end + { + // moving on to new line, flush new highlights and combine into the line highlights + if !new_highlights.is_empty() { + self.0[current_line].highlights = gpui::combine_highlights( + self.0[current_line].highlights.drain(..).into_iter(), + new_highlights.drain(..).into_iter(), + ) + .collect(); + } + current_line += 1; + } + + let mut line = current_line; + while line < total_line_count && range.end > self.line_range(line).start { + let highlight_start = range.start.max(self.line_range(line).start); + let highlight_end = range.end.min(self.line_range(line).end); + new_highlights.push(( + (highlight_start - self.line_range(line).start) + ..(highlight_end - self.line_range(line).start), + highlight.clone(), + )); + line += 1; + } + } + + if !new_highlights.is_empty() { + self.0[current_line].highlights = gpui::combine_highlights( + self.0[current_line].highlights.drain(..).into_iter(), + new_highlights.drain(..).into_iter(), + ) + .collect(); + } + + self + } + + pub(crate) fn line_index_of_range(&self, range: &std::ops::Range) -> Option { + self.0 + .binary_search_by(|line| { + let line_start = line.line_range.start; + let line_end = line.line_range.end; + if (range.start <= line_start && range.end >= line_end) + || (line_start <= range.start && line_end >= range.end) + { + std::cmp::Ordering::Equal + } else if range.start < line_start && range.end < line_end { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Less + } + }) + .ok() + } + + pub(crate) fn replace_highlight_range(&mut self, highlighted_range: &HighlightedRange) { + let (range, _) = &highlighted_range; + + println!("finding range to replace {:?}", range); + + let line = self + .line_index_of_range(range) + .map(|line_i| &mut self.0[line_i]); + + let replace_idx = line.as_ref().and_then(|l| { + let local_range = (range.start - l.line_range.start)..(range.end - l.line_range.start); + (l.highlights.iter().position(|(r, _)| *r == local_range)).map(|i| (i, local_range)) + }); + + println!("line {:?} replace_idx {:?}", line, replace_idx); + + match (line, replace_idx) { + | (Some(line), Some((replace_idx, local_range))) => { + line.highlights[replace_idx] = (local_range, highlighted_range.1) + } + | _ => {} + } } }