markdown: Restore horizontal scrollbars for codeblocks (#40736)

Xipeng Jin created

### Summary

Restore the agent pane’s code-block horizontal scrollbar for easier
scrolling without trackpad and preserve individual scroll state across
multiple code blocks.

### Motivation

Addresses https://github.com/zed-industries/zed/issues/34224, where
agent responses with wide code snippets couldn’t be scrolled
horizontally in the panel. Previously there is no visual effect for
scrollbar to let the user move the code snippet and it was not obviously
to use trackpad or hold down `shift` while scrolling. This PR will
ensure the user being able to only use their mouse to drag the
horizontal scrollbar to show the complete line when the code overflow
the width of code block.

### Changes

- Support auto-hide horizontal scrollbar for rendering code block in
agent panel by adding scrollbar support in markdown.rs
- Add `code_block_scroll_handles` cache in
_crates/markdown/src/markdown.rs_ to give each code block a persistent
`ScrollHandle`.
- Wrap rendered code blocks with custom horizontal scrollbars that match
the vertical scrollbar styling and track hover visibility.
- Retain or clear scroll handles based on whether horizontal overflow is
enabled, preventing leaks when the markdown re-renders.

### How to Test

1. Open the agent panel, request code generation, and ensure wide
snippets show a horizontal scrollbar on hover.
3. Scroll horizontally, navigate away (e.g., change tabs or trigger a
re-render), and confirm the scroll position sticks when returning.
5. Toggle horizontal overflow styling off/on (if applicable) and verify
scrollbars appear or disappear appropriately.

### Screenshots / Demos (if UI change)


https://github.com/user-attachments/assets/e23f94d9-8fe3-42f5-8f77-81b1005a14c8

### Notes for Reviewers

- This is my first time contribution for `zed`, sorry for any code
patten inconsistency. So please let me know if you have any comments and
suggestions to make the code pattern consistent and easy to maintain.
- For now, the horizontal scrollbar is not configurable from the setting
and the style is fixed with the same design as the vertical one. I am
happy to readjust this setting to fit the needs.
- Please let me know if you think any behaviors or designs need to be
changed for the scrollbar.
- All changes live inside _crates/markdown/src/markdown.rs_; no API
surface changes.

Closes #34224 

### Release Notes:

- AI: Show horizontal scroll-bars in wide markdown elements

Change summary

crates/markdown/src/markdown.rs | 141 +++++++++++++++++++++++++++-------
1 file changed, 112 insertions(+), 29 deletions(-)

Detailed changes

crates/markdown/src/markdown.rs 🔗

@@ -22,8 +22,8 @@ use gpui::{
     AnyElement, App, BorderStyle, Bounds, ClipboardItem, CursorStyle, DispatchPhase, Edges, Entity,
     FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image,
     ImageFormat, KeyContext, Length, MouseDownEvent, MouseEvent, MouseMoveEvent, MouseUpEvent,
-    Point, Stateful, StrikethroughStyle, StyleRefinement, StyledText, Task, TextLayout, TextRun,
-    TextStyle, TextStyleRefinement, actions, img, point, quad,
+    Point, ScrollHandle, Stateful, StrikethroughStyle, StyleRefinement, StyledText, Task,
+    TextLayout, TextRun, TextStyle, TextStyleRefinement, actions, img, point, quad,
 };
 use language::{Language, LanguageRegistry, Rope};
 use parser::CodeBlockMetadata;
@@ -31,7 +31,7 @@ use parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse
 use pulldown_cmark::Alignment;
 use sum_tree::TreeMap;
 use theme::SyntaxTheme;
-use ui::{Tooltip, prelude::*};
+use ui::{ScrollAxes, Scrollbars, Tooltip, WithScrollbar, prelude::*};
 use util::ResultExt;
 
 use crate::parser::CodeBlockKind;
@@ -108,6 +108,7 @@ pub struct Markdown {
     fallback_code_block_language: Option<LanguageName>,
     options: Options,
     copied_code_blocks: HashSet<ElementId>,
+    code_block_scroll_handles: HashMap<usize, ScrollHandle>,
 }
 
 struct Options {
@@ -176,6 +177,7 @@ impl Markdown {
                 parse_links_only: false,
             },
             copied_code_blocks: HashSet::default(),
+            code_block_scroll_handles: HashMap::default(),
         };
         this.parse(cx);
         this
@@ -199,11 +201,28 @@ impl Markdown {
                 parse_links_only: true,
             },
             copied_code_blocks: HashSet::default(),
+            code_block_scroll_handles: HashMap::default(),
         };
         this.parse(cx);
         this
     }
 
