diff --git a/Cargo.lock b/Cargo.lock index be46b7e..ca64fb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5506,7 +5506,14 @@ dependencies = [ "serde", "serde_json", "tokio", + "tree-sitter", + "tree-sitter-css", + "tree-sitter-html", + "tree-sitter-javascript", + "tree-sitter-json", "tree-sitter-properties", + "tree-sitter-xml", + "unicode-segmentation", "urlencoding", "uuid", ] @@ -6566,6 +6573,36 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-css" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-json" version = "0.24.8" @@ -6592,6 +6629,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-xml" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e670041f591d994f54d597ddcd8f4ebc930e282c4c76a42268743b71f0c8b6b3" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "triomphe" version = "0.1.16" diff --git a/Cargo.toml b/Cargo.toml index f74d42d..4d0f5c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,13 @@ urlencoding = "2.1.3" mime_guess = "2.0.5" dirs = "6.0.0" tree-sitter-properties = "0.3" +tree-sitter = "0.25.10" +tree-sitter-json = "0.24.8" +tree-sitter-html = "0.23.2" +tree-sitter-xml = "0.7.0" +tree-sitter-css = "0.23.2" +tree-sitter-javascript = "0.23.1" +unicode-segmentation = "1.13.3" base64 = "0.22.1" bytes = { version = "1.11.1", features = ["serde"] } rodio = { version = "0.22.2", default-features = false, features = [ diff --git a/src/app.rs b/src/app.rs index bcb2ebf..e022a51 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ use gpui_component::Root; use crate::actions::*; use crate::assets::Assets; use crate::completion::init_completion_navigation; +use crate::components::init_code_editor; use crate::theme::init_theme; use crate::utils::{register_bulk_kv_language, set_app_focus_handle}; use crate::views::MainView; @@ -23,6 +24,7 @@ impl SetuApp { // Initialize gpui-component (must be called before using any gpui-component features) gpui_component::init(cx); init_completion_navigation(cx); + init_code_editor(cx); // Register bulk key:value tree-sitter grammar for headers/params/env editors register_bulk_kv_language(); diff --git a/src/components/code_editor/document.rs b/src/components/code_editor/document.rs new file mode 100644 index 0000000..03a50a7 --- /dev/null +++ b/src/components/code_editor/document.rs @@ -0,0 +1,1110 @@ +use std::collections::HashSet; +use std::ops::Range; + +use tree_sitter::{ + InputEdit, Language, Node, Parser, Point, Query, QueryCursor, StreamingIterator, Tree, +}; + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +pub enum CodeLanguage { + #[default] + PlainText, + Json, + Html, + Xml, + Css, + JavaScript, +} + +impl CodeLanguage { + pub fn from_content_type(content_type: Option<&str>) -> Self { + let mime = content_type + .unwrap_or_default() + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + + if mime == "application/json" || mime == "text/json" || mime.ends_with("+json") { + Self::Json + } else if mime == "text/html" || mime == "application/xhtml+xml" { + Self::Html + } else if mime == "application/xml" || mime == "text/xml" || mime.ends_with("+xml") { + Self::Xml + } else if mime == "text/css" { + Self::Css + } else if mime.contains("javascript") || mime.contains("ecmascript") { + Self::JavaScript + } else { + Self::PlainText + } + } + + fn grammar(self) -> Option<(Language, &'static str)> { + match self { + Self::PlainText => None, + Self::Json => Some(( + tree_sitter_json::LANGUAGE.into(), + tree_sitter_json::HIGHLIGHTS_QUERY, + )), + Self::Html => Some(( + tree_sitter_html::LANGUAGE.into(), + tree_sitter_html::HIGHLIGHTS_QUERY, + )), + Self::Xml => Some(( + tree_sitter_xml::LANGUAGE_XML.into(), + tree_sitter_xml::XML_HIGHLIGHT_QUERY, + )), + Self::Css => Some(( + tree_sitter_css::LANGUAGE.into(), + tree_sitter_css::HIGHLIGHTS_QUERY, + )), + Self::JavaScript => Some(( + tree_sitter_javascript::LANGUAGE.into(), + tree_sitter_javascript::HIGHLIGHT_QUERY, + )), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SyntaxKind { + Property, + Attribute, + String, + Number, + Boolean, + Constant, + Escape, + Comment, + Keyword, + Tag, + Function, + Type, + Variable, + Operator, + Punctuation, + Embedded, + TextLiteral, +} + +impl SyntaxKind { + pub fn theme_name(self) -> &'static str { + match self { + Self::Property => "property", + Self::Attribute => "attribute", + Self::String => "string", + Self::Number => "number", + Self::Boolean => "boolean", + Self::Constant => "constant", + Self::Escape => "string.escape", + Self::Comment => "comment", + Self::Keyword => "keyword", + Self::Tag => "tag", + Self::Function => "function", + Self::Type => "type", + Self::Variable => "variable", + Self::Operator => "operator", + Self::Punctuation => "punctuation", + Self::Embedded => "embedded", + Self::TextLiteral => "text.literal", + } + } + + fn from_capture(capture: &str) -> Option { + let kind = if capture == "string.special.key" || capture.starts_with("property") { + Self::Property + } else if capture.starts_with("attribute") { + Self::Attribute + } else if capture == "boolean" { + Self::Boolean + } else if capture.starts_with("comment") { + Self::Comment + } else if capture.starts_with("constant") { + Self::Constant + } else if capture.starts_with("constructor") || capture.starts_with("function") { + Self::Function + } else if capture.starts_with("embedded") { + Self::Embedded + } else if capture == "escape" || capture.starts_with("string.escape") { + Self::Escape + } else if capture.starts_with("string") { + Self::String + } else if capture.starts_with("keyword") || capture.starts_with("preproc") { + Self::Keyword + } else if capture.starts_with("number") { + Self::Number + } else if capture.starts_with("operator") { + Self::Operator + } else if capture.starts_with("punctuation") { + Self::Punctuation + } else if capture.starts_with("tag") { + Self::Tag + } else if capture.starts_with("type") { + Self::Type + } else if capture.starts_with("variable") { + Self::Variable + } else if capture.starts_with("markup") || capture == "text.literal" { + Self::TextLiteral + } else { + return None; + }; + Some(kind) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SyntaxSpan { + pub range: Range, + pub kind: SyntaxKind, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FoldKind { + Object, + Array, + Element, + Block, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FoldRegion { + /// The complete JSON object/array, including its delimiters. + pub range: Range, + /// The range replaced by the inline placeholder. Delimiters remain visible. + pub hidden_range: Range, + pub start_row: usize, + pub end_row: usize, + pub kind: FoldKind, + pub child_count: usize, + pub placeholder: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisplaySegment { + Source { + display_range: Range, + source_range: Range, + }, + Fold { + display_range: Range, + source_range: Range, + fold_start: usize, + }, +} + +impl DisplaySegment { + pub fn display_range(&self) -> &Range { + match self { + Self::Source { display_range, .. } | Self::Fold { display_range, .. } => display_range, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisplayLine { + pub text: String, + pub source_row: usize, + pub segments: Vec, +} + +impl DisplayLine { + fn new(source_row: usize) -> Self { + Self { + text: String::new(), + source_row, + segments: Vec::new(), + } + } +} + +/// Editable, tree-sitter-backed document model. +/// +/// Source positions always use UTF-8 byte offsets. Tree-sitter, syntax spans, folds, +/// selections, and display mappings therefore share one coordinate system. +pub struct EditorDocument { + language: CodeLanguage, + parser: Option, + highlight_query: Option, + tree: Option, + text: String, + line_starts: Vec, + folds: Vec, + syntax_spans: Vec, + syntax_spans_by_row: Vec>, + collapsed_starts: HashSet, + display_lines: Vec, + max_display_columns: usize, +} + +impl EditorDocument { + #[cfg(test)] + pub fn new(text: impl Into) -> Self { + Self::with_language(text, CodeLanguage::Json) + } + + pub fn with_language(text: impl Into, language: CodeLanguage) -> Self { + let (parser, highlight_query) = if let Some((grammar, highlights)) = language.grammar() { + let mut parser = Parser::new(); + parser + .set_language(&grammar) + .expect("bundled tree-sitter grammar should load"); + let query = Query::new(&grammar, highlights) + .expect("bundled tree-sitter highlight query should compile"); + (Some(parser), Some(query)) + } else { + (None, None) + }; + let mut document = Self { + language, + parser, + highlight_query, + tree: None, + text: text.into(), + line_starts: vec![0], + folds: Vec::new(), + syntax_spans: Vec::new(), + syntax_spans_by_row: Vec::new(), + collapsed_starts: HashSet::new(), + display_lines: Vec::new(), + max_display_columns: 0, + }; + document.reparse(None); + document + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn line_starts(&self) -> &[usize] { + &self.line_starts + } + + pub fn line_count(&self) -> usize { + self.line_starts.len() + } + + #[cfg(test)] + pub fn folds(&self) -> &[FoldRegion] { + &self.folds + } + + #[cfg(test)] + pub fn syntax_spans(&self) -> &[SyntaxSpan] { + &self.syntax_spans + } + + pub fn syntax_spans_for_row(&self, row: usize) -> &[SyntaxSpan] { + self.syntax_spans_by_row.get(row).map_or(&[], Vec::as_slice) + } + + pub fn display_lines(&self) -> &[DisplayLine] { + &self.display_lines + } + + pub fn max_display_columns(&self) -> usize { + self.max_display_columns + } + + pub fn is_collapsed(&self, fold_start: usize) -> bool { + self.collapsed_starts.contains(&fold_start) + } + + pub fn fold_at_start(&self, fold_start: usize) -> Option<&FoldRegion> { + self.folds + .iter() + .find(|fold| fold.range.start == fold_start) + } + + pub fn fold_starting_on_row(&self, row: usize) -> Option<&FoldRegion> { + self.folds + .iter() + .filter(|fold| fold.start_row == row) + .max_by_key(|fold| fold.range.end - fold.range.start) + } + + pub fn toggle_fold(&mut self, fold_start: usize) -> bool { + if self.fold_at_start(fold_start).is_none() { + return false; + } + if !self.collapsed_starts.remove(&fold_start) { + self.collapsed_starts.insert(fold_start); + } + self.rebuild_display_lines(); + true + } + + #[cfg(test)] + pub fn collapse(&mut self, fold_start: usize) -> bool { + if self.fold_at_start(fold_start).is_none() { + return false; + } + let changed = self.collapsed_starts.insert(fold_start); + if changed { + self.rebuild_display_lines(); + } + changed + } + + pub fn expand_all(&mut self) { + if !self.collapsed_starts.is_empty() { + self.collapsed_starts.clear(); + self.rebuild_display_lines(); + } + } + + pub fn replace(&mut self, range: Range, replacement: &str) -> String { + let range = self.clamp_range_to_char_boundaries(range); + let start_position = self.point_for_offset(range.start); + let old_end_position = self.point_for_offset(range.end); + let new_end_position = point_after_insert(start_position, replacement); + let removed = self.text[range.clone()].to_string(); + let old_len = range.end - range.start; + let delta = replacement.len() as isize - old_len as isize; + + let mut adjusted_collapsed = HashSet::new(); + for start in self.collapsed_starts.drain() { + let Some(fold) = self.folds.iter().find(|fold| fold.range.start == start) else { + continue; + }; + if fold.range.end <= range.start { + adjusted_collapsed.insert(start); + } else if fold.range.start >= range.end { + adjusted_collapsed.insert((start as isize + delta).max(0) as usize); + } + } + self.collapsed_starts = adjusted_collapsed; + + let mut old_tree = self.tree.take(); + if let Some(tree) = &mut old_tree { + tree.edit(&InputEdit { + start_byte: range.start, + old_end_byte: range.end, + new_end_byte: range.start + replacement.len(), + start_position, + old_end_position, + new_end_position, + }); + } + self.text.replace_range(range, replacement); + self.reparse(old_tree.as_ref()); + removed + } + + pub fn point_for_offset(&self, offset: usize) -> Point { + let offset = self.clamp_offset(offset); + let row = self + .line_starts + .partition_point(|line_start| *line_start <= offset) + .saturating_sub(1); + Point::new(row, offset - self.line_starts[row]) + } + + pub fn offset_for_point(&self, row: usize, column: usize) -> usize { + let row = row.min(self.line_starts.len().saturating_sub(1)); + let line_start = self.line_starts[row]; + let line_end = self.line_end(row); + self.floor_char_boundary((line_start + column).min(line_end)) + } + + pub fn line_range(&self, row: usize) -> Range { + let row = row.min(self.line_starts.len().saturating_sub(1)); + self.line_starts[row]..self.line_end(row) + } + + pub fn line_end(&self, row: usize) -> usize { + self.line_starts + .get(row + 1) + .copied() + .map(|offset| offset.saturating_sub(1)) + .unwrap_or(self.text.len()) + } + + pub fn clamp_offset(&self, offset: usize) -> usize { + self.floor_char_boundary(offset.min(self.text.len())) + } + + pub fn containing_collapsed_fold(&self, offset: usize) -> Option<&FoldRegion> { + self.folds.iter().find(|fold| { + self.collapsed_starts.contains(&fold.range.start) + && fold.hidden_range.start < offset + && offset < fold.hidden_range.end + }) + } + + pub fn display_point_for_offset(&self, offset: usize, trailing_bias: bool) -> (usize, usize) { + let offset = self.clamp_offset(offset); + for (display_row, line) in self.display_lines.iter().enumerate() { + for segment in &line.segments { + match segment { + DisplaySegment::Source { + display_range, + source_range, + } if source_range.start <= offset && offset <= source_range.end => { + let local = offset.saturating_sub(source_range.start); + return ( + display_row, + display_range.start + local.min(display_range.len()), + ); + } + DisplaySegment::Fold { + display_range, + source_range, + .. + } if source_range.start <= offset && offset <= source_range.end => { + return ( + display_row, + if trailing_bias { + display_range.end + } else { + display_range.start + }, + ); + } + _ => {} + } + } + } + + let row = self.display_lines.len().saturating_sub(1); + ( + row, + self.display_lines + .get(row) + .map_or(0, |line| line.text.len()), + ) + } + + pub fn source_offset_for_display_point( + &self, + display_row: usize, + display_offset: usize, + trailing_bias: bool, + ) -> usize { + let Some(line) = self.display_lines.get(display_row) else { + return self.text.len(); + }; + let display_offset = display_offset.min(line.text.len()); + for segment in &line.segments { + match segment { + DisplaySegment::Source { + display_range, + source_range, + } if display_range.start <= display_offset + && display_offset <= display_range.end => + { + let local = display_offset.saturating_sub(display_range.start); + return self.clamp_offset(source_range.start + local.min(source_range.len())); + } + DisplaySegment::Fold { + display_range, + source_range, + .. + } if display_range.start <= display_offset + && display_offset <= display_range.end => + { + return if trailing_bias { + source_range.end + } else { + source_range.start + }; + } + _ => {} + } + } + self.line_end(line.source_row) + } + + fn reparse(&mut self, old_tree: Option<&Tree>) { + self.rebuild_line_starts(); + self.tree = self + .parser + .as_mut() + .and_then(|parser| parser.parse(&self.text, old_tree)); + self.folds.clear(); + self.syntax_spans.clear(); + if let Some(tree) = &self.tree { + collect_folds(tree.root_node(), &self.text, self.language, &mut self.folds); + if let Some(query) = &self.highlight_query { + collect_highlights(tree, query, &self.text, &mut self.syntax_spans); + } + } + self.folds + .sort_by_key(|fold| (fold.range.start, fold.range.end)); + self.syntax_spans + .sort_by_key(|span| (span.range.start, span.range.end)); + self.syntax_spans.dedup(); + self.rebuild_syntax_rows(); + self.collapsed_starts + .retain(|start| self.folds.iter().any(|fold| fold.range.start == *start)); + self.rebuild_display_lines(); + } + + fn rebuild_line_starts(&mut self) { + self.line_starts.clear(); + self.line_starts.push(0); + self.line_starts.extend( + self.text + .bytes() + .enumerate() + .filter_map(|(ix, byte)| (byte == b'\n').then_some(ix + 1)), + ); + } + + fn rebuild_syntax_rows(&mut self) { + self.syntax_spans_by_row = vec![Vec::new(); self.line_count()]; + for span in &self.syntax_spans { + let start_row = self + .line_starts + .partition_point(|line_start| *line_start <= span.range.start) + .saturating_sub(1); + let inclusive_end = span.range.end.saturating_sub(1); + let end_row = self + .line_starts + .partition_point(|line_start| *line_start <= inclusive_end) + .saturating_sub(1); + for row in start_row..=end_row.min(self.line_count().saturating_sub(1)) { + self.syntax_spans_by_row[row].push(span.clone()); + } + } + } + + fn rebuild_display_lines(&mut self) { + let collapsed = self.visible_collapsed_folds(); + let mut lines = vec![DisplayLine::new(0)]; + let mut source_cursor = 0; + + for fold in collapsed { + append_source( + &self.text, + &self.line_starts, + source_cursor..fold.hidden_range.start, + &mut lines, + ); + let line = lines.last_mut().expect("display always has a line"); + let display_start = line.text.len(); + line.text.push_str(&fold.placeholder); + let display_end = line.text.len(); + line.segments.push(DisplaySegment::Fold { + display_range: display_start..display_end, + source_range: fold.hidden_range.clone(), + fold_start: fold.range.start, + }); + source_cursor = fold.hidden_range.end; + } + + append_source( + &self.text, + &self.line_starts, + source_cursor..self.text.len(), + &mut lines, + ); + self.display_lines = lines; + self.max_display_columns = self + .display_lines + .iter() + .map(|line| line.text.chars().count()) + .max() + .unwrap_or(0); + } + + fn visible_collapsed_folds(&self) -> Vec { + let mut folds: Vec<_> = self + .folds + .iter() + .filter(|fold| self.collapsed_starts.contains(&fold.range.start)) + .cloned() + .collect(); + folds.sort_by_key(|fold| (fold.hidden_range.start, usize::MAX - fold.hidden_range.end)); + + let mut visible: Vec = Vec::new(); + for fold in folds { + if visible + .last() + .is_some_and(|outer| fold.hidden_range.start < outer.hidden_range.end) + { + continue; + } + visible.push(fold); + } + visible + } + + fn clamp_range_to_char_boundaries(&self, range: Range) -> Range { + let start = self.floor_char_boundary(range.start.min(self.text.len())); + let end = self.ceil_char_boundary(range.end.min(self.text.len())); + start.min(end)..end + } + + fn floor_char_boundary(&self, mut offset: usize) -> usize { + while offset > 0 && !self.text.is_char_boundary(offset) { + offset -= 1; + } + offset + } + + fn ceil_char_boundary(&self, mut offset: usize) -> usize { + while offset < self.text.len() && !self.text.is_char_boundary(offset) { + offset += 1; + } + offset + } +} + +fn point_after_insert(start: Point, replacement: &str) -> Point { + let newline_count = replacement.bytes().filter(|byte| *byte == b'\n').count(); + if newline_count == 0 { + Point::new(start.row, start.column + replacement.len()) + } else { + Point::new( + start.row + newline_count, + replacement + .rsplit_once('\n') + .map_or(0, |(_, suffix)| suffix.len()), + ) + } +} + +fn append_source( + text: &str, + line_starts: &[usize], + range: Range, + lines: &mut Vec, +) { + if range.is_empty() { + return; + } + let mut cursor = range.start; + while cursor < range.end { + let newline = text[cursor..range.end].find('\n').map(|ix| cursor + ix); + let chunk_end = newline.unwrap_or(range.end); + if chunk_end > cursor { + let line = lines.last_mut().expect("display always has a line"); + let display_start = line.text.len(); + line.text.push_str(&text[cursor..chunk_end]); + let display_end = line.text.len(); + line.segments.push(DisplaySegment::Source { + display_range: display_start..display_end, + source_range: cursor..chunk_end, + }); + } + if let Some(newline) = newline { + // A collapsed segment can skip many physical rows before this newline. Derive the + // next row from the source offset instead of incrementing the visible row number. + let source_row = line_starts.partition_point(|start| *start <= newline + 1) - 1; + lines.push(DisplayLine::new(source_row)); + cursor = newline + 1; + } else { + cursor = chunk_end; + } + } +} + +fn collect_folds( + root: Node<'_>, + source: &str, + language: CodeLanguage, + folds: &mut Vec, +) { + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + if node.start_position().row < node.end_position().row + && let Some(fold) = fold_for_node(node, language) + { + folds.push(fold); + } + + let mut cursor = node.walk(); + stack.extend( + node.children(&mut cursor) + .filter(|child| child.end_byte() <= source.len()), + ); + } +} + +fn fold_for_node(node: Node<'_>, language: CodeLanguage) -> Option { + match language { + CodeLanguage::Json if matches!(node.kind(), "object" | "array") => { + let kind = if node.kind() == "object" { + FoldKind::Object + } else { + FoldKind::Array + }; + let child_count = if kind == FoldKind::Object { + let mut cursor = node.walk(); + node.named_children(&mut cursor) + .filter(|child| child.kind() == "pair") + .count() + } else { + node.named_child_count() + }; + delimited_fold(node, kind, child_count, json_fold_noun(kind, child_count)) + } + CodeLanguage::Html + if matches!(node.kind(), "element" | "script_element" | "style_element") => + { + element_fold(node, &["start_tag"], &["end_tag"]) + } + CodeLanguage::Xml if node.kind() == "element" => element_fold(node, &["STag"], &["ETag"]), + CodeLanguage::Css if node.kind() == "block" => { + let child_count = node.named_child_count(); + delimited_fold( + node, + FoldKind::Block, + child_count, + count_noun(child_count, "rule", "rules"), + ) + } + CodeLanguage::JavaScript + if matches!( + node.kind(), + "array" | "object" | "statement_block" | "class_body" | "switch_body" + ) => + { + let child_count = node.named_child_count(); + let fold_kind = match node.kind() { + "array" => FoldKind::Array, + "object" => FoldKind::Object, + _ => FoldKind::Block, + }; + let noun = match fold_kind { + FoldKind::Object => count_noun(child_count, "property", "properties"), + FoldKind::Array => count_noun(child_count, "item", "items"), + FoldKind::Block => count_noun(child_count, "statement", "statements"), + FoldKind::Element => unreachable!(), + }; + delimited_fold(node, fold_kind, child_count, noun) + } + _ => None, + } +} + +fn delimited_fold( + node: Node<'_>, + kind: FoldKind, + child_count: usize, + noun: &'static str, +) -> Option { + let range = node.byte_range(); + (range.end > range.start + 1).then(|| FoldRegion { + range: range.clone(), + hidden_range: range.start + 1..range.end - 1, + start_row: node.start_position().row, + end_row: node.end_position().row, + kind, + child_count, + placeholder: format!(" {child_count} {noun} "), + }) +} + +fn element_fold(node: Node<'_>, start_kinds: &[&str], end_kinds: &[&str]) -> Option { + let mut cursor = node.walk(); + let children: Vec<_> = node.named_children(&mut cursor).collect(); + let start = children + .iter() + .find(|child| start_kinds.contains(&child.kind()))?; + let end = children + .iter() + .rev() + .find(|child| end_kinds.contains(&child.kind()))?; + if start.end_byte() >= end.start_byte() { + return None; + } + let child_count = children + .iter() + .filter(|child| !start_kinds.contains(&child.kind()) && !end_kinds.contains(&child.kind())) + .count(); + Some(FoldRegion { + range: node.byte_range(), + hidden_range: start.end_byte()..end.start_byte(), + start_row: node.start_position().row, + end_row: node.end_position().row, + kind: FoldKind::Element, + child_count, + placeholder: format!( + " {child_count} {} ", + count_noun(child_count, "node", "nodes") + ), + }) +} + +fn json_fold_noun(kind: FoldKind, count: usize) -> &'static str { + match kind { + FoldKind::Object => count_noun(count, "key", "keys"), + FoldKind::Array => count_noun(count, "item", "items"), + FoldKind::Element | FoldKind::Block => unreachable!(), + } +} + +fn count_noun(count: usize, singular: &'static str, plural: &'static str) -> &'static str { + if count == 1 { singular } else { plural } +} + +fn collect_highlights( + tree: &Tree, + query: &Query, + source: &str, + syntax_spans: &mut Vec, +) { + let capture_names = query.capture_names(); + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(query, tree.root_node(), source.as_bytes()); + while let Some(query_match) = matches.next() { + for capture in query_match.captures { + let Some(kind) = capture_names + .get(capture.index as usize) + .and_then(|name| SyntaxKind::from_capture(name)) + else { + continue; + }; + let range = capture.node.byte_range(); + if range.end <= source.len() && !range.is_empty() { + syntax_spans.push(SyntaxSpan { range, kind }); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const JSON: &str = r#"{ + "method": "GET", + "headers": { + "Accept": "application/json", + "X-Trace": "abc" + }, + "items": [ + 1, + 2, + 3 + ] +}"#; + + #[test] + fn content_types_select_their_response_grammar() { + assert_eq!( + CodeLanguage::from_content_type(Some("application/problem+json; charset=utf-8")), + CodeLanguage::Json + ); + assert_eq!( + CodeLanguage::from_content_type(Some("text/html")), + CodeLanguage::Html + ); + assert_eq!( + CodeLanguage::from_content_type(Some("application/atom+xml")), + CodeLanguage::Xml + ); + assert_eq!( + CodeLanguage::from_content_type(Some("text/css")), + CodeLanguage::Css + ); + assert_eq!( + CodeLanguage::from_content_type(Some("application/javascript")), + CodeLanguage::JavaScript + ); + assert_eq!( + CodeLanguage::from_content_type(Some("text/plain")), + CodeLanguage::PlainText + ); + } + + #[test] + fn html_uses_tag_highlights_and_element_folds() { + let document = EditorDocument::with_language( + "
\n

Hello

\n
", + CodeLanguage::Html, + ); + + assert!( + document + .syntax_spans() + .iter() + .any(|span| span.kind == SyntaxKind::Tag) + ); + assert!( + document + .syntax_spans() + .iter() + .any(|span| span.kind == SyntaxKind::Attribute) + ); + assert!( + document + .folds() + .iter() + .any(|fold| fold.kind == FoldKind::Element) + ); + } + + #[test] + fn xml_css_and_javascript_use_language_specific_syntax() { + let xml = EditorDocument::with_language( + "\n Hello\n", + CodeLanguage::Xml, + ); + assert!( + xml.syntax_spans() + .iter() + .any(|span| span.kind == SyntaxKind::Tag) + ); + assert!( + xml.folds() + .iter() + .any(|fold| fold.kind == FoldKind::Element) + ); + + let css = EditorDocument::with_language(".card {\n color: red;\n}", CodeLanguage::Css); + assert!( + css.syntax_spans() + .iter() + .any(|span| span.kind == SyntaxKind::Property) + ); + assert!(css.folds().iter().any(|fold| fold.kind == FoldKind::Block)); + + let javascript = EditorDocument::with_language( + "function greet() {\n return \"hello\";\n}", + CodeLanguage::JavaScript, + ); + assert!( + javascript + .syntax_spans() + .iter() + .any(|span| span.kind == SyntaxKind::Keyword) + ); + assert!( + javascript + .folds() + .iter() + .any(|fold| fold.kind == FoldKind::Block) + ); + } + + #[test] + fn plain_text_does_not_create_fake_json_syntax_or_folds() { + let document = EditorDocument::with_language( + "ordinary text with { braces }\nand [brackets]", + CodeLanguage::PlainText, + ); + + assert!(document.syntax_spans().is_empty()); + assert!(document.folds().is_empty()); + } + + #[test] + fn tree_sitter_extracts_json_fold_summaries() { + let document = EditorDocument::new(JSON); + let headers = document + .folds() + .iter() + .find(|fold| fold.kind == FoldKind::Object && fold.child_count == 2) + .expect("headers object"); + assert_eq!(headers.placeholder, " 2 keys "); + let items = document + .folds() + .iter() + .find(|fold| fold.kind == FoldKind::Array) + .expect("items array"); + assert_eq!(items.placeholder, " 3 items "); + } + + #[test] + fn deeply_nested_json_collects_folds_without_call_stack_recursion() { + const DEPTH: usize = 10_000; + let source = format!("{}\n0{}", "[".repeat(DEPTH), "]".repeat(DEPTH)); + + let document = EditorDocument::new(source); + + assert_eq!(document.folds().len(), DEPTH); + } + + #[test] + fn gutter_fold_prefers_outermost_construct_on_shared_start_row() { + let document = EditorDocument::with_language( + "function build() { return {\n value: 1\n}; }", + CodeLanguage::JavaScript, + ); + let fold = document.fold_starting_on_row(0).expect("row zero fold"); + + assert_eq!(fold.kind, FoldKind::Block); + assert!( + document + .folds() + .iter() + .any(|candidate| candidate.kind == FoldKind::Object && candidate.start_row == 0) + ); + } + + #[test] + fn collapsed_range_becomes_inline_display_segment() { + let mut document = EditorDocument::new(JSON); + let headers_start = document + .folds() + .iter() + .find(|fold| fold.kind == FoldKind::Object && fold.child_count == 2) + .unwrap() + .range + .start; + assert!(document.collapse(headers_start)); + + let collapsed_line = document + .display_lines() + .iter() + .find(|line| line.text.contains("headers")) + .unwrap(); + assert_eq!(collapsed_line.text.trim(), r#""headers": { 2 keys },"#); + assert!( + collapsed_line + .segments + .iter() + .any(|segment| matches!(segment, DisplaySegment::Fold { .. })) + ); + assert!( + document + .display_lines() + .iter() + .any(|line| line.source_row == 6 && line.text.contains("items")) + ); + } + + #[test] + fn edits_before_collapsed_fold_preserve_fold_state() { + let mut document = EditorDocument::new(JSON); + let start = document + .folds() + .iter() + .find(|fold| fold.kind == FoldKind::Array) + .unwrap() + .range + .start; + document.collapse(start); + document.replace(0..0, " \n"); + assert!(document.is_collapsed(start + 2)); + } + + #[test] + fn incremental_parse_tracks_multiline_json_edits() { + let mut document = EditorDocument::new("{\n \"a\": 1\n}"); + let insert_at = document.text().find("\n}").unwrap(); + document.replace(insert_at..insert_at, ",\n \"b\": 2"); + + let root = document + .folds() + .iter() + .find(|fold| fold.kind == FoldKind::Object) + .unwrap(); + assert_eq!(root.child_count, 2); + assert_eq!(root.end_row, 3); + } + + #[test] + fn unicode_points_use_byte_columns_without_splitting_characters() { + let document = EditorDocument::new("{\n \"emoji\": \"πŸŽ‰\"\n}"); + let emoji = document.text().find('πŸŽ‰').unwrap(); + let point = document.point_for_offset(emoji); + assert_eq!(document.offset_for_point(point.row, point.column), emoji); + assert_eq!(document.clamp_offset(emoji + 1), emoji); + } +} diff --git a/src/components/code_editor/element.rs b/src/components/code_editor/element.rs new file mode 100644 index 0000000..a0d75b4 --- /dev/null +++ b/src/components/code_editor/element.rs @@ -0,0 +1,1103 @@ +use gpui::prelude::*; +use gpui::{ + App, BorderStyle, Bounds, CursorStyle, Element, ElementId, ElementInputHandler, Entity, + GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, LayoutId, + MouseButton, PaintQuad, Pixels, Point, ShapedLine, Style, TextAlign, TextRun, Window, div, + fill, point, px, quad, relative, size, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::input::Input; +use gpui_component::scroll::Scrollbar; +use gpui_component::{ActiveTheme as _, Icon, Sizable as _}; +use std::ops::Range; + +use super::document::{DisplayLine, DisplaySegment, EditorDocument, SyntaxKind}; +use super::state::{ + CodeEditorState, EDITOR_CONTEXT, EDITOR_LINE_HEIGHT, FoldHitbox, TAB_SIZE, VisibleLineLayout, +}; +use crate::icons::IconName; + +const MIN_GUTTER_WIDTH: Pixels = px(58.0); +const GUTTER_LEFT_PADDING: Pixels = px(8.0); +const LINE_NUMBER_FOLD_GAP: Pixels = px(8.0); +const FOLD_RIGHT_PADDING: Pixels = px(3.0); +const TEXT_LEFT_PADDING: Pixels = px(8.0); +const RIGHT_PADDING: Pixels = px(24.0); +const FOLD_ICON_SIZE: Pixels = px(16.0); +const BADGE_VERTICAL_INSET: Pixels = px(2.0); +const OVERSCAN_ROWS: usize = 6; + +pub(super) struct EditorElement { + state: Entity, +} + +impl EditorElement { + pub fn new(state: Entity) -> Self { + Self { state } + } +} + +struct PaintedLine { + is_text: bool, + display_row: usize, + display_range: Range, + text_bounds: Bounds, + line_number: ShapedLine, + line_number_origin: Point, + text: ShapedLine, + text_origin: Point, +} + +#[derive(Clone)] +struct VisualRow { + display_row: usize, + display_range: Range, + continuation: bool, +} + +pub(super) struct LayoutState { + rows: LayoutRows, + gutter_width: Pixels, +} + +enum LayoutRows { + Logical, + Wrapped(Vec), +} + +impl LayoutRows { + fn len(&self, document: &EditorDocument) -> usize { + match self { + Self::Logical => document.display_lines().len(), + Self::Wrapped(rows) => rows.len(), + } + } + + fn row(&self, index: usize, document: &EditorDocument) -> VisualRow { + match self { + Self::Logical => VisualRow { + display_row: index, + display_range: 0..document.display_lines()[index].text.len(), + continuation: false, + }, + Self::Wrapped(rows) => rows[index].clone(), + } + } +} + +pub(super) struct PrepaintState { + lines: Vec, + gutter_quads: Vec, + background_quads: Vec, + indent_guide_quads: Vec, + search_quads: Vec, + selection_quads: Vec, + fold_quads: Vec, + cursor: Option, + fold_hitboxes: Vec, + fold_cursor_hitboxes: Vec, + gutter_cursor_hitbox: Hitbox, + gutter_bounds: Bounds, + viewport: Bounds, +} + +impl IntoElement for EditorElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for EditorElement { + type RequestLayoutState = LayoutState; + type PrepaintState = PrepaintState; + + fn id(&self) -> Option { + Some("setu-code-editor-content".into()) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let state = self.state.read(cx); + let line_height = window.line_height(); + let max_columns = state.document.max_display_columns(); + let estimated_character_width = + window.text_style().font_size.to_pixels(window.rem_size()) * 0.62; + let gutter_width = gutter_width_for_line_count( + state.document.line_count(), + window.text_style().font_size.to_pixels(window.rem_size()), + ); + let tracked_width = state.scroll_handle.bounds().size.width; + let viewport_width = if tracked_width > px(100.0) { + tracked_width + } else { + px(800.0) + }; + let wrap_columns = (((viewport_width - gutter_width - TEXT_LEFT_PADDING - RIGHT_PADDING) + / estimated_character_width) + .floor() + .max(1.0)) as usize; + let rows = if state.soft_wrap { + LayoutRows::Wrapped(visual_rows(&state.document, Some(wrap_columns))) + } else { + LayoutRows::Logical + }; + let content_width = if state.soft_wrap { + viewport_width + } else { + gutter_width + + TEXT_LEFT_PADDING + + estimated_character_width * max_columns as f32 + + RIGHT_PADDING + }; + let content_height = line_height * rows.len(&state.document).max(1) as f32; + let mut style = Style::default(); + style.size.width = relative(1.0).into(); + style.min_size.width = content_width.into(); + style.size.height = content_height.into(); + ( + window.request_layout(style, [], cx), + LayoutState { rows, gutter_width }, + ) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let state = self.state.read(cx); + let theme = cx.theme(); + let line_height = window.line_height(); + let text_style = window.text_style(); + let font_size = text_style.font_size.to_pixels(window.rem_size()); + let viewport = { + let tracked = state.scroll_handle.bounds(); + if tracked.size.width > px(0.) && tracked.size.height > px(0.) { + tracked + } else { + bounds + } + }; + let gutter_bounds = Bounds::new( + point(viewport.left(), viewport.top()), + size(layout.gutter_width, viewport.size.height), + ); + let gutter_cursor_hitbox = window.insert_hitbox(gutter_bounds, HitboxBehavior::Normal); + let mut gutter_quads = vec![ + fill(gutter_bounds, theme.background.opacity(0.38)), + fill( + Bounds::new( + point(gutter_bounds.right() - px(1.0), gutter_bounds.top()), + size(px(1.0), gutter_bounds.size.height), + ), + theme.border.opacity(0.65), + ), + ]; + let mouse_position = window.mouse_position(); + let first_visible = (((viewport.top() - bounds.top()) / line_height) + .floor() + .max(0.0) as usize) + .saturating_sub(OVERSCAN_ROWS); + let visible_count = + ((viewport.size.height / line_height).ceil().max(1.0) as usize) + OVERSCAN_ROWS * 2; + let last_visible = (first_visible + visible_count).min(layout.rows.len(&state.document)); + + let cursor_display = state + .document + .display_point_for_offset(state.cursor(), state.selection.reversed); + let cursor_source_row = state.document.point_for_offset(state.cursor()).row; + let active_indent = indentation_columns( + &state.document.text()[state.document.line_range(cursor_source_row)], + ); + let selection_start = state + .document + .display_point_for_offset(state.selected_range().start, false); + let selection_end = state + .document + .display_point_for_offset(state.selected_range().end, true); + + let mut painted_lines = Vec::with_capacity(last_visible.saturating_sub(first_visible)); + let mut background_quads = Vec::new(); + let mut indent_guide_quads = Vec::new(); + let mut search_quads = Vec::new(); + let mut selection_quads = Vec::new(); + let mut fold_quads = Vec::new(); + let mut fold_hitboxes = Vec::new(); + let mut fold_cursor_hitboxes = Vec::new(); + let mut cursor_quad = None; + + for visual_row in first_visible..last_visible { + let row = layout.rows.row(visual_row, &state.document); + let source_line = &state.document.display_lines()[row.display_row]; + let display_line = cropped_display_line(source_line, row.display_range.clone()); + let display_row = row.display_row; + let y = bounds.top() + line_height * visual_row as f32; + let text_origin = point(bounds.left() + layout.gutter_width + TEXT_LEFT_PADDING, y); + let text_bounds = Bounds::new( + text_origin, + size((bounds.right() - text_origin.x).max(px(1.0)), line_height), + ); + let runs = text_runs_for_line( + &display_line, + &state.document, + text_style.font(), + theme.foreground, + theme.muted_foreground, + &theme.highlight_theme, + ); + let shaped = window.text_system().shape_line( + display_line.text.clone().into(), + font_size, + &runs, + None, + ); + + let source_indent = indentation_columns( + &state.document.text()[state.document.line_range(display_line.source_row)], + ); + let character_width = font_size * 0.62; + for column in (TAB_SIZE..=source_indent).step_by(TAB_SIZE) { + let x = text_origin.x + character_width * column as f32; + indent_guide_quads.push(fill( + Bounds::new(point(x, y), size(px(1.0), line_height)), + if column == active_indent { + theme.accent.opacity(0.65) + } else { + theme.border.opacity(0.55) + }, + )); + } + + for (match_index, matched) in state.search_matches.iter().enumerate() { + let start = state + .document + .display_point_for_offset(matched.start, false); + let end = state.document.display_point_for_offset(matched.end, true); + if display_row < start.0 || display_row > end.0 { + continue; + } + let match_start = if display_row == start.0 { + start.1 + } else { + row.display_range.start + }; + let match_end = if display_row == end.0 { + end.1 + } else { + row.display_range.end + }; + let local_start = match_start + .max(row.display_range.start) + .saturating_sub(row.display_range.start) + .min(display_line.text.len()); + let local_end = match_end + .min(row.display_range.end) + .saturating_sub(row.display_range.start) + .min(display_line.text.len()); + let x_start = shaped.x_for_index(local_start); + let x_end = shaped.x_for_index(local_end); + if x_end > x_start { + search_quads.push( + fill( + Bounds::from_corners( + point(text_origin.x + x_start, y + px(2.0)), + point(text_origin.x + x_end, y + line_height - px(2.0)), + ), + if state.active_search_match == Some(match_index) { + theme.info + } else { + theme.accent + }, + ) + .corner_radii(px(2.0)), + ); + } + } + + let cursor_on_row = cursor_display.0 == display_row + && visual_row_contains(&row, cursor_display.1, source_line.text.len()); + let gutter_hovered = gutter_bounds.contains(&mouse_position) + && y <= mouse_position.y + && mouse_position.y <= y + line_height; + if cursor_on_row || gutter_hovered { + gutter_quads.push(fill( + Bounds::new( + point(gutter_bounds.left(), y), + size(gutter_bounds.size.width, line_height), + ), + if gutter_hovered { + theme.accent.opacity(0.16) + } else { + theme.accent.opacity(0.09) + }, + )); + } + if cursor_on_row { + background_quads.push(fill( + Bounds::new( + point(bounds.left(), y), + size(bounds.size.width, line_height), + ), + theme + .highlight_theme + .style + .editor_active_line + .unwrap_or(theme.muted), + )); + } + + if !state.selected_range().is_empty() + && display_row >= selection_start.0 + && display_row <= selection_end.0 + { + let selection_row_start = if display_row == selection_start.0 { + selection_start.1 + } else { + row.display_range.start + }; + let selection_row_end = if display_row == selection_end.0 { + selection_end.1 + } else { + row.display_range.end + }; + let local_start = selection_row_start + .max(row.display_range.start) + .saturating_sub(row.display_range.start) + .min(display_line.text.len()); + let local_end = selection_row_end + .min(row.display_range.end) + .saturating_sub(row.display_range.start) + .min(display_line.text.len()); + let start = shaped.x_for_index(local_start); + let end = shaped.x_for_index(local_end); + if end > start { + selection_quads.push(fill( + Bounds::from_corners( + point(text_origin.x + start, y), + point(text_origin.x + end, y + line_height), + ), + theme.selection, + )); + } + } + + for segment in &display_line.segments { + if let DisplaySegment::Fold { + display_range, + fold_start, + .. + } = segment + { + let x_start = text_origin.x + shaped.x_for_index(display_range.start); + let x_end = text_origin.x + shaped.x_for_index(display_range.end); + let badge_bounds = Bounds::from_corners( + point(x_start, y + BADGE_VERTICAL_INSET), + point(x_end, y + line_height - BADGE_VERTICAL_INSET), + ); + fold_quads.push(quad( + badge_bounds, + px(4.0), + theme.accent, + px(1.0), + theme.border, + BorderStyle::Solid, + )); + fold_hitboxes.push(FoldHitbox { + bounds: badge_bounds, + fold_start: *fold_start, + }); + fold_cursor_hitboxes + .push(window.insert_hitbox(badge_bounds, HitboxBehavior::Normal)); + } + } + + if state.focus_handle.is_focused(window) && cursor_on_row { + let local_cursor = cursor_display.1.saturating_sub(row.display_range.start); + let cursor_x = text_origin.x + shaped.x_for_index(local_cursor); + cursor_quad = Some(fill( + Bounds::new( + point(cursor_x, y + px(2.0)), + size(px(1.5), line_height - px(4.0)), + ), + theme.caret, + )); + } + + // Like Zed, wrapped continuation rows leave the line-number column blank. + let line_number = if row.continuation { + ShapedLine::default() + } else { + let line_number = (display_line.source_row + 1).to_string(); + let line_number_run = TextRun { + len: line_number.len(), + font: text_style.font(), + color: if cursor_on_row || gutter_hovered { + theme.foreground + } else { + theme.muted_foreground + }, + background_color: None, + underline: None, + strikethrough: None, + }; + window.text_system().shape_line( + line_number.into(), + font_size, + &[line_number_run], + None, + ) + }; + let line_number_origin = point( + gutter_bounds.right() - FOLD_ICON_SIZE - LINE_NUMBER_FOLD_GAP - line_number.width, + y, + ); + + if !row.continuation + && let Some(fold) = state.document.fold_starting_on_row(display_line.source_row) + { + let icon_bounds = Bounds::new( + point( + gutter_bounds.right() - FOLD_ICON_SIZE - FOLD_RIGHT_PADDING, + y, + ), + size(FOLD_ICON_SIZE, line_height), + ); + fold_hitboxes.push(FoldHitbox { + bounds: icon_bounds, + fold_start: fold.range.start, + }); + fold_cursor_hitboxes + .push(window.insert_hitbox(icon_bounds, HitboxBehavior::Normal)); + let glyph = if state.document.is_collapsed(fold.range.start) { + "β€Ί" + } else { + "βŒ„" + }; + let glyph_run = TextRun { + len: glyph.len(), + font: text_style.font(), + color: if icon_bounds.contains(&mouse_position) { + theme.accent_foreground + } else { + theme.muted_foreground + }, + background_color: None, + underline: None, + strikethrough: None, + }; + let glyph = + window + .text_system() + .shape_line(glyph.into(), font_size, &[glyph_run], None); + // Fold glyphs are painted as ordinary lines and share the line-number origin. + painted_lines.push(PaintedLine { + is_text: false, + display_row, + display_range: row.display_range.clone(), + text_bounds: icon_bounds, + line_number: glyph, + line_number_origin: icon_bounds.origin, + text: ShapedLine::default(), + text_origin: icon_bounds.origin, + }); + } + + painted_lines.push(PaintedLine { + is_text: true, + display_row, + display_range: row.display_range.clone(), + text_bounds, + line_number, + line_number_origin, + text: shaped, + text_origin, + }); + } + PrepaintState { + lines: painted_lines, + gutter_quads, + background_quads, + indent_guide_quads, + search_quads, + selection_quads, + fold_quads, + cursor: cursor_quad, + fold_hitboxes, + fold_cursor_hitboxes, + gutter_cursor_hitbox, + gutter_bounds, + viewport, + } + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let focus_handle = self.state.read(cx).focus_handle.clone(); + window.handle_input( + &focus_handle, + ElementInputHandler::new(prepaint.viewport, self.state.clone()), + cx, + ); + + window.set_cursor_style(CursorStyle::Arrow, &prepaint.gutter_cursor_hitbox); + for hitbox in &prepaint.fold_cursor_hitboxes { + window.set_cursor_style(CursorStyle::PointingHand, hitbox); + } + + for quad in prepaint.background_quads.drain(..) { + window.paint_quad(quad); + } + for quad in prepaint.gutter_quads.drain(..) { + window.paint_quad(quad); + } + for quad in prepaint.indent_guide_quads.drain(..) { + window.paint_quad(quad); + } + for quad in prepaint.search_quads.drain(..) { + window.paint_quad(quad); + } + for quad in prepaint.selection_quads.drain(..) { + window.paint_quad(quad); + } + for quad in prepaint.fold_quads.drain(..) { + window.paint_quad(quad); + } + + let line_height = window.line_height(); + for line in &prepaint.lines { + if !line.line_number.text.is_empty() { + line.line_number + .paint( + line.line_number_origin, + line_height, + TextAlign::Left, + None, + window, + cx, + ) + .ok(); + } + if !line.text.text.is_empty() { + line.text + .paint( + line.text_origin, + line_height, + TextAlign::Left, + None, + window, + cx, + ) + .ok(); + } + } + + if let Some(cursor) = prepaint.cursor.take() { + window.paint_quad(cursor); + } + + let layouts = prepaint + .lines + .iter() + .filter(|line| line.is_text) + .map(|line| VisibleLineLayout { + display_row: line.display_row, + display_start: line.display_range.start, + display_end: line.display_range.end, + text_bounds: line.text_bounds, + line: line.text.clone(), + }) + .collect(); + let fold_hitboxes = std::mem::take(&mut prepaint.fold_hitboxes); + let viewport = prepaint.viewport; + let gutter_bounds = prepaint.gutter_bounds; + self.state.update(cx, |state, _| { + state.visible_layouts = layouts; + state.fold_hitboxes = fold_hitboxes; + state.viewport_bounds = Some(viewport); + state.gutter_bounds = Some(gutter_bounds); + }); + } +} + +fn gutter_width_for_line_count(line_count: usize, font_size: Pixels) -> Pixels { + let digits = line_count.max(1).to_string().len() as f32; + let line_number_width = font_size * 0.62 * digits; + MIN_GUTTER_WIDTH.max( + GUTTER_LEFT_PADDING + + line_number_width + + LINE_NUMBER_FOLD_GAP + + FOLD_ICON_SIZE + + FOLD_RIGHT_PADDING, + ) +} + +fn visual_rows(document: &EditorDocument, wrap_columns: Option) -> Vec { + let mut rows = Vec::new(); + for (display_row, line) in document.display_lines().iter().enumerate() { + let Some(wrap_columns) = wrap_columns else { + rows.push(VisualRow { + display_row, + display_range: 0..line.text.len(), + continuation: false, + }); + continue; + }; + if line.text.is_empty() { + rows.push(VisualRow { + display_row, + display_range: 0..0, + continuation: false, + }); + continue; + } + + let mut start = 0; + while start < line.text.len() { + let mut end = line.text[start..] + .char_indices() + .nth(wrap_columns) + .map_or(line.text.len(), |(offset, _)| start + offset); + for segment in &line.segments { + if let DisplaySegment::Fold { display_range, .. } = segment + && display_range.start < end + && end < display_range.end + { + end = display_range.end; + break; + } + } + rows.push(VisualRow { + display_row, + display_range: start..end, + continuation: start > 0, + }); + start = end; + } + } + rows +} + +fn indentation_columns(line: &str) -> usize { + line.chars() + .take_while(|character| matches!(character, ' ' | '\t')) + .fold(0, |columns, character| { + if character == '\t' { + columns + TAB_SIZE + } else { + columns + 1 + } + }) +} + +fn visual_row_contains(row: &VisualRow, offset: usize, line_len: usize) -> bool { + row.display_range.start <= offset + && (offset < row.display_range.end + || (offset == row.display_range.end && row.display_range.end == line_len)) +} + +fn cropped_display_line(line: &DisplayLine, range: Range) -> DisplayLine { + let mut cropped = DisplayLine { + text: line.text[range.clone()].to_string(), + source_row: line.source_row, + segments: Vec::new(), + }; + for segment in &line.segments { + let display_range = segment.display_range(); + let start = display_range.start.max(range.start); + let end = display_range.end.min(range.end); + if start >= end { + continue; + } + let local_display = start - range.start..end - range.start; + match segment { + DisplaySegment::Source { source_range, .. } => { + let source_start = source_range.start + start - display_range.start; + cropped.segments.push(DisplaySegment::Source { + display_range: local_display, + source_range: source_start..source_start + (end - start), + }); + } + DisplaySegment::Fold { + source_range, + fold_start, + .. + } => cropped.segments.push(DisplaySegment::Fold { + display_range: local_display, + source_range: source_range.clone(), + fold_start: *fold_start, + }), + } + } + cropped +} + +fn text_runs_for_line( + line: &DisplayLine, + document: &EditorDocument, + font: gpui::Font, + foreground: Hsla, + placeholder_color: Hsla, + highlight_theme: &gpui_component::highlighter::HighlightTheme, +) -> Vec { + let mut runs = Vec::new(); + for segment in &line.segments { + match segment { + DisplaySegment::Fold { display_range, .. } => push_run( + &mut runs, + display_range.len(), + font.clone(), + placeholder_color, + ), + DisplaySegment::Source { + display_range: _, + source_range, + } => { + let syntax_spans = document + .syntax_spans_for_row(document.point_for_offset(source_range.start).row); + let mut boundaries = vec![source_range.start, source_range.end]; + for span in syntax_spans.iter().filter(|span| { + span.range.start < source_range.end && span.range.end > source_range.start + }) { + boundaries.push(span.range.start.max(source_range.start)); + boundaries.push(span.range.end.min(source_range.end)); + } + boundaries.sort_unstable(); + boundaries.dedup(); + for pair in boundaries.windows(2) { + let range = pair[0]..pair[1]; + if range.is_empty() { + continue; + } + let syntax = syntax_at(syntax_spans, range.start); + let color = syntax + .and_then(|kind| highlight_theme.style(kind.theme_name())) + .and_then(|style| style.color) + .unwrap_or(foreground); + push_run(&mut runs, range.len(), font.clone(), color); + } + } + } + } + if runs.is_empty() && !line.text.is_empty() { + push_run(&mut runs, line.text.len(), font, foreground); + } + runs +} + +fn syntax_at(syntax_spans: &[super::document::SyntaxSpan], offset: usize) -> Option { + syntax_spans + .iter() + .filter(|span| span.range.start <= offset && offset < span.range.end) + .min_by_key(|span| span.range.len()) + .map(|span| span.kind) +} + +fn push_run(runs: &mut Vec, len: usize, font: gpui::Font, color: Hsla) { + if len == 0 { + return; + } + runs.push(TextRun { + len, + font, + color, + background_color: None, + underline: None, + strikethrough: None, + }); +} + +#[cfg(test)] +mod tests { + use gpui::px; + + use super::{DisplaySegment, EditorDocument, gutter_width_for_line_count, visual_rows}; + + #[test] + fn gutter_grows_to_fit_large_line_numbers() { + let small = gutter_width_for_line_count(999, px(12.0)); + let large = gutter_width_for_line_count(24_586, px(12.0)); + + assert_eq!(small, super::MIN_GUTTER_WIDTH); + assert!(large > small); + } + + #[test] + fn wrapped_rows_cover_each_display_line_without_splitting_utf8() { + let document = EditorDocument::new(r#"{"message":"πŸ’πŸ’πŸ’πŸ’πŸ’πŸ’"}"#); + let line = &document.display_lines()[0]; + let rows = visual_rows(&document, Some(5)); + + assert!(rows.len() > 1); + assert_eq!(rows.first().unwrap().display_range.start, 0); + assert_eq!(rows.last().unwrap().display_range.end, line.text.len()); + assert!( + rows.windows(2) + .all(|rows| { rows[0].display_range.end == rows[1].display_range.start }) + ); + assert!(rows.iter().all(|row| { + line.text.is_char_boundary(row.display_range.start) + && line.text.is_char_boundary(row.display_range.end) + })); + } + + #[test] + fn wrapped_rows_keep_fold_placeholders_atomic() { + let mut document = + EditorDocument::new("{\n \"headers\": {\n \"a\": 1,\n \"b\": 2\n }\n}"); + let start = document + .folds() + .iter() + .find(|fold| fold.child_count == 2) + .unwrap() + .range + .start; + assert!(document.collapse(start)); + let rows = visual_rows(&document, Some(4)); + let folded_line = document + .display_lines() + .iter() + .position(|line| { + line.segments + .iter() + .any(|segment| matches!(segment, DisplaySegment::Fold { .. })) + }) + .unwrap(); + let fold_range = document.display_lines()[folded_line] + .segments + .iter() + .find_map(|segment| match segment { + DisplaySegment::Fold { display_range, .. } => Some(display_range.clone()), + _ => None, + }) + .unwrap(); + + assert!(rows.iter().any(|row| { + row.display_row == folded_line + && row.display_range.start <= fold_range.start + && row.display_range.end >= fold_range.end + })); + } +} + +impl Render for CodeEditorState { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let scroll_handle = self.scroll_handle.clone(); + let editor_entity = cx.entity(); + let previous_match_editor = editor_entity.clone(); + let next_match_editor = editor_entity.clone(); + let close_search_editor = editor_entity.clone(); + let match_status = if self.search_matches.is_empty() { + "No results".to_string() + } else { + format!( + "{} / {}", + self.active_search_match.unwrap_or(0) + 1, + self.search_matches.len() + ) + }; + let editor_viewport = div() + .id("setu-code-editor-viewport") + .absolute() + .inset_0() + .overflow_scroll() + .track_scroll(&scroll_handle) + .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) + .on_mouse_move(cx.listener(Self::on_mouse_move)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) + .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up)) + .child(EditorElement::new(cx.entity())); + + div() + .id("setu-code-editor") + .relative() + .flex() + .flex_col() + .size_full() + .overflow_hidden() + .track_focus(&self.focus_handle) + .key_context(EDITOR_CONTEXT) + .cursor(CursorStyle::IBeam) + .on_action(cx.listener(Self::backspace)) + .on_action(cx.listener(Self::delete)) + .on_action(cx.listener(Self::delete_to_line_start)) + .on_action(cx.listener(Self::delete_to_line_end)) + .on_action(cx.listener(Self::delete_word_left)) + .on_action(cx.listener(Self::delete_word_right)) + .on_action(cx.listener(Self::move_left)) + .on_action(cx.listener(Self::move_right)) + .on_action(cx.listener(Self::move_up)) + .on_action(cx.listener(Self::move_down)) + .on_action(cx.listener(Self::move_page_up)) + .on_action(cx.listener(Self::move_page_down)) + .on_action(cx.listener(Self::move_home)) + .on_action(cx.listener(Self::move_end)) + .on_action(cx.listener(Self::move_to_start)) + .on_action(cx.listener(Self::move_to_end)) + .on_action(cx.listener(Self::move_word_left)) + .on_action(cx.listener(Self::move_word_right)) + .on_action(cx.listener(Self::select_left)) + .on_action(cx.listener(Self::select_right)) + .on_action(cx.listener(Self::select_word_left)) + .on_action(cx.listener(Self::select_word_right)) + .on_action(cx.listener(Self::select_up)) + .on_action(cx.listener(Self::select_down)) + .on_action(cx.listener(Self::select_page_up)) + .on_action(cx.listener(Self::select_page_down)) + .on_action(cx.listener(Self::select_home)) + .on_action(cx.listener(Self::select_end)) + .on_action(cx.listener(Self::select_to_start)) + .on_action(cx.listener(Self::select_to_end)) + .on_action(cx.listener(Self::select_all)) + .on_action(cx.listener(Self::enter)) + .on_action(cx.listener(Self::tab)) + .on_action(cx.listener(Self::outdent)) + .on_action(cx.listener(Self::copy)) + .on_action(cx.listener(Self::cut)) + .on_action(cx.listener(Self::paste)) + .on_action(cx.listener(Self::undo)) + .on_action(cx.listener(Self::redo)) + .on_action(cx.listener(Self::expand_all_action)) + .on_action(cx.listener(Self::show_character_palette)) + .on_action(cx.listener(Self::find_action)) + .on_action(cx.listener(Self::find_next_action)) + .on_action(cx.listener(Self::find_previous_action)) + .on_action(cx.listener(Self::close_search_action)) + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(12.0)) + .line_height(EDITOR_LINE_HEIGHT) + .bg(cx.theme().muted) + .child(editor_viewport) + .child( + div() + .absolute() + .top_0() + .right_0() + .bottom(px(8.0)) + .w(px(8.0)) + .child(Scrollbar::vertical(&scroll_handle)), + ) + .when(!self.soft_wrap, |editor| { + editor.child( + div() + .id("setu-code-editor-search") + .absolute() + .left_0() + .right(px(8.0)) + .bottom_0() + .h(px(8.0)) + .child(Scrollbar::horizontal(&scroll_handle)), + ) + }) + .when(self.search_open, |editor| { + editor.child( + div() + .absolute() + .top(px(8.0)) + .right(px(16.0)) + .w(px(370.0)) + .h(px(34.0)) + .flex() + .items_center() + .gap(px(8.0)) + .px(px(8.0)) + .rounded(px(6.0)) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().popover) + .cursor(CursorStyle::Arrow) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + div() + .flex_1() + .child(Input::new(&self.search_input).appearance(false).small()), + ) + .child( + div() + .flex() + .items_center() + .gap(px(2.0)) + .child( + Button::new("setu-code-editor-search-previous") + .icon(Icon::new(IconName::ChevronUp).size(px(12.0))) + .ghost() + .xsmall() + .tooltip("Previous match (Shift+Enter)") + .on_click(move |_, _, cx| { + cx.stop_propagation(); + previous_match_editor.update(cx, |editor, cx| { + editor.activate_search_match(-1, cx); + }); + }), + ) + .child( + Button::new("setu-code-editor-search-next") + .icon(Icon::new(IconName::ChevronDown).size(px(12.0))) + .ghost() + .xsmall() + .tooltip("Next match (Enter)") + .on_click(move |_, _, cx| { + cx.stop_propagation(); + next_match_editor.update(cx, |editor, cx| { + editor.activate_search_match(1, cx); + }); + }), + ), + ) + .child( + div() + .text_size(px(10.0)) + .text_color(cx.theme().muted_foreground) + .child(match_status), + ) + .child( + div() + .id("setu-code-editor-search-close") + .flex_none() + .w(px(20.0)) + .h(px(20.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.0)) + .cursor_pointer() + .text_color(cx.theme().muted_foreground) + .hover(|style| { + style + .bg(cx.theme().secondary) + .text_color(cx.theme().foreground) + }) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + close_search_editor.update(cx, |editor, cx| { + editor.close_search(window, cx); + }); + }) + .child(IconName::Close), + ), + ) + }) + } +} diff --git a/src/components/code_editor/mod.rs b/src/components/code_editor/mod.rs new file mode 100644 index 0000000..233d683 --- /dev/null +++ b/src/components/code_editor/mod.rs @@ -0,0 +1,6 @@ +mod document; +mod element; +mod state; + +pub use document::CodeLanguage; +pub use state::{CodeEditorState, PreparedCodeDocument, init_code_editor}; diff --git a/src/components/code_editor/state.rs b/src/components/code_editor/state.rs new file mode 100644 index 0000000..cf215fb --- /dev/null +++ b/src/components/code_editor/state.rs @@ -0,0 +1,1682 @@ +use std::ops::Range; + +use gpui::{ + App, AppContext as _, Bounds, ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, + FocusHandle, Focusable, KeyBinding, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, + Point, ScrollHandle, ShapedLine, Subscription, UTF16Selection, Window, actions, +}; +use gpui_component::input::{InputEvent, InputState}; +use unicode_segmentation::UnicodeSegmentation as _; + +use super::document::{CodeLanguage, EditorDocument}; + +pub(super) const EDITOR_CONTEXT: &str = "SetuCodeEditor"; +pub(super) const TAB_SIZE: usize = 2; +pub(super) const EDITOR_LINE_HEIGHT: Pixels = gpui::px(20.0); +const MAX_EDIT_HISTORY_ENTRIES: usize = 300; + +actions!( + setu_code_editor, + [ + Backspace, + Delete, + DeleteToLineStart, + DeleteToLineEnd, + DeleteWordLeft, + DeleteWordRight, + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + MoveHome, + MoveEnd, + MoveToStart, + MoveToEnd, + MoveWordLeft, + MoveWordRight, + MovePageUp, + MovePageDown, + SelectLeft, + SelectRight, + SelectWordLeft, + SelectWordRight, + SelectUp, + SelectDown, + SelectPageUp, + SelectPageDown, + SelectHome, + SelectEnd, + SelectToStart, + SelectToEnd, + SelectAll, + Enter, + Tab, + Outdent, + Copy, + Cut, + Paste, + Undo, + Redo, + ExpandAllFolds, + ShowCharacterPalette, + Find, + FindNext, + FindPrevious, + CloseSearch, + ] +); + +pub fn init_code_editor(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("backspace", Backspace, Some(EDITOR_CONTEXT)), + KeyBinding::new("delete", Delete, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-backspace", DeleteToLineStart, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-delete", DeleteToLineEnd, Some(EDITOR_CONTEXT)), + KeyBinding::new("alt-backspace", DeleteWordLeft, Some(EDITOR_CONTEXT)), + KeyBinding::new("alt-delete", DeleteWordRight, Some(EDITOR_CONTEXT)), + KeyBinding::new("left", MoveLeft, Some(EDITOR_CONTEXT)), + KeyBinding::new("right", MoveRight, Some(EDITOR_CONTEXT)), + KeyBinding::new("up", MoveUp, Some(EDITOR_CONTEXT)), + KeyBinding::new("down", MoveDown, Some(EDITOR_CONTEXT)), + KeyBinding::new("pageup", MovePageUp, Some(EDITOR_CONTEXT)), + KeyBinding::new("pagedown", MovePageDown, Some(EDITOR_CONTEXT)), + KeyBinding::new("home", MoveHome, Some(EDITOR_CONTEXT)), + KeyBinding::new("end", MoveEnd, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-left", MoveHome, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-right", MoveEnd, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-up", MoveToStart, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-down", MoveToEnd, Some(EDITOR_CONTEXT)), + KeyBinding::new("alt-left", MoveWordLeft, Some(EDITOR_CONTEXT)), + KeyBinding::new("alt-right", MoveWordRight, Some(EDITOR_CONTEXT)), + KeyBinding::new("ctrl-left", MoveWordLeft, Some(EDITOR_CONTEXT)), + KeyBinding::new("ctrl-right", MoveWordRight, Some(EDITOR_CONTEXT)), + KeyBinding::new("shift-left", SelectLeft, Some(EDITOR_CONTEXT)), + KeyBinding::new("shift-right", SelectRight, Some(EDITOR_CONTEXT)), + KeyBinding::new("alt-shift-left", SelectWordLeft, Some(EDITOR_CONTEXT)), + KeyBinding::new("alt-shift-right", SelectWordRight, Some(EDITOR_CONTEXT)), + KeyBinding::new("shift-up", SelectUp, Some(EDITOR_CONTEXT)), + KeyBinding::new("shift-down", SelectDown, Some(EDITOR_CONTEXT)), + KeyBinding::new("shift-pageup", SelectPageUp, Some(EDITOR_CONTEXT)), + KeyBinding::new("shift-pagedown", SelectPageDown, Some(EDITOR_CONTEXT)), + KeyBinding::new("shift-home", SelectHome, Some(EDITOR_CONTEXT)), + KeyBinding::new("shift-end", SelectEnd, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-shift-left", SelectHome, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-shift-right", SelectEnd, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-shift-up", SelectToStart, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-shift-down", SelectToEnd, Some(EDITOR_CONTEXT)), + KeyBinding::new("enter", Enter, Some(EDITOR_CONTEXT)), + KeyBinding::new("tab", Tab, Some(EDITOR_CONTEXT)), + KeyBinding::new("shift-tab", Outdent, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-a", SelectAll, Some(EDITOR_CONTEXT)), + KeyBinding::new("ctrl-a", SelectAll, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-c", Copy, Some(EDITOR_CONTEXT)), + KeyBinding::new("ctrl-c", Copy, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-x", Cut, Some(EDITOR_CONTEXT)), + KeyBinding::new("ctrl-x", Cut, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-v", Paste, Some(EDITOR_CONTEXT)), + KeyBinding::new("ctrl-v", Paste, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-z", Undo, Some(EDITOR_CONTEXT)), + KeyBinding::new("ctrl-z", Undo, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-shift-z", Redo, Some(EDITOR_CONTEXT)), + KeyBinding::new("ctrl-y", Redo, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-alt-]", ExpandAllFolds, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-f", Find, Some(EDITOR_CONTEXT)), + KeyBinding::new("ctrl-f", Find, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-g", FindNext, Some(EDITOR_CONTEXT)), + KeyBinding::new("cmd-shift-g", FindPrevious, Some(EDITOR_CONTEXT)), + KeyBinding::new("escape", CloseSearch, Some(EDITOR_CONTEXT)), + ]); +} + +#[derive(Clone, Debug)] +pub enum CodeEditorEvent { + Change, +} + +#[derive(Clone, Debug)] +pub(super) struct Selection { + pub range: Range, + pub reversed: bool, +} + +impl Selection { + fn cursor(&self) -> usize { + if self.reversed { + self.range.start + } else { + self.range.end + } + } +} + +#[derive(Clone)] +pub(super) struct VisibleLineLayout { + pub display_row: usize, + pub display_start: usize, + pub display_end: usize, + pub text_bounds: Bounds, + pub line: ShapedLine, +} + +#[derive(Clone, Debug)] +pub(super) struct FoldHitbox { + pub bounds: Bounds, + pub fold_start: usize, +} + +#[derive(Clone, Debug)] +struct Edit { + start: usize, + old_text: String, + new_text: String, + before: Selection, + after: Selection, +} + +fn push_edit_history(history: &mut Vec, edit: Edit) { + let excess = history + .len() + .saturating_add(1) + .saturating_sub(MAX_EDIT_HISTORY_ENTRIES); + if excess > 0 { + history.drain(..excess); + } + history.push(edit); +} + +pub struct CodeEditorState { + pub(super) document: EditorDocument, + pub(super) focus_handle: FocusHandle, + pub(super) selection: Selection, + pub(super) marked_range: Option>, + pub(super) scroll_handle: ScrollHandle, + pub(super) visible_layouts: Vec, + pub(super) fold_hitboxes: Vec, + pub(super) viewport_bounds: Option>, + pub(super) gutter_bounds: Option>, + pub(super) selecting: bool, + pub(super) preferred_column: Option, + pub(super) soft_wrap: bool, + pub(super) read_only: bool, + pub(super) search_input: Entity, + pub(super) search_open: bool, + pub(super) search_matches: Vec>, + pub(super) active_search_match: Option, + _search_subscription: Option, + undo_stack: Vec, + redo_stack: Vec, +} + +pub struct PreparedCodeDocument { + document: EditorDocument, +} + +impl CodeEditorState { + pub fn prepare_with_language( + text: impl Into, + language: CodeLanguage, + ) -> PreparedCodeDocument { + PreparedCodeDocument { + document: EditorDocument::with_language(text, language), + } + } + + pub fn from_prepared( + prepared: PreparedCodeDocument, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search_input = + cx.new(|cx| InputState::new(window, cx).placeholder("Find in response…")); + let mut this = Self { + document: prepared.document, + focus_handle: cx.focus_handle(), + selection: Selection { + range: 0..0, + reversed: false, + }, + marked_range: None, + scroll_handle: ScrollHandle::new(), + visible_layouts: Vec::new(), + fold_hitboxes: Vec::new(), + viewport_bounds: None, + gutter_bounds: None, + selecting: false, + preferred_column: None, + soft_wrap: false, + read_only: false, + search_input: search_input.clone(), + search_open: false, + search_matches: Vec::new(), + active_search_match: None, + _search_subscription: None, + undo_stack: Vec::new(), + redo_stack: Vec::new(), + }; + this._search_subscription = Some(cx.subscribe_in( + &search_input, + window, + |this, input, event, _window, cx| match event { + InputEvent::Change => { + let query = input.read(cx).value(); + this.refresh_search(query.as_ref(), cx); + } + InputEvent::PressEnter { secondary } => { + this.activate_search_match(if *secondary { -1 } else { 1 }, cx); + } + _ => {} + }, + )); + this + } + + pub fn text(&self) -> &str { + self.document.text() + } + + pub fn set_soft_wrap(&mut self, soft_wrap: bool, cx: &mut Context) { + if self.soft_wrap != soft_wrap { + self.soft_wrap = soft_wrap; + cx.notify(); + } + } + + pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context) { + if self.read_only != read_only { + self.read_only = read_only; + self.marked_range = None; + cx.notify(); + } + } + + pub fn trigger_search(&mut self, window: &mut Window, cx: &mut Context) { + if !self.search_open && !self.selection.range.is_empty() { + let selected = self.document.text()[self.selection.range.clone()].to_string(); + if !selected.contains('\n') { + self.search_input.update(cx, |input, cx| { + input.set_value(selected.clone(), window, cx) + }); + self.refresh_search(&selected, cx); + } + } + self.search_open = true; + self.search_input + .update(cx, |input, cx| input.focus(window, cx)); + cx.notify(); + } + + pub fn close_search(&mut self, window: &mut Window, cx: &mut Context) { + if self.search_open { + self.search_open = false; + self.focus_handle.focus(window, cx); + cx.notify(); + } + } + + fn refresh_search(&mut self, query: &str, cx: &mut Context) { + self.search_matches = find_matches(self.document.text(), query); + self.active_search_match = (!self.search_matches.is_empty()).then_some(0); + if !self.search_matches.is_empty() { + self.select_active_search_match(cx); + } + cx.notify(); + } + + fn refresh_search_matches(&mut self, cx: &App) { + let query = self.search_input.read(cx).value(); + self.search_matches = find_matches(self.document.text(), query.as_ref()); + if self.search_matches.is_empty() { + self.active_search_match = None; + } else { + self.active_search_match = Some( + self.active_search_match + .unwrap_or(0) + .min(self.search_matches.len() - 1), + ); + } + } + + pub(super) fn activate_search_match(&mut self, delta: isize, cx: &mut Context) { + if self.search_matches.is_empty() { + return; + } + let current = self.active_search_match.unwrap_or(0) as isize; + let len = self.search_matches.len() as isize; + self.active_search_match = Some((current + delta).rem_euclid(len) as usize); + self.select_active_search_match(cx); + } + + fn select_active_search_match(&mut self, cx: &mut Context) { + let Some(range) = self + .active_search_match + .and_then(|index| self.search_matches.get(index)) + .cloned() + else { + return; + }; + self.document.expand_all(); + self.selection = Selection { + range, + reversed: false, + }; + self.reveal_cursor(); + cx.notify(); + } + + pub fn toggle_fold(&mut self, fold_start: usize, cx: &mut Context) { + if self.document.toggle_fold(fold_start) { + self.normalize_selection_for_folds(); + cx.notify(); + } + } + + pub fn expand_all_folds(&mut self, cx: &mut Context) { + self.document.expand_all(); + cx.notify(); + } + + pub(super) fn cursor(&self) -> usize { + self.selection.cursor() + } + + pub(super) fn selected_range(&self) -> Range { + self.selection.range.clone() + } + + fn move_to(&mut self, offset: usize, cx: &mut Context) { + let offset = self.visible_offset(offset, false); + self.selection = Selection { + range: offset..offset, + reversed: false, + }; + self.preferred_column = None; + self.reveal_cursor(); + cx.notify(); + } + + fn select_to(&mut self, offset: usize, cx: &mut Context) { + let offset = self.visible_offset(offset, self.selection.reversed); + if self.selection.reversed { + self.selection.range.start = offset; + } else { + self.selection.range.end = offset; + } + if self.selection.range.end < self.selection.range.start { + self.selection.reversed = !self.selection.reversed; + self.selection.range = self.selection.range.end..self.selection.range.start; + } + self.reveal_cursor(); + cx.notify(); + } + + fn visible_offset(&self, offset: usize, trailing: bool) -> usize { + let offset = self.document.clamp_offset(offset); + self.document + .containing_collapsed_fold(offset) + .map(|fold| { + if trailing { + fold.hidden_range.end + } else { + fold.hidden_range.start + } + }) + .unwrap_or(offset) + } + + fn normalize_selection_for_folds(&mut self) { + let cursor = self.visible_offset(self.cursor(), false); + if self.selection.range.is_empty() { + self.selection.range = cursor..cursor; + return; + } + self.selection.range.start = self.visible_offset(self.selection.range.start, false); + self.selection.range.end = self.visible_offset(self.selection.range.end, true); + } + + fn reveal_cursor(&self) { + let Some(viewport) = self.viewport_bounds else { + return; + }; + let (display_row, display_offset) = self + .document + .display_point_for_offset(self.cursor(), self.selection.reversed); + let mut scroll_offset = self.scroll_handle.offset(); + if let Some(layout) = self.visible_layouts.iter().find(|layout| { + layout.display_row == display_row + && layout.display_start <= display_offset + && display_offset <= layout.display_end + }) { + if layout.text_bounds.top() < viewport.top() { + scroll_offset.y += viewport.top() - layout.text_bounds.top(); + } else if layout.text_bounds.bottom() > viewport.bottom() { + scroll_offset.y -= layout.text_bounds.bottom() - viewport.bottom(); + } + let cursor_x = layout.text_bounds.left() + + layout + .line + .x_for_index(display_offset.saturating_sub(layout.display_start)); + let text_viewport_left = self + .gutter_bounds + .map_or(viewport.left(), |gutter| gutter.right()); + if cursor_x < text_viewport_left { + scroll_offset.x += text_viewport_left - cursor_x; + } else if cursor_x > viewport.right() - gpui::px(20.0) { + scroll_offset.x -= cursor_x - (viewport.right() - gpui::px(20.0)); + } + } else { + let target_y = EDITOR_LINE_HEIGHT * display_row as f32; + scroll_offset.y = -(target_y - viewport.size.height / 2.0); + } + let max = self.scroll_handle.max_offset(); + scroll_offset.x = scroll_offset.x.clamp(-max.width, gpui::px(0.0)); + scroll_offset.y = scroll_offset.y.clamp(-max.height, gpui::px(0.0)); + self.scroll_handle.set_offset(scroll_offset); + } + + fn replace_selection(&mut self, text: &str, cx: &mut Context) { + let range = self.selection.range.clone(); + self.replace_range(range, text, true, cx); + } + + fn replace_typed_text(&mut self, range: Range, text: &str, cx: &mut Context) { + if self.marked_range.is_none() && range.is_empty() && text.chars().count() == 1 { + let typed = text.chars().next().unwrap(); + if is_closing_delimiter(typed) && self.document.text()[range.start..].starts_with(typed) + { + self.move_to(range.start + typed.len_utf8(), cx); + return; + } + if matches!(typed, '}' | ']') { + let row = self.document.point_for_offset(range.start).row; + let line_start = self.document.line_starts()[row]; + let prefix = &self.document.text()[line_start..range.start]; + if !prefix.is_empty() + && prefix + .chars() + .all(|character| matches!(character, ' ' | '\t')) + { + let keep = if prefix.ends_with('\t') { + prefix.len() - 1 + } else { + prefix.len().saturating_sub(TAB_SIZE) + }; + let replacement = format!("{}{typed}", &prefix[..keep]); + self.replace_range(line_start..range.start, &replacement, true, cx); + return; + } + } + } + + if self.marked_range.is_none() + && text.chars().count() == 1 + && let Some(close) = text.chars().next().and_then(closing_delimiter) + { + let start = range.start; + let selected = self.document.text()[range.clone()].to_string(); + let replacement = format!("{text}{selected}{close}"); + self.replace_range(range, &replacement, true, cx); + self.selection = if selected.is_empty() { + Selection { + range: start + text.len()..start + text.len(), + reversed: false, + } + } else { + Selection { + range: start + text.len()..start + text.len() + selected.len(), + reversed: false, + } + }; + if let Some(edit) = self.undo_stack.last_mut() { + edit.after = self.selection.clone(); + } + cx.notify(); + return; + } + + self.replace_range(range, text, true, cx); + } + + fn replace_range( + &mut self, + range: Range, + text: &str, + record_undo: bool, + cx: &mut Context, + ) { + if self.read_only { + return; + } + let before = self.selection.clone(); + let start = range.start; + let old_text = self.document.replace(range, text); + let cursor = start + text.len(); + self.selection = Selection { + range: cursor..cursor, + reversed: false, + }; + self.marked_range = None; + self.preferred_column = None; + if record_undo { + push_edit_history( + &mut self.undo_stack, + Edit { + start, + old_text, + new_text: text.to_string(), + before, + after: self.selection.clone(), + }, + ); + self.redo_stack.clear(); + } + self.refresh_search_matches(cx); + self.reveal_cursor(); + cx.notify(); + cx.emit(CodeEditorEvent::Change); + } + + fn previous_grapheme(&self, offset: usize) -> usize { + self.document.text()[..offset] + .grapheme_indices(true) + .next_back() + .map_or(0, |(index, _)| index) + } + + fn next_grapheme(&self, offset: usize) -> usize { + let text = self.document.text(); + text[offset..] + .grapheme_indices(true) + .find_map(|(index, _)| (index > 0).then_some(offset + index)) + .unwrap_or(text.len()) + } + + fn previous_word(&self, offset: usize) -> usize { + let prefix = &self.document.text()[..offset]; + let mut boundary = 0; + for (index, word) in prefix.split_word_bound_indices() { + if !word.trim().is_empty() { + boundary = index; + } + } + boundary + } + + fn next_word(&self, offset: usize) -> usize { + let text = self.document.text(); + let suffix = &text[offset..]; + let mut saw_word = false; + for (index, word) in suffix.split_word_bound_indices() { + if word.trim().is_empty() { + if saw_word { + return offset + index; + } + } else { + saw_word = true; + } + } + text.len() + } + + fn vertical_offset(&mut self, delta: isize) -> usize { + let display_point = self + .document + .display_point_for_offset(self.cursor(), self.selection.reversed); + if let Some(current_index) = self.visible_layouts.iter().position(|layout| { + layout.display_row == display_point.0 + && layout.display_start <= display_point.1 + && display_point.1 <= layout.display_end + }) { + let preferred = *self + .preferred_column + .get_or_insert(display_point.1 - self.visible_layouts[current_index].display_start); + let target_index = (current_index as isize + delta) + .clamp(0, self.visible_layouts.len().saturating_sub(1) as isize) + as usize; + if target_index != current_index { + let target = &self.visible_layouts[target_index]; + let display_offset = target.display_start + + preferred.min(target.display_end.saturating_sub(target.display_start)); + return self.document.source_offset_for_display_point( + target.display_row, + display_offset, + false, + ); + } + } + + let point = self.document.point_for_offset(self.cursor()); + let preferred = *self.preferred_column.get_or_insert(point.column); + let target_row = (point.row as isize + delta) + .clamp(0, self.document.line_count().saturating_sub(1) as isize) + as usize; + self.document.offset_for_point(target_row, preferred) + } + + fn page_row_count(&self) -> isize { + self.viewport_bounds + .map(|bounds| (bounds.size.height / EDITOR_LINE_HEIGHT).floor().max(1.0) as isize) + .unwrap_or(20) + } + + fn indentation_for_newline(&self) -> String { + let point = self.document.point_for_offset(self.cursor()); + let line = &self.document.text()[self.document.line_range(point.row)]; + let indent: String = line + .chars() + .take_while(|character| matches!(character, ' ' | '\t')) + .collect(); + let before_cursor = &self.document.text()[..self.cursor()]; + let extra = before_cursor + .chars() + .next_back() + .is_some_and(|character| matches!(character, '{' | '[')); + if extra { + format!("{indent}{}", " ".repeat(TAB_SIZE)) + } else { + indent + } + } + + fn edit_selected_lines(&mut self, outdent: bool, cx: &mut Context) { + let before = self.selection.clone(); + let cursor = self.cursor(); + let start_row = self.document.point_for_offset(before.range.start).row; + let end_probe = if !before.range.is_empty() + && before.range.end > 0 + && self + .document + .line_starts() + .binary_search(&before.range.end) + .is_ok() + { + self.previous_grapheme(before.range.end) + } else { + before.range.end + }; + let end_row = self.document.point_for_offset(end_probe).row; + let edit_start = self.document.line_starts()[start_row]; + let edit_end = self + .document + .line_starts() + .get(end_row + 1) + .copied() + .unwrap_or(self.document.text().len()); + let source = &self.document.text()[edit_start..edit_end]; + let (replacement, first_removed) = transform_line_indentation(source, outdent); + + if replacement == source { + return; + } + let had_selection = !before.range.is_empty(); + let replacement_len = replacement.len(); + self.replace_range(edit_start..edit_end, &replacement, true, cx); + self.selection = if had_selection { + Selection { + range: edit_start..edit_start + replacement_len, + reversed: before.reversed, + } + } else { + let adjusted = if outdent { + cursor.saturating_sub(first_removed.min(cursor - edit_start)) + } else { + cursor + TAB_SIZE + }; + Selection { + range: adjusted..adjusted, + reversed: false, + } + }; + if let Some(edit) = self.undo_stack.last_mut() { + edit.after = self.selection.clone(); + } + cx.notify(); + } + + pub(super) fn on_mouse_down( + &mut self, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(fold_start) = self + .fold_hitboxes + .iter() + .find(|hitbox| hitbox.bounds.contains(&event.position)) + .map(|hitbox| hitbox.fold_start) + { + self.toggle_fold(fold_start, cx); + cx.stop_propagation(); + return; + } + + self.focus_handle.focus(window, cx); + let offset = self.index_for_mouse_position(event.position); + let clicked_gutter = self + .gutter_bounds + .is_some_and(|gutter| gutter.contains(&event.position)); + if clicked_gutter { + self.selection = Selection { + range: line_range_at(&self.document, offset), + reversed: false, + }; + self.selecting = true; + self.preferred_column = None; + cx.notify(); + return; + } + match event.click_count { + 1 if event.modifiers.shift => self.select_to(offset, cx), + 1 => self.move_to(offset, cx), + 2 => { + self.selection = Selection { + range: word_range_at(self.document.text(), offset), + reversed: false, + }; + self.preferred_column = None; + cx.notify(); + } + 3 => { + self.selection = Selection { + range: line_range_at(&self.document, offset), + reversed: false, + }; + self.preferred_column = None; + cx.notify(); + } + 4.. => self.select_all(&SelectAll, window, cx), + _ => self.move_to(offset, cx), + } + self.selecting = true; + } + + pub(super) fn on_mouse_move( + &mut self, + event: &MouseMoveEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selecting { + if let Some(viewport) = self.viewport_bounds { + let delta = selection_autoscroll_delta(event.position, viewport); + if delta.x != gpui::px(0.0) || delta.y != gpui::px(0.0) { + let mut offset = self.scroll_handle.offset(); + offset.x -= delta.x; + offset.y -= delta.y; + let max = self.scroll_handle.max_offset(); + offset.x = offset.x.clamp(-max.width, gpui::px(0.0)); + offset.y = offset.y.clamp(-max.height, gpui::px(0.0)); + self.scroll_handle.set_offset(offset); + } + } + self.select_to(self.index_for_mouse_position(event.position), cx); + } + } + + pub(super) fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context) { + self.selecting = false; + } + + fn index_for_mouse_position(&self, position: Point) -> usize { + let Some(layout) = self + .visible_layouts + .iter() + .find(|layout| { + position.y >= layout.text_bounds.top() && position.y <= layout.text_bounds.bottom() + }) + .or_else(|| { + self.visible_layouts.iter().min_by(|a, b| { + let a_distance = (position.y - a.text_bounds.top()).abs(); + let b_distance = (position.y - b.text_bounds.top()).abs(); + a_distance + .partial_cmp(&b_distance) + .unwrap_or(std::cmp::Ordering::Equal) + }) + }) + else { + return 0; + }; + let display_offset = layout + .line + .closest_index_for_x(position.x - layout.text_bounds.left()) + + layout.display_start; + self.document + .source_offset_for_display_point(layout.display_row, display_offset, false) + } + + pub(super) fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } + if self.selection.range.is_empty() { + let cursor = self.cursor(); + if cursor > 0 + && cursor < self.document.text().len() + && matching_pair( + self.document.text()[..cursor].chars().next_back(), + self.document.text()[cursor..].chars().next(), + ) + { + let start = self.previous_grapheme(cursor); + let end = self.next_grapheme(cursor); + self.selection.range = start..end; + self.replace_selection("", cx); + return; + } + + let point = self.document.point_for_offset(cursor); + let line_start = self.document.line_starts()[point.row]; + if self.document.text()[line_start..cursor] + .chars() + .all(|character| character == ' ') + { + let remove = (cursor - line_start).min(TAB_SIZE); + self.selection.range = cursor - remove..cursor; + self.replace_selection("", cx); + return; + } + self.selection.range = self.previous_grapheme(cursor)..cursor; + } + self.replace_selection("", cx); + } + + pub(super) fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } + if self.selection.range.is_empty() { + let cursor = self.cursor(); + self.selection.range = cursor..self.next_grapheme(cursor); + } + self.replace_selection("", cx); + } + + pub(super) fn delete_to_line_start( + &mut self, + _: &DeleteToLineStart, + _: &mut Window, + cx: &mut Context, + ) { + if self.read_only { + return; + } + if self.selection.range.is_empty() { + let cursor = self.cursor(); + let row = self.document.point_for_offset(cursor).row; + let line_start = self.document.line_starts()[row]; + self.selection.range = if cursor == line_start && cursor > 0 { + self.previous_grapheme(cursor)..cursor + } else { + line_start..cursor + }; + } + self.replace_selection("", cx); + } + + pub(super) fn delete_to_line_end( + &mut self, + _: &DeleteToLineEnd, + _: &mut Window, + cx: &mut Context, + ) { + if self.read_only { + return; + } + if self.selection.range.is_empty() { + let cursor = self.cursor(); + let row = self.document.point_for_offset(cursor).row; + let line_end = self.document.line_end(row); + self.selection.range = if cursor == line_end && cursor < self.document.text().len() { + cursor..self.next_grapheme(cursor) + } else { + cursor..line_end + }; + } + self.replace_selection("", cx); + } + + pub(super) fn delete_word_left( + &mut self, + _: &DeleteWordLeft, + _: &mut Window, + cx: &mut Context, + ) { + if self.read_only { + return; + } + if self.selection.range.is_empty() { + let cursor = self.cursor(); + self.selection.range = self.previous_word(cursor)..cursor; + } + self.replace_selection("", cx); + } + + pub(super) fn delete_word_right( + &mut self, + _: &DeleteWordRight, + _: &mut Window, + cx: &mut Context, + ) { + if self.read_only { + return; + } + if self.selection.range.is_empty() { + let cursor = self.cursor(); + self.selection.range = cursor..self.next_word(cursor); + } + self.replace_selection("", cx); + } + + pub(super) fn move_left(&mut self, _: &MoveLeft, _: &mut Window, cx: &mut Context) { + let offset = if self.selection.range.is_empty() { + self.previous_grapheme(self.cursor()) + } else { + self.selection.range.start + }; + self.move_to(offset, cx); + } + + pub(super) fn move_right(&mut self, _: &MoveRight, _: &mut Window, cx: &mut Context) { + let offset = if self.selection.range.is_empty() { + self.next_grapheme(self.cursor()) + } else { + self.selection.range.end + }; + self.move_to(offset, cx); + } + + pub(super) fn move_up(&mut self, _: &MoveUp, _: &mut Window, cx: &mut Context) { + let offset = self.vertical_offset(-1); + self.move_to(offset, cx); + } + + pub(super) fn move_down(&mut self, _: &MoveDown, _: &mut Window, cx: &mut Context) { + let offset = self.vertical_offset(1); + self.move_to(offset, cx); + } + + pub(super) fn move_page_up(&mut self, _: &MovePageUp, _: &mut Window, cx: &mut Context) { + let offset = self.vertical_offset(-self.page_row_count()); + self.move_to(offset, cx); + } + + pub(super) fn move_page_down( + &mut self, + _: &MovePageDown, + _: &mut Window, + cx: &mut Context, + ) { + let offset = self.vertical_offset(self.page_row_count()); + self.move_to(offset, cx); + } + + pub(super) fn move_home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut Context) { + let point = self.document.point_for_offset(self.cursor()); + let line_start = self.document.line_starts()[point.row]; + let first_content = first_non_whitespace_offset(&self.document, point.row); + self.move_to( + if self.cursor() == first_content { + line_start + } else { + first_content + }, + cx, + ); + } + + pub(super) fn move_end(&mut self, _: &MoveEnd, _: &mut Window, cx: &mut Context) { + let point = self.document.point_for_offset(self.cursor()); + self.move_to(self.document.line_end(point.row), cx); + } + + pub(super) fn move_to_start( + &mut self, + _: &MoveToStart, + _: &mut Window, + cx: &mut Context, + ) { + self.move_to(0, cx); + } + + pub(super) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context) { + self.move_to(self.document.text().len(), cx); + } + + pub(super) fn move_word_left( + &mut self, + _: &MoveWordLeft, + _: &mut Window, + cx: &mut Context, + ) { + self.move_to(self.previous_word(self.cursor()), cx); + } + + pub(super) fn move_word_right( + &mut self, + _: &MoveWordRight, + _: &mut Window, + cx: &mut Context, + ) { + self.move_to(self.next_word(self.cursor()), cx); + } + + pub(super) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { + self.select_to(self.previous_grapheme(self.cursor()), cx); + } + + pub(super) fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { + self.select_to(self.next_grapheme(self.cursor()), cx); + } + + pub(super) fn select_word_left( + &mut self, + _: &SelectWordLeft, + _: &mut Window, + cx: &mut Context, + ) { + self.select_to(self.previous_word(self.cursor()), cx); + } + + pub(super) fn select_word_right( + &mut self, + _: &SelectWordRight, + _: &mut Window, + cx: &mut Context, + ) { + self.select_to(self.next_word(self.cursor()), cx); + } + + pub(super) fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { + let offset = self.vertical_offset(-1); + self.select_to(offset, cx); + } + + pub(super) fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { + let offset = self.vertical_offset(1); + self.select_to(offset, cx); + } + + pub(super) fn select_page_up( + &mut self, + _: &SelectPageUp, + _: &mut Window, + cx: &mut Context, + ) { + let offset = self.vertical_offset(-self.page_row_count()); + self.select_to(offset, cx); + } + + pub(super) fn select_page_down( + &mut self, + _: &SelectPageDown, + _: &mut Window, + cx: &mut Context, + ) { + let offset = self.vertical_offset(self.page_row_count()); + self.select_to(offset, cx); + } + + pub(super) fn select_home(&mut self, _: &SelectHome, _: &mut Window, cx: &mut Context) { + let row = self.document.point_for_offset(self.cursor()).row; + let line_start = self.document.line_starts()[row]; + let first_content = first_non_whitespace_offset(&self.document, row); + self.select_to( + if self.cursor() == first_content { + line_start + } else { + first_content + }, + cx, + ); + } + + pub(super) fn select_end(&mut self, _: &SelectEnd, _: &mut Window, cx: &mut Context) { + let row = self.document.point_for_offset(self.cursor()).row; + self.select_to(self.document.line_end(row), cx); + } + + pub(super) fn select_to_start( + &mut self, + _: &SelectToStart, + _: &mut Window, + cx: &mut Context, + ) { + self.select_to(0, cx); + } + + pub(super) fn select_to_end( + &mut self, + _: &SelectToEnd, + _: &mut Window, + cx: &mut Context, + ) { + self.select_to(self.document.text().len(), cx); + } + + pub(super) fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.selection = Selection { + range: 0..self.document.text().len(), + reversed: false, + }; + cx.notify(); + } + + pub(super) fn enter(&mut self, _: &Enter, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } + let indent = self.indentation_for_newline(); + let cursor = self.cursor(); + let current_indent: String = indent + .chars() + .take(indent.len().saturating_sub(TAB_SIZE)) + .collect(); + let between_pair = self.selection.range.is_empty() + && cursor > 0 + && cursor < self.document.text().len() + && matching_pair( + self.document.text()[..cursor].chars().next_back(), + self.document.text()[cursor..].chars().next(), + ); + if between_pair { + let insertion = format!("\n{indent}\n{current_indent}"); + let inner_cursor = cursor + 1 + indent.len(); + self.replace_selection(&insertion, cx); + self.selection = Selection { + range: inner_cursor..inner_cursor, + reversed: false, + }; + if let Some(edit) = self.undo_stack.last_mut() { + edit.after = self.selection.clone(); + } + cx.notify(); + } else { + self.replace_selection(&format!("\n{indent}"), cx); + } + } + + pub(super) fn tab(&mut self, _: &Tab, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } + if self.selection.range.is_empty() { + self.replace_selection(&" ".repeat(TAB_SIZE), cx); + } else { + self.edit_selected_lines(false, cx); + } + } + + pub(super) fn outdent(&mut self, _: &Outdent, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } + self.edit_selected_lines(true, cx); + } + + pub(super) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { + if !self.selection.range.is_empty() { + cx.write_to_clipboard(ClipboardItem::new_string( + self.document.text()[self.selection.range.clone()].to_string(), + )); + } + } + + pub(super) fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } + if !self.selection.range.is_empty() { + cx.write_to_clipboard(ClipboardItem::new_string( + self.document.text()[self.selection.range.clone()].to_string(), + )); + self.replace_selection("", cx); + } + } + + pub(super) fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } + if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { + self.replace_selection(&text, cx); + } + } + + pub(super) fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } + let Some(edit) = self.undo_stack.pop() else { + return; + }; + self.document + .replace(edit.start..edit.start + edit.new_text.len(), &edit.old_text); + self.selection = edit.before.clone(); + push_edit_history(&mut self.redo_stack, edit); + self.refresh_search_matches(cx); + cx.notify(); + cx.emit(CodeEditorEvent::Change); + } + + pub(super) fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } + let Some(edit) = self.redo_stack.pop() else { + return; + }; + self.document + .replace(edit.start..edit.start + edit.old_text.len(), &edit.new_text); + self.selection = edit.after.clone(); + push_edit_history(&mut self.undo_stack, edit); + self.refresh_search_matches(cx); + cx.notify(); + cx.emit(CodeEditorEvent::Change); + } + + pub(super) fn expand_all_action( + &mut self, + _: &ExpandAllFolds, + _: &mut Window, + cx: &mut Context, + ) { + self.expand_all_folds(cx); + } + + pub(super) fn show_character_palette( + &mut self, + _: &ShowCharacterPalette, + window: &mut Window, + _: &mut Context, + ) { + if self.read_only { + return; + } + window.show_character_palette(); + } + + pub(super) fn find_action(&mut self, _: &Find, window: &mut Window, cx: &mut Context) { + self.trigger_search(window, cx); + } + + pub(super) fn find_next_action( + &mut self, + _: &FindNext, + _: &mut Window, + cx: &mut Context, + ) { + self.activate_search_match(1, cx); + } + + pub(super) fn find_previous_action( + &mut self, + _: &FindPrevious, + _: &mut Window, + cx: &mut Context, + ) { + self.activate_search_match(-1, cx); + } + + pub(super) fn close_search_action( + &mut self, + _: &CloseSearch, + window: &mut Window, + cx: &mut Context, + ) { + self.close_search(window, cx); + } + + fn offset_from_utf16(&self, offset: usize) -> usize { + let mut utf8_offset = 0; + let mut utf16_count = 0; + for character in self.document.text().chars() { + if utf16_count >= offset { + break; + } + utf16_count += character.len_utf16(); + utf8_offset += character.len_utf8(); + } + utf8_offset + } + + fn offset_to_utf16(&self, offset: usize) -> usize { + self.document.text()[..self.document.clamp_offset(offset)] + .chars() + .map(char::len_utf16) + .sum() + } + + fn range_to_utf16(&self, range: &Range) -> Range { + self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) + } + + fn range_from_utf16(&self, range: &Range) -> Range { + self.offset_from_utf16(range.start)..self.offset_from_utf16(range.end) + } +} + +fn find_matches(text: &str, query: &str) -> Vec> { + if query.is_empty() { + return Vec::new(); + } + text.match_indices(query) + .map(|(start, matched)| start..start + matched.len()) + .collect() +} + +fn word_range_at(text: &str, offset: usize) -> Range { + if text.is_empty() { + return 0..0; + } + let offset = offset.min(text.len()); + let probe = if offset == text.len() { + text[..offset] + .char_indices() + .next_back() + .map_or(0, |(index, _)| index) + } else { + offset + }; + text.split_word_bound_indices() + .find_map(|(start, segment)| { + let end = start + segment.len(); + (start <= probe && probe < end).then_some(start..end) + }) + .unwrap_or(offset..offset) +} + +fn line_range_at(document: &EditorDocument, offset: usize) -> Range { + let row = document.point_for_offset(offset).row; + let start = document.line_starts()[row]; + let end = document + .line_starts() + .get(row + 1) + .copied() + .unwrap_or(document.text().len()); + start..end +} + +fn first_non_whitespace_offset(document: &EditorDocument, row: usize) -> usize { + let range = document.line_range(row); + range.start + + document.text()[range.clone()] + .char_indices() + .find_map(|(offset, character)| (!character.is_whitespace()).then_some(offset)) + .unwrap_or(range.len()) +} + +fn closing_delimiter(open: char) -> Option { + match open { + '{' => Some('}'), + '[' => Some(']'), + '(' => Some(')'), + '"' => Some('"'), + '\'' => Some('\''), + '`' => Some('`'), + _ => None, + } +} + +fn is_closing_delimiter(character: char) -> bool { + matches!(character, '}' | ']' | ')' | '"' | '\'' | '`') +} + +fn matching_pair(left: Option, right: Option) -> bool { + left.and_then(closing_delimiter) == right +} + +fn transform_line_indentation(source: &str, outdent: bool) -> (String, usize) { + let mut replacement = String::with_capacity(source.len() + TAB_SIZE * source.lines().count()); + let mut first_removed = 0; + for (index, line) in source.split_inclusive('\n').enumerate() { + if outdent { + let remove = if line.starts_with('\t') { + 1 + } else { + line.bytes() + .take(TAB_SIZE) + .take_while(|byte| *byte == b' ') + .count() + }; + if index == 0 { + first_removed = remove; + } + replacement.push_str(&line[remove..]); + } else { + replacement.push_str(&" ".repeat(TAB_SIZE)); + replacement.push_str(line); + } + } + (replacement, first_removed) +} + +fn selection_autoscroll_delta(position: Point, viewport: Bounds) -> Point { + let vertical_margin = gpui::px(20.0).min(viewport.size.height / 3.0); + let horizontal_margin = gpui::px(28.0).min(viewport.size.width / 3.0); + let top = viewport.top() + vertical_margin; + let bottom = viewport.bottom() - vertical_margin; + let left = viewport.left() + horizontal_margin; + let right = viewport.right() - horizontal_margin; + let scale = |distance: Pixels| (distance * 0.35).clamp(gpui::px(1.0), gpui::px(42.0)); + + Point::new( + if position.x < left { + -scale(left - position.x) + } else if position.x > right { + scale(position.x - right) + } else { + gpui::px(0.0) + }, + if position.y < top { + -scale(top - position.y) + } else if position.y > bottom { + scale(position.y - bottom) + } else { + gpui::px(0.0) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::{ + Edit, EditorDocument, MAX_EDIT_HISTORY_ENTRIES, Selection, find_matches, line_range_at, + push_edit_history, selection_autoscroll_delta, transform_line_indentation, word_range_at, + }; + use gpui::{Bounds, point, px, size}; + + #[test] + fn search_ranges_are_utf8_byte_offsets() { + assert_eq!(find_matches("πŸ’ key πŸ’", "key"), vec![5..8]); + assert_eq!(find_matches("πŸ’ key πŸ’", "πŸ’"), vec![0..4, 9..13]); + } + + #[test] + fn double_click_ranges_select_words_and_punctuation() { + let text = "alpha: πŸ’value"; + assert_eq!(word_range_at(text, 2), 0..5); + assert_eq!(word_range_at(text, 5), 5..6); + assert_eq!(word_range_at(text, 11), 11..16); + } + + #[test] + fn triple_click_range_includes_the_line_ending() { + let document = EditorDocument::new("first\nsecond\nthird"); + assert_eq!(line_range_at(&document, 8), 6..13); + assert_eq!(line_range_at(&document, 14), 13..18); + } + + #[test] + fn delimiter_pairs_cover_json_and_common_code_text() { + assert!(super::matching_pair(Some('{'), Some('}'))); + assert!(super::matching_pair(Some('"'), Some('"'))); + assert!(!super::matching_pair(Some('['), Some('}'))); + } + + #[test] + fn selected_lines_indent_and_outdent_as_one_edit() { + let source = "alpha\n beta\n"; + let (indented, _) = transform_line_indentation(source, false); + assert_eq!(indented, " alpha\n beta\n"); + let (outdented, first_removed) = transform_line_indentation(&indented, true); + assert_eq!(outdented, source); + assert_eq!(first_removed, 2); + } + + #[test] + fn edit_history_drops_oldest_entries_at_the_limit() { + let mut history = Vec::new(); + for start in 0..MAX_EDIT_HISTORY_ENTRIES + 5 { + push_edit_history( + &mut history, + Edit { + start, + old_text: String::new(), + new_text: "x".to_string(), + before: Selection { + range: start..start, + reversed: false, + }, + after: Selection { + range: start + 1..start + 1, + reversed: false, + }, + }, + ); + } + + assert_eq!(history.len(), MAX_EDIT_HISTORY_ENTRIES); + assert_eq!(history.first().unwrap().start, 5); + assert_eq!(history.last().unwrap().start, MAX_EDIT_HISTORY_ENTRIES + 4); + } + + #[test] + fn drag_selection_scrolls_near_and_beyond_viewport_edges() { + let viewport = Bounds::new(point(px(0.0), px(0.0)), size(px(400.0), px(300.0))); + assert_eq!( + selection_autoscroll_delta(point(px(200.0), px(150.0)), viewport), + point(px(0.0), px(0.0)) + ); + let below = selection_autoscroll_delta(point(px(200.0), px(340.0)), viewport); + assert!(below.y > px(0.0)); + let above = selection_autoscroll_delta(point(px(200.0), px(-40.0)), viewport); + assert!(above.y < px(0.0)); + } +} + +impl Focusable for CodeEditorState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for CodeEditorState {} + +impl EntityInputHandler for CodeEditorState { + fn text_for_range( + &mut self, + range_utf16: Range, + actual_range: &mut Option>, + _: &mut Window, + _: &mut Context, + ) -> Option { + let range = self.range_from_utf16(&range_utf16); + actual_range.replace(self.range_to_utf16(&range)); + Some(self.document.text()[range].to_string()) + } + + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut Context, + ) -> Option { + Some(UTF16Selection { + range: self.range_to_utf16(&self.selection.range), + reversed: self.selection.reversed, + }) + } + + fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> { + self.marked_range + .as_ref() + .map(|range| self.range_to_utf16(range)) + } + + fn unmark_text(&mut self, _: &mut Window, _: &mut Context) { + self.marked_range = None; + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _: &mut Window, + cx: &mut Context, + ) { + if self.read_only { + return; + } + let range = range_utf16 + .as_ref() + .map(|range| self.range_from_utf16(range)) + .or_else(|| self.marked_range.clone()) + .unwrap_or_else(|| self.selection.range.clone()); + self.replace_typed_text(range, new_text, cx); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + new_selected_range_utf16: Option>, + _: &mut Window, + cx: &mut Context, + ) { + if self.read_only { + return; + } + let range = range_utf16 + .as_ref() + .map(|range| self.range_from_utf16(range)) + .or_else(|| self.marked_range.clone()) + .unwrap_or_else(|| self.selection.range.clone()); + let start = range.start; + self.replace_range(range, new_text, true, cx); + self.marked_range = (!new_text.is_empty()).then_some(start..start + new_text.len()); + if let Some(selected) = new_selected_range_utf16 { + let selected = self.range_from_utf16(&selected); + self.selection.range = start + selected.start..start + selected.end; + } + } + + fn bounds_for_range( + &mut self, + range_utf16: Range, + _: Bounds, + _: &mut Window, + _: &mut Context, + ) -> Option> { + let range = self.range_from_utf16(&range_utf16); + let (row, offset) = self.document.display_point_for_offset(range.start, false); + let layout = self.visible_layouts.iter().find(|layout| { + layout.display_row == row + && layout.display_start <= offset + && offset <= layout.display_end + })?; + let local_offset = offset.saturating_sub(layout.display_start); + let x = layout.text_bounds.left() + layout.line.x_for_index(local_offset); + Some(Bounds::new( + Point::new(x, layout.text_bounds.top()), + gpui::size(gpui::px(1.), layout.text_bounds.size.height), + )) + } + + fn character_index_for_point( + &mut self, + point: Point, + _: &mut Window, + _: &mut Context, + ) -> Option { + Some(self.offset_to_utf16(self.index_for_mouse_position(point))) + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 1d8f8f2..54ad95c 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -2,6 +2,7 @@ pub mod app_sidebar; pub mod audio_player; pub mod auth_editor; pub mod body_type_selector; +pub mod code_editor; pub mod collections_panel; pub mod custom_dropdown; pub mod environment_panel; @@ -21,6 +22,7 @@ pub mod url_bar; pub use app_sidebar::*; pub use auth_editor::*; pub use body_type_selector::*; +pub use code_editor::*; pub use custom_dropdown::*; pub use environment_panel::*; pub use form_data_editor::*; diff --git a/src/views/response_view.rs b/src/views/response_view.rs index 9c2a2f9..8ff2579 100644 --- a/src/views/response_view.rs +++ b/src/views/response_view.rs @@ -16,14 +16,13 @@ use gpui_component::Sizable; use gpui_component::VirtualListScrollHandle; use gpui_component::WindowExt; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::input::{Input, InputState}; use gpui_component::notification::NotificationType; use gpui_component::scroll::Scrollbar; use gpui_component::spinner::Spinner; use gpui_component::v_virtual_list; -use crate::components::StatusBadge; use crate::components::audio_player::AudioPlayer; +use crate::components::{CodeEditorState, CodeLanguage, PreparedCodeDocument, StatusBadge}; use crate::entities::{ ContentCategory, ResponseData, ResponseEntity, ResponseEvent, ResponseState, ResponseTextSnapshot, @@ -55,6 +54,7 @@ pub enum ResponseTab { struct ResponseTextKey { body_hash: u64, category: ContentCategory, + language: CodeLanguage, } #[derive(Debug, Clone)] @@ -71,13 +71,20 @@ struct VirtualTextDisplay { } enum TextDisplay { - Editor(Entity), + Editor(Entity), Virtual(VirtualTextDisplay), } +enum PreparedTextModel { + Editor(PreparedCodeDocument), + Virtual { + content: Arc, + rows: Arc>, + }, +} + struct PreparedTextDisplay { key: ResponseTextKey, - content: Arc, display: TextDisplay, } @@ -124,7 +131,6 @@ fn virtual_text_rows(content: &str) -> Vec { if chunk_end == chunk_start { chunk_end = line_end; } - rows.push(VirtualTextRow { line_number, continuation, @@ -146,7 +152,6 @@ fn virtual_text_rows(content: &str) -> Vec { byte_range: content.len()..content.len(), }); } - rows } @@ -215,10 +220,12 @@ impl ResponseView { fn ensure_body_display(&mut self, window: &mut Window, cx: &mut Context) { let Some((key, snapshot)) = self.response.read(cx).data.as_ref().map(|data| { let category = data.content_category(); + let language = CodeLanguage::from_content_type(data.content_type.as_deref()); ( ResponseTextKey { body_hash: data.body_hash(), category, + language, }, data.text_snapshot(), ) @@ -304,6 +311,7 @@ impl ResponseView { ResponseTextKey { body_hash: data.body_hash(), category: data.content_category(), + language: CodeLanguage::from_content_type(data.content_type.as_deref()), }, data.text_snapshot(), ) @@ -359,9 +367,19 @@ impl ResponseView { } else { snapshot.raw_body() }; - let rows = should_virtualize_response_text(&content) - .then(|| Arc::new(virtual_text_rows(&content))); - Some((content, rows)) + let model = if should_virtualize_response_text(&content) { + let rows = Arc::new(virtual_text_rows(&content)); + PreparedTextModel::Virtual { + content: content.clone(), + rows, + } + } else { + PreparedTextModel::Editor(CodeEditorState::prepare_with_language( + content.to_string(), + key.language, + )) + }; + Some((content, model)) }) .await; @@ -379,7 +397,7 @@ impl ResponseView { if generation_clock.load(Ordering::Acquire) != generation { return; } - let Ok((content, rows)) = result else { + let Ok((content, model)) = result else { let _ = view.update(app, |this, cx| { if formatted && this.requested_body == Some(key) { this.requested_body = None; @@ -422,37 +440,28 @@ impl ResponseView { return; } - let display = if let Some(rows) = rows { - TextDisplay::Virtual(VirtualTextDisplay { - content: content.clone(), - rows, - scroll_handle: UniformListScrollHandle::new(), - }) - } else { - let language = if formatted { - key.category.language() - } else { - "text" - }; - let wrap_lines = this.wrap_lines; - let editor_content = content.clone(); - let editor = cx.new(move |cx| { - InputState::new(window, cx) - .code_editor(language) - .folding(formatted) - .line_number(true) - .searchable(true) - .soft_wrap(wrap_lines) - .default_value(editor_content) - }); - TextDisplay::Editor(editor) + let display = match model { + PreparedTextModel::Editor(editor_document) => { + let wrap_lines = this.wrap_lines; + let editor = cx.new(move |cx| { + let mut editor = + CodeEditorState::from_prepared(editor_document, window, cx); + editor.set_soft_wrap(wrap_lines, cx); + editor.set_read_only(true, cx); + editor + }); + TextDisplay::Editor(editor) + } + PreparedTextModel::Virtual { content, rows } => { + TextDisplay::Virtual(VirtualTextDisplay { + content, + rows, + scroll_handle: UniformListScrollHandle::new(), + }) + } }; - let prepared = PreparedTextDisplay { - key, - content, - display, - }; + let prepared = PreparedTextDisplay { key, display }; if formatted { this.body_display = Some(prepared); } else { @@ -470,40 +479,38 @@ impl ResponseView { cx.notify(); } - pub fn toggle_wrap_lines(&mut self, window: &mut Window, cx: &mut Context) { + pub fn toggle_wrap_lines(&mut self, _window: &mut Window, cx: &mut Context) { self.wrap_lines = !self.wrap_lines; if let Some(PreparedTextDisplay { display: TextDisplay::Editor(editor), .. }) = &self.body_display { - editor.update(cx, |state, cx| { - state.set_soft_wrap(self.wrap_lines, window, cx); - }); + editor.update(cx, |state, cx| state.set_soft_wrap(self.wrap_lines, cx)); } if let Some(PreparedTextDisplay { display: TextDisplay::Editor(editor), .. }) = &self.raw_display { - editor.update(cx, |state, cx| { - state.set_soft_wrap(self.wrap_lines, window, cx); - }); + editor.update(cx, |state, cx| state.set_soft_wrap(self.wrap_lines, cx)); } cx.notify(); } - pub fn trigger_search(&mut self, window: &mut Window, _cx: &mut Context) { + pub fn trigger_search(&mut self, window: &mut Window, cx: &mut Context) { let editor = match self.active_tab { ResponseTab::Body => self.body_display.as_ref(), ResponseTab::Raw => self.raw_display.as_ref(), ResponseTab::Headers => None, } - .and_then(|display| match &display.display { + .and_then(|prepared| match &prepared.display { TextDisplay::Editor(editor) => Some(editor.clone()), TextDisplay::Virtual(_) => None, }); - crate::utils::trigger_editor_search(editor, window); + if let Some(editor) = editor { + editor.update(cx, |editor, cx| editor.trigger_search(window, cx)); + } } fn active_text_is_virtual(&self) -> bool { @@ -512,22 +519,25 @@ impl ResponseView { ResponseTab::Raw => self.raw_display.as_ref(), ResponseTab::Headers => None, }; - display.is_some_and(|display| matches!(display.display, TextDisplay::Virtual(_))) + display.is_some_and(|prepared| matches!(&prepared.display, TextDisplay::Virtual(_))) } - fn prepared_text_for_tab(&self, tab: ResponseTab) -> Option> { + fn prepared_text_for_tab(&self, tab: ResponseTab, cx: &App) -> Option> { match tab { ResponseTab::Body => self.body_display.as_ref(), ResponseTab::Raw => self.raw_display.as_ref(), ResponseTab::Headers => None, } - .map(|display| display.content.clone()) + .map(|prepared| match &prepared.display { + TextDisplay::Editor(editor) => Arc::from(editor.read(cx).text()), + TextDisplay::Virtual(display) => display.content.clone(), + }) } fn copy_response(&mut self, window: &mut Window, cx: &mut Context) { let active_tab = self.active_tab; if matches!(active_tab, ResponseTab::Body | ResponseTab::Raw) { - if let Some(content) = self.prepared_text_for_tab(active_tab) + if let Some(content) = self.prepared_text_for_tab(active_tab, cx) && content.len() < LARGE_RESPONSE_THRESHOLD_BYTES { cx.write_to_clipboard(gpui::ClipboardItem::new_string(content.to_string())); @@ -538,7 +548,7 @@ impl ResponseView { return; } - let prepared = self.prepared_text_for_tab(active_tab); + let prepared = self.prepared_text_for_tab(active_tab, cx); let snapshot = prepared.is_none().then(|| { self.response .read(cx) @@ -608,7 +618,7 @@ impl ResponseView { } let active_tab = self.active_tab; - let prepared_text = self.prepared_text_for_tab(active_tab); + let prepared_text = self.prepared_text_for_tab(active_tab, cx); let requires_prepared_text = matches!(active_tab, ResponseTab::Body | ResponseTab::Raw) && self.response.read(cx).data.as_ref().is_some_and(|data| { let category = data.content_category(); @@ -788,62 +798,47 @@ impl ResponseView { #[cfg(test)] mod tests { - use super::{ - LARGE_RESPONSE_MAX_EDITOR_LINES, LARGE_RESPONSE_THRESHOLD_BYTES, - VIRTUAL_TEXT_ROW_MAX_BYTES, should_virtualize_response_text, virtual_text_rows, - }; - - fn rendered_rows(content: &str) -> Vec<&str> { - virtual_text_rows(content) - .into_iter() - .map(|row| &content[row.byte_range.clone()]) - .collect() - } + use super::*; #[test] fn virtual_rows_preserve_lines_and_mark_continuations() { let long_line = "x".repeat(VIRTUAL_TEXT_ROW_MAX_BYTES + 12); - let content = format!("first\n{long_line}\n\nlast"); + let content = format!("first\n{long_line}\nthird"); + let rows = virtual_text_rows(&content); - assert_eq!(rows[0].line_number, 1); - assert!(!rows[0].continuation); + assert_eq!(&content[rows[0].byte_range.clone()], "first"); assert_eq!(rows[1].line_number, 2); assert!(!rows[1].continuation); assert_eq!(rows[2].line_number, 2); assert!(rows[2].continuation); - assert_eq!(rows[3].line_number, 3); - assert_eq!(rows[3].byte_range.start, rows[3].byte_range.end); - assert_eq!(rows[4].line_number, 4); - assert_eq!( - rendered_rows(&content), - vec![ - "first", - &long_line[..VIRTUAL_TEXT_ROW_MAX_BYTES], - "xxxxxxxxxxxx", - "", - "last" - ] + format!( + "{}{}", + &content[rows[1].byte_range.clone()], + &content[rows[2].byte_range.clone()] + ), + long_line ); + assert_eq!(&content[rows[3].byte_range.clone()], "third"); } #[test] - fn virtual_rows_split_only_at_utf8_boundaries() { - let content = "πŸ’".repeat(VIRTUAL_TEXT_ROW_MAX_BYTES); + fn virtual_rows_keep_utf8_ranges_on_character_boundaries() { + let content = format!("{}\n", "πŸŽ‰".repeat(VIRTUAL_TEXT_ROW_MAX_BYTES)); + let rows = virtual_text_rows(&content); - assert!(rows.len() > 1); - assert!(rows.iter().all(|row| { - content.is_char_boundary(row.byte_range.start) - && content.is_char_boundary(row.byte_range.end) - && row.byte_range.len() <= VIRTUAL_TEXT_ROW_MAX_BYTES - })); + for row in &rows { + assert!(content.is_char_boundary(row.byte_range.start)); + assert!(content.is_char_boundary(row.byte_range.end)); + } } #[test] - fn virtual_rows_keep_a_trailing_empty_line() { + fn virtual_rows_include_trailing_empty_line() { let content = "first\n"; + let rows = virtual_text_rows(content); assert_eq!(rows.len(), 2); @@ -852,14 +847,14 @@ mod tests { } #[test] - fn virtualizes_by_bytes_or_line_count() { + fn large_or_many_line_responses_use_virtual_display() { + assert!(!should_virtualize_response_text("small\nresponse")); assert!(should_virtualize_response_text( &"x".repeat(LARGE_RESPONSE_THRESHOLD_BYTES) )); assert!(should_virtualize_response_text( &"\n".repeat(LARGE_RESPONSE_MAX_EDITOR_LINES) )); - assert!(!should_virtualize_response_text("small\nresponse")); } } @@ -1345,6 +1340,7 @@ impl ResponseView { let key = ResponseTextKey { body_hash: data.body_hash(), category: content_type, + language: CodeLanguage::from_content_type(data.content_type.as_deref()), }; self.render_prepared_text( "body", @@ -1365,6 +1361,7 @@ impl ResponseView { let key = ResponseTextKey { body_hash: data.body_hash(), category: data.content_category(), + language: CodeLanguage::from_content_type(data.content_type.as_deref()), }; self.render_prepared_text( "raw", @@ -1412,10 +1409,9 @@ impl ResponseView { .flex_1() .w_full() .h_full() - .overflow_y_scroll() - .overflow_x_hidden() + .overflow_hidden() .bg(theme.muted) - .child(Input::new(editor).appearance(false).size_full().p_0()) + .child(editor.clone()) .into_any_element(), TextDisplay::Virtual(display) => { let content = display.content.clone(); @@ -1425,6 +1421,9 @@ impl ResponseView { let text_color = theme.foreground; let border_color = theme.border.opacity(0.25); let mono_font = cx.theme().mono_font_family.clone(); + let max_line_number = rows.last().map_or(1, |row| row.line_number); + let gutter_width = + px(28.0 + max_line_number.to_string().len() as f32 * 8.0).max(px(58.0)); div() .id(SharedString::from(format!("{id_prefix}-virtual-container"))) @@ -1446,7 +1445,7 @@ impl ResponseView { let row = &rows[index]; let text = content[row.byte_range.clone()].to_string(); let line_number = if row.continuation { - "Β·".to_string() + String::new() } else { row.line_number.to_string() }; @@ -1467,8 +1466,8 @@ impl ResponseView { .whitespace_nowrap() .child( div() - .w(px(56.0)) - .min_w(px(56.0)) + .w(gutter_width) + .min_w(gutter_width) .pr(px(10.0)) .text_align(gpui::TextAlign::Right) .text_color(line_number_color)