editor: Prevent non‑boundary highlight indices in UTF‑8 (#38510)

Miao created

Closes #38359

Release Notes:

- Use byte offsets for highlights; fix UTF‑8 crash

Change summary

crates/debugger_ui/src/new_process_modal.rs       |  1 
crates/picker/src/highlighted_match_with_paths.rs | 49 ++++++++++++++--
crates/recent_projects/src/recent_projects.rs     | 25 +++----
crates/tasks_ui/src/modal.rs                      |  1 
4 files changed, 52 insertions(+), 24 deletions(-)

Detailed changes

crates/debugger_ui/src/new_process_modal.rs 🔗

@@ -1514,7 +1514,6 @@ impl PickerDelegate for DebugDelegate {
         let highlighted_location = HighlightedMatch {
             text: hit.string.clone(),
             highlight_positions: hit.positions.clone(),
-            char_count: hit.string.chars().count(),
             color: Color::Default,
         };
 

crates/picker/src/highlighted_match_with_paths.rs 🔗

@@ -10,36 +10,36 @@ pub struct HighlightedMatchWithPaths {
 pub struct HighlightedMatch {
     pub text: String,
     pub highlight_positions: Vec<usize>,
-    pub char_count: usize,
     pub color: Color,
 }
 
 impl HighlightedMatch {
     pub fn join(components: impl Iterator<Item = Self>, separator: &str) -> Self {
-        let mut char_count = 0;
-        let separator_char_count = separator.chars().count();
+        // Track a running byte offset and insert separators between parts.
+        let mut first = true;
+        let mut byte_offset = 0;
         let mut text = String::new();
         let mut highlight_positions = Vec::new();
         for component in components {
-            if char_count != 0 {
+            if !first {
                 text.push_str(separator);
-                char_count += separator_char_count;
+                byte_offset += separator.len();
             }
+            first = false;
 
             highlight_positions.extend(
                 component
                     .highlight_positions
                     .iter()
-                    .map(|position| position + char_count),
+                    .map(|position| position + byte_offset),
             );
             text.push_str(&component.text);
-            char_count += component.text.chars().count();
+            byte_offset += component.text.len();
         }
 
         Self {
             text,
             highlight_positions,
-            char_count,
             color: Color::Default,
         }
     }
@@ -73,3 +73,36 @@ impl RenderOnce for HighlightedMatchWithPaths {
             })
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn join_offsets_positions_by_bytes_not_chars() {
+        // "αβγ" is 3 Unicode scalar values, 6 bytes in UTF-8.
+        let left_text = "αβγ".to_string();
+        let right_text = "label".to_string();
+        let left = HighlightedMatch {
+            text: left_text,
+            highlight_positions: vec![],
+            color: Color::Default,
+        };
+        let right = HighlightedMatch {
+            text: right_text,
+            highlight_positions: vec![0, 1],
+            color: Color::Default,
+        };
+        let joined = HighlightedMatch::join([left, right].into_iter(), "");
+
+        assert!(
+            joined
+                .highlight_positions
+                .iter()
+                .all(|&p| joined.text.is_char_boundary(p)),
+            "join produced non-boundary positions {:?} for text {:?}",
+            joined.highlight_positions,
+            joined.text
+        );
+    }
+}

crates/recent_projects/src/recent_projects.rs 🔗

@@ -463,8 +463,7 @@ impl PickerDelegate for RecentProjectsDelegate {
             .map(|path| {
                 let highlighted_text =
                     highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
-
-                path_start_offset += highlighted_text.1.char_count;
+                path_start_offset += highlighted_text.1.text.len();
                 highlighted_text
             })
             .unzip();
@@ -590,34 +589,33 @@ fn highlights_for_path(
     path_start_offset: usize,
 ) -> (Option<HighlightedMatch>, HighlightedMatch) {
     let path_string = path.to_string_lossy();
-    let path_char_count = path_string.chars().count();
+    let path_text = path_string.to_string();
+    let path_byte_len = path_text.len();
     // Get the subset of match highlight positions that line up with the given path.
     // Also adjusts them to start at the path start
     let path_positions = match_positions
         .iter()
         .copied()
         .skip_while(|position| *position < path_start_offset)
-        .take_while(|position| *position < path_start_offset + path_char_count)
+        .take_while(|position| *position < path_start_offset + path_byte_len)
         .map(|position| position - path_start_offset)
         .collect::<Vec<_>>();
 
     // Again subset the highlight positions to just those that line up with the file_name
     // again adjusted to the start of the file_name
     let file_name_text_and_positions = path.file_name().map(|file_name| {
-        let text = file_name.to_string_lossy();
-        let char_count = text.chars().count();
-        let file_name_start = path_char_count - char_count;
+        let file_name_text = file_name.to_string_lossy().to_string();
+        let file_name_start_byte = path_byte_len - file_name_text.len();
         let highlight_positions = path_positions
             .iter()
             .copied()
-            .skip_while(|position| *position < file_name_start)
-            .take_while(|position| *position < file_name_start + char_count)
-            .map(|position| position - file_name_start)
+            .skip_while(|position| *position < file_name_start_byte)
+            .take_while(|position| *position < file_name_start_byte + file_name_text.len())
+            .map(|position| position - file_name_start_byte)
             .collect::<Vec<_>>();
         HighlightedMatch {
-            text: text.to_string(),
+            text: file_name_text,
             highlight_positions,
-            char_count,
             color: Color::Default,
         }
     });
@@ -625,9 +623,8 @@ fn highlights_for_path(
     (
         file_name_text_and_positions,
         HighlightedMatch {
-            text: path_string.to_string(),
+            text: path_text,
             highlight_positions: path_positions,
-            char_count: path_char_count,
             color: Color::Default,
         },
     )

crates/tasks_ui/src/modal.rs 🔗

@@ -482,7 +482,6 @@ impl PickerDelegate for TasksModalDelegate {
         let highlighted_location = HighlightedMatch {
             text: hit.string.clone(),
             highlight_positions: hit.positions.clone(),
-            char_count: hit.string.chars().count(),
             color: Color::Default,
         };
         let icon = match source_kind {