+    fn code_block_scroll_handle(&mut self, id: usize) -> ScrollHandle {
+        self.code_block_scroll_handles
+            .entry(id)
+            .or_insert_with(ScrollHandle::new)
+            .clone()
+    }
+
+    fn retain_code_block_scroll_handles(&mut self, ids: &HashSet<usize>) {
+        self.code_block_scroll_handles
+            .retain(|id, _| ids.contains(id));
+    }
+
+    fn clear_code_block_scroll_handles(&mut self) {
+        self.code_block_scroll_handles.clear();
+    }
+
     pub fn is_parsing(&self) -> bool {
         self.pending_parse.is_some()
     }
@@ -754,14 +773,19 @@ impl Element for MarkdownElement {
             self.style.base_text_style.clone(),
             self.style.syntax.clone(),
         );
-        let markdown = self.markdown.read(cx);
-        let parsed_markdown = &markdown.parsed_markdown;
-        let images = &markdown.images_by_source_offset;
+        let (parsed_markdown, images) = {
+            let markdown = self.markdown.read(cx);
+            (
+                markdown.parsed_markdown.clone(),
+                markdown.images_by_source_offset.clone(),
+            )
+        };
         let markdown_end = if let Some(last) = parsed_markdown.events.last() {
             last.0.end
         } else {
             0
         };
+        let mut code_block_ids = HashSet::default();
 
         let mut current_code_block_metadata = None;
         let mut current_img_block_range: Option<Range<usize>> = None;
