From 8922118402d61751cf97c507015e0d581c4b885b Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Wed, 15 Apr 2026 10:47:29 +0300 Subject: [PATCH] perf(rendering): eliminate O(N^2) bottleneck in fold indicator detection Refactor fold indicator calculation to iterate only over visible ViewLines instead of performing redundant linear scans of the entire viewport byte buffer. This fixes 100% CPU usage when thousands of lines are hidden by a collapsed fold. - Add source_start_byte to ViewLine for efficient mapping. - Refactor fold_indicators_for_viewport to use visible lines. - Remove redundant O(N) byte_offset_of_line_in_bytes helper. - Update tests and remove unused folding helper functions. --- crates/fresh-editor/src/view/folding.rs | 104 ++++++------------ .../src/view/ui/split_rendering.rs | 92 ++++++++++------ .../fresh-editor/src/view/ui/view_pipeline.rs | 6 + 3 files changed, 93 insertions(+), 109 deletions(-) diff --git a/crates/fresh-editor/src/view/folding.rs b/crates/fresh-editor/src/view/folding.rs index 5db97805f..e8fb4afe9 100644 --- a/crates/fresh-editor/src/view/folding.rs +++ b/crates/fresh-editor/src/view/folding.rs @@ -307,53 +307,34 @@ pub mod indent_folding { (indent, all_blank) } - /// Identify foldable lines in a raw byte slice by analysing indentation. - /// - /// Works without any line metadata, so it can be used on large files whose - /// piece tree has not been scanned for line feeds. - /// - /// `max_lookahead` limits how many lines *ahead* of each candidate we scan - /// to decide foldability. - /// - /// Returns an iterator of 0-based line indices (within the slice) that are - /// foldable. - pub fn foldable_lines_in_bytes( - bytes: &[u8], - tab_size: usize, - max_lookahead: usize, - ) -> Vec { - // Split into lines (preserving empty trailing line if present). - let lines: Vec<&[u8]> = bytes.split(|&b| b == b'\n').collect(); - let line_count = lines.len(); - let mut result = Vec::new(); + /// Check if the first line in the given slice is foldable. + /// Uses subsequent lines in the slice for lookahead. + pub fn is_line_foldable_in_bytes(lines: &[&[u8]], tab_size: usize) -> bool { + if lines.is_empty() { + return false; + } - for i in 0..line_count { - let (header_indent, header_blank) = slice_indent(lines[i], tab_size); - if header_blank { - continue; - } + let (header_indent, header_blank) = slice_indent(lines[0], tab_size); + if header_blank { + return false; + } - // Find next non-blank line within lookahead. - let limit = line_count.min(i + 1 + max_lookahead); - let mut next = i + 1; - while next < limit { - let (_, blank) = slice_indent(lines[next], tab_size); - if !blank { - break; - } - next += 1; - } - if next >= limit { - continue; + // Find next non-blank line within the provided lines. + let mut next = 1; + while next < lines.len() { + let (_, blank) = slice_indent(lines[next], tab_size); + if !blank { + break; } + next += 1; + } - let (next_indent, _) = slice_indent(lines[next], tab_size); - if next_indent > header_indent { - result.push(i); - } + if next >= lines.len() { + return false; } - result + let (next_indent, _) = slice_indent(lines[next], tab_size); + next_indent > header_indent } /// Byte-based fold-end search for a single header line. @@ -519,44 +500,21 @@ pub mod indent_folding { } #[test] - fn test_foldable_lines_basic() { - let text = b"fn main() {\n println!();\n}\n"; - let foldable = foldable_lines_in_bytes(text, 4, 50); - assert_eq!(foldable, vec![0]); // line 0 is foldable - } - - #[test] - fn test_foldable_lines_nested() { - let text = b"fn main() {\n if true {\n x();\n }\n}\n"; - let foldable = foldable_lines_in_bytes(text, 4, 50); - assert_eq!(foldable, vec![0, 1]); // both fn and if are foldable - } - - #[test] - fn test_foldable_lines_not_foldable() { - let text = b"line1\nline2\nline3\n"; - let foldable = foldable_lines_in_bytes(text, 4, 50); - assert!(foldable.is_empty()); + fn test_is_line_foldable_basic() { + let lines: Vec<&[u8]> = vec![b"fn main() {", b" println!();", b"}"]; + assert!(is_line_foldable_in_bytes(&lines, 4)); } #[test] - fn test_foldable_lines_blank_lines_skipped() { - // Blank line between header and indented line should still be foldable - let text = b"fn main() {\n\n println!();\n}\n"; - let foldable = foldable_lines_in_bytes(text, 4, 50); - assert_eq!(foldable, vec![0]); + fn test_is_line_foldable_not_foldable() { + let lines: Vec<&[u8]> = vec![b"line1", b"line2", b"line3"]; + assert!(!is_line_foldable_in_bytes(&lines, 4)); } #[test] - fn test_foldable_lines_max_lookahead() { - // With max_lookahead=1, a blank line between header and content means - // the lookahead can't reach the indented line. - let text = b"fn main() {\n\n\n println!();\n}\n"; - let foldable_short = foldable_lines_in_bytes(text, 4, 1); - assert!(foldable_short.is_empty()); - - let foldable_long = foldable_lines_in_bytes(text, 4, 50); - assert_eq!(foldable_long, vec![0]); + fn test_is_line_foldable_blank_lines_skipped() { + let lines: Vec<&[u8]> = vec![b"fn main() {", b"", b" println!();", b"}"]; + assert!(is_line_foldable_in_bytes(&lines, 4)); } } } diff --git a/crates/fresh-editor/src/view/ui/split_rendering.rs b/crates/fresh-editor/src/view/ui/split_rendering.rs index e46633999..6a6c61c0e 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering.rs @@ -3321,6 +3321,7 @@ impl SplitRenderer { ViewLine { text, + source_start_byte: None, // Per-character data: all None - no source mapping (this is injected content) char_source_bytes: vec![None; len], // All have the virtual text's style @@ -4521,6 +4522,7 @@ impl SplitRenderer { highlight_context_bytes: usize, view_mode: &ViewMode, diagnostics_inline_text: bool, + view_lines: &[ViewLine], ) -> DecorationContext { use crate::view::folding::indent_folding; @@ -4670,8 +4672,7 @@ impl SplitRenderer { line_indicators.entry(key).or_insert(diff_ind); } - let fold_indicators = - Self::fold_indicators_for_viewport(state, folds, viewport_start, viewport_end); + let fold_indicators = Self::fold_indicators_for_viewport(state, folds, view_lines); DecorationContext { highlight_spans, @@ -4688,8 +4689,7 @@ impl SplitRenderer { fn fold_indicators_for_viewport( state: &EditorState, folds: &FoldManager, - viewport_start: usize, - viewport_end: usize, + view_lines: &[ViewLine], ) -> BTreeMap { let mut indicators = BTreeMap::new(); @@ -4699,7 +4699,13 @@ impl SplitRenderer { } if !state.folding_ranges.is_empty() { - // Use LSP-provided folding ranges — key by line-start byte + // Use LSP-provided folding ranges. + // Filter to only ranges that start on one of our visible view lines. + let visible_starts: HashSet = view_lines + .iter() + .filter_map(|l| l.source_start_byte) + .collect(); + for range in &state.folding_ranges { let start_line = range.start_line as usize; let end_line = range.end_line as usize; @@ -4707,9 +4713,11 @@ impl SplitRenderer { continue; } if let Some(line_byte) = state.buffer.line_start_offset(start_line) { - indicators - .entry(line_byte) - .or_insert(FoldIndicator { collapsed: false }); + if visible_starts.contains(&line_byte) { + indicators + .entry(line_byte) + .or_insert(FoldIndicator { collapsed: false }); + } } } } else { @@ -4717,15 +4725,28 @@ impl SplitRenderer { use crate::view::folding::indent_folding; let tab_size = state.buffer_settings.tab_size; let max_lookahead = crate::config::INDENT_FOLD_INDICATOR_MAX_SCAN; - let bytes = state.buffer.slice_bytes(viewport_start..viewport_end); - if !bytes.is_empty() { - let foldable = - indent_folding::foldable_lines_in_bytes(&bytes, tab_size, max_lookahead); - for line_idx in foldable { - let byte_off = Self::byte_offset_of_line_in_bytes(&bytes, line_idx); - indicators - .entry(viewport_start + byte_off) - .or_insert(FoldIndicator { collapsed: false }); + + // Iterate through ONLY the visible view lines. This automatically ignores + // thousands of lines hidden by collapsed folds. + for (i, view_line) in view_lines.iter().enumerate() { + // Only source lines (not wrapped continuations) can be fold headers + if let Some(line_start_byte) = view_line.source_start_byte { + if view_line.line_start.is_continuation() { + continue; + } + + // Check if this line is foldable by looking at subsequent visible lines + let mut subsequent_lines = Vec::new(); + let lookahead_limit = (i + 1 + max_lookahead).min(view_lines.len()); + for j in i..lookahead_limit { + subsequent_lines.push(view_lines[j].text.as_bytes()); + } + + if indent_folding::is_line_foldable_in_bytes(&subsequent_lines, tab_size) { + indicators + .entry(line_start_byte) + .or_insert(FoldIndicator { collapsed: false }); + } } } } @@ -4796,22 +4817,6 @@ impl SplitRenderer { indicators } - /// Given a byte slice, return the byte offset of line N (0-indexed) - /// within that slice. - fn byte_offset_of_line_in_bytes(bytes: &[u8], line_idx: usize) -> usize { - let mut current_line = 0; - for (i, &b) in bytes.iter().enumerate() { - if current_line == line_idx { - return i; - } - if b == b'\n' { - current_line += 1; - } - } - // If we exhausted the bytes without reaching the line, return end - bytes.len() - } - // semantic token colors are mapped when overlays are created fn calculate_viewport_end( @@ -4939,6 +4944,7 @@ impl SplitRenderer { static EMPTY_LINE: std::sync::OnceLock = std::sync::OnceLock::new(); EMPTY_LINE.get_or_init(|| ViewLine { text: String::new(), + source_start_byte: None, char_source_bytes: Vec::new(), char_styles: Vec::new(), char_visual_cols: Vec::new(), @@ -6319,6 +6325,7 @@ impl SplitRenderer { highlight_context_bytes, &view_mode, diagnostics_inline_text, + &view_data.lines, ); let calculated_offset = viewport.top_view_line_offset; @@ -6969,6 +6976,7 @@ mod tests { 100_000, // default highlight context bytes &ViewMode::Source, // Tests use source mode false, // inline diagnostics off for test + &[], ); let mut dummy_theme_map = Vec::new(); @@ -7074,13 +7082,24 @@ mod tests { let mut folds = FoldManager::new(); folds.add(&mut state.marker_list, start, end, None); - let indicators = - SplitRenderer::fold_indicators_for_viewport(&state, &folds, 0, state.buffer.len()); + let line1_byte = state.buffer.line_start_offset(1).unwrap(); + let view_lines = vec![ViewLine { + text: "b\n".to_string(), + source_start_byte: Some(line1_byte), + char_source_bytes: vec![Some(line1_byte), Some(line1_byte + 1)], + char_styles: vec![None, None], + char_visual_cols: vec![0, 1], + visual_to_char: vec![0, 1], + tab_starts: HashSet::new(), + line_start: LineStart::AfterSourceNewline, + ends_with_newline: true, + }]; + + let indicators = SplitRenderer::fold_indicators_for_viewport(&state, &folds, &view_lines); // Collapsed fold: header is line 0 (byte 0) assert_eq!(indicators.get(&0).map(|i| i.collapsed), Some(true)); // LSP range starting at line 1 (byte 2, since "a\n" is 2 bytes) - let line1_byte = state.buffer.line_start_offset(1).unwrap(); assert_eq!( indicators.get(&line1_byte).map(|i| i.collapsed), Some(false) @@ -8525,6 +8544,7 @@ mod tests { 100_000, &ViewMode::Source, false, + &[], ); SplitRenderer::render_view_lines(LineRenderInput { diff --git a/crates/fresh-editor/src/view/ui/view_pipeline.rs b/crates/fresh-editor/src/view/ui/view_pipeline.rs index d965d320f..83eaf1492 100644 --- a/crates/fresh-editor/src/view/ui/view_pipeline.rs +++ b/crates/fresh-editor/src/view/ui/view_pipeline.rs @@ -31,6 +31,9 @@ pub struct ViewLine { /// The display text for this line (tabs expanded to spaces, etc.) pub text: String, + /// Absolute source byte offset of the start of this line (if it has one) + pub source_start_byte: Option, + // === Per-CHARACTER mappings (indexed by char position in text) === /// Source byte offset for each character /// Length == text.chars().count() @@ -210,8 +213,10 @@ impl<'a> Iterator for ViewLineIterator<'a> { if self.at_buffer_end && matches!(self.next_line_start, LineStart::AfterSourceNewline) { // Flip to Beginning so the *next* call returns None. self.next_line_start = LineStart::Beginning; + let last_source_byte = self.tokens.last().and_then(|t| t.source_offset); return Some(ViewLine { text: String::new(), + source_start_byte: last_source_byte.map(|s| s + 1), char_source_bytes: vec![], char_styles: vec![], char_visual_cols: vec![], @@ -452,6 +457,7 @@ impl<'a> Iterator for ViewLineIterator<'a> { Some(ViewLine { text, + source_start_byte: char_source_bytes.iter().find_map(|s| *s), char_source_bytes, char_styles, char_visual_cols,