@@ -841,39 +865,69 @@ impl Element for MarkdownElement {
                             current_code_block_metadata = Some(metadata.clone());
 
                             let is_indented = matches!(kind, CodeBlockKind::Indented);
+                            let scroll_handle = if self.style.code_block_overflow_x_scroll {
+                                code_block_ids.insert(range.start);
+                                Some(self.markdown.update(cx, |markdown, _| {
+                                    markdown.code_block_scroll_handle(range.start)
+                                }))
+                            } else {
+                                None
+                            };
 
                             match (&self.code_block_renderer, is_indented) {
                                 (CodeBlockRenderer::Default { .. }, _) | (_, true) => {
                                     // This is a parent container that we can position the copy button inside.
-                                    builder.push_div(
-                                        div().group("code_block").relative().w_full(),
-                                        range,
-                                        markdown_end,
-                                    );
+                                    let parent_container =
+                                        div().group("code_block").relative().w_full();
+
+                                    let mut parent_container: AnyDiv = if let Some(scroll_handle) =
+                                        scroll_handle.as_ref()
+                                    {
+                                        let scrollbars = Scrollbars::new(ScrollAxes::Horizontal)
+                                            .id(("markdown-code-block-scrollbar", range.start))
+                                            .tracked_scroll_handle(scroll_handle.clone())
+                                            .with_track_along(
+                                                ScrollAxes::Horizontal,
+                                                cx.theme().colors().editor_background,
+                                            )
+                                            .notify_content();
+
+                                        parent_container
+                                            .rounded_lg()
+                                            .custom_scrollbars(scrollbars, window, cx)
+                                            .into()
+                                    } else {
+                                        parent_container.into()
+                                    };
+
+                                    if let CodeBlockRenderer::Default { border: true, .. } =
+                                        &self.code_block_renderer
+                                    {
+                                        parent_container = parent_container
+                                            .rounded_md()
+                                            .border_1()
+                                            .border_color(cx.theme().colors().border_variant);
+                                    }
 
-                                    let mut code_block = div()
+                                    parent_container.style().refine(&self.style.code_block);
+                                    builder.push_div(parent_container, range, markdown_end);
+
+                                    let code_block = div()
                                         .id(("code-block", range.start))
                                         .rounded_lg()
                                         .map(|mut code_block| {
-                                            if self.style.code_block_overflow_x_scroll {
+                                            if let Some(scroll_handle) = scroll_handle.as_ref() {
                                                 code_block.style().restrict_scroll_to_axis =
                                                     Some(true);
-                                                code_block.flex().overflow_x_scroll()
+                                                code_block
+                                                    .flex()
+                                                    .overflow_x_scroll()
+                                                    .track_scroll(scroll_handle)
                                             } else {
                                                 code_block.w_full()
                                             }
                                         });
 
-                                    if let CodeBlockRenderer::Default { border: true, .. } =
-                                        &self.code_block_renderer
-                                    {
-                                        code_block = code_block
-                                            .rounded_md()
-                                            .border_1()
-                                            .border_color(cx.theme().colors().border_variant);
-                                    }
-
-                                    code_block.style().refine(&self.style.code_block);
                                     if let Some(code_block_text_style) = &self.style.code_block.text
                                     {
                                         builder.push_text_style(code_block_text_style.to_owned());
@@ -884,33 +938,53 @@ impl Element for MarkdownElement {
                                 (CodeBlockRenderer::Custom { render, .. }, _) => {
                                     let parent_container = render(
                                         kind,
-                                        parsed_markdown,
+                                        &parsed_markdown,
                                         range.clone(),
                                         metadata.clone(),
                                         window,
                                         cx,
                                     );
 
+                                    let mut parent_container: AnyDiv = if let Some(scroll_handle) =
+                                        scroll_handle.as_ref()
+                                    {
+                                        let scrollbars = Scrollbars::new(ScrollAxes::Horizontal)
+                                            .id(("markdown-code-block-scrollbar", range.start))
+                                            .tracked_scroll_handle(scroll_handle.clone())
+                                            .with_track_along(
+                                                ScrollAxes::Horizontal,
+                                                cx.theme().colors().editor_background,
+                                            )
+                                            .notify_content();
+
+                                        parent_container
+                                            .rounded_b_lg()
+                                            .custom_scrollbars(scrollbars, window, cx)
+                                            .into()
+                                    } else {
+                                        parent_container.into()
+                                    };
+
+                                    parent_container.style().refine(&self.style.code_block);
                                     builder.push_div(parent_container, range, markdown_end);
 
-                                    let mut code_block = div()
+                                    let code_block = div()
                                         .id(("code-block", range.start))
                                         .rounded_b_lg()
                                         .map(|mut code_block| {
-                                            if self.style.code_block_overflow_x_scroll {
+                                            if let Some(scroll_handle) = scroll_handle.as_ref() {
                                                 code_block.style().restrict_scroll_to_axis =
                                                     Some(true);
                                                 code_block
                                                     .flex()
                                                     .overflow_x_scroll()
                                                     .overflow_y_hidden()
+                                                    .track_scroll(scroll_handle)
                                             } else {
                                                 code_block.w_full().overflow_hidden()
                                             }
                                         });
 
-                                    code_block.style().refine(&self.style.code_block);
-
                                     if let Some(code_block_text_style) = &self.style.code_block.text
                                     {
                                         builder.push_text_style(code_block_text_style.to_owned());
@@ -1218,6 +1292,15 @@ impl Element for MarkdownElement {
                 _ => log::debug!("unsupported markdown event {:?}", event),
             }
         }
+        if self.style.code_block_overflow_x_scroll {
+            let code_block_ids = code_block_ids;
+            self.markdown.update(cx, move |markdown, _| {
+                markdown.retain_code_block_scroll_handles(&code_block_ids);
+            });
+        } else {
+            self.markdown
+                .update(cx, |markdown, _| markdown.clear_code_block_scroll_handles());
+        }
         let mut rendered_markdown = builder.build();
         let child_layout_id = rendered_markdown.element.request_layout(window, cx);
         let layout_id = window.request_layout(gpui::Style::default(), [child_layout_id], cx);